Tradecraft

Prism Bleed

Act 02 · The Two Views

Act 2. Foundation-level walk through what ARM64X is, how its loading path diverges from ARM64 and ARM64EC, why DVRT had to be invented, and the runtime scaffolding that keeps a hybrid process coherent.

#windows#pe#loader#arm64#arm64x#prism#malware-research

Act 1 ended at a table. Three fields (Machine, CHPEMetadataPointer, DVRT-ARM64X) read at three checkpoints, producing four possible runtime realities from one file on disk. Pure x64, pure ARM64, ARM64EC, ARM64X.

Act 2 zooms into the fourth. Not because it's malicious (it isn't; the mechanism was built for good reasons), but because to see what's coming later, you need to understand what ARM64X actually is, how its loading path differs from every other case, and why the OS had to invent an entire mechanism called DVRT to make it work.

This act is a foundation-level walk. Three parts:

  • What ARM64X is and how it sits next to ARM64 and ARM64EC.
  • How each of those three cases actually loads. Where the loader's paths diverge, and where they converge.
  • Why DVRT exists. What problem it was invented to solve, and how its ARM64X-specific type actually rewrites bytes at load time.

Hold that shape. Everything after Act 2 hinges on it.

ARM64, ARM64EC, ARM64X: the three, cleanly

Three related things get called "ARM64-ish" and they need pinning down before we go anywhere else.

ARM64 is native ARM64 code. Compiled by the ARM64 toolchain, runs directly on ARM CPUs, follows the standard AAPCS64 calling convention. On disk the PE reports IMAGE_FILE_MACHINE_ARM64 (0xAA64). Nothing about the loader flow is unusual: mapped, relocated, imports snapped, initialized, executed. This is the boring case, and boring is what makes it useful as a baseline.

ARM64EC is also native ARM64 code. Same instruction set, same silicon, same CPU. What differs is the ABI. ARM64EC is compiled against a modified calling convention that mimics x64's register usage and stack layout so an emulated x64 function and a native EC function can interoperate at function-call granularity inside the same process. Same address space, mixed native and emulated code calling into each other one function at a time, without any per-call bridging. EC stands for Emulation Compatible, and that's the whole trick: it looks like x64 to a caller and runs like ARM64 on the CPU.

On disk, an ARM64EC binary reports IMAGE_FILE_MACHINE_AMD64 (0x8664). It lies about being x64 in its Machine field so an x64-emulated process is willing to load it. A private marker (0xA641) shows up in intermediate object files during the build to tag EC objects, but the linked PE writes 0x8664. If you see 0xA641 in a shipped binary, something is wrong.

ARM64X isn't really an architecture at all. It's a container. Inside a single PE file, an ARM64X binary carries both an ARM64 view and an ARM64EC view, plus a script for turning the file into either one at load time. On disk it reports IMAGE_FILE_MACHINE_ARM64 (0xAA64), same as a plain ARM64 binary at that field. What distinguishes it is what's inside.

The intended use case is narrow and important. Windows on ARM needs system DLLs that can be loaded into pure ARM64 processes and into hybrid processes running emulated x64. Shipping two copies of every OS DLL would be a servicing nightmare: every patch, every hotfix, twice. ARM64X exists so one file serves both worlds. ntdll.dll, kernelbase.dll, kernel32.dll, all ARM64X. An ARM64 process loads ntdll, gets the ARM64 view. An emulated x64 process loads the same ntdll, gets the EC view. Two implementations of the same API, one file on disk.

How each of them loads

The three cases share Act 1's phase pipeline: map, fixup, snap, init. They diverge in what actually happens inside those phases.

Three-way load comparison. Pure ARM64, ARM64EC, and ARM64X across the four loader phases. ARM64X's column splits at fixup into an ARM64-view lane and an EC-view lane based on the target process's machine type.

