An ARM64 hypervisor bug shows the NX bit isn't just about security

The blog purplesyringa.moe publishes a guest post by Sonya, a friend of the site's owner, recounting a bug hunt in a bare-metal ARM64 hypervisor she was writing for postmarketOS. Enabling the CTR_EL0 register intercept, the whole point of the hypervisor, made the target phone randomly lock up during boot; after a few seconds the watchdog fired and reset the system. The saga started several months before the post and ran through several dead ends: she verified the exception-handler trampoline single-stepped correctly in QEMU, then found and fixed a genuine ARM Icache/Dcache incoherency bug (a function-pointer table was being sorted as data and then executed, which she fixed by moving the sort to build time), and later suspected the specific Cortex-A53 cores on the phone's MediaTek MT6735 chip were out of spec; a friend, Alisa, helped check the published Cortex-A53 errata list, but nothing matched.
None of that fixed the crash. Filtering the compiled kernel with objdump for references to ctr_el0 turned up 22 matching instructions; patching them let her narrow the fault to one function that read the register through a table of function pointers. Rewriting the same logic as a direct, statically linked call worked every time; calling it indirectly through a pointer, semantically identical code, still crashed. She reduced the two versions in assembly until the only difference left was a single instruction: a dynamic indirect branch (blr x0) against a static direct branch (bl get_ctr_el0).
That difference was the clue. A dynamic branch goes through the CPU's branch predictor, while a static branch is always known ahead of time and cannot mispredict. A misprediction has to land somewhere, and the most likely guess, she reasoned, was address 0x0, which in her hypervisor's 1:1 memory mapping pointed at the phone's bootrom, locked out at that point in boot. The crash was not a data race but a speculative instruction fetch from that location. ARM's Device memory attribute, which she already used for the hypervisor's MMIO regions, blocks speculative data accesses but not speculative instruction fetches; those are stopped only by marking the memory non-executable. Making that change let the hypervisor boot the phone all the way to Android without any kernel patches, for the first time in half a year.
A search afterward turned up the relevant line from ARM's own documentation, which she quotes directly: marking a region Device prevents speculative data accesses only, and marking it non-executable prevents speculative instruction accesses, so blocking every kind of speculative access to a region requires both. She had previously treated Data Execution Prevention purely as a defense against stack-overflow exploits and had skipped implementing it because the hypervisor was never meant for production use. The bug reframed the NX bit for her as an architectural attribute the CPU needs to know how to speculate safely, not just an anti-exploit control; since the fix required mapping non-executable memory anyway, she went on to mark everything but the payload in the hypervisor as non-executable.
Key facts
- Enabling the CTR_EL0 register intercept in a bare-metal ARM64 hypervisor for postmarketOS made a MediaTek MT6735 phone (Cortex-A53 cores) randomly lock up and watchdog-reset during boot.
- The multi-month hunt cleared a real Icache/Dcache coherency bug and 22 objdump-located references to ctr_el0 in the kernel, but neither explained the crash; checking the published Cortex-A53 errata list with a friend's help found nothing matching either.
- The cause was a mispredicted dynamic branch (blr) speculatively fetching instructions from address 0x0, which the hypervisor's 1:1 mapping pointed at the phone's locked-out bootrom; a static branch (bl) to the same logic never triggered the bug.
- Marking the affected memory non-executable stopped the speculative instruction fetch and let the hypervisor boot all the way to Android for the first time in half a year, with no kernel patches needed.
- ARM's own documentation, quoted in the post, confirms that marking memory Device blocks only speculative data accesses; blocking speculative instruction accesses requires marking it non-executable as well.
Why it matters
The NX bit is usually framed purely as an anti-exploit control, the thing behind DEP that stops code execution from a stack after a buffer overflow. This debugging trace shows it does something else on ARM: it is the only mechanism that stops the CPU from speculatively fetching instructions out of memory it should never execute, independent of any attack. A region marked Device, ARM's setting for memory-mapped I/O, blocks speculative data access but leaves speculative instruction fetches wide open; only the non-executable bit closes that gap. For anyone writing low-level ARM64 code, that makes NX a correctness requirement for memory-mapped I/O, not only a security hardening step.
Who it affects
Developers writing bare-metal code on ARM64, hypervisors, bootloaders, or kernels that map memory-mapped I/O regions and don't mark them non-executable, are exposed to exactly this class of bug: a stray speculative instruction fetch that manifests as an unreproducible-looking crash. The postmarketOS hypervisor project this bug was found in is the direct case, but the underlying ARM guarantee applies to any bare-metal ARM64 project doing its own memory-attribute setup rather than relying on a mainstream kernel's existing page tables.
How to use it
The post's practical fix: mark every region that should never execute, including MMIO/Device memory, as non-executable in addition to marking it Device, since the two attributes block two different kinds of speculative access. A workaround exists for cases that genuinely need to execute code from Device memory, disabling the instruction cache for that stretch, but the post notes ARM's own documentation still calls such accesses illegal and discourages the practice rather than sanctioning it.
How solid is it
The account is a first-person engineering trace with verifiable steps rather than a general claim: the exception handler was checked by single-stepping in QEMU, a separate real Icache/Dcache coherency bug was found and fixed independently of the main crash, the kernel binary was patched at the 22 objdump-located instructions, the working-versus-broken comparison that isolated a single differing instruction (blr versus bl) was carried out separately on her own hypervisor handler function, and the eventual fix was confirmed by the system booting to Android for the first time in half a year. The author also produces a direct quote from ARM's own documentation that matches the diagnosis. No CVE or errata number backs the finding; the known Cortex-A53 errata list was checked and had nothing matching, which the author reads as evidence the behavior is an architectural guarantee rather than a chip-specific bug, though the source itself doesn't state whether it generalizes beyond this SoC.
Risks and caveats
The bug was diagnosed on one specific phone's SoC, a MediaTek MT6735 with Cortex-A53 cores, and the source does not state whether the speculative-instruction-fetch behavior is universal across ARM64 hardware or specific to this chip; the author's framing rests on ARM's documented architectural guarantees rather than on a chip erratum, but that is an inference, not a confirmed fact from the source. No phone model, no CVE or errata identifier, and no note on whether the fix was shared with postmarketOS or any other project are given. This is a guest post on a personal blog recounting one developer's debugging session, not a formal vendor disclosure or peer-reviewed writeup.
“There is a subtle distinction here that is easy to miss. Marking a region as Device prevents speculative data accesses only. Marking a region as non-executable prevents speculative instruction accesses. This means that, to prevent any speculative accesses, a region must be marked as both Device and non-executable.”
— ARM documentation, quoted in the post