Step 211. 힙 입문: 할당자 동작, use-after-free 개념 — 반납된 땅의 재사용

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는 땅만 반납하고 포인터 변수를 비우지 않습니다. 여기에 재사용이 겹치면 시나리오가 완성됩니다:

  1. 프로그램이 객체 A를 할당하고 포인터 p에 보관
  2. A를 반납 — 하지만 p는 살아 있음 (댕글링)
  3. 공격자가 같은 크기로 새 할당 → 재사용 규칙에 따라 A의 자리에 들어감
  4. 공격자 데이터를 그 자리에 씀
  5. 프로그램이 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의 시나리오가 다섯 단계 그대로입니다.

  1. 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를 바꿔 두 실험을 하고 결과를 노트에 정리하세요:

  1. 크기를 다르게: evil을 malloc(sizeof(Account))가 아니라 더 큰 크기(예: malloc(0x100))로 할당하면 어떤 주소가 나오나요? acc의 자리가 재사용되나요?
  2. 순서를 바꿔: free 후 malloc 없이 바로 acc를 읽으면 어떤 값이 보이나요? (힌트: 헤더를 빌려 쓰는 대기열의 흔적 — Step 61의 "쓰레기 값"과 비교)
  3. 두 결과로 "재사용의 조건"을 한 문장으로 정리하세요.

연습문제

문제 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. 모범 답안과 완료 기준

미션 모범 답안

  1. 크기를 다르게 하면: evil이 acc의 자리가 아닌 새 주소를 받습니다. tcache는 크기별로 칸이 나뉘어, 0x100 요청은 0x20 청크 대기열을 보지 않기 때문입니다. acc로 읽는 값은 바뀌지 않은 흔적(대기열이 쓴 내부 값)입니다.
  2. 재할당 없이 바로 읽으면: name 자리에 낯선 큰 정수의 흔적이 보입니다 — 반납된 청크의 앞부분을 대기열이 "다음 빈 청크 주소(next 포인터)" 장부로 쓰기 때문입니다. 이 장부가 Step 212(tcache poisoning)의 공격 대상입니다.
  3. 정리 예: "재사용은 같은 크기의 대기열에 방금 반납된 청크가 있을 때 일어나며, 크기가 다르면 다른 땅을 받는다."

검증하는 법: ① 두 실험의 주소를 출력으로 비교했는가, ② 한 문장 정리에 "같은 크기"라는 조건이 들어 있는가.

연습문제 해답

문제 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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.