Tradecraft

Prism Bleed

Act 01 · The Fork

How the Windows loader classifies a PE image across its map / fixup / snap / init pipeline, and why that pipeline is really a classifier for pure x64, pure ARM64, ARM64EC, and ARM64X.

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

The Portable Executable format is one skeleton with metadata variations layered on top — EXE, DLL, same shape, different flags. On disk it's static bytes; in memory it's a running image, and what sits between those two states is the Windows loader. It maps the file's sections into memory, applies base relocations so absolute addresses match where the image actually landed, walks the dependency graph, resolves every import along the way, and pulls it off without tripping the deadlocks a naive traversal would produce. If you've written a reflective loader, this shape is familiar: map sections, fix up relocations, resolve the IAT, run init. That's what a loader does. The Windows user-mode loader is the reference implementation of the same operation — and understanding its shape is what makes the ARM64X divergence later visible instead of magical.

This is the specific way formats are abused with the reflective loading, if we can setup the dependencies and does what the windows loaders does like resolving iat, mapping in memory fixing sections, then thats a loader.

TL;DR. The user-mode loader in ntdll runs four phases: map → fixup → snap → init. It isn't only loading the image; it's classifying it. Three fields, read across those phases (Machine, CHPEMetadataPointer, and the ARM64X DVRT), sort every PE into one of four things: Pure x64, Pure ARM64, ARM64EC, ARM64X. The rest of this post walks the road so the fork makes sense. The payoff is ARM64X, the polyglot that becomes two different images from the same bytes.

The Windows user-mode loader pipeline as a classifier: map, fixup, snap, init, with the fork each phase introduces and the four outcomes they produce.

WinDbg is the tool. Set breakpoints on ntdll!LdrpLoadDllInternal, ntdll!LdrpMapDllFullPath, ntdll!LdrpSnapModule, and ntdll!LdrpCallInitRoutine, and the loader will stop at each phase so you can inspect the state. Everything below is narration for what those breakpoints show.

The stack of abstractions

kernel32.dll is a facade. LoadLibrary and CreateProcess live there for compatibility's sake, but they don't do work; they forward. LoadLibrary funnels down through LoadLibraryEx into ntdll!LdrLoadDll. CreateProcess funnels down (mostly through kernelbase) into NtCreateUserProcess. Peel one layer and you find another.

The NT layer is also an abstraction. NtCreateUserProcess doesn't load anything itself. It traps into the kernel, which creates the process object, the initial section, and the first thread, then hands control back to user mode where ntdll's process initializer (LdrpInitializeLdrpInitializeProcess) takes over and starts pulling in dependencies one at a time.

Why all the stacking? Because NT separates policy from mechanism, and does it religiously. The kernel exposes small, dumb primitives: create a section, map a view, change a page's protection. It has no opinion about what a PE file is. The loader is the policy layer that knows how to compose those primitives into a correctly-loaded image: which order to map in, how to fix up addresses, when to run initializers. That split is why LoadLibrary has looked identical for thirty years while the machine beneath it has been rebuilt more than once, most dramatically when the parallel loader landed in Windows 8/10 and turned a serial walk into a concurrent one. The facade held. The engine got swapped.

The Windows loader is a beautiful piece of machinery. And the beauty has a specific shape. Hold onto the shape, because everything in this post is a variation on it.

The orchestrator

LdrpLoadDllInternal is a sequencer, not a worker. It drives four phases in order: map, fixup, snap, init. Each phase has its own specialist:

  • map: LdrpMapDllFullPath / LdrpMapDllWithSectionHandle
  • fixup: the relocation pass (LdrProcessRelocationBlock doing per-block work)
  • snap: LdrpSnapModule
  • init: LdrpCallInitRoutine

The specialists don't call each other laterally. Snap doesn't invoke init, fixup doesn't invoke map. They communicate through shared per-module state: read it, do the work, transition the state, return, and the orchestrator advances. But there's one honest exception to keep in mind, because the loader itself makes it: snap recurses. When snap discovers module A depends on module B, it drives B through B's own map-fixup-snap pipeline before A can finish snapping. So the model is "phases don't chain sideways," but the graph gets built by snap reaching down and running the whole pipeline again on each dependency. That recursion is not a wart; it's how the dependency graph comes into existence.

