Step 212. 힙 익스플로잇 맛보기: tcache poisoning — 대기열 장부를 덮다

Step 212. 힙 익스플로잇 맛보기: tcache poisoning — 대기열 장부를 덮다

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★★ | 예상 소요 시간 6시간

전제: Step 211(힙 입문 — 청크, tcache, LIFO 재사용, UAF). 포인터와 XOR 연산을 읽을 수 있습니다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: WSL 우분투(gcc). 실측 환경은 Ubuntu 24.04, gcc 13.3.0, glibc 2.39입니다.
  • 주의: 오늘의 공격은 전부 여러분이 직접 쓴 프로그램 안에서, 여러분의 변수를 표적으로 삼아 일어납니다. 최신 glibc의 방어(safe-linking)가 켜진 상태에서 그 방어까지 포함해 재현합니다.

Step 211에서 우리는 반납된 청크가 대기열(tcache)로 들어가고, 다음 malloc이 그 자리를 다시 준다는 것을 봤습니다. 그런데 그 대기열은 어떻게 "다음 빈 청크"를 기억할까요? 정답은 — 반납된 청크의 앞 8바이트에 다음 청크의 주소를 적어 두는 것. 그 장부를 덮으면, 다음 malloc은 공격자가 지정한 아무 주소나 내줍니다. 이것이 tcache poisoning, 힙 공격의 "Hello World"입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • tcache가 단일 연결 리스트라는 것과 next 포인터의 위치를 설명한다
  • next 포인터를 덮어 malloc이 임의 주소를 반환하게 만드는 흐름을 재현한다
  • glibc 2.32+의 safe-linking(포인터 난독화)이 무엇이고 왜 생겼는지 설명한다
  • 임의 주소 할당이 "임의 주소 쓰기"로 이어지는 개념 지도를 그린다
  • 힙 공격의 전제 조건(UAF·힙 오버플로우 같은 쓰기 경로)을 말할 수 있다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C 언어, WSL 우분투 bash (실측: gcc 13.3.0, glibc 2.39, x86-64)
오늘의 코드·개념 *(size_t *)청크(next 포인터 읽기/쓰기), PROTECT/REVEAL(safe-linking 계산), 크기·정렬 감각
필요한 개념 tcache 단일 연결 리스트, next 포인터, safe-linking, 임의 주소 할당 → 임의 쓰기
오늘의 산출물 tcache poisoning 재현 프로그램 + 난독화 복호 관찰 로그 + 개념 지도

2-1. tcache의 내부 — 반납된 청크가 장부를 품다

Step 211에서 반납된 청크는 대기열에 들어간다고 배웠습니다. 그 대기열은 별도의 자료구조가 아닙니다 — 반납된 청크 자체가 리스트의 마디입니다:

tcache 대기열(크기 0x20 칸, LIFO):

 head ──→ [ b 청크 ]        [ a 청크 ]
           앞 8바이트: ───→  앞 8바이트: NULL(끝)
           "다음은 a"        "다음은 없음"
           (나머지 공간은 옛 사용자 데이터 그대로)

반납된 청크의 앞 8바이트에는 다음 빈 청크의 주소(next) 가 적힙니다. malloc은 head가 가리키는 청크를 꺼내면서, 그 청크에 적힌 next를 새 head로 삼습니다. 장부를 따로 안 두고 빈 청크 안에 적는 재치 있는 설계입니다 — 어차피 빈 땅이니까요. 그런데 이 말은 곧, 빈 청크에 쓸 수 있는 공격자는 장부를 고칠 수 있다는 뜻입니다.

2-2. poisoning의 5단계

  1. a, b를 같은 크기로 할당한다
  2. 둘을 반납한다 — 대기열: head → b → a → NULL
  3. UAF나 힙 오버플로우로 b의 next를 표적 주소로 덮는다 — 대기열: head → b → target → ...
  4. malloc 한 번 — b가 나오고, head는 target이 된다
  5. malloc 두 번째 — target 주소가 반환된다. 여기에 쓰면 임의 주소 쓰기 완성

Step 210의 GOT overwrite와 연결해 보세요. 임의 쓰기가 생기면 GOT를 덮을 수 있고, __free_hook 같은 할당자의 훅을 덮는 길도 열립니다.

2-3. safe-linking — 장부를 난독화하는 방어

