Home › Compilers & runtimes › Interpreters & JITs
algorithm low

Executable memory under W^X

Problem A program that generates machine code at run time must put it into memory the CPU will execute, while the operating system forbids any page from being writable and executable at once. Without a protocol the code is either rejected or exposes a writable code page.

How JITs and code generators emit machine code under W^X: RW to RX flips, dual mappings, per-thread write toggles, and the instruction-cache flush.

emit = O(code bytes) copy + 1 protection change + 1 I-cache flush per batch; toggle cost grows with threads (TLB shootdown)
jitmprotectmmapicachememory-protectioncode-emission
Overview

A JIT must write bytes and then execute them, but hardened systems refuse pages that are writable and executable at once (W^X). So write while the page is RW, switch to RX, run, and flip back only to patch. Variants hide the flip behind two mappings of the same memory or a cheap per-thread switch; on ARM the instruction cache needs an explicit flush after the write.

Key facts

Use when

  • Any run-time code generator: JIT compilers (language VMs, regex and query engines), WebAssembly runtimes, binary translators, dynamic patching.

Avoid when

  • An interpreter or ahead-of-time compilation is fast enough: no executable memory means a smaller attack surface and no entitlement.

Deep dive

How it works

In: machine-code bytes, host page size · Out: a function pointer into a page that is executable and not writable · State: the code region (page-aligned arena, per-thread write state on macOS) · Cost: O(n) copy + one protection change + one I-cache flush; the flip is about 0.5-0.9 µs per mprotect pair on one thread here (measured), more with many threads

  1. Reserve whole pages with mmap (anonymous, private). Policy decides the form: Linux and OpenBSD allow RW then mprotect, macOS on Apple Silicon needs MAP_JIT.
  2. Make the region writable: mprotect(RW), or on macOS pthread_jit_write_protect_np(0) (affects only the calling thread).
  3. Copy or emit the code; patch relocations and call targets here.
  4. Make it executable and not writable: mprotect(RX), or pthread_jit_write_protect_np(1).
  5. On AArch64 flush: sys_icache_invalidate(start, len) on macOS, __builtin___clear_cache(begin, end) (GCC/Clang) on Linux. x86-64 needs nothing.
  6. Cast the address to a function pointer and call it. Repatching (inline caches, deoptimization) repeats steps 2-5 on the touched pages.

Dual mapping avoids flipping: create a shared memory object (memfd_create on Linux, shm_open elsewhere), map it twice, once RW at address A (writer only, ideally unmapped or private) and once RX at address B (what runs). Writes through A become visible through B; B is never writable. Relative branches inside the code assume B, so emit for B's addresses.

Per-thread toggles make the switch a register write, not a page-table change. Apple's pthread_jit_write_protect_np documents this: with protection enabled the thread cannot write the MAP_JIT region and may execute it; disabled, it can write and not execute. Other threads keep executing meanwhile. Intel PKU (pkey_mprotect, WRPKRU) gives a per-thread write gate on x86, but the kernel documents that protection keys apply to data accesses only, not instruction fetch, so it can deny writes but cannot grant execute; arm64 protection keys (POR_EL0) do cover instruction fetch.

Code

Emits fn(x) = x + 42 for the host and calls it. Compiled and run on Apple M4 Pro (arm64 macOS, unsigned binary). The Linux x86-64 branch is written to the man pages but was not run here.

use std::ffi::c_void;

extern "C" {
    fn mmap(addr: *mut c_void, len: usize, prot: i32, flags: i32, fd: i32, off: i64) -> *mut c_void;
    fn munmap(addr: *mut c_void, len: usize) -> i32;
}
const PROT_READ: i32 = 1;
const PROT_WRITE: i32 = 2;
const PROT_EXEC: i32 = 4;
const MAP_PRIVATE: i32 = 0x2;
#[cfg(target_os = "macos")]
const MAP_ANON: i32 = 0x1000;
#[cfg(target_os = "linux")]
const MAP_ANON: i32 = 0x20;
#[cfg(target_os = "macos")]
const MAP_JIT: i32 = 0x800;

#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
extern "C" {
    fn pthread_jit_write_protect_np(enabled: i32); // per-thread: 0 = this thread may write
    fn sys_icache_invalidate(start: *mut c_void, len: usize);
}
#[cfg(target_os = "linux")]
extern "C" {
    fn mprotect(addr: *mut c_void, len: usize, prot: i32) -> i32;
}