Two structures per module

The shared state the specialists read and write is two structures, one module apiece.

LDR_DATA_TABLE_ENTRY, the LDTE, is identity. Where the module lives (DllBase), how big it is (SizeOfImage), its names (BaseDllName, FullDllName), its entry point, and the LIST_ENTRY linkages that thread it onto the PEB's module lists. LDTE answers what and where.

LDR_DDAG_NODE, the DDAG node, is progress and relationships. A State field marching through the pipeline, and two edge lists: Dependencies (modules this one needs) and IncomingDependencies (modules that need this one). DDAG answers how far along, and who's connected to whom.

LDTE is the noun. DDAG is the verb and the wiring.

The graph

Every loaded module gets a DDAG node. The nodes wire together by dependency: A imports from B, so A's node holds an outgoing edge to B and B's node holds an incoming edge from A. Snap adds that edge as it walks A's imports and pulls B in. Do this across every module the process needs and you get a directed graph rooted at whatever load request started it all, fanning outward through every reachable dependency.

Each specialist reads the shared state, does its work, transitions the DDAG state (Mapping → Mapped, Snapping → Snapped, and so on), and returns. The orchestrator asserts the transition and moves on. A state machine over a per-module context, embedded in a graph that spans the whole process, driven by a sequencer that never touches the work. Hold that shape.

Aside: how the loader keeps that graph acyclic (cycles, Tarjan, the parallel loader). Interesting, but skippable; not needed for the fork.

Circular dependencies are legal. A imports B and B imports A is a real, shippable configuration; it happens among system DLLs. The loader does not reject it, and it is not a compile-time error. What the loader does instead is elegant: it runs a strongly-connected-component condensation over the graph. The LDR_DDAG_NODE fields PreorderNumber, LowestLink, and CondenseLink are the giveaway. That's Tarjan's SCC algorithm, bookkeeping and all. When the loader finds a cycle (a group of mutually-dependent modules), it collapses that whole group into a single DDAG node. That's why a DDAG node owns a Modules list rather than a single module: after condensation, one node can represent several modules that form a cycle.

So the "DAG" in DDAG isn't maintained by forbidding cycles. It's maintained by swallowing them. Every cycle becomes one node, and the graph of nodes stays acyclic even when the graph of modules didn't. This is the condense pass (LdrModulesCondensed state) that shows up at the tail of snapping. It's not housekeeping; it's the mechanism that makes the acyclic guarantee true.

The payoff is initialization. Because the node graph is acyclic, init can walk it in topological order (dependencies first, dependents last) so B's DllMain runs before A's, and A can safely call into B while initializing. When a real cycle exists, the condensed node is initialized as a unit, and the relative order of the tangled DllMains inside it is undefined, which is exactly the classic "don't rely on init order across circular DLLs" caveat, now with a mechanism attached to it.

The graph is also what makes the parallel loader tractable. Worker threads advance independent parts of the graph concurrently, and the edges enforce the ordering: A can't cross into snap until B and C are past their own snap. Concurrency without the graph would be a race; the graph is the referee.

Phase one: map

Path resolution first. The loader checks KnownDlls, pre-created image section objects sitting in the \KnownDlls object-manager directory for high-value libraries (kernel32, kernelbase, and friends). These are already mapped and relocated once, at a session-wide base, and shared page-for-page across every process, so there's nothing left to do but map the view. (ntdll is even more special: the kernel maps it before the loader is alive at all.) Miss KnownDlls and the loader falls through to NtOpenFile + NtCreateSection with SEC_IMAGE.

The kernel does the real PE work at NtCreateSection. It parses the headers, computes per-section page protections at the prototype-PTE level, and enforces the image's characteristics. When the loader then calls NtMapViewOfSection, it gets back an image whose sections are already at their correct protections: .text executable, .rdata read-only, .data read-write. User mode never sees a monolithic RWX blob it has to carve up. Protection is baked in at map time by the memory manager. This is also why an image-mapped page and a hand-VirtualAlloc'd RWX region look nothing alike to whatever is watching: the loader's pages were never RWX to begin with.

