← 목록으로

힙 익스플로잇 기초: Use-After-Free와 Double Free

[Pwn] Pwnable · 작성: 2026-07-19 19:31:03 · 수정: 2026-07-21 23:21:58 · 조회 23

힙 익스플로잇(UAF/Double Free)을 "해제된 메모리를 다시 쓰면 위험하다"는 규칙으로만 알면, 왜 그게 위험한지를 설명 못 한다. 진짜 원인은 glibc의 메모리 할당자(ptmalloc)가 해제된 청크의 데이터 영역 자체를 "다음 할당을 위한 관리 정보(free list)"로 재활용한다는, 성능을 위한 설계 결정에 있다.

커널/할당자 레벨: free()된 메모리에 남는 것

malloc()으로 받은 메모리 블록(청크, chunk)은 유저가 보는 데이터 영역 앞에 청크 헤더(크기, 이전 청크가 사용 중인지 여부 등의 메타데이터)가 붙어있다. free(ptr)를 호출하면, glibc는 그 메모리를 OS에 즉시 반납하지 않고 "이 크기의 청크가 나중에 다시 필요할 때 빠르게 재사용할 수 있도록" 같은 크기의 청크들을 모아두는 **연결 리스트(bin, 특히 작은 크기는 tcache/fastbin)**에 등록한다. 그런데 이 연결 리스트의 "다음 노드를 가리키는 포인터(fd, forward pointer)"를 어디에 저장할까? — 놀랍게도 이미 해제되어 "안 쓰는" 유저 데이터 영역의 맨 앞 8바이트를 그대로 재활용한다. 즉 free()된 직후의 청크는, 유저 입장에서는 "다 쓴 메모리"지만 할당자 입장에서는 그 안에 다음 가용 청크를 가리키는 살아있는 포인터가 들어있는 상태다.

Use-After-Free: "다 쓴 포인터"가 실제로는 살아있는 데이터를 가리킬 때

struct user *u = malloc(sizeof(struct user));
u->is_admin = 0;
free(u);
// ... 코드 다른 어딘가에서 u를 또 쓸 수 있다면 (버그) ...
u->is_admin = 1;   // 이미 free된 메모리에 씀 — UAF

free(u) 이후에도 u라는 포인터 변수 자체는 여전히 예전 주소값을 들고 있다(이게 댕글링 포인터다). 만약 이 시점 이후 프로그램 로직이 실수로 u를 다시 참조하면, 그 메모리가 그 사이에 다른 malloc() 호출로 재할당되어 완전히 다른 객체가 들어있을 수도 있다. 공격자가 이 타이밍을 노려 free된 직후, 같은 크기로 자신이 원하는 데이터를 채운 새 객체를 할당시키면(Heap Grooming), 원래 코드가 u->is_admin을 통해 접근하려던 위치가 실제로는 공격자가 채워 넣은 임의 데이터가 되어버린다 — 타입이 다른 두 객체가 같은 메모리 주소를 서로 다른 의미로 해석하게 만드는 것, 이게 UAF의 본질이다.

Double Free: 같은 청크가 리스트에 두 번 등록될 때

free(ptr);
free(ptr);   // 같은 포인터를 두 번 해제

앞서 설명한 free list(tcache) 구조상, ptr을 한 번 더 free하면 같은 청크가 free list에 중복으로 등록된다. 이후 malloc()을 두 번 호출하면, 첫 번째 malloc()이 이 청크를 반환하면서 그 유저 데이터 영역(=free list의 fd 포인터가 저장된 자리)을 공격자가 자유롭게 덮어쓸 수 있게 된다 — 그 자리에 공격자가 원하는 임의 주소를 써넣으면, 그게 다음 malloc() 호출 시 free list의 "다음 가용 청크 주소"로 그대로 신뢰되어, 다음 malloc()이 공격자가 지정한 임의 주소를 그대로 반환한다. 이게 바로 Fake Chunk를 이용한 임의 주소 쓰기(Arbitrary Write) 원시 능력으로 확장되는 지점이다 — GOT 엔트리 주소를 이 방식으로 "할당받아서" 그 자리에 원하는 함수 포인터를 직접 써넣으면 실행 흐름을 통째로 하이재킹할 수 있다.

페이로드가 실제로 작동하는 순서 (tcache poisoning)

  1. 같은 크기의 청크 두 개를 할당(chunk_a, chunk_b)하고 둘 다 free() — tcache에 둘 다 등록됨
  2. UAF나 Double Free로, tcache에 남아있는 chunk_bfd 포인터(원래는 chunk_a를 가리켜야 함)를 덮어써서 임의의 대상 주소(GOT 엔트리, 함수 포인터가 저장된 변수 등)를 가리키게 조작
  3. malloc()을 다시 호출 → tcache가 chunk_b를 반환
  4. malloc()을 한 번 더 호출 → tcache는 방금 조작해둔 fd가 가리키는 임의의 대상 주소를 그대로 "새 청크"로 반환
  5. 이 반환된 포인터에 원하는 값을 쓰면(*ptr = system_addr), 대상 주소(예: GOT 엔트리)에 임의 값이 그대로 써짐 — 이후 그 함수가 호출되는 순간 실행 흐름 하이재킹

여기서 끝나지 않는다: 체이닝

힙 익스플로잇을 "메모리를 잘못 관리하는 버그"가 아니라 "할당자가 성능을 위해 해제된 유저 데이터 영역을 관리 메타데이터로 재활용한다"는 설계와, 애플리케이션의 포인터 생명주기 관리 실수가 만나는 지점으로 이해하면, 왜 이 취약점 클래스가 스택 버퍼 오버플로우보다 훨씬 다루기 어렵지만 동시에 훨씬 강력한 임의 읽기/쓰기 원시 능력으로 이어지는지가 같은 논리로 설명된다.

Pwnable 카테고리의 글 (8/14)

  1. 메모리 구조 기초: 스택, 힙, 레지스터부터 이해하기
  2. 스택 버퍼 오버플로우: 가장 기본적인 메모리 손상 공격
  3. Format String 취약점: printf가 위험해지는 순간
  4. Stack Canary: 원리와 우회 방법
  5. NX/DEP 우회와 Return-to-libc
  6. ASLR과 정보 유출(Leak)
  7. ROP (Return-Oriented Programming) 완전 정복
  8. 힙 익스플로잇 기초: Use-After-Free와 Double Free
  9. pwntools 실전 활용법
  10. UDF(User-Defined Function) 기반 DB 권한 상승: 원리부터 실전까지
  11. Pass-the-Hash / Pass-the-Ticket: 원리와 실전
  12. eBPF 기반 지속성(Persistence) 기법
  13. Full RELRO 환경에서의 GOT Overwrite와 우회
  14. ret2dlresolve: 심볼 정보 없이 임의 함수 호출하기
← ROP (Return-Oriented Programming) 완전 정복 pwntools 실전 활용법 →