Unsafe Unlink
Unsafe Unlink?
unsafe, unlink는 헤더 값이 조작된 fake chunk와 다른 인접한 청크가 병합되면서 비정상적으로 unlink되어 발생하는 취약점이다.
1. 공격 조건
- 헤더 조작 : free된 청크의 fd, bk 포인터를 원하는 주소로 덮어쓸 수 있어야 한다
- unlink : 조작된 청크와 인접한 청크를 free하여 병합을 유도하고 unlink가 호출되게 만든다.
2. unlink
fastbin을 제외한 나머지 bin들은 인접한 free chunk가 있다면 병합할 수 있다.
bin list 는 double linked list 구조로 되어 있기 때문에 병합이 일어날 경우 fd, bk 포인터를 정리해주는 작업이 필요하다.
이 작업을 unlink라고 한다.

위와 같은 상태에서 아래의 연산을 수행하며 unlink가 진행된다.
- BK→fd = P→fd
- FD→bk = P→bk

3. glibc 2.23 unlink 확인
glibc 2.23의 unlink코드를 확인해보자
#define unlink(AV, P, BK, FD) { \\
FD = P->fd; \\
BK = P->bk; \\
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \\
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \\
else { \\
FD->bk = BK; \\
BK->fd = FD; \\
// ... (large bin을 위한 추가 로직) \\
} \\
}
첫 번째 if 문에서 다음과 같은 조건을 확인한다.
- P의 다음 청크(FD)의 bk 포인터가 P 자신을 가리키는 지 확인한다
- P의 이전 청크(BK)의 fd 포인터가 P 자신을 가리키는 지 확인한다.
→ 앞 뒤 청크의 fd와 bk 포인터를 확인하여 연결리스트의 무결성을 확인한다.
이 검증을 통과하면 FD->bk = BK; 와 BK->fd = FD; 연산이 수행되어 리스트에서 P를 제거하고 앞뒤 청크를 연결하는 unlink 동작을 하게 된다.
if (!in_smallbin_range (P->size) \\
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \\
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \\
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \\
malloc_printerr (check_action, \\
"corrupted double-linked list (not small)", \\
P, AV); \\
if (FD->fd_nextsize == NULL) { \\
if (P->fd_nextsize == P) \\
FD->fd_nextsize = FD->bk_nextsize = FD; \\
else { \\
FD->fd_nextsize = P->fd_nextsize; \\
FD->bk_nextsize = P->bk_nextsize; \\
P->fd_nextsize->bk_nextsize = FD; \\
P->bk_nextsize->fd_nextsize = FD; \\
} \\
} else { \\
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \\
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \\
} \\
} \\
smallbin이 아니고 nextsize가 존재할 경우 largebin처리 로직으로 넘어가게 된다.
fd_nextsize와 bk_nextsize도 마찬가지로 무결성을 확인한다.
공격 시나리오
1. chunk 2개 생성

fastbin 크기가 아닌 2개의 청크를 생성한다.
fastbin 크기가 아닌 이유?
fastbin에 속한 청크는 free되어도 인접 청크와 즉시 병합되지 않아 unlink가 호출되지 않기 때문이다.
2. fake chunk 생성

- A 청크에서 fake chunk를 만들어준다.
if문을 우회하기 위해서 값을 아래와 같이 설정한다.
- prve_size : 0
- size : 0
- fd : [원하는 주소] - 24
- bk : [원하는 주소] - 16
- 다음 chunk의 prev_size : fake chunk size
- 다음 chunk의 PREV_INUSE(P) bit : 0
3. chunk B 해제