Fork 1: the coarse cut. NtCreateSection reads IMAGE_FILE_HEADER.Machine and decides whether it will map this image into this process at all. Wrong architecture, wrong subsystem, wrong bitness: rejected right here, before user mode gets a say.

But Machine is a coarse blade. It can't tell you everything you care about:

  • An ARM64EC binary reports IMAGE_FILE_MACHINE_AMD64 (0x8664). It lies about being x64 despite carrying ARM64 instructions.
  • An ARM64X polyglot reports IMAGE_FILE_MACHINE_ARM64 (0xAA64), indistinguishable at this field from a plain ARM64 binary.

So Machine is the first cut, not the last. The finer forks come later.

Back in user mode, the loader allocates the LDTE, fills it from the mapped headers, and threads it onto the PEB's InLoadOrderModuleList, InMemoryOrderModuleList, and the base-address red-black tree (LdrpModuleBaseAddressIndex) used for fast "who owns this address?" lookups. Note what's not touched yet: InInitializationOrderModuleList. That one stays empty until init. DDAG advances to Mapped.

Phase two: fixup

The image almost never lands at its preferred base (ASLR guarantees that), so the relocation pass walks the .reloc directory and patches every absolute reference to match where the image actually sits. IMAGE_REL_BASED_DIR64 on x64 (plain 64-bit pointer writes); the ARM64 relocation family on ARM64 (IMAGE_REL_BASED_ARM64_MOV and kin, which patch the immediate operands of MOV/branch instruction sequences rather than writing pointers into data). Control Flow Guard indirections get wired to ntdll's dispatch here too, so later indirect calls out of the module consult the process-wide CFG bitmap.

Fork 2: the refine, and the interesting one. Before running standard relocations, the loader inspects IMAGE_LOAD_CONFIG_DIRECTORY.CHPEMetadataPointer. Non-null means the image carries CHPE (Compiled Hybrid PE) metadata, the hybrid marker, so the loader now knows it's holding an ARM64EC or ARM64X image, not a pure one. Then it checks the load config's DynamicValueRelocTable (the DVRT) for entries typed IMAGE_DYNAMIC_RELOCATION_ARM64X. Their presence means polyglot, and their contents get applied in this phase, before the standard relocs.

Here's the substrate a classic-loader reader has probably never seen: standard fixup is one pass over one table, and every entry is a relocation. ARM64X bolts a prior pass onto this phase, over a different table, and the entries there aren't relocations. They're transform ops. Zero this range, overwrite that value, patch this delta. They physically rewrite bytes to select which of the image's two personalities the mapped copy will present. What they do, and how the same bytes become two different images, is Act 2. For now: the fork is decided in fixup, before a single import is resolved.

Phase three: snap

Snap is where the graph gets built. The loader walks the module's import descriptors, resolves each dependency name through the API set schema (the virtual api-ms-win-core-*.dll names redirect through the PEB's ApiSetMap to their real host DLLs), and for each dependency either finds it already loaded (adds a DDAG edge) or recurses, running the full map-fixup-snap pipeline on the dependency. That recursion, not a separate pass, is how the whole dependency graph materializes.

Once a dependency is ready, LdrpSnapModule walks the import lookup table and writes resolved export addresses into the IAT. Resolution by name is a binary search over the exporting module's AddressOfNames array, which the linker keeps sorted, not a hash lookup. (The loader does keep a hash table, LdrpHashTable, but that's for finding whether a module is already loaded by base name, a different question entirely. Don't conflate the two.) Forwarded exports ("NTDLL.RtlAllocateHeap") trigger nested resolution. Delay-load imports skip this phase and get resolved on first call.

