[Exploit Tech Analysis][Heap] Unsafe Unlink / Safe Unlink
heap에 대한 기초 지식이 필요합니다.
(chunk, bin, tcache, consolidation 등)
heap을 공부하다 보면 필연적으로 bin의 개념을 마주칠 수밖에 없다. 여기서 중요한 것은 fastbin
을 제외한 모든 bin은 double linked list로 관리된다는 것이다1.
이로 인해 리스트에 변동이 생기면 unlink가 발생할 수밖에 없는데, 살펴볼 unsafe unlink / safe unlink는 청크가 병합(consolidation)되는 과정에서 일어나는
unlink를 악용하여 임의 주소 쓰기가 가능하도록 한다.
1. Precondition
- 힙을 관리하는 변수의 주소를 알고 있어야 한다.
이런 특징으로 PIE가 걸려 있지 않은 바이너리에서 전역 변수로 힙 영역의 메모리가 관리될 때 유용하게 사용할 수 있다. - Heap Overflow 취약점이 존재해야 한다.
이를 통해 청크의 헤더를 조작해야 하기 때문이다.
2. Unlink 과정
일반적으로 unlink는 다음과 같이 이루어진다. 예시를 위해 unlink 대상인 원소를 P
라 하자.
원소 P
의 다음 원소는 P->fd
이며, 원소 P
의 이전 원소는 P->bk
이다.
FD
라는 변수에P->fd
를 저장한다.BK
라는 변수에P->bk
를 저장한다.FD->bk
(=P->fd->bk
)를BK
(=P->bk
)로 설정한다.BK->fd
(=P->bk->fd
)를FD
(=P->fd
)로 설정한다.
과정별로 그림을 그려 보면 다음과 같다. (양방향 화살표 ⟷가 단방향 화살표 ↤↦로 바뀌는 것에 주목하자)

앞으로 계속 쓰이니 흐름을 확실하게 직접 따라가보고 이해해야 한다.
3. Unsafe Unlink
3-1. 공격 상황
다음과 같은 heap 상태가 주어졌다고 하자.

이때 chunk 0에 대한 heap overflow 취약점이 발생해, 공격자가 다음과 같은 상황을 만들 수 있다고 하자.
- 해제된 청크의 모습을 닮은 fake chunk를 구성할 수 있음
- fd, bk는 쓰고 싶은 임의 주소 값을 넣음
- chunk 1의
prev_size
를 구성한 fake chunk의 크기로 맞춤 - size 필드의 P(prev_in_use) 비트를 0으로 만듦 그림으로 표현하면 다음과 같다.

3-2. 공격 과정
위와 같은 상황이 갖춰졌을 때 chunk 1
을 해제하면, free()
는 해제 대상 청크의 P
(prev_in_use) flag가 0인 것을 확인한다. 그렇다면 이전 청크와 병합이 가능하다는 뜻이므로,
free()
함수는 다음과 같이 병합을 시도한다.
- 현재 청크의
prev_size
값을 읽어,current_chunk - prev_size
에 있는 청크를P
청크로 설정한다. (여기서P
는 flag가 아니다!) P
를 대상으로 위에서 봤던 unlink 작업을 그대로 수행한다.
위 예시에서 공격자가 prev_size
값을 미리 fake chunk의 크기로 맞춰두었기 때문에, P
는 공격자가 구성한 fake chunk가 된다2. 이때 fd
와 bk
는 공격자가
임의로 설정할 수 있는 주소이기 때문에, AAW 프리미티브로 사용할 수 있다.
4. Safe Unlink
4-1. Mitigation
예전의 glibc-2.13 이전에는 아무 검증 없이 이 과정을 수행했기 때문에, 위에서처럼 해제하고자 하는 청크(P)의 fd와 bk를 잘 조작해주면 임의 주소에 연결해 쓰는 것이 가능했다. 그러나 glibc-2.13 이후 버전에서 처음으로 이에 대한 mitigation이 등장했고, glibc-2.3에서 더욱 엄격한 검증이 이루어지도록 바뀌었다. 주요 변경점은 다음과 같다.
// Def. in /malloc/malloc.c, line 1608 (@glibc-2.39)
static void
unlink_chunk (mstate av, mchunkptr p)
{
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");
...
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");
...
}
다음과 같은 2개의 조건을 동시에 만족해야 함을 볼 수 있다.
- 대상 청크(p)의 크기가 다음 청크의 prev_size와 같아야 한다.
fd->bk = p
이며 동시에bk->fd=p
여야 한다.
두 조건은 조금만 생각해 보면 정상적인 상황에서는 당연한 조건들이다.
4-2. Bypass 1st condition
첫 번째 조건을 테스트할 때 사용하는 next_chunk
매크로는 다음과 같이 정의되어 있다.
// Def. in /malloc/malloc.c, line 1396 (@glibc-2.39)
/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr) (((char *) (p)) + chunksize (p)))
이로부터 현재 청크의 주소에 현재 청크의 사이즈를 더한 주소를 구하는 매크로임을 알 수 있다. 그러나 만약 fake chunk의 크기를 악의적으로 0
으로 설정한다면,
이 매크로의 결과는 항상 자기 자신을 가리키게 된다. fake_chunk + 0 = fake_chunk
이니 당연한 논리이다. 이렇게 첫 번째 조건은 fake chunk를 구성할 때
size
와 prev_size
모두 0으로 설정하므로써 우회할 수 있다.
4-3. Bypass 2nd condition
두 번째 조건을 최대한 우회하기 위해 힙 영역의 메모리가 관리되는 주소를 알아야 한다. 결론적으로 말하면 fd
에는 우리가 원하는 주소에서 0x18
을 뺀 값,
bk
에는 우리가 원하는 주소에서 0x10
을 뺀 주소를 넣으면 된다. 이에 대한 설명은 직접 unlink 과정을 따라가면서 봐야 한다.
4-4. 공격 과정
우선 다음과 같이 청크가 주어져 있다고 하자. 0x18
주소에 쓰는 것이 목표인 상황이고, 0x18
은 보라색 청크의 주소를 가리키고 있는 상태이다.

