Just-In-Time compilation and JIT memory regions
Overview of JIT memory management and W^X enforcement
Just-In-Time (JIT) compilation generates machine code at runtime, which traditionally requires memory that is writable (to emit new code) and executable (to run that code). However, macOS enforces a Write-Xor-Execute (W^X) policy: memory pages should never be writable and executable at the same time.
Allowing arbitrary RWX pages undermines code-signing and opens the door to code-injection exploits. Apple’s solution is to introduce special JIT memory regions and new mechanisms so that JIT code can run efficiently without violating W^X. In macOS, JIT pages are mapped with special flags and are rapidly toggled between writable and executable states, rather than being RWX simultaneously. This guarantees that JIT memory is either writable or executable, never both at once, greatly increasing security.
MAP_JIT: Allocating JIT memory regions
Starting in macOS 10.14 (Mojave), Apple introduced an “Enhanced (Hardened) Runtime” and the MAP_JIT
flag for memory mapping. Developers must use mmap(..., MAP_JIT, ...)
to allocate JIT regions. A JIT region created with MAP_JIT
is an anonymous, private mapping (not from a file) that the kernel marks for JIT use. In fact, the XNU kernel will reject improper uses; for example, MAP_JIT
cannot be combined with MAP_SHARED
or MAP_FIXED
, and must be used with anonymous memory.
On macOS, an app running with the Hardened Runtime (which is required for notarized and App Store apps) must have the com.apple.security.cs.allow-jit
entitlement to successfully allocate a JIT region. The kernel’s code-signing monitor enforces this: if a hardened app without the entitlement calls mmap
with MAP_JIT
, the call is denied (EPERM/EINVAL
). (For non-hardened processes, MAP_JIT
isn’t strictly required, but on Apple silicon hardware the W^X enforcement will still apply as described below.) Notably, macOS allows at most one JIT region per process when using this entitlement. JIT compilers (like JavaScriptCore or V8) typically request a large contiguous JIT memory pool up front and manage their code allocations inside that single region.
When a JIT region is created, the system initially maps it with read+execute (R-X) permission for all threads. This is a key difference from typical memory mappings: by default, code in the JIT region can execute, but it cannot be written to (since W is off) until explicitly allowed. In contrast, on older Intel Macs (or when running without hardened runtime), a JIT mapping might initially be RWX. In macOS 10.14–10.15 on Intel, using MAP_JIT
essentially exempted the region from code-signing checks and allowed RWX usage. This was a temporary compromise—trading off W^X in those cases—until Apple silicon provided hardware support to enforce W^X even for JIT. Starting with macOS 11 (Big Sur) on Apple silicon, no page can ever be genuinely RWX at a given time. The JIT region’s internal permissions are controlled in a new way, as described next.
Thread-specific JIT permissions and pthread_jit_write_protect_np
macOS uses a mechanism of per-thread memory permissions to allow JIT code generation without violating W^X. In practice, this means a JIT page is mapped with a special attribute that allows it to quickly flip between R-X and RW- on a per-thread basis, instead of ever being truly RWX. Apple’s API for this is the function pthread_jit_write_protect_np(int enabled)
. When JIT write protection is “enabled” (set to true) for the current thread, the thread sees JIT pages as read/executable and not writable. When disabled (set to false), the current thread gains write access to JIT pages but loses execute permission. Crucially, this toggle affects only the calling thread. Other threads in the process remain in whatever state they were in (by default, they stay in execute-enabled mode, which is R-X).
In practice, a JIT compiler will do something like:
pthread_jit_write_protect_np(DISABLE); // allow this thread to write to JIT pages (make them RW- for this thread)
emit_machine_code_into_region(...); // JIT compiles and writes bytes into the JIT region
pthread_jit_write_protect_np(ENABLE); // re-protect JIT pages (R-X for this thread again, so code can execute)
Under the hood, pthread_jit_write_protect_np
is extremely fast; it does not incure a full mprotect()
* or TLB flush.*
A full mprotect()
is a system call that updates the page table entries for a given memory region to change its access permissions—incurring kernel overhead and typically triggering TLB shootdowns across cores. Also, a TLB flush invalidates entries in the CPU’s translation lookaside buffer so that subsequent memory accesses must refetch their virtual-to-physical mappings from the updated page tables.
Instead, it leverages a hardware feature (originally known as APRR – Arm Permission Restriction Registers, also called “Fast Permission Restrictions”). Essentially, the AArch64 MMU has an extra indirection for page permissions: the kernel marks JIT pages in a way that the hardware will consult a thread-local permission register to decide whether those pages are executable or writable. A thread-local permission register is a per-thread hardware register that holds dynamic access-permission flags (e.g., executable vs. writable) which the MMU consults when resolving page permissions, enabling fast, thread-specific permission changes without global mprotect()
or TLB flush.
Toggling the register bit for the current thread flips all those pages between R-X and RW- instantly by changing the effective permission index, without changing the page table entries each time. This design means a thread can “unlock” JIT memory for writing, do its code generation, then “lock” it again, with minimal overhead.
Important: Because this protection is per-thread, Apple recommends that only one thread at a time uses a given JIT region. If one thread were writing to a JIT page while another thread is executing from it, you’d effectively recreate an RWX condition (one thread sees it as writable, another as executable). Apple’s documentation explicitly warns that giving multiple threads access to the same JIT region opens a potential attack vector; mega yikes. JIT engines like WebKit’s JavaScriptCore ensure that JIT pages are accessed by one thread at a time when modifying code, to avoid any window where one core is running code that another core is modifying.
On systems that do not support per-thread JIT permission (e.g. Intel Macs, or older OS versions), pthread_jit_write_protect_np()
is essentially a no-op and JIT pages might be simultaneously W+X. But as of Apple silicon, it is fully supported and enforced by default for all apps (even those that don’t explicitly adopt the hardened runtime). In other words, on an M1/M2 Mac, if you try to mark a page RWX via mmap
or mprotect
, the OS/CPU will implicitly treat it as R-X and require you to use the thread toggle approach to ever write to it. (Apple’s Rosetta 2 translation system actually uses this mechanism behind the scenes to support x86 JIT assumptions: Rosetta will catch a memory access fault when x86 code tries to write to an execute-protected page, then flip the permission, let the write happen, and flip it back – all transparently to the x86 process.)
JIT memory in macOS versions
macOS 10.14 (Mojave, 2018)
Introduced the Hardened Runtime with an opt-in “Runtime Hardening” for apps. This included enforcing W^X by default; an app opting in could no longer have writable+executable pages unless it used the new JIT allowance. Apple added the MAP_JIT
flag at this time, mainly to let apps like web browsers or VMs generate code without disabling the entire code-signing enforcement.
Developers targeting 10.14+ had to adapt their JIT allocation code: call mmap
with MAP_JIT
and ensure their code signature includes the allow-jit
entitlement. Without using MAP_JIT, a hardened app would hit errors mapping or mprotecting executable memory. (Apple provided a temporary workaround entitlement com.apple.security.cs.disable-executable-page-protection
to turn off the code signature enforcement entirely, but using MAP_JIT
is the more fine-grained solution.)
macOS 10.15 (Catalina, 2019)
Hardened Runtime became mandatory for notarized apps, so the allow-jit entitlement became crucial for any third-party JIT usage. Catalina was still on Intel CPUs only, so the MAP_JIT
pages could be legitimately RWX at times. W^X was enforced at the OS level (via code signing rules) rather than hardware. JIT frameworks on Catalina often used a dual-mapping strategy if security was a concern: e.g., map one view of the region as RW and another as RX (the “dual mapping” trick) or simply accept RWX on that region. The groundwork for Apple Silicon was already in place though; the APIs like pthread_jit_write_protect_np
were introduced around this time (declared in MacOS 11 SDK, but they are marked as unavailable on iOS).
macOS 11.0 (Big Sur, 2020)
Apple Silicon debut. This is where the JIT architecture changed most dramatically under the hood. On ARM64e Macs (M1 chip), the kernel uses the APRR mechanism to enforce per-thread permissions on JIT pages for all processes. Even apps that aren’t hardened runtime are subject to the no-RWX rule in hardware.
Many JIT engines (OpenJDK, .NET, etc.) discovered that on M1 they had to start calling pthread_jit_write_protect_np(0/1)
around their code-gen or else face EXC_BAD_ACCESS
errors. Early Big Sur releases (11.0 and 11.1) were somewhat forgiving; some reports suggest that an mprotect
call could still toggle permissions on a MAP_JIT page. But by macOS 11.2, Apple tightened this: calling mprotect
on a JIT page to add write or exec would reliably fail with permission errors. Apple’s guidance: use the dedicated thread toggle APIs, not mprotect, to manage JIT memory permissions. Rosetta 2’s transparent support masked some of these issues for x86 code, but native Arm64 JIT code had to follow the new rules.
Big Sur on Apple silicon also introduced Pointer Authentication Codes (PAC) (I have a whole section on this in the ARM folder) at the user level (arm64e ABI). Many system libraries and pointers are PAC-protected. Though PAC is mostly transparent to apps, JIT compilers that work with function pointers needed to be mindful of it. For instance, if a JIT engine wanted to call a JIT-compiled function via a function pointer, on arm64e it may need to sign that pointer with an appropriate PAC key before branching. The system provides APIs and opcodes for this (e.g., the pointer authentication intrinsics or instructions). Apple documentation notes that the OS will authenticate the PAC when jumping to JIT-compiled code in certain contexts. In practice, Safari’s JIT on iOS/macOS uses PAC to sign JIT return addresses and function pointers, making it harder for an exploit to forge a jump into the middle of JIT code or to reuse JIT code in unintended ways. PAC became another mitigation layer starting in this timeframe (more on PAC below and elsewhere on this website/repository).
macOS 12 (Monterey, 2021) and beyond
In Monterey, Apple introduced an optional stronger JIT write protection scheme via the JIT write-allowlist entitlement.
The com.apple.security.cs.jit-write-allowlist
entitlement, if adopted, forces the process to use a stricter API: pthread_jit_write_with_callback_np()
instead of directly toggling write protection.
With this mode, the app must declare at compile-time which functions are allowed to modify JIT memory (using a macro PTHREAD_JIT_WRITE_ALLOW_CALLBACKS_NP
), and the OS will only permit those callbacks to temporarily disable write protection. Direct calls to pthread_jit_write_protect_np
are disabled in that mode.
This is a hardening for cases where an attacker might hijack control flow and try to call the unlock function illicitly; under the allowlist mode, that call would be ignored unless it’s coming from a whitelisted PC (program counter). Monterey’s introduction of this feature indicates Apple’s efforts to make JIT memory as exploitation-resistant as possible. The allowlist can even be extended to code loaded at runtime (dlopen) with a secondary entitlement to “freeze” the allowlist late. macOS 12/13 also introduced improvements in PAC and BTI usage; on M1/M2, JIT code emitted by WebKit includes BTI instructions so that an attacker can’t jump into the middle of a JIT function easily (any indirect branch to a location without a BTI landing pad will abort).
macOS 13-14 (Ventura, Sonoma, 2022-2023)
Most major JIT-using runtimes (JavaScriptCore, V8, JVMs, etc.) have incorporated MAP_JIT
and the Apple silicon APIs. Security research in this era (e.g., Safari exploits in Pwn2Own contests–I think there is 2 or 3 of them) shows that while JIT is still a target, attackers now must chain multiple vulnerabilities to bypass the layers of protection. For example, an attacker might need an information leak to find the JIT region (since it’s at an unpredictable address), a memory corruption to write malicious code into it, and a way to pivot execution there despite PAC and W^X (perhaps by abusing a legitimate JIT callback). Each macOS version hardened one or more of these steps.
Apple’s M2 chips also introduced a hardware feature called Secure Page Table Monitor (SPTM), a successor to PPL, though on macOS its role is mainly to protect kernel page tables (since user code isn’t universally signed). SPTM and related measures ensure that even the kernel or hypervisor enforce certain invariants on code pages.