Fork 3: the resolve. For a pure image, snap writes one export address per IAT slot and moves on. Single lane. For a hybrid image, the lane doubles. In an ARM64EC process, an import resolves differently depending on whether the target lives in native EC code or in x64 code that needs emulating: x64-bound calls get routed through fast-forward sequences into the emulator, while EC-to-EC calls resolve direct. The decision is made per-import against the image's hybrid code-range map that classifies code as native or emulated. In an ARM64X image loaded as x64, the same duality applies, resolved to the x64 view.

The emulator itself deserves a name, because there's confusion in the wild. Prism is Microsoft's branding for the x64-on-ARM64 emulation engine (Windows 11 24H2 onward). The module that actually does the JIT translation is xtajit64se.dll, the Prism-generation engine, always mapped when an x64 binary runs on Windows-on-ARM. (Its predecessor was xtajit64.dll; the se build is the current one.) And here's the detail that surprises people the first time they see it under a debugger: an emulated x64 process on ARM64 doesn't execute as "x64" in any native sense. Prism translates its instruction stream into ARM64 and runs it on the ARM64EC substrate. Attach WinDbg to a pure x64 EXE on a 24H2/25H2 ARM box and the prompt reads ARM64EC>, with xtajit64se.dll sitting in the module list next to ntdll. Your x64 image is realizing on top of EC. That's the fork made visible.

Snap advances the DDAG through Snapping, Snapped, the condense pass (LdrModulesCondensed, the same SCC condensation that swallows cycles), and ReadyToInit.

Phase four: init

A topological walk of the DDAG: dependencies first, root last. For each node: TLS callbacks fire with DLL_PROCESS_ATTACH, then the entry point (DllMain) with DLL_PROCESS_ATTACH, then registered DLL-notification callbacks (LdrRegisterDllNotification subscribers). Now the module gets threaded onto InInitializationOrderModuleList, its first appearance there, even though it's been on the other two lists since map. That staggered list membership is a small, precise tell of how the pipeline is structured: presence, then progress.

Init doesn't fork in ways that matter for this post. It's here to close the pipeline.

The pipeline is a classifier

Step back. What we walked isn't only a loader. It's a classifier with the forks wired into the phases. At each stage the loader is deciding what kind of image this actually is and adjusting.

At map, Machine sets the coarse type: AMD64 or ARM64. A gate, nothing subtle.

At fixup, CHPEMetadataPointer refines it to pure-or-hybrid, and the presence of ARM64X-typed DVRT entries refines again to hybrid-or-polyglot. Two fields, three possibilities.

At snap, whatever fixup locked in decides how imports resolve: one lane for pure images, two for hybrid, with x64-bound calls sent through Prism and native calls resolved direct. Same phase, opposite behavior, entirely determined by the earlier forks.

Three fields. Three checkpoints. Four outcomes.

Decision tree: Machine selects AMD64 or ARM64; CHPEMetadataPointer splits pure from hybrid; the ARM64X DVRT marks the polyglot. Four outcomes total.

Machine     CHPE      DVRT-ARM64X   → Classification
──────────────────────────────────────────────────────
AMD64       NULL      —             → Pure x64
ARM64       NULL      —             → Pure ARM64
AMD64       present   —             → ARM64EC
ARM64       present   present       → ARM64X   ← this post

Pure x64. Classical load. No DVRT, no code-range map, no emulator, one-lane imports. Runs natively on x64 hosts and, on ARM64 hosts, under Prism (as ARM64EC underneath).

Pure ARM64. Structurally identical to pure x64: different reloc types, different instruction set. Loads only on ARM64 hosts.

ARM64EC. ARM64 instructions in a subset that's ABI-compatible with x64, so native and emulated code interoperate at function-call granularity inside one process. Loads only on ARM64 hosts. Code-range map populated by snap; dual-lane imports; Prism attached for any x64 regions the process drags in.

ARM64X. The polyglot. On ARM64 hosts it loads as ARM64/EC; under emulation it loads as x64. The DVRT transform ops applied back in fixup are what let the same bytes on disk realize as two different images at runtime, which is where Act 2 begins.

That's the fork. One file, one format, and a loader that quietly decides, across three phases and three fields, which of four things it's really holding. Everything the rest of this post exploits lives in the gap between what a file says it is at Machine and what it becomes by the time init runs.