Step 211. 힙 입문: 할당자 동작, use-after-free 개념 — 반납된 땅의 재사용
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 5시간
전제: Step 61(malloc과 free — 빌리기/반납/댕글링 포인터 기초), Step 209~210(Pwn 입문, GOT). gdb 기본 사용.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투(gcc, gdb). 실측 환경은 Ubuntu 24.04, gcc 13.3.0, glibc 2.39입니다.
- 주의: glibc 버전마다 힙 관리자의 세부 동작이 다릅니다. 오늘의 숫자(청크 크기, 재사용 순서)는 glibc 2.39 실측이며, 다른 버전에서는 다를 수 있습니다 — 개념은 같습니다.
Step 61에서 우리는 malloc/free를 "빌리고 반납하는 규칙"으로 배웠고, 댕글링 포인터도 일부러 만들어 봤습니다. 오늘은 한 겹 아래로 내려갑니다 — 반납된 메모리가 실제로 어디에, 어떤 모습으로 보관되고, 다음 malloc이 왜 그 자리를 다시 주는지. 이 재사용 메커니즘이 바로 use-after-free(UAF) 공격의 무대이고, 실제 CVE 목록의 단골입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 힙 청크(chunk) 의 구조(헤더 + 사용자 데이터)를 그리고 크기를 계산한다
- malloc 두 번의 주소 간격으로 청크 크기를 실측한다
- free → malloc 시 주소가 재사용되는 것과 그 순서(LIFO)를 관찰한다
- UAF 상황을 코드로 재현하고 "옛 포인터가 새 데이터를 읽는" 순간을 확인한다
- tcache/bin의 존재와 glibc 버전 의존성을 설명한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 언어, WSL 우분투 bash (실측: gcc 13.3.0, glibc 2.39, x86-64) |
| 오늘의 명령어·코드 | malloc/free, 포인터[-1](청크 헤더 들여다보기), gcc -Wall, gdb 주소 관찰 |
| 필요한 개념 | 청크 헤더, 정렬(16바이트), tcache/bin(재사용 대기열), LIFO 재사용, 댕글링 포인터, UAF |
| 오늘의 산출물 | 청크 구조 그림 + 재사용 관찰 로그 + UAF 재현 프로그램 |
2-1. 청크 — malloc이 실제로 주는 것
malloc(0x20)으로 32바이트를 빌리면, 할당자는 32바이트보다 큰 땅을 준비합니다. 사용자 데이터 앞에 헤더가 붙기 때문입니다:
실제 청크: [ 헤더 8~16바이트: 크기와 상태 ][ 사용자 데이터: 우리가 받는 부분 ]
↑ 우리가 받는 포인터는 헤더 "뒤"를 가리킨다
헤더에는 이 청크의 크기와 플래그가 적혀 있고, free는 이 헤더를 보고 "몇 바이트짜리 땅이 반납됐는가"를 압니다. 오늘 포인터[-1]로 이 헤더를 직접 들여다봅니다.
2-2. 재사용 대기열 — tcache와 bin
반납된 청크는 힙 관리자의 대기열에 들어갑니다. glibc의 대기열은 종류가 여러 개인데, 오늘 알아둘 것은 두 개뿐입니다:
- tcache(thread cache): 스레드별로 떠 있는 작은 캐시. 크기별 칸마다 최대 7개까지, 후입선출(LIFO — 나중에 반납된 것이 먼저 나감). 가장 먼저 확인되는 대기열이라 오늘의 재사용 실험은 전부 tcache에서 일어납니다.
- bin(fastbin, unsorted bin 등): tcache가 차면 가는 본창고. 세부는 Step 212 이후로 미뤄도 됩니다.
핵심은 하나 — 반납된 청크는 다음 malloc의 후보라는 것. 이 재사용이 성능의 원천이자, UAF 공격의 문입니다.
2-3. use-after-free — 옛 주인이 새 데이터를 읽는다
Step 61에서 봤듯 free는 땅만 반납하고 포인터 변수를 비우지 않습니다. 여기에 재사용이 겹치면 시나리오가 완성됩니다:
- 프로그램이 객체 A를 할당하고 포인터 p에 보관
- A를 반납 — 하지만 p는 살아 있음 (댕글링)
- 공격자가 같은 크기로 새 할당 → 재사용 규칙에 따라 A의 자리에 들어감
- 공격자 데이터를 그 자리에 씀
- 프로그램이 p로 읽음 → 공격자의 데이터를 A인 줄 알고 사용
객체에 함수 포인터나 권한 플래그가 있었다면 그대로 탈취됩니다. 오늘 이 5단계를 코드로 정확히 재현합니다.
3. 따라 하기
3-1. 청크의 실측 — 헤더와 간격
입력 (chunk.c)
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *a = malloc(0x20); /* 32바이트 요청 */
char *b = malloc(0x20);
printf("a = %p\n", (void *)a);
printf("b = %p\n", (void *)b);
printf("b - a = %#lx\n", (unsigned long)(b - a));
printf("a 바로 앞 8바이트(청크 헤더): %#lx\n", ((unsigned long *)a)[-1]);
printf("b 바로 앞 8바이트(청크 헤더): %#lx\n", ((unsigned long *)b)[-1]);
free(a);
free(b);
return 0;
}
컴파일과 실행
mkdir -p ~/lab209_213 && cd ~/lab209_213
gcc -Wall -o chunk chunk.c
./chunk
a = 0x5aebe35972a0
b = 0x5aebe35972d0
b - a = 0x30
a 바로 앞 8바이트(청크 헤더): 0x31
b 바로 앞 8바이트(청크 헤더): 0x31
(2026-09-09 실측. 주소는 실행마다 다릅니다.)
출력 읽는 법: 세 숫자가 오늘의 첫 발견입니다.
b - a = 0x30(48바이트): 32바이트를 빌렸는데 청크 간격은 48. 헤더와 정렬이 먹은 몫입니다.- 헤더 값
0x31: 청크 크기0x30에 맨 아래 비트의 플래그 1개가 붙은 것입니다 (바로 앞 청크가 사용 중이라는 표시, PREV_INUSE). 16바이트 정렬 덕에 아래 4비트가 항상 0이라 그 자리에 플래그를 끼워 넣는 트릭입니다. - 즉 32바이트 요청은 실제로 0x30바이트짜리 청크를 하나 차지합니다.
예측:
malloc(0x20)대신malloc(0x28)로 바꾸면 간격과 헤더가 어떻게 될까요? (힌트: 16바이트 정렬.) 직접 바꿔 확인해 보세요.
3-2. 반납과 재사용 — 대기열의 후입선출
입력 (reuse.c)
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *a = malloc(0x20);
char *b = malloc(0x20);
printf("a = %p, b = %p\n", (void *)a, (void *)b);
free(a);
free(b);
printf("free 순서: a 먼저, b 나중\n");
char *c = malloc(0x20);
char *d = malloc(0x20);
printf("c = %p (b 자리 재사용?)\n", (void *)c);
printf("d = %p (a 자리 재사용?)\n", (void *)d);
free(c); free(d);
return 0;
}
컴파일과 실행
gcc -Wall -o reuse reuse.c
./reuse
a = 0x610edddd22a0, b = 0x610edddd22d0
free 순서: a 먼저, b 나중
c = 0x610edddd22d0 (b 자리 재사용?)
d = 0x610edddd22a0 (a 자리 재사용?)
(2026-09-09 실측.)
출력 읽는 법: 반납 순서(a → b)와 정반대로 나왔습니다 — c는 b의 자리, d는 a의 자리. 나중에 반납된 것이 먼저 나가는 LIFO, 스택처럼 쌓이는 대기열이라는 뜻입니다. 이것이 tcache의 동작이고, "같은 크기로 빌리면 방금 반납한 자리를 받을 가능성이 크다"는 사실이 UAF 재현의 열쇠입니다.
3-3. UAF 재현 — 옛 포인터가 새 데이터를 읽는다
입력 (uaf.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[16];
int is_admin;
int pad;
} Account;
int main(void) {
Account *acc = malloc(sizeof(Account));
strcpy(acc->name, "guest");
acc->is_admin = 0;
printf("[1] 할당: %p, name=%s, is_admin=%d\n",
(void *)acc, acc->name, acc->is_admin);
free(acc); /* acc는 여전히 그 주소를 기억하는 댕글링 포인터 */
/* 같은 크기로 새 할당 → 방금 반납된 청크가 재사용됨 */
char *evil = malloc(sizeof(Account));
memset(evil, 0, sizeof(Account));
strcpy(evil, "hacker");
*(int *)(evil + 16) = 1; /* is_admin 자리에 1을 씀 */
printf("[2] 새 할당 evil: %p (같은 주소!)\n", (void *)evil);
/* 옛 포인터로 읽으면? */
printf("[3] 댕글링 포인터 acc로 읽기: name=%s, is_admin=%d\n",
acc->name, acc->is_admin);
if (acc->is_admin) {
printf("[!] 옛 객체가 관리자가 됐다 — 이것이 UAF 공격의 뼈대\n");
}
free(evil);
return 0;
}
컴파일과 실행
gcc -Wall -o uaf uaf.c
./uaf
uaf.c:30:12: warning: pointer 'acc' used after 'free' [-Wuse-after-free]
(같은 경고가 28, 29번 줄에도 — 컴파일러가 정확히 짚어 준다)
[1] 할당: 0x61812ce802a0, name=guest, is_admin=0
[2] 새 할당 evil: 0x61812ce802a0 (같은 주소!)
[3] 댕글링 포인터 acc로 읽기: name=hacker, is_admin=1
[!] 옛 객체가 관리자가 됐다 — 이것이 UAF 공격의 뼈대
(2026-09-09 실측.)
출력 읽는 법: 2-3의 시나리오가 다섯 단계 그대로입니다.
acc가 guest 객체를 가리킴 → 2. free → 3. 같은 크기의evil이 정확히 같은 주소(0x…2a0)에 들어옴 — 3-2의 재사용 규칙 그대로 → 4. evil이 그 자리에 "hacker"와 is_admin=1을 씀 → 5. 프로그램이 옛 포인터acc로 읽자 hacker, 관리자.
Step 61에서는 반납 후 읽기가 "쓰레기 값"이었는데, 오늘은 그 자리를 우리가 정한 값으로 채웠습니다. 쓰레기를 읽는 것이 버그라면, 내용을 지배하는 것이 공격입니다. 그리고 컴파일러의 -Wuse-after-free 경고 — gcc는 이 코드가 뭘 하는지 알고 있었습니다. 실전 코드에서 이 경고가 뜨면 공격 표면이 열린 것입니다.
3-4. gdb로 보는 재사용 — 관찰 습관
재사용이 "규칙"임을 한 번 더 확인하고 싶다면 gdb로 봅니다:
gdb -batch -ex "break main" -ex run -ex "info proc mappings" ./reuse 2>&1 | grep heap
실행 초반에는 [heap] 줄이 없다가, malloc 이후에 생깁니다 — 힙 구역 자체가 malloc에 의해 만들어지기 때문입니다. 디버거로 힙을 들여다보는 본격적인 방법(청크 나열, bin 보기)은 gef 같은 확장 도구의 영역이라, 이 환경에는 없습니다 — Step 212에서 개념으로 다시 만납니다. 오늘은 "주소를 출력해 비교하는" 방법만으로도 재사용을 증명할 수 있다는 것을 봤습니다.
4. 미션과 연습문제
미션 — 크기를 바꾼 UAF 실험
uaf.c를 바꿔 두 실험을 하고 결과를 노트에 정리하세요:
- 크기를 다르게: evil을
malloc(sizeof(Account))가 아니라 더 큰 크기(예:malloc(0x100))로 할당하면 어떤 주소가 나오나요? acc의 자리가 재사용되나요? - 순서를 바꿔: free 후 malloc 없이 바로 acc를 읽으면 어떤 값이 보이나요? (힌트: 헤더를 빌려 쓰는 대기열의 흔적 — Step 61의 "쓰레기 값"과 비교)
- 두 결과로 "재사용의 조건"을 한 문장으로 정리하세요.
연습문제
문제 1. malloc(0x20)의 청크 간격이 0x20이 아니라 0x30이었습니다. 차이 0x10은 어디에 쓰이나요? 헤더 값 0x31의 마지막 1은 무엇인가요?
문제 2. 3-2에서 c가 b의 자리를, d가 a의 자리를 받았습니다. 이 순서를 보고 tcache 대기열의 동작 방식을 한 단어로 말하고, 그것이 UAF 공격 계획에 왜 유리한가요?
문제 3. UAF 공격에서 "같은 크기로 할당"이 왜 중요한가요? 3-3에서 evil이 다른 주소에 들어갔다면 공격은 어떻게 됐을까요?
문제 4. 컴파일러가 -Wuse-after-free 경고를 줄 수 있는데도 실전 소프트웨어에서 UAF 취약점이 계속 나오는 이유는 무엇일까요? (힌트: 오늘 예시처럼 한 함수 안에서 보이는 경우만 경고가 가능할까요?)
5. 모범 답안과 완료 기준
미션 모범 답안
- 크기를 다르게 하면: evil이 acc의 자리가 아닌 새 주소를 받습니다. tcache는 크기별로 칸이 나뉘어, 0x100 요청은 0x20 청크 대기열을 보지 않기 때문입니다. acc로 읽는 값은 바뀌지 않은 흔적(대기열이 쓴 내부 값)입니다.
- 재할당 없이 바로 읽으면: name 자리에 낯선 큰 정수의 흔적이 보입니다 — 반납된 청크의 앞부분을 대기열이 "다음 빈 청크 주소(next 포인터)" 장부로 쓰기 때문입니다. 이 장부가 Step 212(tcache poisoning)의 공격 대상입니다.
- 정리 예: "재사용은 같은 크기의 대기열에 방금 반납된 청크가 있을 때 일어나며, 크기가 다르면 다른 땅을 받는다."
검증하는 법: ① 두 실험의 주소를 출력으로 비교했는가, ② 한 문장 정리에 "같은 크기"라는 조건이 들어 있는가.
연습문제 해답
문제 1 해답. 0x10(16바이트)은 헤더 8바이트와 16바이트 정렬 패딩에 쓰입니다. 헤더에는 청크 크기가 기록되고, 16바이트 정렬로 항상 0인 아래 비트들에는 플래그가 들어갑니다 — 0x31의 1은 PREV_INUSE, "앞 청크가 사용 중"이라는 표시입니다.
문제 2 해답. LIFO(후입선출)입니다. 공격 계획에 유리한 이유는 예측 가능성입니다 — "방금 free한 그 자리"를 다음 malloc이 주므로, 공격자는 재사용될 위치를 정확히 알고 데이터를 심을 수 있습니다.
문제 3 해답. 대기열이 크기별로 나뉘기 때문입니다. 같은 크기여야 방금 반납된 청크가 있는 칸에서 할당받습니다. evil이 다른 주소에 들어갔다면 acc는 여전히 반납된 자리의 흔적을 읽을 뿐, 공격자의 데이터는 아무 데도 못 갑니다 — 공격 실패입니다.
문제 4 해답. 컴파일러 경고는 한 함수 안에서 free와 사용이 가까이 보이는 단순한 경우에만 동작합니다. 실전의 UAF는 객체가 콜백·목록·다른 모듈로 흘러간 뒤 반납되고, 훨씬 나중에 다른 경로로 사용되는 형태라 정적 분석이 못 따라갑니다. 그래서 경고는 보조 수단이고, 설계(소유권 명확화, free 후 NULL)와 실행 중 검사 도구가 함께 필요합니다.
완료 기준 체크리스트
- [ ] 청크 구조(헤더 + 사용자 데이터)를 그림으로 그릴 수 있다
- [ ]
malloc(0x20)의 실제 청크 크기(0x30)와 헤더 값(0x31)의 의미를 설명할 수 있다 - [ ] free → malloc의 주소 재사용과 LIFO 순서를 실험으로 확인했다
- [ ] UAF 5단계(할당·반납·재할당·덮어쓰기·옛 포인터로 읽기)를 재현했다
- [ ]
-Wuse-after-free경고가 나오는 이유와 한계를 설명할 수 있다 - [ ] tcache가 크기별 칸으로 나뉜다는 사실과 glibc 버전 의존성을 안다
- [ ] 미션: 크기/순서를 바꾼 실험으로 재사용 조건을 정리했다
6. 흔한 실수와 해결
벽 1. 주소가 매번 달라져서 비교가 안 된다
증상: 실행할 때마다 a, b의 주소가 바뀝니다.
원인: ASLR과 PIE — 힙의 시작 주소가 매번 무작위입니다.
해결: 절대 주소는 외우지 말고 차이와 일치 여부만 보세요. 오늘의 발견은 "0x…2a0"이 아니라 "간격이 0x30"이고 "c와 b가 같다"는 관계입니다. 관계는 ASLR과 무관하게 항상 성립합니다.
벽 2. 포인터[-1]이 이상한 큰 수가 나온다
증상: 헤더를 읽었는데 0x31이 아니라 엉뚱한 값이 나옵니다.
원인: 캐스팅 실수입니다. ((unsigned long *)a)[-1]처럼 8바이트 단위로 캐스팅해야 합니다. a[-1](char 기준)로 읽으면 1바이트만 읽습니다.
해결: 캐스팅 형태를 통째로 외우세요. 그리고 이 들여다보기는 학습용입니다 — 실전 코드에서 헤더를 직접 읽거나 쓰는 것은 할당자와의 약속 위반입니다.
벽 3. free 후에도 값이 그대로라 "재사용이 안 됐다"고 생각한다
증상: free 직후 읽었는데 guest가 그대로 보입니다.
원인: 반납 직후에는 대기열 장부(next 포인터)만 청크 앞부분에 쓰이고, 나머지는 당분간 남습니다. "값이 보인다"와 "땅이 내 것이다"는 다릅니다.
해결: 3-2처럼 새 할당을 끼워 재사용을 확인하세요. UAF의 위험은 "당장 보인다/안 보인다"가 아니라 "언제든 바뀔 수 있다"에 있습니다.
벽 4. 같은 코드인데 다른 환경에서 다르게 동작한다
증상: 강의 자료(우분투 18.04 등)와 재사용 순서나 탐지가 다릅니다.
원인: glibc 버전 차이입니다. tcache는 2.26에 도입됐고, 2.32에서 next 포인터 난독화(safe-linking), 이후 버전에서 각종 탐지가 추가됐습니다. 이 환경은 2.39입니다.
해결: 개념(재사용, LIFO, UAF)은 버전을 넘어 같습니다. 숫자가 다르면 ldd --version으로 glibc 버전부터 확인하는 습관을 들이세요.
벽 5. UAF인데 컴파일 경고가 없다
증상: 분명 free 후 사용인데 -Wuse-after-free가 안 뜹니다.
원인: 경고는 단순한 경우에만 동작합니다 (벽 4의 연습문제 4번). 함수를 넘나들면 조용합니다.
해결: 경고가 없다고 안전이라고 생각하지 마세요. 반대로, 경고가 떴다면 그 코드는 이미 위험이 눈에 보이는 상태입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 청크(chunk) | 할당의 단위 — 헤더(크기+플래그) + 사용자 데이터 |
| 헤더의 플래그 비트 | 16바이트 정렬로 비는 아래 비트에 상태 표시 (0x31의 1 = PREV_INUSE) |
| tcache | 스레드별 재사용 캐시 — 크기별 칸, LIFO, 최대 7개 |
| 주소 재사용 | 같은 크기로 빌리면 방금 반납된 자리를 받는다 |
| UAF | 반납된 자리에 새 데이터가 들어온 뒤 옛 포인터가 그것을 쓰는 공격 |
| safe-linking 등 | glibc 버전마다 다른 할당자 방어 — 숫자는 버전을 확인 |
오늘의 명령어·코드
| 코드·명령 | 하는 일 |
|---|---|
((unsigned long *)p)[-1] |
청크 헤더 들여다보기 (학습용) |
b - a (포인터 뺄셈) |
청크 간격 실측 → 실제 청크 크기 |
gcc -Wall |
-Wuse-after-free 등 경고 활성화 |
gdb -ex "info proc mappings" |
힙 구역의 생성 확인 |
ldd --version |
glibc 버전 확인 — 힙 동작의 기준선 |
명령어보다 중요한 감각
오늘의 핵심 문장은 "반납된 땅은 사라지지 않고 대기열로 간다"입니다. 그리고 대기열은 규칙적입니다 — 같은 크기, LIFO. 공격이란 이 규칙성을 이용해 다음 할당의 위치를 예측하는 것이었습니다. 힙 공격이 어렵게 느껴지는 이유는 개념이 아니라 규칙의 세부가 버전마다 바뀌기 때문이고, 그래서 버전 확인이 첫걸음입니다.
하나 더 — 오늘 acc가 읽은 것은 쓰레기가 아니라 공격자의 데이터였습니다. Step 61에서 "반납 후 읽지 마라"를 배웠다면, 오늘은 "반납 후 읽히면 당한다"를 봤습니다. 그리고 그 대기열 장부(next 포인터)를 직접 덮으면 무슨 일이 벌어지는가가 tcache poisoning(Step 212)의 주제입니다 — 장부도 결국 메모리이고, 메모리는 덮입니다.
전부 체크되면 Step 211 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.