이제 fake chunk를 만들고, 파란색 청크의 prev_size
와 P
flag를 조작한다.
- 만든 가상 청크의
prev_size
,size
는 전부 0 - 파란색 청크의
prev_size
값은 fake chunk의 사이즈 - 파란색 청크의
P
flag는 0 - fd에는 [목표 주소 - 24]
- bk에는 [목표 주소 - 16]

마지막으로 파란색 청크를 해제하면 위에서 말한 조건 검사를 시작한다. 위에서 말했듯
chunksize(P) == prev_size (next_chunk(P))
조건부터 보면, next_chunk(P) = fake + 0x0
이므로 자기 자신이고, 이것의 prev_size
는 0으로 미리 설정해 뒀다. 마찬가지로 자신의 size
역시 0으로 설정해 두었기 때문에 조건을 만족한다.
두 번쨰로 FD->bk == P && BK->fd == P
조건을 만족해야 한다. 이때 이 상황을 그림으로 표현해보면 이해가 쉽다.

p->fd
가 가리키는 주소인 0x0
으로부터 청크를 그려 보면 노란색으로 표시해 둔 것처럼 target
이 있는 자리가 원래 bk
가 있는 자리임을 알 수 있고, 위에서
target
은 이미 보라색 청크를 가리키고 있는 상태라 말했다. 따라서 FD->bk == P
를 만족한다. BK->fd == P
도 마찬가지이므로 조금만 생각해 보면 쉽게
이 조건도 만족함을 알 수 있다.
따라서 두 조건 모두 만족했으므로 다음과 같이 unlink가 진행되고, 결과적으로 target
에는 0x0
이 써진다.


이제 target
에 접근해 쓰거나 읽으면 결과적으로 0x0에 쓰는 것이 된다. 굉장히 제약이 많은 AAW가 가능하나 만약 청크가 배열로 관리된다면 굉장히 강력해진다.
이를 어떻게 활용하는지는 다음에 다뤄 볼 예정이다.
5. Safer(?) Unlink
2024년 1월에 릴리즈된 glibc-2.39 버전부터는 원래 _int_free()
→ unlink_chunk()
로 이어지던 흐름이
_int_free()
→ _int_free_merge_chunk()
→ unlink_chunk()
로 바뀌며 새로운 검사가 하나 더 추가되었다. 추가된 함수를 간단히 살펴보면
static void
_int_free_merge_chunk (mstate av, mchunkptr p, INTERNAL_SIZE_T size)
{
...
/* Consolidate backward. */
if (!prev_inuse(p)) // (1)
{
INTERNAL_SIZE_T prevsize = prev_size (p); // (2)
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize)); // (3)
if (__glibc_unlikely (chunksize(p) != prevsize)) // (4)
malloc_printerr ("corrupted size vs. prev_size while consolidating");
unlink_chunk (av, p);
}
...
}
(1) 지금은 p가 파란색 청크(free된 청크)이다. P
flag를 검사한다.
(2) 파란색 청크의 prev_size
를 prevsize
에 저장한다.
(3) 여기서 p가 가리키는 대상이 fake chunk로 바뀐다.
(4) 조건을 검사한다.
위와 같이 동작함을 알 수 있다. 풀어 설명하면 추가된 조건인 chunksize(p) == prevsize
는
를 검사하는 조건이다. 만약 기존처럼 fake chunk의 size
에 0을 써 두면 이 조건에서 걸리게 된다.

그러나 이는 생각보다 간단히 우회가 가능하다. 위 그림과 같이 fake chunk의 size
를 실제 크기로 맞춰주면 된다. 이렇게 하면 새로 추가된 조건과 기존 조건을
모두 통과할 수 있다. 새로 추가된 조건을 통과하게 되는 것은 당연하고, 기존 조건이 왜 통과되는지를 조금 생각해 보면
fake chunk + size
값이 이제 자기 자신이 아니라 파란색 청크를 가리킴- 파란색 청크의
prev_size
도 결국 fake chunk의 사이즈로 설정해 두었으므로 두 값이 같음
이므로 둘 다 조건을 만족하는 것을 알 수 있다.
Leave a comment