이 공격이 너무 잘 통하자 glibc 2.32(2020년)부터 safe-linking이 추가됐습니다. next를 그대로 적지 않고 이렇게 난독화합니다:

저장값 = (청크의 주소 >> 12) XOR 실제_next
복원값 = (청크의 주소 >> 12) XOR 저장값

청크 주소의 윗 비트들을 키처럼 섞는 것입니다. 공격자가 힙 주소를 모르면 올바른 난독화 값을 만들 수 없고, 엉뚱한 값을 덮으면 다음 할당이 쓰레기 주소로 가서 즉시 죽습니다. 즉 safe-linking은 "공격을 원천 차단"이 아니라 "힙 주소를 모르면 못 하게" 만드는 방어입니다. 오늘 우리는 자기 프로그램 안에서 주소를 아는 상태로 이 계산까지 포함해 재현합니다 — 실전에서는 힙 주소 leak이 먼저 필요하다는 뜻입니다.


3. 따라 하기

3-1. 재현 프로그램 — poison.c

입력 (poison.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

/* glibc safe-linking: 청크에 저장되는 next = (청크주소 >> 12) ^ 실제다음포인터 */
#define PROTECT(pos, ptr) (((size_t)(pos) >> 12) ^ (size_t)(ptr))
#define REVEAL(pos, v)    (((size_t)(pos) >> 12) ^ (size_t)(v))

char target[0x20] __attribute__((aligned(16))) = "top secret data";

int main(void) {
    char *a = malloc(0x18);
    char *b = malloc(0x18);
    strcpy(a, "AAAA");
    strcpy(b, "BBBB");
    printf("a=%p b=%p target=%p\n", (void *)a, (void *)b, (void *)target);

    free(a);
    free(b);
    /* tcache 대기열(후입선출): b -> a -> NULL */

    size_t stored = *(size_t *)b;
    printf("b 앞 8바이트(저장된 next): %#lx\n", stored);
    printf("복원하면: %p (= a, safe-linking 복호)\n", (void *)REVEAL(b, stored));

    /* 공격 한 줄: b의 next를 target으로 덮는다 (UAF 쓰기 상황 모사) */
    *(size_t *)b = PROTECT(b, target);

    char *c = malloc(0x18);
    printf("c=%p (b 자리 재사용)\n", (void *)c);
    char *d = malloc(0x18);
    printf("d=%p <- target과 같으면 poisoning 성공\n", (void *)d);

    strcpy(d, "HACKED!");
    printf("target 내용: %s\n", target);
    return 0;
}

읽는 법: target은 우리 프로그램의 "중요한 데이터" 구실을 하는 전역 변수입니다 (16바이트 정렬은 glibc가 반환 청크의 정렬을 검사하기 때문에 맞춘 것입니다). PROTECT 매크로가 safe-linking의 암호화를 흉내 낸 것 — 우리는 자기 프로그램 안이라 청크 주소를 알고, 따라서 올바른 난독화 값을 만들 수 있습니다. 공격의 한 줄은 *(size_t *)b = PROTECT(b, target); — 반납된 b의 장부를 표적으로 덮는 것입니다.

3-2. 실행 — 난독화, 덮기, 그리고 탈취

cd ~/lab209_213
gcc -Wall -o poison poison.c
./poison
poison.c:28:18: warning: pointer 'b' used after 'free' [-Wuse-after-free]
   (28, 25, 23번 줄에 같은 경고 — 컴파일러가 공격 경로를 정확히 짚는다)
a=0x600929d372a0 b=0x600929d372c0 target=0x60090d918010
b 앞 8바이트(저장된 next): 0x600f2941ef97
복원하면: 0x600929d372a0 (= a, safe-linking 복호)
c=0x600929d372c0 (b 자리 재사용)
d=0x60090d918010 <- target과 같으면 poisoning 성공
target 내용: HACKED!

(2026-09-09 실측. 주소는 실행마다 다릅니다.)

출력 읽는 법: 한 줄씩 뜯어봅시다.

  • 저장된 next = 0x600f2941ef97: a의 주소(0x600929d372a0)와 전혀 다르게 보입니다 — safe-linking이 난독화한 값입니다.
  • 복원하면 = a: (b >> 12) ^ 저장값으로 계산하니 정확히 a가 나옵니다. 난독화가 가역적이고, 키는 청크 주소 자신임을 확인했습니다.
  • c = b 자리: 첫 malloc은 정상 — 대기열 맨 앞 b가 나왔습니다. 이때 head는 "b에 적힌 next", 즉 우리가 덮은 target이 됩니다.
  • d = target: 두 번째 malloc이 힙이 아닌 곳, 전역 변수 target의 주소를 반환했습니다. 할당자가 속은 것입니다.
  • target 내용: HACKED!: d에 쓰자 target이 바뀌었습니다 — 임의 주소 쓰기 완성입니다.

주목할 것: target은 힙이 아니라 바이너리의 데이터 영역입니다. malloc이 힙 밖의 주소를 "새 땅"이라며 내준 것 — 이것이 poisoning의 위력입니다.

3-3. 방어가 없던 시절과의 비교 — 난독화가 바꾼 것

safe-linking이 없던 glibc(2.31 이하)에서는 저장된 next가 a의 주소 그대로였고, 공격자는 그냥 *(size_t *)b = target; 한 줄이면 됐습니다. 지금 우리가 PROTECT를 계산해야 했던 이유가 곧 방어의 효과입니다 — 청크 주소를 모르는 공격자는 올바른 저장값을 만들 수 없습니다.

반대로 말하면, safe-linking 시대의 공격은 "힙 주소를 먼저 leak하고 시작한다"가 됩니다. 난독화의 키가 청크 주소이므로, freed 청크의 저장값을 한 번 읽을 수만 있으면(REVEAL로) 힙 주소가 역산됩니다 — 방어는 공격을 막는 것이 아니라 전제 조건을 하나 더 요구하는 것입니다.

3-4. 탐지의 벽 — 더블 프리

같은 방어 시대의 또 다른 장치를 봅니다. 같은 청크를 두 번 반납하면(Step 61의 double free) 옛날에는 대기열이 꼬여 공격 재료가 됐는데, 지금은:

입력 (df.c)

#include <stdlib.h>
int main(void) {
    char *a = malloc(0x18);
    free(a);
    free(a);
    return 0;
}
gcc -o df df.c
./df
free(): double free detected in tcache 2
Aborted (core dumped)

(2026-09-09 실측.)

출력 읽는 법: tcache는 각 청크의 뒷부분에 key라는 표식을 함께 적어 두고, 반납 때 "이미 내 대기열에 있는 청크인가"를 검사합니다. 걸리면 즉시 중단. 현대의 힙 공격은 이런 탐지들 사이의 빈틈을 찾는 싸움이고, 그래서 오늘 우리는 정공법(허용된 경로의 UAF 쓰기 모사)으로 재현한 것입니다.

3-5. 개념 지도 — 이 공격이 어디로 이어지는가

쓰기 경로 확보 (UAF, 힙 오버플로우, double free 우회)
        ↓
tcache poisoning → 임의 주소 "할당" → 그 주소에 쓰기
        ↓
표적 예시:  GOT 덮기 (Step 210과 연결)
            __free_hook / __malloc_hook 덮기 (구형 glibc)
            리턴 주소·함수 포인터가 있는 구조체 덮기

"임의 주소에 원하는 값을 쓴다"는 Pwn의 만능 열쇠입니다. Step 210에서는 scanf 버그로 그 열쇠를 얻었고, 오늘은 힙 대기열로 얻었습니다 — 도착지는 같습니다.


4. 미션과 연습문제

미션 — 두 번째 표적

poison.c를 확장해 보세요:

  1. 전역 변수 int admin_flag = 0;을 target 옆에 추가한다 (16바이트 정렬 유지)
  2. poisoning으로 admin_flag의 주소를 할당받아 1로 덮는다
  3. if (admin_flag) puts("관리자 권한 획득");이 실행되는 것을 확인한다
  4. 노트에 정리한다: "내가 실제로 덮은 것은 무엇이며, 할당자는 왜 속았는가" 두 줄

연습문제

문제 1. tcache 대기열이 "별도의 리스트 구조"가 아니라 "반납된 청크 자체"라는 사실이 왜 공격의 문을 여나요?

문제 2. 3-2에서 첫 번째 malloc(c)은 정상적으로 b를 반환했는데, 두 번째 malloc(d)이 target을 반환한 이유를 head의 이동으로 설명하세요.

문제 3. safe-linking에서 난독화의 "키"는 무엇이며, 그래서 공격자에게 새로 필요해진 전제 조건은 무엇인가요?

문제 4. 3-4의 double free 탐지는 어떤 값을 검사하는 것인가요? 그리고 이 탐지가 poisoning 자체를 막는 것은 아닌 이유를 설명하세요.


5. 모범 답안과 완료 기준

미션 모범 답안

char target[0x20] __attribute__((aligned(16))) = "top secret data";
int admin_flag __attribute__((aligned(16))) = 0;

를 선언하고, 공격 줄을 *(size_t *)b = PROTECT(b, &admin_flag);로 바꿉니다. 두 번째 malloc으로 받은 포인터에 *(int *)d = 1;로 쓰면 admin_flag가 1이 되어 분기가 실행됩니다. 노트 정리의 예:

덮은 것: 반납된 청크 b의 next 포인터(대기열 장부)
속은 이유: malloc은 대기열의 next를 검증 없이 신뢰해 그 주소를 반환한다

검증하는 법: ① admin_flag가 1로 바뀌고 분기가 실행됐는가, ② d의 주소가 admin_flag의 주소와 같은지 출력으로 확인했는가, ③ 노트에 "검증 없는 신뢰"가 핵심으로 적혀 있는가.

연습문제 해답

문제 1 해답. 대기열의 연결 정보(next)가 공격자가 쓸 수 있는 메모리 안에 있기 때문입니다. 별도의 보호된 장소에 장부를 뒀다면 청크를 덮어도 대기열은 안전했을 것입니다. 빈 땅을 장부로 재활용하는 효율이, 그 땅에 쓰기 권한이 있는 한 장부 조작의 문이 됩니다.

문제 2 해답. malloc은 head가 가리키는 청크를 꺼내면서, 그 청크에 적힌 next를 새 head로 삼습니다. 우리가 b의 next를 target으로 덮어 뒀으므로, c가 b를 꺼낸 순간 head는 target이 됐고, 다음 malloc은 head인 target을 반환했습니다. 할당자는 next가 진짜 빈 청크인지 확인하지 않습니다.

문제 3 해답. 키는 청크 자신의 주소를 12비트 오른쪽으로 시프트한 값입니다 (저장값 = (청크주소 >> 12) ^ next). 공격자에게 새로 필요해진 전제는 힙 주소의 지식입니다 — 주소를 모르면 올바른 난독화 값을 만들 수 없고, 잘못 덮으면 다음 할당이 쓰레기 주소로 가서 죽습니다. 그래서 현대 힙 공격은 leak부터 시작합니다.

문제 4 해답. 반납되는 청크의 key 필드가 tcache 구조체를 가리키는지 검사합니다 — 이미 반납된 청크에는 그 표식이 있으니 두 번 반납이 걸립니다. 그러나 poisoning은 반납을 두 번 하는 공격이 아니라 반납된 청크의 next를 덮는 공격이라, key 검사와는 무관한 경로를 탑니다. 탐지는 공격의 한 갈래를 막을 뿐, 다른 갈래는 남습니다.

완료 기준 체크리스트

  • [ ] tcache가 단일 연결 리스트이며 next가 반납된 청크의 앞 8바이트에 있음을 설명할 수 있다
  • [ ] poisoning 5단계(할당→반납→덮기→malloc×2)를 순서대로 말할 수 있다
  • [ ] safe-linking의 계산식(저장값 = (주소>>12) ^ next)과 존재 이유를 설명할 수 있다
  • [ ] poison.c를 컴파일해 d == target, target 덮어쓰기까지 재현했다
  • [ ] 난독화된 저장값과 복원값의 차이를 출력에서 확인했다
  • [ ] 임의 주소 할당이 GOT 덮기 등 임의 쓰기로 이어지는 지도를 그릴 수 있다
  • [ ] 미션: admin_flag를 poisoning으로 1로 만들고 노트를 정리했다

6. 흔한 실수와 해결

벽 1. malloc(): unaligned tcache chunk detected로 죽는다

증상: poisoning 후 두 번째 malloc에서 이 메시지와 함께 중단됩니다.
원인: 표적 주소가 16바이트 정렬이 아닙니다. glibc는 tcache에서 꺼내는 주소의 정렬을 검사합니다.
해결: 오늘의 target이 __attribute__((aligned(16)))인 이유입니다. 표적이 정렬된 주소인지 먼저 확인하세요 — 실전에서도 정렬이 공격 성패를 가릅니다.

벽 2. 저장값을 안 거치고 *(size_t *)b = target;으로 덮었다

증상: 두 번째 malloc이 엉뚱한 주소를 주거나 Segmentation fault가 납니다.
원인: safe-linking을 우회하지 않은 것입니다. 난독화된 값을 읽어야 하는 자리에 생주소를 쓰면, 복원 과정에서 쓰레기 주소가 됩니다.
해결: PROTECT(청크주소, 표적) 계산을 거치세요. 이 환경(glibc 2.39)에서는 필수이며, 그것이 2-3의 방어 그 자체입니다.

벽 3. 인터넷의 예제 코드가 그대로 안 된다

증상: 유명한 how2heap의 tcache 예제가 최신 환경에서 실패합니다.
원인: 정상입니다. 그 예제들은 safe-linking 이전(glibc 2.31 이하)을 가정합니다. glibc 버전에 따라 되는 공격과 안 되는 공격이 갈립니다.
해결: ldd --version으로 버전을 확인하고, 버전에 맞는 변형(오늘의 PROTECT 계산 같은)을 적용하세요. "예제가 안 된다"가 아니라 "방어가 작동하고 있다"가 정확한 해석입니다.

벽 4. free 직후 읽은 값이 주소가 아니라 이상한 수다

증상: 3-2의 0x600f2941ef97 같은 값을 보고 "주소가 깨졌다"고 생각합니다.
원인: 그것이 난독화된 정상 값입니다. safe-linking 전에는 0x600929d372a0이 그대로 보였을 것입니다.
해결: REVEAL(청크주소, 저장값)으로 복원해 보세요. 진짜 next가 나옵니다 — 오늘 실측에서 a의 주소가 정확히 나왔듯이요.

벽 5. "이걸 실제 서버에도 되는 거 아닌가"라는 과신

증상: 재현이 되니 어디든 될 것 같은 기분이 듭니다.
원인: 오늘의 재현은 자기 프로그램 안에서 주소를 전부 아는 상태였습니다. 실전은 ASLR·힙 주소 불명·각종 탐지 속에서 leak부터 쌓아 올려야 하는 훨씬 긴 싸움입니다.
해결: 오늘 배운 것은 "구조의 이해"입니다. 실전 익스플로잇은 이 구조 위에 leak과 우회를 겹친 것이고, 그 조립은 Step 214 이후의 긴 여정입니다. 그리고 그 실험은 반드시 합법 플랫폼에서만.


7. 정리

오늘의 개념

개념 한 줄 설명
tcache 연결 리스트 반납된 청크가 next 포인터로 이어진 대기열 — 장부가 곧 빈 청크
tcache poisoning next를 덮어 malloc이 임의 주소를 반환하게 만드는 공격
임의 주소 쓰기 poisoning의 결실 — Pwn의 만능 열쇠, GOT 덮기 등으로 이어짐
safe-linking next를 (청크주소>>12)로 XOR 난독화하는 glibc 2.32+ 방어
tcache key 검사 double free를 탐지해 중단시키는 방어
leak 난독화 시대의 전제 조건 — 힙 주소를 먼저 읽어 내는 단계

오늘의 코드

코드 하는 일
*(size_t *)freed_chunk 반납된 청크의 next(난독화된 값) 읽기
PROTECT(pos, ptr) safe-linking 저장값 계산 — (pos>>12) ^ ptr
REVEAL(pos, v) 저장값 복원 — 같은 식 (XOR은 자기 역연산)
__attribute__((aligned(16))) 표적 변수의 정렬 보장 (tcache 정렬 검사 통과용)
ldd --version glibc 버전 확인 — 어떤 방어가 있는지의 기준선

명령어보다 중요한 감각

오늘 공격의 뼈대는 한 문장입니다 — 할당자는 대기열의 next를 검증 없이 신뢰한다. 장부가 빈 청크 안에 있고, 그곳에 쓸 수 있으면, 할당자는 우리가 적은 주소를 "새 땅"으로 내줍니다. 효율을 위한 신뢰가 공격 표면이 되는 구조는 Step 210의 GOT와 정확히 닮았습니다.

그리고 방어의 역설도 봤습니다. safe-linking은 공격을 없애지 않고 전제(힙 주소)를 하나 더 요구했을 뿐입니다. 보안이란 공격을 불가능하게 만드는 것이 아니라 더 어렵고 더 시끄럽게 만드는 것 — Level 3의 마지막 구간에서 계속 만날 진실입니다.


전부 체크되면 Step 212 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.