- chunk B를 해제할 때 PREV_INUSE bit가 0이기 때문에 malloc은 B의 이전 청크(fake chunk)가 해제된 상태로 인식하게 된다.
- 인접한 fake chunk와 chunk B가 병합을 시도하기 위해 unlink가 호출된다.
4. 연결 리스트 무결성 검증
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \\
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \\
glibc 2.23의 무결성 검증 로직이다.
가짜 청크의 값을 확인해보자
- P→fd (FD) = [원하는 주소] - 24
- P→bk (BK) = [원하는 주소] - 16
1. FD->bk != P 우회
- P→fd(FD)를 [원하는 주소] - 24로 설정한다.
- FD→bk는 FD 주소에서 bk필드 오프셋 +24만큼 떨어져 있기 때문에 *(원하는 주소 - 24 + 24)가 되어 *(원하는 주소)가 된다.
2. BK→fd ≠ P 우회
- P→bk(BK)를 [원하는 주소] - 16으로 설정한다.
- BK→fd는 BK주소에서 fd필드 오프셋 +16만큼 떨어져 있기 때문에 *(원하는 주소 - 16 + 16)가 되어 *(원하는 주소)가 된다.
공격자는 미리 *[원하는 주소]의 값을 P(fake chunk 주소)와 같게 만들어 연결 리스트 무결성 검증 로직을 우회한다.
5. unlink 진행
if 문이 통과되면 unlink가 진행된다.\
1. FD→bk = BK
- FD→bk는 *(원하는 주소)를 의미한다.
- BK는 공격자가 설정한 [원하는 주소] - 16이다.
2. BK→fd = FD
- BK→fd는 *(원하는 주소)를 의미한다.
- FD는 공격자가 설정한 [원하는 주소] - 24이다.
unlink가 모두 끝난 후에 [원하는 주소]에 [원하는 주소] - 24 값이 남게 된다.
최종적으로 BK->fd = FD 연산에 의해 *[원하는 주소]에는 FD의 값, 즉 [원하는 주소] - 24이 쓰여 공격자는 임의 주소에 값을 쓸 수 있게 된다.
예제 코드
#include <stdio.h>
#include <stdlib.h>
#define ALLOC_SIZE 0x410
#define FAKE_FD_OFFSET 2
#define FAKE_BK_OFFSET 3
#define PREV_IN_USE_OFFSET -2
#define CHUNK_SIZE_OFFSET -1
long *ptr1;
long *ptr2;
long target = 0;
int main(void){
fprintf(stderr, "target : 0x%lx\\n", target);
ptr1 = malloc(ALLOC_SIZE);
ptr2 = malloc(ALLOC_SIZE);
ptr1[FAKE_FD_OFFSET] = (long)&ptr1 - sizeof(long)*3;
ptr1[FAKE_BK_OFFSET] = (long)&ptr1 - sizeof(long)*2;
ptr2[PREV_IN_USE_OFFSET] = ALLOC_SIZE;
ptr2[CHUNK_SIZE_OFFSET] &= ~1;
free(ptr2);
/*
Unlink is triggered.
ptr1->FAKE_BK->FD = FAKE_FD
now, ptr1 == (long)&ptr1 - sizeof(long)*3;
*/
// Arbitrary Write Primitive
ptr1[3] = (long)⌖
ptr1[0] = 0xdeadbeefcafebabe;
fprintf(stderr, "target : 0x%lx\\n", target);
}
1. 청크 할당 및 fake chunk 생성
ptr1 = malloc(ALLOC_SIZE);
ptr2 = malloc(ALLOC_SIZE);
ptr1[FAKE_FD_OFFSET] = (long)&ptr1 - sizeof(long)*3;
ptr1[FAKE_BK_OFFSET] = (long)&ptr1 - sizeof(long)*2;
- 0x410 크기의 청크 ptr1과 ptr2를 할당한다.
- 사용 중인 청크 ptr1의 데이터 영역에 가짜 청크 fd와 bk 포인터를 만든다.
- ptr1[2] (fake chunk fd) : &ptr1 - 24
- ptr1[3] (fake chunk bk) : &ptr1 - 16
- 여기서 P(fake chunk)는 &ptr1[2]가 위치한 주소, 즉 ptr1의 데이터 영역이 된다.