The two boring rows are boring on purpose. Pure ARM64 runs the classical loader: same shape Windows has done on x64 for two decades, just with ARM64 relocation types. ARM64EC runs the same pipeline but every phase does more work to sustain the ARM64-with-x64-ABI illusion: dual IATs, bit-25 marker, EC-aware CFG dispatch. It's the ARM64X row that's the wildcard, and it's worth pulling the details out of the diagram before we chase what actually causes the split.

                       Pure ARM64          ARM64EC              ARM64X
                       ──────────          ───────              ──────
Machine (on disk)      0xAA64              0x8664 (lies)        0xAA64

Loads into...          ARM64 process       x64-emulated proc    both — depends on
                                                                target process's
                                                                machine type

Phase 1 — map          Section maps        Section maps         Section maps
                       normally.           normally.            normally.

Phase 2 — fixup        Standard base       Standard base        DVRT type-6 walk FIRST
                       relocations.        relocations.         (only if target proc
                       No CHPE metadata.   CHPE metadata        is x64-emulated):
                                           present, but no      apply ZEROFILL / VALUE
                                           DVRT type-6 walk.    / DELTA ops to rewrite
                                                                the mapped image into
                                                                the EC view.
                                                                THEN standard base
                                                                relocations run on
                                                                whichever view is in
                                                                memory.

Phase 3 — snap         Single IAT.         Dual IAT: primary    Dual IAT if EC view
                                           + auxiliary IAT.     was materialized;
                                                                single IAT if ARM64
                                                                view was kept.
                                                                Fast-forward decision
                                                                per import in the EC
                                                                case.

Phase 4 — init         Standard init.      EC-aware init.       EC-aware if EC view;
                                           Bit 25 of the        standard if ARM64
                                           module's LDTE Flags  view. Same bit-25
                                           gets set. ARM64X-    signal, same CFG
                                           aware CFG dispatcher dispatcher install
                                           installed.           conditionally.

Runtime dispatch       Direct calls,       Bitmap-consulted     Same as ARM64EC (if
                       standard CFG.       (LdrpEcBitmapData).  EC view is live), or
                                           Three indirect-call  direct (if ARM64 view
                                           patterns coexist:    is live).
                                           same-world (x15),
                                           cross-world (x10/
                                           x11), aux-IAT
                                           forwarder (x16).

ARM64X's loading path branches at fixup based on which world the target process is. If the process is pure ARM64, ARM64X collapses into "pure ARM64" for the rest of the pipeline: the ARM64 view lives, snap and init look normal. If the process is x64-emulated, ARM64X collapses into "ARM64EC," but only after a preceding step that rewrites the mapped image in place to produce the EC view. That preceding step is DVRT.

Which is the real question of this act.

Why DVRT exists at all

