A root-cause analysis of the crash, a system-wide workaround, and a user-space fallback.
Warning
The workaround changes an undocumented kernel boot argument and disables a hardware security feature for the entire system. Read Risks and limitations before applying it.
Last verified: 2026-09-29 · macOS 27.0 (26A428) · Apple M2 Max
- TL;DR
- Symptoms
- Test environment
- Root cause
- Workaround: disable hardware TPRO via boot-args
- Fallback without rebooting:
libjitfix.dylib - Verification results
- Risks and limitations
- Open questions
- How the analysis was done
- Repository layout
- References
- On affected machines, every native arm64 HotSpot JVM tested (OpenJDK 17, 21 and 26) aborts during start-up with
SIGBUSinCodeHeap::allocate, before any Java code runs. - Cause. When the kernel marks a process with
dyld_hw_tpro=1, dyld protects its internal state with hardware TPRO (Trusted Path Read-Only). Each time dyld toggles TPRO, it writes a complete value into a per-thread permission register that also holds the thread'sMAP_JITwrite/execute state. This silently switches the thread back to execute mode. HotSpot caches its own view of that state, so after the firstdlopen()during VM start-up it writes to its code cache while the thread is actually in execute mode. - Workaround. Add
sprr_tpro=0 sprr_tpro_pagers=0to the boot arguments and reboot. The kernel then no longer passesdyld_hw_tpro=1, dyld falls back tomprotect()-based protection, and the JIT state is no longer overwritten. Nothing in the JVM is modified, so this applies to every JVM version and vendor. - Fallback. A small interposing library,
libjitfix.dylib, restores the JIT write state after dyld calls. It needs no reboot but has more limitations.
# SIGBUS (0xa) at pc=0x..., pid=..., tid=...
# Problematic frame:
# V [libjvm.dylib+0x...] CodeHeap::allocate(unsigned long)+0x...
siginfo: si_signo: 10 (SIGBUS), si_code: 1 (BUS_ADRALN), si_addr: <page-aligned address>
- The macOS crash report shows
EXC_BAD_ACCESS (SIGABRT)withKERN_PROTECTION_FAILUREat the same address. java -versionis enough to reproduce it, and it reproduces every time.- The native stack is always
CodeHeap::allocate←CodeCache::allocate←BufferBlob::create← stub generation ←init_globals←Threads::create_vm←JNI_CreateJavaVM. - The faulting address is the first byte of a freshly committed code-heap region whose protection is
rwx/rwxaccording tovmmap, so the memory is mapped correctly. - No JVM option helps.
-Xint,-Xshare:off,-XX:-SegmentedCodeCache, a smaller-XX:ReservedCodeCacheSizeand different collectors were all tried. - IDEs, Gradle, Maven and applications that bundle a JVM crash the same way.
| Item | Value |
|---|---|
| Hardware | Apple M2 Max (Mac14,6, T6020) |
| OS | macOS 27.0 (26A428), xnu-13432.1.9, dyld-27062 |
| Security settings | SIP disabled; AMFI enabled (no amfi_get_out_of_my_way); other boot-args: -v ipc_control_port_options=0 |
| JVMs | Homebrew OpenJDK 17.0.18, 21.0.11, 26.0.1 (all arm64) |
On macOS/arm64, HotSpot allocates its code cache with MAP_JIT. For a given thread, a MAP_JIT page is either writable or executable, never both. The thread switches between the two with pthread_jit_write_protect_np(), which writes an Apple-specific system register. HotSpot tracks each thread's current mode (WXWrite / WXExec) itself and calls pthread_jit_write_protect_np() only when it believes the mode needs to change.
On the test machine the state can be read from user space (mrs x0, S3_6_C15_C1_5), and it is bit 21:
| Thread state | S3_6_C15_C1_5 |
|---|---|
| JIT execute | 0x2010002030100000 |
| JIT write | 0x2010002030300000 |
dyld's internal memory manager (lsl::MemoryManager) can protect dyld's own state with hardware TPRO. Its constructor enables this only if the kernel passed dyld_hw_tpro in the process's apple[] strings (_simple_getenv(apple, "dyld_hw_tpro")).
With TPRO enabled, dyld makes its state writable or read-only (for example in dlopen_from, around image-load notifications and around initializers) by loading a precomputed value from the commpage and writing it to S3_6_C15_C1_5 with msr. One such site is dyld4::APIs::dlopen_from + 764 in dyld-27062. The value is written as a whole, so the thread's current JIT bit is not preserved. Every value observed (0x2010003030100000, 0x2010002030100000) has bit 21 cleared, which means JIT execute mode.
The transition was captured by setting lldb breakpoints on every instruction in dyld and libsystem that writes this register, then running java -version:
thread state before : W (JIT writable)
msr S3_6_C15_C1_5, x0 x0 = 0x2010003030100000 dyld4::APIs::dlopen_from
<- os::Bsd::dlopen_helper
<- ClassLoader::load_jimage_library
<- ClassLoader::lookup_vm_options
<- Arguments::parse
<- Threads::create_vm
thread state after : X (JIT execute)
HotSpot switches the thread to write mode early in Threads::create_vm, then dlopens libjimage and libjava. Still believing the thread is writable, it generates stubs into the code cache, and the first store raises SIGBUS.
Two observations support this chain:
- In a process that does not receive
dyld_hw_tpro, the samedlopenexecutes no write to this register, and the thread stays in write mode. - Re-asserting write mode after each dyld call (
libjitfix.dylib, below) was enough to make the JVMs tested with it (17 and 26) start and pass the full workload in all 7 configurations.
The kernel decides this in the exec path. xnu computes a per-process security configuration, which appears in apple[] as security_config=0x…. Bit 0x2 enables hardware TPRO for dyld; when it is set, apple[] also contains dyld_hw_tpro=1. Setting bit 0x2 requires, among other conditions, two global switches to be non-zero, and one of them is the tunable boot argument sprr_tpro (default 1). A second tunable, sprr_tpro_pagers (default 1), gates dyld_hw_tpro_pagers=1.
This comes from static analysis of kernel.release.t6020 from the build above. The kernel is stripped, so the kernel functions have no names. The meaning of the bits is inferred from the code that sets and reads them.
On the test machine the affected JVMs receive security_config=0x1a dyld_hw_tpro=1. The remaining bits (0x18) come from a separate condition in the same function.
Which executables get the flag is only partly understood:
- The flag follows the file (inode). A hard link to the JDK's
javastill receives it; a byte-identical copy (a new inode) does not, and that copy runs normally. - It does not depend on path, file name, file contents, the SDK version the binary was built with (15.0 through 26.5 were all affected), creation time, or how often the binary has been run.
- Of 168 Homebrew executables that could be checked, 92 had the flag. All 92 had been ad-hoc re-signed with
codesign(not linker-signed). Of the 76 unflagged ones, 75 were linker-signed and 1 had been re-signed, so re-signing alone does not explain the flag.
Copying a JDK can therefore make it work, but since the selection criterion is unknown, this is not a reliable fix.
The same crash has been reported since macOS 14, always on machines with SIP disabled (JBR-6473, JDK-8326663, corretto-11#385). An analysis in the Corretto issue attributes it to dyld taking this path when it considers the process platform-signed. On earlier macOS versions that happened to every process once AMFI was also disabled. OpenJDK closed JDK-8326663 as Won't Fix in February 2026, noting that enabling write mode just before CodeHeap::allocate would be undone by any later dlopen.
On the test machine, AMFI is enabled and the flag comes from the macOS 27 kernel logic described above. JetBrainsRuntime#641 reports the same crash on macOS 27 on an M1 Pro, without stating the SIP status. Whether re-enabling SIP avoids the problem on macOS 27 was not tested.
The same dyld routine that parses dyld_hw_tpro also checks whether the process name starts with MATLAB, a product that ships its own JVM. What that special case changes was not analyzed.
Prerequisites
- An Apple Silicon Mac on which custom boot arguments take effect. This requires SIP to be disabled (Permissive Security).
sysctl kern.bootargsshows the arguments the running kernel was actually booted with. - An administrator account.
Steps
-
Build the tools and confirm that you are affected. The expected output is
CRASH(…) security_config=0x1a dyld_hw_tpro=1.make && ./verify.sh -
Note your current boot arguments. You will keep them; the next step only appends to them.
nvram boot-args
-
Append the two arguments.
sudo nvram boot-args="$(nvram boot-args 2>/dev/null | cut -f2-) sprr_tpro=0 sprr_tpro_pagers=0" -
Reboot.
-
Verify.
verify.shshould reportOK security_config=0x18 dyld_hw_tpro=nonefor every JVM, andtest/run.shshould reportPASSfor every configuration../verify.sh && ./test/run.sh
Why both arguments? sprr_tpro=0 alone removes dyld_hw_tpro. Some processes may still receive dyld_hw_tpro_pagers=1, which asks dyld to use TPRO pagers for the shared cache. sprr_tpro_pagers=0 removes that as well, so the kernel and dyld stay consistent.
Reverting. Remove the two arguments and reboot:
sudo nvram boot-args="$(nvram boot-args | cut -f2- | sed -E 's/ *sprr_tpro(_pagers)?=0//g')"If this leaves boot-args empty, delete the variable instead:
sudo nvram -d boot-argsmake
DYLD_INSERT_LIBRARIES="$PWD/libjitfix.dylib" java -versionThe library interposes dlopen, dlclose, dlsym, dladdr and dlopen_preflight. If the calling thread was in JIT write mode before the call and is in execute mode afterwards, it calls pthread_jit_write_protect_np(0). Set JITFIX_VERBOSE=1 to log each restore. In testing with JDK 26 and a JIT-heavy workload, dyld reset the state 9 times and the library restored it each time.
Use this only as a stop-gap; see its limitations.
The test machine was measured before and after applying the boot arguments. test/run.sh runs a workload with C1/C2 compilation, concurrent compilation on 8 threads, invokedynamic, and native libraries (libzip, libnet, libnio, libawt) under 7 configurations: default, JDWP agent, JFR, -Xcomp, C1 only, -Xint, and -Xshare:off with Serial GC.
| Before | After sprr_tpro=0 sprr_tpro_pagers=0 |
|
|---|---|---|
apple[] of the JVMs |
security_config=0x1a dyld_hw_tpro=1 |
security_config=0x18, no dyld_hw_tpro |
java -version (17 / 21 / 26) |
SIGBUS |
OK |
test/run.sh (3 JDKs × 7 configurations) |
all crash | 21 / 21 PASS |
JIT-state resets by dyld during the workload (JDK 26, counted by libjitfix.dylib with JITFIX_VERBOSE=1) |
9 | 0 |
| Executables flagged by the kernel (Homebrew, 168 checked) | 92 × 0x1a |
92 × 0x18 |
| TPRO-related kernel log messages after reboot | — | none |
- It weakens a security mitigation for the whole system. With
sprr_tpro=0, no process receives hardware TPRO, including Apple applications that opt into Enhanced Security (for example browsers and messaging apps). dyld and the system libraries fall back to software (mprotect()-based) protection, but an attacker who already has a memory-write primitive has an easier job tampering with that state. With SIP also disabled, consider this carefully before using the machine for untrusted content. - It relies on an undocumented, unsupported kernel tunable.
sprr_tprois an internal switch. Apple can rename or remove it, or change its meaning, in any update. That could silently bring the crash back or cause different problems. Run./verify.shafter every macOS update, and usesysctl kern.bootargsto check that the arguments are still active. - Side effects have not been fully analyzed. The same kernel variable is also read by virtual-memory mapping code. The paths reviewed simply skip TPRO-specific handling when it is disabled, which is the same software path that most third-party processes already use, but the review was not exhaustive. After the reboot on the test machine, there were no TPRO-related kernel log messages. One third-party application that loads an injected third-party library crashed once at login; no causal link was established. Software that patches or hooks memory while it is being loaded is the most likely to be sensitive to this change.
- Editing boot arguments is a low-level operation. A typo can prevent a normal boot. Always keep the existing arguments. To recover, start up in macOS Recovery, open Terminal, and restore the previous value with
nvram boot-args="…"or delete it withnvram -d boot-args, then restart. - It only works while custom boot arguments are honored. If you re-enable SIP (or otherwise raise the security policy), the arguments may be ignored and the crash may return.
- It was tested on exactly one configuration, listed under Test environment. The
sprr_tprotunable exists in all 13 Apple Silicon kernels shipped with this build (T6000 through T8152). Behavior on other chips and macOS versions has not been verified.
- It only works in processes that honor
DYLD_INSERT_LIBRARIES, meaning binaries without the hardened runtime or with theallow-dyld-environment-variablesentitlement. The variable must also actually reach the JVM, which is not guaranteed for IDEs, Gradle daemons and other launchers. Setting it globally, for example withlaunchctl setenv, injects the library into every process and is not recommended. - It only covers the interposed dyld entry points. Other paths into dyld that may toggle TPRO, such as other
_dyld_*APIs or thread-local-variable setup, are not covered. The library relies on HotSpot's own W^X transitions to correct the state in those cases. - It deliberately undoes a state change that dyld made for security reasons. Saving a permission state and restoring it later is the kind of pattern that upstream developers considered unsafe to adopt in general, because the saved state exists in memory for a short time.
- It is a workaround, not a fix for HotSpot or for macOS.
This project is not affiliated with Apple, Oracle or the OpenJDK project. The findings come from observing and reverse-engineering one specific build and may be wrong in details. Everything here is provided as is, without warranty; use it at your own risk.
- The exact criterion the kernel uses to decide which executables receive the flag.
- Whether macOS 27 with SIP enabled is affected.
- What the
MATLABspecial case in dyld does. - Behavior on Apple Silicon generations other than M2.
Reports from other machines are welcome. The output of ./verify.sh and sysctl -n machdep.cpu.brand_string kern.bootargs together with sw_vers is enough to start.
- Reproduction and isolation. A minimal C program that follows HotSpot's exact sequence (reserve a
MAP_JITregion,mprotectparts of it to RWX, switch to write mode, write) works. So the problem depends on what else happens inside the JVM process. - Interposition.
DYLD_INSERT_LIBRARIEShooks logged everymmap,mprotectandpthread_jit_write_protect_npcall. They showed that the thread's write mode was lost between HotSpot's own calls. - State probe. The JIT state was read directly from
S3_6_C15_C1_5at HotSpot function boundaries with lldb, which narrowed the change down to a singledlopen(). - Catching the writer. lldb breakpoints were set on every instruction in dyld and libsystem that writes the register, which pinpointed dyld's TPRO toggle.
- Finding the gate. dyld was disassembled (
dyld_hw_tpro→lsl::MemoryManager), and so was the kernel (security_config,dyld_hw_tpro=1, and the tunable table entries forsprr_tproandsprr_tpro_pagers). - Confirming the fix. The boot arguments were applied, the machine rebooted, and the measurements above were repeated.
tprocheck.c (which prints a process's security_config and dyld_hw_tpro) and jitfix.c (which reads the JIT state) show how to observe the relevant state yourself.
| Path | Purpose |
|---|---|
tprocheck.c |
Injected library that prints the security_config / dyld_hw_tpro a process received, then exits before main |
jitfix.c |
User-space fallback that restores JIT write mode after dyld calls |
verify.sh |
Shows active boot-args, and for each installed JVM its TPRO status and whether it starts |
test/Workload.java, test/run.sh |
JIT-heavy workload run under 7 HotSpot configurations |
Makefile |
make, make verify, make test, make clean |
Requirements: Xcode Command Line Tools (clang), zsh, and at least one JDK.
- JDK-8326663: HotSpot crashes when macOS System Integrity Protection is disabled (closed, Won't Fix)
- JetBrainsRuntime#641: [macOS 27] JVM aborts at startup in
CodeHeap::allocate - JBR-6473: IDE cannot start properly due to
SIGBUSatCodeHeap::allocatewhen SIP disabled - corretto-11#385:
SIGBUSin Corretto 11 with SIP disabled, including an analysis of the dyld behavior - Sven Peter, Apple Silicon Hardware Secrets: SPRR and Guarded Exception Levels: background on the per-thread permission registers