청크는 위와 같은 그림이 된다.
fake chunk의 헤더와 fd, bk 포인터를 조작했으니 ptr2의 헤더를 조작해야 한다.
gdb로 malloc 이후의 청크를 확인해보자

ptr1의 chunk이다.

ptr1의 주소는 0x601078이다.

fake chunk의 fd, bk에 ptr1 - 24, ptr1 - 16 의 값이 설정된 것을 볼 수 있다.
ptr2의 chunk이다.

2. ptr2 조작
ptr2[PREV_IN_USE_OFFSET] = ALLOC_SIZE;
ptr2[CHUNK_SIZE_OFFSET] &= ~1;
- ptr2의 헤더를 조작한다.
- ptr2[-2] (ptr2 prev_size) : ptr1청크의 크기인 0x410을 넣는다.
- ptr2[-1] (ptr2 size) : &=~1 연산으로 가장 마지막 비트인 PREV_INUSE 비트를 0으로 만든다
ptr2의 청크를 다시 확인해보자

prev_size는 0x410, size의 PREV_INUSE 비트가 0으로 설정된 것을 볼 수 있다.
3. unlink
free(ptr2);
- free 함수는 ptr2의 PREV_INUSE 비트가 0이기 때문에 prev_size를 읽고 이전 청크(ptr1)와 병합을 시도한다. → 병합을 위해 unlink를 호출한다.
- unlink는 ptr1의 fd와 bk를 읽지만 실제로는 ptr1의 데이터 영역에 만들어둔 fake chunk의 fd와 bk 값을 읽는다.
- unlink의 핵심 연산 BK→fd = FD 가 실행된다.
- BK→fd는 *(&ptr1)이 되고, FD는 &ptr1 - 24이다.
- 결과적으로 ptr1 = &ptr1 - 24가 실행된다.
free 이후의 heap을 살펴보자

ptr1을 병합하려고 시도했기 때문에 상태가 Freed라고 뜨는 것을 볼 수 있다.
ptr2는 잘못된 병합으로 인해 청크가 깨져 Corrupt ?! 라고 뜨는 것을 볼 수 있다.
ptr1의 청크를 확인해보자

size의 PREV_INUSE 비트가 1로 청크가 여전히 사용 중 상태인 것을 알 수 있다.
unlink 연산의 결과로 ptr1은 &ptr1 - 24 의 값을 가리키고 있다.
4. 포인터 조작
ptr1[3] = (long)⌖
ptr1[0] = 0xdeadbeefcafebabe;
- ptr1 의 현재 값은 &ptr1 - 24이다.
- ptr1[3]은 C언어에서 *(ptr1 + 3)과 같다.
→ ptr1[3]은 전역변수 ptr1 자체의 주소를 가리키게 된다.
ptr1 포인터로 target을 가리키도록 변경 후 ptr1[0] = 0xdeadbeefcafebabe 로 target의 값을 덮어씌운다.

최종적으로 실행해보면 target의 값이 0xdeadbeefcafebabe로 바뀐 것을 확인할 수 있다.
ptr1을 해제한다면?
unlink는 병합시에 발생한다. 하지만 ptr1은 첫 번째 청크이기 때문에 이전 청크가 없고 ptr2는 사용 중이기 때문에 병합이 일어나지 않는다.
→ ptr1 을 unsorted bin에 넣고 끝난다.
그럼 ptr2를 unlink 시키려면 어떻게 해야할까?
ptr3 를 새롭게 할당 후에 ptr2에 fake chunk를 생성하고 ptr3를 해제하여 unlink를 트리거 시키면 된다.
'Pwnable > Heap' 카테고리의 다른 글
| Unsorted bin Memory Leak (0) | 2026.01.20 |
|---|---|
| Unsorted bin attack (0) | 2026.01.19 |
| Fastbin Consolidate (1) | 2026.01.14 |
| Fastbin Duplicate (0) | 2026.01.13 |
| Heap - glibc 2.23 (3) (0) | 2026.01.13 |