// fn(x: u64) -> u64 { x + 42 }
#[cfg(target_arch = "aarch64")]
const CODE: [u8; 8] = [0x00, 0xA8, 0x00, 0x91, 0xC0, 0x03, 0x5F, 0xD6]; // add x0,x0,#42 ; ret
#[cfg(target_arch = "x86_64")]
const CODE: [u8; 5] = [0x48, 0x8D, 0x47, 0x2A, 0xC3]; // lea rax,[rdi+42] ; ret

const PAGE: usize = 16384; // 16 KiB on Apple Silicon; 4 KiB pages on x86-64 Linux, a multiple is fine

/// Returns a page holding CODE, executable and never writable+executable at once
/// (on macOS the W^X switch is per thread, via the MAP_JIT mapping).
unsafe fn emit() -> *mut u8 {
    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
    {
        let p = mmap(std::ptr::null_mut(), PAGE, PROT_READ | PROT_WRITE | PROT_EXEC,
                     MAP_PRIVATE | MAP_ANON | MAP_JIT, -1, 0);
        assert!(p as isize != -1, "mmap MAP_JIT failed");
        pthread_jit_write_protect_np(0);                 // this thread: RW view
        std::ptr::copy_nonoverlapping(CODE.as_ptr(), p as *mut u8, CODE.len());
        pthread_jit_write_protect_np(1);                 // this thread: RX view
        sys_icache_invalidate(p, CODE.len());            // I-cache is not coherent with stores
        p as *mut u8
    }
    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
    {
        let p = mmap(std::ptr::null_mut(), PAGE, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANON, -1, 0);
        assert!(p as isize != -1, "mmap failed");
        std::ptr::copy_nonoverlapping(CODE.as_ptr(), p as *mut u8, CODE.len());
        // RW -> RX in one call; x86 keeps I-cache coherent with stores, no flush needed
        assert_eq!(mprotect(p, PAGE, PROT_READ | PROT_EXEC), 0, "mprotect failed");
        p as *mut u8
    }
}

fn main() {
    unsafe {
        let p = emit();
        let f: extern "C" fn(u64) -> u64 = std::mem::transmute(p);
        assert_eq!(f(0), 42);
        assert_eq!(f(100), 142);
        println!("ok: f(100) = {}", f(100));
        munmap(p as *mut c_void, PAGE);
    }
}

Under the hardened runtime a signed macOS binary also needs the com.apple.security.cs.allow-jit entitlement for MAP_JIT.

Complexity and constants

  • Emit: O(n) copy, one protection change, one I-cache flush per batch.
  • Measured here (Apple M4 Pro, macOS, 16 KiB pages, one thread, 200k iterations): pthread_jit_write_protect_np(0)+(1) pair 24-40 ns; sys_icache_invalidate on 64 bytes about 100 ns; mprotect RW to RX to RW on one page 0.5-0.9 µs per pair.
  • Cost drivers: syscalls (one per mprotect), page-table edits, TLB invalidation on every thread that ran the process, I-cache flush by cache line, and the rounding of the region to whole pages (16 KiB on Apple Silicon, 4 KiB usual on x86-64 Linux).

Efficiency

  • Cost: per code buffer, one RW/RX flip plus one flush. Measured on M4 Pro: flip by thread toggle 24-40 ns per pair, by mprotect 0.5-0.9 µs per pair on one thread with one page; single-thread numbers understate multi-thread cost, which was not measured. Bound: syscall and TLB latency, not copying.
  • Levers:
    • Batch: emit a whole function (or arena chunk) between one flip and one flush; gain is the ratio of flips saved, one syscall pair per function instead of per patch (measured pair costs above); when code is compiled in bursts.
    • Per-thread toggle instead of mprotect — about 15-20x cheaper per pair in my measurement (24-40 ns vs 0.5-0.9 µs) and no cross-thread TLB work; when the platform has MAP_JIT or protection keys.
    • Dual mapping — no flip at all on the hot path, patch through the RW alias; gain is zero page-table edits per patch; cost is double virtual address use and a shared-memory object.
    • Pool code in a large arena, flip pages not the whole arena, and flush only the written range (len bytes, not the page); when patching many small stubs.
    • Skip the flush on x86-64 (GCC documents __builtin___clear_cache as having no effect where not needed); keep it on AArch64.
  • Naive → standard → state of the art: mmap RWX per function, rejected by hardened systems · mmap RW, write, mprotect RX per function, one syscall pair and TLB shootdown per function · per-thread write toggle or dual mapping with chunked arenas and one flush per batch, no page-table edits on the patch path.