Windows has always needed some post-load patching of PE images, because a PE file compiled to run at load address X almost never lands at X at runtime. ASLR shuffles the base address on every load. Absolute addresses baked into the image (pointers into the module's own data, jump table targets, function pointers in vtables) end up pointing at the wrong place, and something has to fix them.

That something is the loader, and the fix-up instructions live in the classical .reloc section. Every PE has one. It's a per-page list of "at this offset on this page, add the slide (actual load address minus preferred address) to whatever value is stored there." Simple, uniform, one operation, applied to every image. This is the mechanism that lets ASLR pick any address without breaking anything.

Over time, Microsoft accumulated more patch scenarios that don't fit .reloc's single-op model:

  • Spectre retpoline mitigation needs specific code sequences patched into indirect call sites at load time, gated on whether the kernel enabled retpoline.
  • Return Flow Guard (deprecated but still in the machinery) needed prologue/epilogue patching for stack-return validation.
  • Hotpatch and function override need runtime redirection of specific function entries.
  • ARM64X view materialization, the one we're chasing, needs whole ranges of the image rewritten to produce a different code stream.

Rather than add four new sections and four new PE header entries and four new loader passes, Microsoft consolidated all of them into one typed patch table reached through a pointer in the Load Config Directory. That table is the Dynamic Value Relocation Table, or DVRT.

The mental model:

.reloc says "add the slide to these addresses."

DVRT says "here are several typed lists of more elaborate patches. Apply the ones that are relevant to this load."

Each DVRT block carries a type discriminator (Microsoft calls it a "Symbol") telling the loader how to decode and apply it. The values you'll see in a modern PE:

  • Types 3, 4, 5. Retpoline patches (import call, indirect call, switch table). Applied when retpoline mitigation is on.
  • Type 6. ARM64X view materialization. Applied when the target process is x64-emulated.
  • Type 7. Function override / hotpatch.

For this post, and for everything that follows, only type 6 matters. The rest are context showing that DVRT is a general mechanism; ARM64X is one of its consumers.

What DVRT type-6 actually does

Every other DVRT type applies patches you could in principle describe as a relocation: "at this address, adjust this value by this delta." Type 6 is different. Its entries aren't relocations. They're transform ops that physically rewrite bytes in the mapped image to change what code and data are present.

There are three primitives:

  • ZEROFILL. Write zeros over a range. Used to blank out ARM64-only code when materializing the EC view (that code doesn't exist in the EC view; ZEROFILL is how it disappears).
  • VALUE. Overwrite a range with a specific value. This is what swaps the Machine field from 0xAA64 to 0x8664 when materializing the EC view, swings the entry point RVA to point at the EC view's entry, and patches specific import thunks.
  • DELTA. Add a signed delta to a value. Used for offset-style patches where the EC view differs from the ARM64 view by a fixed amount.

Three primitives, applied in sequence, are enough to transform any part of the mapped image into any other bytes. That's the point.

DVRT type-6's three transform ops in action. ZEROFILL blanking an ARM64-only code range, VALUE flipping the Machine field from 0xAA64 to 0x8664 (the moment the image starts lying about being x64), and DELTA shifting an RVA by a signed amount.

Where does the type-6 script come from? It's computed by the linker, not written by hand. The build flow for an ARM64X binary is:

  1. Compile the sources with the ARM64 toolchain. Link. You have an ARM64 EXE or DLL.
  2. Compile the (typically same) sources with the ARM64EC toolchain. Link. You have an ARM64EC EXE or DLL.
  3. Run a final link /MACHINE:ARM64X step that merges the two. The linker diffs the two output images. It picks the ARM64 view as the physical baseline that lives on disk. Every place the EC view differs (differing code bytes, differing header fields, differing IAT thunks) gets encoded as a DVRT type-6 fixup.

The patch script is the delta between the two builds.

That's a specific and important fact to sit with. The linker doesn't inspect the semantics of what you built for each view. It runs a byte-level diff between two PE outputs and encodes the differences. Whatever you compiled for each view goes into the diff verbatim. The intended workflow assumes both views implement the same API from shared sources; the mechanism doesn't check that assumption anywhere.

So on disk, an ARM64X file is: the ARM64 view (real bytes, real code) + a DVRT type-6 script (a list of transform ops that describe how the EC view differs). When a pure ARM64 process loads it, the type-6 script is skipped and the ARM64 view runs as-is. When an x64-emulated process loads it, the type-6 script runs at fixup time, rewrites the mapped image byte by byte, and the EC view runs on those newly-materialized bytes.

Same file. Two views. One script that converts between them, applied conditionally by the loader.

The runtime scaffolding

Once fixup finishes, the loader knows which view it materialized. If it's the ARM64 view, everything from here is the classical loader path. If it's the EC view, several pieces of machinery come alive to make hybrid execution work. This is the substrate the rest of the process runs on top of.

The flag. LdrpInitializeEcModule validates the CHPEv2 metadata that shipped with the image and sets bit 25 of the module's LDR_DATA_TABLE_ENTRY.Flags. That bit is the runtime signal every hybrid-aware routine checks with a single instruction before doing hybrid-specific work: the answer to "is this module EC?"

The bitmap. LdrpEcBitmapData is a global structure spanning the process address space with one bit per 4KB page: set means "this page contains EC code," clear means it doesn't. The routine RtlIsEcCode performs the lookup (shift, mask, load, return) in a handful of instructions. Every indirect call from EC code that might target either world consults this bitmap to decide whether an ABI transition is needed. Without the bitmap, hybrid dispatch would require per-call metadata lookup and the model would be too expensive to ship. The bitmap is what makes world classification cheap enough to check on every call.

The auxiliary IAT. Alongside the primary IAT, every hybrid module carries an auxiliary IAT holding EC-shaped pointers to the same imports. It's populated at import-binding time, kept in lockstep with the primary, and consulted by EC callers so imports arrive with the correct ABI shape. LdrpHandleEcModuleIatChange maintains this consistency dynamically; new imports resolved at delay-load or via LoadLibrary propagate to both tables.

Prism, if needed. If the process is running actual emulated x64 code as opposed to pure EC, xtajit64se.dll gets mapped alongside ntdll. That's the Prism engine. Any x64 instructions the process executes get JIT-translated to ARM64 on the fly, using the ARM64EC substrate as the native execution layer.

That's the substrate. But the substrate is only useful because code dispatches through it, and hybrid dispatch is more nuanced than a plain indirect call. Microsoft's Arm64EC ABI documentation is the authoritative reference for the call-checker conventions this section describes; what follows tracks their canonical encoding, with a small observation of our own on top.

Three ways to make a call

Three distinct indirect-call sequences appear inside a hybrid image, each with its own instruction shape and register convention.

Pattern one. Classic Arm64 CFG call checker. This is the pre-ARM64EC convention. Microsoft's docs note that inside an ARM64EC binary, this is still the pattern emitted for x64 code regions and for pure ARM64 code. The target function pointer lives in x15, and the checker function pointer (__guard_check_icall_fptr) is loaded via x16:

mov     x15, <target>
adrp    x16, __guard_check_icall_fptr
ldr     x16, [x16, __guard_check_icall_fptr]
blr     x16                         ; CFG check
blr     x15                         ; call

Validation only. No architecture check, no world transition. This is what pure ARM64 code emits and what x64 code embedded inside a hybrid image continues to emit for its own indirect calls.

Pattern two. ARM64EC unprotected indirect call. When EC code makes an indirect call and CFG is either disabled or unnecessary for that specific site, the runtime still needs an architecture check on the target, because the callee might be x64 code requiring transition through the emulator. That job is done by __os_arm64x_check_icall:

mov     x11, <target>
adrp    x9,  __os_arm64x_check_icall
ldr     x9,  [x9, __os_arm64x_check_icall]
blr     x9                          ; arch check only, no CFG
blr     x11                         ; call (x11 may have been rewritten)

Target in x11. Checker helper pointer in x9. No exit-thunk parameter, because with no CFG there's no signature-typed transition prepared. If the helper determines the target is x64, it rewrites x11 to point at a generic dispatch trampoline; if EC, x11 is left alone.

Pattern three. ARM64EC CFG-protected indirect call. The full EC dispatch: architecture check plus CFG validation plus signature-typed transition thunk staged in case a world crossing is needed. This is __os_arm64x_check_icall_cfg:

mov     x11, <target>
adrp    x9,  __os_arm64x_check_icall_cfg
ldr     x9,  [x9, __os_arm64x_check_icall_cfg]
adrp    x10, <signature-typed exit thunk>
add     x10, x10, <signature-typed exit thunk>
blr     x9                          ; arch check + CFG check
blr     x11                         ; call (x11 may have been rewritten)

Target in x11. Checker helper pointer in x9. Exit-thunk pointer staged in x10. On return from the checker, Microsoft's docs specify the exact register semantics: if the target was EC, x11 is left unchanged and the exit thunk in x10 is unused. If the target was x64, the checker rewrites x11 to point at the exit thunk (copied from x10), and places the actual x64 target address in x9. The exit thunk consults x9 when it dispatches into the emulator.

The exit thunks are signature-typed: one per unique call signature that appears at any cross-world site in the image. $iexit_thunk$cdecl$i8$i8di8i8i8 handles "returns int64; takes int64, double, int64, int64, int64." The linker enumerates every distinct signature at cross-world call sites and emits one thunk per signature, not per site. Hundreds of call sites, dozens of thunks, all living in the dedicated .hexpthk section.

Microsoft's docs note two ABI-critical discipline rules for all three patterns: the call to the target (blr x15, or blr x11) must immediately follow the call to the checker, and neither the target pointer nor the checker pointer should ever be copied to another register or spilled to memory. The correctness of CFG plus world classification depends on this discipline.

Summary of the three patterns:

Pattern                                Target reg   Checker reg   Exit thunk reg
─────────────────────────────────────  ──────────   ───────────   ───────────────
Classic Arm64 CFG                       x15          x16           —
ARM64EC __os_arm64x_check_icall         x11          x9            —
ARM64EC __os_arm64x_check_icall_cfg     x11          x9            x10

Our observation on top of Microsoft's taxonomy. Enumerating ntdll on a Windows-on-ARM host shows very different distributions of these patterns by DLL. Ntdll's internal call sites are dense with both ARM64EC checker variants because ntdll self-contains both worlds: it ships an ARM64 implementation and an ARM64EC implementation of every function that needs one, and routes between them via __os_arm64x_check_icall[_cfg]. Kernel32, one layer up, emits fewer EC checker sequences in its own code. It exposes EC-shaped forwarders backed by Microsoft's Fast-Forward Sequence mechanism (small x64 stubs at exported function addresses that tail-call the real ARM64EC implementation, documented here). Ntdll is the ABI foundation and has to be complete on both sides. Kernel32 is a facade and can afford to be one-sided and forward. The mix of patterns you see across DLLs is a fingerprint of each DLL's role in the stack.

That's the whole substrate. Fixup materialized the view. The flag says "this module is hybrid." The bitmap says "this address is EC code." The dispatch patterns route calls based on the bitmap's answers. The aux IAT keeps EC imports shaped correctly across time. Prism JITs any actual x64 that shows up. Together they turn a static file transformation at load time into a stable, cheap, running hybrid process.

The mechanism in the wild

Everything Act 2 has explained is already running on any Windows-on-ARM host, right now, inside every OS DLL. If it feels abstract, it doesn't have to. Open ntdll.dll in a disassembler and look at the exports for any commonly-invoked function (LdrLoadDll is a good one) and you will find three physical entry points for the same logical function.

ntdll's export list around LdrLoadDll: three variants at three addresses

Three names, three addresses, one function. Each address is a different piece of the ARM64X hybrid machinery Microsoft built.

LdrLoadDll at 0x1800baf90 is the ARM64 native implementation. Compiled by the plain ARM64 toolchain, AAPCS64 ABI. This is what runs when a pure ARM64 process resolves LdrLoadDll and calls into it.

Bare LdrLoadDll: the ARM64 native implementation

A disassembler that doesn't know the containing image is ARM64X will disassemble this address as x64 (because the EC view has overwritten the Machine field via DVRT-6) and produce noise like unimplemented {add edx, ebp} in the pseudo-code. The bytes are real ARM64 instructions. The tool is looking at them through the wrong lens.

#LdrLoadDll at 0x18020a040 is the ARM64EC implementation. Compiled by the /arm64EC toolchain: same instruction set, x64-shaped ABI, so an emulated x64 caller can invoke it without a per-call transition. The # prefix is the compiler's marker for EC-compiled symbols. Every function ntdll ships with a distinct EC implementation gets a #-prefixed symbol in the same image.

#LdrLoadDll: the ARM64EC implementation with x64-shaped ABI

EXP+#LdrLoadDll at 0x1802f0c00 is a nine-byte export-forwarder stub:

The EXP+#LdrLoadDll stub: nine bytes, one tail-call into the EC implementation

Two instructions. When an EC caller resolves LdrLoadDll through the export table, the address it gets is this stub. The stub tail-calls into #LdrLoadDll. The EXP+ prefix marks it as the export-table-visible variant: it's the address the loader publishes to callers who go through the export directory.

A fourth entry in that export list, #LdrLoadDll$hybpatch_thunk at 0x1802e4500, is the hotpatching-aware dispatch variant, another routing stub Microsoft uses for hybrid images that participate in hotpatch. Not load-bearing for the bleed; mentioned only because you'll see it and wonder what it is.

Now walk the whole export table. Enumerate ntdll's exports and you will find hundreds of these triads: LdrLoadDll / #LdrLoadDll / EXP+#LdrLoadDll, NtOpenFile / #NtOpenFile / EXP+#NtOpenFile, RtlAllocateHeap / #RtlAllocateHeap / EXP+#RtlAllocateHeap, and so on for every function that has a distinct EC implementation. Every dual-implemented function is present twice, plus its forwarder. This is not an implementation detail Microsoft hid. It is the operational shape of every OS DLL on Windows-on-ARM.

This is what Microsoft chose. When they built the ARM64X container so one system DLL could serve both pure-ARM64 and x64-emulated processes, they accepted (as a design decision) that a single file on disk would carry two implementations of every dual-implemented function. The linker's /machine:arm64x mode ships in the standard MSVC toolchain because it is the tool Microsoft themselves use to build those DLLs. The DVRT type-6 transform ops encode the diff between the two implementations. The runtime scaffolding (bit 25, LdrpEcBitmapData, the three dispatch patterns, the aux IAT) exists to route calls between the two implementations at runtime.

Everything Act 2 covered is not a description of some obscure feature. It's a description of how your ntdll.dll works right now. The disassembly is on your machine. You can go look at it.

The mechanism the rest of this series discusses is the mechanism your ntdll.dll uses to load every DLL you've ever loaded.

Act 3 is what happens when the two implementations are no longer of the same function.

Closing

That's Act 2 at foundation level. Three-way distinction between ARM64, ARM64EC, and ARM64X. A phase-by-phase comparison of how each one loads. The problem DVRT was invented to solve, and the three transform-op primitives its type-6 uses to materialize an EC view from an ARM64 baseline. The runtime scaffolding that keeps a hybrid process coherent once the view is in place. And, as of the last section, the mechanism visible in ntdll.dll itself: three entry points per function, hundreds of triads, all shipped by Microsoft and in use on every WoA host.

Everything Act 2 covered is the OS working as designed. Every mechanism serves a legitimate purpose. The linker's byte-level diff, the loader's conditional transform walk, the bitmap's O(1) classification, the aux IAT's dynamic sync: none of it is a bug. All of it exists because a container that carries two implementations of the same API needs to become one of them at load time and sustain the illusion at runtime. Your ntdll.dll demonstrates it, function by function, right now.

Act 3 is where the mechanism meets an input it wasn't designed to police. Same primitives, same loader, same runtime. Two views inside a single file that no longer implement the same thing, and what happens when you build one and run it.

The frame worth entering Act 3 with is this: Act 2 walked the mechanism, which is documented and works exactly as Microsoft designed. Act 3 walks the consequences of that design when the two views the mechanism was built to accommodate stop being semantically equivalent. The construction path Act 3 uses is exactly the build workflow Microsoft documents. What has not been widely explored is what happens when a developer deliberately makes the two views implement different observable behaviors, and lets the loader's view selection at CreateProcess time pick which one runs.

That's next.