Implementation notes

  • Page-align and size regions to the real page size (sysconf(_SC_PAGESIZE)); never hard-code 4096 (Apple Silicon is 16 KiB).
  • Linux mprotect on part of a mapping splits it into separate VMAs and vm.max_map_count limits their number (from memory); allocate one arena and sub-allocate.
  • Flush exactly the bytes written, after the last store and before the first execution, with the region already RX or toggled. On macOS also check pthread_jit_write_protect_supported_np().
  • pthread_jit_write_with_callback_np restricts writes to callbacks listed in a static allow-list, narrowing which code can write the region.
  • memfd_create dual mapping: ftruncate, then mmap twice with MAP_SHARED; sealing (F_SEAL_*) is documented on the same man page.
  • Threads: with a per-thread toggle, every thread that writes code toggles for itself; a thread that forgets leaves its writes faulting. Keep the writable window short and never run JIT-compiled code on a thread in write mode (execution is denied there on macOS).
  • Policy differences: macOS needs MAP_JIT and, under hardened runtime, the allow-jit entitlement; OpenBSD refuses W+X mappings unless the filesystem is mounted wxallowed and the binary is tagged wxneeded; SELinux can deny execmem / execmod, and the safe path is RW then mprotect RX with no RWX ever (from memory).

Pitfalls

  • Missing I-cache flush on AArch64: intermittent illegal-instruction or stale-code crashes that disappear under a debugger.
  • Flushing before the code is final, or patching after the flush without flushing again.
  • Leaving the thread in write mode (macOS) or the page RW (Linux) while calling into JIT code: it faults.
  • Patching code that another thread is executing: even under W^X the patch must be atomic with respect to instruction fetch (single aligned store of a branch, or stop the other threads); consult the architecture manual.
  • Leaving the RW alias of a dual mapping mapped everywhere: it defeats the point; map it only in the emitting component.

Variants and alternatives

mprotect flipportable, simplest; costs a syscall and TLB shootdown per flip.
Dual mapping (memfd / shm)no flips, more address space, needs shared memory object.
Per-thread toggle (MAP_JIT)cheapest on Apple Silicon.
Protection keysx86 PKU gate for writes (data only), arm64 overlay keys cover fetch; the libmpk paper abstracts MPK key limits.
Interpreter / AOTno executable memory at all; see JIT compilation for when generating code pays.

In production

Wasmtime and Cranelift JIT, LuaJIT, V8 and JavaScriptCore (code space protected, per-thread toggles on Apple Silicon, from memory), .NET and the JVM code cache (from memory). Patching consumers: inline caches, deoptimization.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
wasmtimeRustbytecodealliance/wasmtimecargo add wasmtime————JIT memory handling in Wasmtime / Cranelift
cranelift-jitRustbytecodealliance/wasmtime (cranelift/jit)cargo add cranelift-jit————executable memory for Cranelift output
dynasm-rsRustCensoredUsername/dynasm-rscargo add dynasmrt————assembler runtime with executable buffers
sljitCzherczeg/sljit—————allocator with W^X support (from memory)

Sources

  • Apple, Porting Just-In-Time Compilers to Apple Silicon. doc
  • Apple, Allow JIT entitlement (com.apple.security.cs.allow-jit). doc
  • macOS man pages pthread_jit_write_protect_np(3), cache(3) (sys_icache_invalidate), mmap(2) (read locally).
  • Linux man-pages: mprotect(2), memfd_create(2), pkeys(7); Linux kernel docs, Memory Protection Keys (data-only on x86, covers fetch on arm64).
  • OpenBSD, mmap(2) (W+X rules).
  • GCC manual, Other Built-in Functions (__builtin___clear_cache).
  • Soyeon Park et al., libmpk: Software Abstraction for Intel Memory Protection Keys (Intel MPK), USENIX ATC 2019. paper

Used by

Systems (1):

Alternatives and related