Tradecraft

Prism Bleed

Act 03 · The Bleed

Act 3. Same file, same hash, one valid Authenticode signature, two different programs. The invoker as the lever, the DVRT type-6 fixups as the receipt, the signature as the anchor.

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

Same file. Same hash. Same signature. Two different programs.

One file, one hash, two runtime programs. A diagrammatic view of the bleed. The signed binary sits at the center under a locked Authenticode band; two invocation lanes descend from it, each showing lever → fixup outcome → what actually runs. The bottom strip names the invariant: nothing about the file, or the signature over it, distinguishes the two runs.

The Authenticode digest E6B7C84861A41BE3BE93F314ED31DE1E870D22863ECDC2D72E35A0F1C3A73318 is identical before and after both invocations. The signing certificate chain is identical. signtool verify /pa /v prints Successfully verified both times. Between those two verifications, one invocation of the signed binary silently wrote a file to disk; the other popped a MessageBox and touched nothing on disk at all. What changed was one argument to a small invoker, arm64 versus amd64, passed through a documented Win32 attribute any caller of CreateProcess is allowed to set.

Act 1 introduced the loader as a classifier and named four possible outcomes for a mapped PE image. Act 2 zoomed into the fourth (ARM64X) and walked its DVRT type-6 view-materialization mechanism, the transform ops, and pointed at ntdll.dll as the operational shape of every OS DLL on Windows-on-ARM. If those two acts landed, this one is confirmation, not novelty.

Two things worth being explicit about before the receipts.

The mechanism is documented. Microsoft's ARM64X PE documentation describes the dual-view container and the loader transformations at map time; the companion build page documents a supported workflow for combining two projects into one ARM64X output via the ARM64ProjectForX property. The construction of prismGlitch2.exe follows that workflow exactly. Nothing novel or hidden; it works exactly as designed.

The signing certificate is orthogonal to the mechanism. prismGlitch2.exe is signed with a self-issued cert (CN=PrismBleedTest) added to the test host's trusted roots so signtool /pa chains to it. That is demonstration scaffolding. The reveal is not "self-signed certs work"; it is that ARM64X is signing-authority-agnostic. Any code-signing certificate (Microsoft's, a public CA's, a purchased-or-stolen production one) produces the same result on the same file. The signature covers the file bytes; the file bytes cover both views.

What has not been widely explored is what happens when those two views are made intentionally divergent. Microsoft's documentation consistently describes ARM64X as a mechanism for shipping symmetric implementations: same API, two ABI shapes, one file. Nothing in the docs discusses the case where the ARM64 view performs one action and the EC view performs another. Nothing in the mechanism enforces symmetry. The linker computes a byte-level diff; the loader applies it conditional on target machine type; the signature covers the file bytes; the invoker picks the view. All of it by design, on inputs the design did not require to be equivalent.

Below are the receipts.

The lever: PROC_THREAD_ATTRIBUTE_MACHINE_TYPE

The invoker is a small program called 1nvoke.exe. Its entire job is to declare, at process-creation time, which machine type the child process should be treated as. The interesting lines are these:

// PROC_THREAD_ATTRIBUTE_MACHINE_TYPE is not always defined in public
// Win32 headers. This is the canonical definition: attribute value
// 25, thread=FALSE, input=TRUE, additive=FALSE.
#ifndef PROC_THREAD_ATTRIBUTE_MACHINE_TYPE
#define PROC_THREAD_ATTRIBUTE_MACHINE_TYPE \
    ProcThreadAttributeValue(25, FALSE, TRUE, FALSE)
#endif
 
USHORT machine = /* 0xAA64 for ARM64 view, 0x8664 for EC view */;
 
SIZE_T attrSize = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &attrSize);
LPPROC_THREAD_ATTRIBUTE_LIST attrList =
    HeapAlloc(GetProcessHeap(), 0, attrSize);
InitializeProcThreadAttributeList(attrList, 1, 0, &attrSize);
 
UpdateProcThreadAttribute(
    attrList, 0,
    PROC_THREAD_ATTRIBUTE_MACHINE_TYPE,
    &machine, sizeof(machine),
    NULL, NULL);
 
STARTUPINFOEXW si = { 0 };
si.StartupInfo.cb = sizeof(STARTUPINFOEXW);
si.lpAttributeList = attrList;
 
CreateProcessW(
    exe_path, cmdline, NULL, NULL, FALSE,
    EXTENDED_STARTUPINFO_PRESENT,
    NULL, NULL,
    (LPSTARTUPINFOW)&si, &pi);

Fifteen lines carrying the whole lever. InitializeProcThreadAttributeList builds an attribute list, UpdateProcThreadAttribute populates it with PROC_THREAD_ATTRIBUTE_MACHINE_TYPE set to a two-byte machine value, and CreateProcessW is called with EXTENDED_STARTUPINFO_PRESENT and the attribute list wired into STARTUPINFOEXW. The loader on the other side reads that attribute and treats it as the authoritative target machine type for the new process.

Two things land about this attribute.

First: it is attribute value 25. A small, undocumented-in-public-headers integer. It is not gated behind a privilege. It is not audited. It is not authenticated. Any process that can call CreateProcessW (which on Windows is any process) can set it. The declaration says "I want the new process treated as machine type X" and the loader honors it. That is the lever the whole bleed rotates on.

Second: the attribute is documented for legitimate cross-architecture use. It is how Windows itself launches x86 processes on ARM64 hosts, how a 64-bit process spawns a 32-bit child under WoW64, how a launcher can pin a particular architecture when spawning an ARM64X system DLL host. Prism Bleed does not misuse the attribute. It uses it exactly as designed, on an input the design did not require to be symmetric: an ARM64X binary whose two views encode different programs.

The same story as link /machine:arm64x in the build. The mechanism is behaving correctly on a corner it was not built to police.

Building prismGlitch2.exe

The construction is unremarkable. That is the point.

Two source files, deliberately different behavior surfaces. Here is the ARM64 view, the program that lives in the physical bytes on disk. It writes a file and returns. No UI. No visible artifact except the file it created:

// writeFileArm64.c
#include <windows.h>
 
int main(void) {
    const wchar_t* path = L"C:\\Users\\Public\\prism-arm64-marker.txt";
 
    HANDLE h = CreateFileW(
        path, GENERIC_WRITE, 0, NULL,
        CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (h == INVALID_HANDLE_VALUE) return 1;
 
    static const char msg[] =
        "ARM64 native view of prismGlitch.exe wrote this file.\r\n"
        "No user interaction. No visible window. Side effect on disk.\r\n";
 
    DWORD written = 0;
    WriteFile(h, msg, (DWORD)(sizeof(msg) - 1), &written, NULL);
    CloseHandle(h);
    return 0;
}

And here is the ARM64EC view, the program that will materialize out of the same file whenever the loader is asked for the emulated x64 view. It resolves MessageBoxW at runtime via LoadLibrary and GetProcAddress, shows a dialog, and returns. No file I/O:

// msgBox64arm.c
#include <windows.h>
 
typedef int (WINAPI *PFN_MessageBoxW)(HWND, LPCWSTR, LPCWSTR, UINT);
 
int main(void) {
    HMODULE u32 = LoadLibraryW(L"user32.dll");
    if (!u32) return 1;
 
    PFN_MessageBoxW pMessageBoxW =
        (PFN_MessageBoxW)GetProcAddress(u32, "MessageBoxW");
    if (!pMessageBoxW) { FreeLibrary(u32); return 1; }
 
    pMessageBoxW(NULL,
        L"ARM64EC / Emulated View reached MessageBox.\n\n"
        L"No file was written. Interaction only.",
        L"Prism Bleed",
        MB_ICONWARNING | MB_OK);
 
    FreeLibrary(u32);
    return 0;
}

Nothing in either file is hostile. Nothing is hidden. No shellcode, no encoded payload, no unpacker stub, no anti-analysis check, no runtime patching, no obfuscation. Two main() functions, twenty lines total, doing exactly what they say. The two views deliberately touch different Win32 subsystems: file I/O in the ARM64 view (kernel32.dll primitives), UI in the EC view (user32.dll via runtime resolution), so a defender inspecting behavior can tell them apart at a glance.

The trick is not in the source. It is in the build.

Three commands compile and link the two source files into a single ARM64X binary:

> cl.exe /arm64EC /c /MT /O2 .\msgBox64arm.c
> cl.exe /c /MT /O2 .\writeFileArm64.c
> link.exe /machine:arm64x /SUBSYSTEM:console /ENTRY:main ^
    /OUT:prismGlitch2.exe ^
    .\msgBox64arm.obj .\writeFileArm64.obj ^
    user32.lib kernel32.lib

Read those three lines carefully because the whole primitive lives in them.

The first cl.exe compiles msgBox64arm.c with /arm64EC. That flag tells MSVC to emit ARM64 machine instructions against the ARM64EC ABI (the x64-shaped calling convention Act 2 pinned down) and to mark the resulting object as an EC object. The intermediate msgBox64arm.obj carries the private 0xA641 machine marker Act 2 mentioned. It is a fragment of an ARM64EC image, waiting to be linked.

The second cl.exe compiles writeFileArm64.c with no /arm64EC flag. This is a plain ARM64 compile. Standard AAPCS64 ABI, ordinary object file. writeFileArm64.obj is a fragment of an ARM64 image.

The link.exe step is where the magic (and it is legitimately magic) happens. /machine:arm64x tells the linker: these are the two views of the same PE image; merge them. The linker takes writeFileArm64.obj and lays down the ARM64 view as the physical baseline of the output file. Then it takes msgBox64arm.obj and diffs it against that baseline. Every place the EC view differs from the ARM64 view (different code bytes, different Machine header value, different entry-point RVA, different IAT thunks) becomes an entry in a DVRT type-6 fixup block written into the output.

/ENTRY:main bypasses the CRT startup wrapper, so neither view drags in mainCRTStartup and its associated bookkeeping. Both views' entry points go straight to main. This has a nice side effect on the receipts: the resulting DVRT-6 block contains only the fixups the demo actually needs, no CRT-init noise. Cleaner map.

The resulting prismGlitch2.exe is one file that carries two programs. The ARM64 program is the physical byte sequence on disk: real code, real headers, real relocations. The EC program is the DVRT type-6 script: a compact list of VALUE, ZEROFILL, and DELTA operations that describes how to rewrite the physical bytes into the EC view at fixup time. The linker computed that script by literally diffing the two .obj files' linked images against each other and encoding the deltas. Whatever we compiled for each side, semantically and meaningfully as programs, is now inside the file. The mechanism did not care that one side writes a file to disk and the other pops a UI dialog. It just recorded the differences.

That is the entire construction. Two cl.exe invocations, one link.exe invocation, one 21KB output file. No obfuscator. No packer. No post-link patching. No custom tooling. link.exe /machine:arm64x did the whole thing, using a documented and supported linker mode Microsoft ships in the standard MSVC toolchain because it is the mechanism they themselves use to build system DLLs.

The barrier to reproducing this is not tooling. The barrier is understanding. The tooling was already on your machine.

Build and both invocations, side by side. Left: two cl.exe compiles, one link.exe /machine:arm64x merge, then .\1nvoke.exe arm64 running silently. Right: .\1nvoke.exe amd64 in an x64 dev prompt popping the MessageBox. File Explorer at top shows the marker file that appeared next to the built binary.

The receipts, annotated

Now the payoff. Run dumpbin /headers prismGlitch2.exe and dumpbin /loadconfig prismGlitch2.exe on the finished file, and every field maps to something Acts 1 or 2 taught you to look for.

File Header:

FILE HEADER VALUES
    AA64 machine (ARM64) (ARM64X)
       7 number of sections
    1008 entry point (0000000140001008)

AA64 machine (ARM64) (ARM64X) is Act 1's first checkpoint, in the field itself. The Machine field says 0xAA64, an ARM64 binary as far as any tool that only reads that field is concerned. The (ARM64X) annotation is dumpbin's own recognition that this file is a container, not a plain ARM64 image; it consults the load config's hybrid metadata pointer to decide whether to print it. Any scanner that reads only the Machine field sees ARM64. A scanner that follows the load config sees ARM64X. Most scanners read only the Machine field.

The entry point at RVA 0x1008 is the ARM64 view's entry, the file-writing program. When the ARM64 view is materialized (or, equivalently, when DVRT-6 is skipped because the target process is pure ARM64), that is where execution begins.

Load Config Directory:

     00000100 Guard Flags
              CF instrumented
     Hybrid metadata pointer   (non-null)
     Dynamic value relocation table offset

CF instrumented inside Guard Flags is what Act 2's LdrpInitializeEcCfgScpHelpers reads at load time to decide which of two CFG dispatch functions to install. The stricter variant gets wired up. Not load-bearing for the bleed, but a signal that the runtime scaffolding Act 2 described is present and armed.

Hybrid metadata pointer is Act 1's second checkpoint: the CHPEv2 metadata pointer inside the load config. Non-null means the loader knows this is a hybrid image. Every routine in Act 2 that checks bit 25 of the module's LDR_DATA_TABLE_ENTRY.Flags, every accessor like LdrpGetModuleEcMetadata, traces back to this pointer.

Dynamic value relocation table offset is Act 2's DVRT, in the field itself. That is where the linker parked the transform script.

The DVRT itself, the receipt Act 2 was written to explain:

Dynamic Value Relocation Table (version: 1)

Symbol VA: 0000000000000006 IMAGE_DYNAMIC_RELOCATION_ARM64X

Fixup RVAs:
  [00000000] page 00000000 rva 000000E4, 2 bytes, target value 8664
  [00000001] page 00000000 rva 00000108, 4 bytes, target value 4000
  [00000002] page 00000000 rva 00000180, 4 bytes, target value 9048
  [00000003] page 00000000 rva 00000184, 4 bytes, target value 18
  [00000004] page 00000000 rva 000001B8, 4 bytes, target value 62E0
  [00000005] page 00000000 rva 000001BC, 4 bytes, target value 140
  [00000006] page 00005000 rva 00005020, 8 bytes, target value 6910
  [00000007] page 00005000 rva 00005028, 8 bytes, zero fill
  [00000008] page 00006000 rva 00006288, 4 bytes, target value 1008
  [00000009] page 00006000 rva 000062A0, 4 bytes, target value 9000
  [0000000a] page 00006000 rva 000062A4, 4 bytes, target value 48
  [0000000b] page 00006000 rva 000068B8, 8 bytes, delta 0x3
  [0000000c] page 00006000 rva 000068C8, 8 bytes, delta 0x3
  [0000000d] page 00006000 rva 00006900, 8 bytes, target value 6910
  [0000000e] page 00006000 rva 00006908, 8 bytes, zero fill

Symbol VA: 0000000000000006 IMAGE_DYNAMIC_RELOCATION_ARM64X. That is Act 2's type 6, by name, in the file. Type 6 = ARM64X view materialization. dumpbin prints the constant name. No ambiguity about what block this is.

Fifteen fixup entries. All three of the transform-op primitives Act 2 named, VALUE, ZEROFILL, and DELTA, are present:

  • Fixups [0] through [6], [8], [9], [a], [d] are VALUE ops. Each rewrites a specific byte range with a specific value.
  • Fixups [7] and [e] are ZEROFILL ops. Each blanks a byte range in the mapped image.
  • Fixups [b] and [c] are DELTA ops. Each adds a signed delta of 0x3 to a value.

Three primitives, in one fifteen-entry block. Act 2 described them abstractly; the receipt shows them by name.

The single most consequential fixup is [0]:

Two bytes at RVA 0x000000E4 get overwritten with the value 0x8664.

RVA 0xE4 is where the Machine field lives in the PE file's IMAGE_FILE_HEADER on this build. When this fixup is applied (that is, when the target process is x64-emulated and the loader walks DVRT-6), the Machine field is rewritten from 0xAA64 to 0x8664. Those two bytes are how the file becomes x64 in the mapped image. The ARM64 view's dumpbin says AA64 machine; the EC view's mapped image says 8664. One VALUE op, two bytes, the entire header lie.

Fixups [1] through [5] are more low-RVA VALUE ops inside the optional header and its data-directory table, swinging entry-point RVAs, image sizes, and directory addresses so that the mapped EC view reflects its own layout rather than the ARM64 view's. The exact offset-to-field mapping isn't important. What is important is that Act 2's VALUE primitive is doing exactly what it was described as doing: overwriting header words to make the file present as one view versus the other.

Fixups [6] onward touch the code and data pages (0x5000 and 0x6000). Fixups [6], [7], [8], [9], [a], [d], [e] are IAT and dispatch-metadata rewrites, swinging the aux IAT and the redirection metadata pointers. Fixups [b] and [c] are the DELTA ops adjusting eight-byte pointer values by +3 each. Every one of them is Act 2's mechanism doing its declared job.

CHPEv2 metadata block + section table — every field, mapped against Act 2 (click to expand)

And here is the payoff for the whole runtime-scaffolding section of Act 2. The same load-config dump also prints the CHPEv2 metadata block Microsoft's linker embedded in the file. Every field Act 2 named appears here:

Section contains the following hybrid metadata:

    2 Version                                                 
    00006000 Offset of Arm64X dispatch call function pointer (no redirection)
    00006008 Offset of Arm64X dispatch return function pointer
    00006010 Offset of Arm64X dispatch indirect call function pointer
    00006020 Offset of Arm64X dispatch indirect call function pointer (with CFG check)
    00002038 Offset of Arm64X alternative entry point
    00007000 Offset of Arm64X auxiliary import address table
    0000657C Offset of Arm64X x64 code ranges to entry points table
    0000A000 Offset of Arm64X arm64x redirection metadata table
    00009048 Offset of Arm64X extra RFE table
    00006038 Offset of Arm64X dispatch function pointer
    00006960 Offset of Arm64X copy of auxiliary import address table

Hybrid Code Address Range Table
    arm64    0x140001000 - 0x1400010B3   (ARM64 native)
    arm64ec  0x140002000 - 0x14000215B   (ARM64EC native)
    x64      0x140003000 - 0x14000400F   (x64 → JIT'd by Prism)

Read that list against Act 2. The auxiliary import address table at 0x7000 (plus its copy at 0x6960) is the aux IAT Act 2 spent a whole section on: the second IAT that keeps EC-shaped import pointers in lockstep with the primary. The arm64x redirection metadata table at 0xA000 is the redirection table Act 2 described: the (fromRVA, toRVA) pairs that route calls between dual implementations. The dispatch function pointers at 0x6000, 0x6008, 0x6010, 0x6020 are the ARM64X-aware CFG dispatchers Act 2 named: the same-world, cross-world, and CFG-checked variants. The alternative entry point at 0x2038 is the EC view's entry (distinct from the ARM64 view's 0x1008 in the file header; one entry per view).

And the Hybrid Code Address Range Table is a small architectural pun. Three code regions in one file: arm64 native code (0x10000x10B3, the file-writing behavior), arm64ec native code (0x20000x215B, the MessageBox behavior), and (this is the fun one) an x64 code range (0x30000x400F) that will be JIT-translated by Prism at runtime. That last range is the boundary between EC-native and Prism-emulated made physical. A ~4KB block of x64 code is embedded in the file, in a range Prism knows to intercept and translate. Act 2 said the runtime consulted LdrpEcBitmapData to decide per-page whether code was EC or something else; the bitmap gets populated from this table.

Finally, the section table names two Act 2 concepts as sections in their own right:

    .a64xrm    Arm64X Redirection Metadata
    .hexpthk   Hybrid Exit / Entry Thunks

.a64xrm is where the redirection metadata physically lives. .hexpthk is where the signature-typed exit and entry thunks Act 2 described as the cross-world dispatch mechanism physically live. dumpbin prints them in the section summary; you can go look at them.

Everything Act 2 predicted is in the receipts. The Machine-field VALUE op is there, at the exact RVA. All three transform-op primitives are there, by name. The CHPEv2 metadata block is there, with every offset Act 2 named. The three code ranges are there, showing the ARM64 / EC / x64 breakdown inside one file. The section table has .a64xrm and .hexpthk as their own entries. The whole mechanism is on display, and none of it is a bug.

Look at the receipts one more time with a sharper question in mind. Fixups [0] through [5] rewrite header and data-directory words: structural bookkeeping so the mapped EC view is internally consistent as an x64 image rather than an ARM64 one. Fixups [6] onward, in the 0x5000 and 0x6000 pages, rewrite IAT thunks and dispatch metadata pointers: behavioral bookkeeping so calls route correctly for the EC view. What the fixups do not have to touch, because Act 2's mechanism is symmetric where it can be, is a large chunk of the file. Two twenty-line programs, one 21KB output, fifteen fixup entries. The mechanism does its job efficiently; the divergence is bounded to exactly what needs to change.

What ARM64X expected is that those changes represent two implementations of the same function differing in ABI only. What ARM64X did not expect (and cannot enforce) is that those changes represent two implementations of entirely different functions. The bleed lives entirely in that gap.

The signature

The last receipt is the one that makes this uncomfortable for defenders.

prismGlitch2.exe is signed. Not by a Microsoft certificate or a public CA, but by a self-issued code-signing certificate (CN=PrismBleedTest), generated in about a minute with New-SelfSignedCertificate and installed as a trusted root on the test host so signtool /pa can chain to it. The signing workflow is standard; there is nothing exotic about it. Any code-signing certificate (self-issued for research, or a real one purchased or stolen for an operation) would produce the same result.

Full signing test in one shell session. Top: signtool verify /pa /v before any invocation; Successfully verified: .\prismGlitch2.exe with authenticode digest E6B7C848...73318. Middle: .\1nvoke.exe arm64 runs silently; Get-Content prints the ARM64 view's file. Marker deleted; .\1nvoke.exe amd64 runs; Test-Path returns False. Bottom: signtool verify /pa /v again; identical authenticode digest, same certificate chain, Successfully verified a second time.

The Authenticode digest computed at signing is:

Hash of file (sha256):
    E6B7C84861A41BE3BE93F314ED31DE1E870D22863ECDC2D72E35A0F1C3A73318

Before either view is invoked, signtool verify /pa /v .\prismGlitch2.exe reports:

Signing Certificate Chain:
    Issued to: PrismBleedTest
    Issued by: PrismBleedTest
    Expires:   Tue Jul 06 23:48:18 2027
    SHA1 hash: 731BE5B855B107CF2D15A99AF109DC58EF9A0787

Successfully verified: .\prismGlitch2.exe

Number of files successfully Verified: 1
Number of warnings: 0
Number of errors: 0

Then .\1nvoke.exe arm64 .\prismGlitch2.exe runs. Process created, process exits. Get-Content .\prism-arm64-marker.txt prints the ARM64 view's message. The signed binary just wrote a file to disk.

Delete the marker. .\1nvoke.exe amd64 .\prismGlitch2.exe runs. Process created, MessageBox appears, user clicks OK, process exits. Test-Path .\prism-arm64-marker.txt returns False. The signed binary just showed a UI dialog and touched nothing on disk.

Rerun signtool verify /pa /v .\prismGlitch2.exe. Same digest, same certificate chain, same result:

Hash of file (sha256):
    E6B7C84861A41BE3BE93F314ED31DE1E870D22863ECDC2D72E35A0F1C3A73318
Successfully verified: .\prismGlitch2.exe

Same file. Same authenticode hash. Same certificate. Successfully verified twice. And in between, the binary just carried two completely different programs.

The paradox at foundation level. Top band: what Authenticode attests to (file bytes, SHA-256 digest, code signature, chain to trust anchor), all locked. Middle: the invoker's machine-type attribute picks a view at CreateProcess. Bottom bands: the two runtime realities the signature covers equally, and the explicit list of what signing does and does not attest to.

This is the fact that the rest of the post rotates on. Authenticode is a code-integrity mechanism. signtool verify /pa confirms that the file's bytes have not been modified since signing and that the certificate chain resolves. It does not, and has never claimed to, attest to what those bytes do at runtime. Code signing is authorship over bytes, not a behavior guarantee.

What Prism Bleed makes visible is that on an ARM64X binary, those bytes materialize as one of two runtime programs depending on the invoker's declared machine type. The integrity signature covers the bytes; the bytes cover both views. One valid signature, one valid certificate chain, two runtime realities the signature was never designed to distinguish between.

Any signing infrastructure that treats an Authenticode signature as an authorization for what the file does at runtime (reputation systems, allowlisting policies, "signed = trusted" heuristics) is answering a question about one of two possible programs. The other one is one CreateProcess flag away, and its signature is just as valid.

Where this came from, and what it isn't

This didn't start as a project. I had a Windows-on-ARM box, and one evening I set breakpoints on LdrpMinimalMapModule and LdrpLoadDllInternal in WinDbg to see how a fresh process actually gets built. Somewhere in the middle of that trace my eyes stuck on something that didn't quite fit (a call sequence that behaved differently than the same code path would on x64 Windows) and the next few nights were spent working backward through why. Everything above is what fell out of that. The 1nvoke.exe demo is scaffolding to make what I found reproducible; the two source files are the smallest asymmetric thing I could build; the signing test is the beat that made it feel worth writing down.

This is not an EDR-evasion paper. I have not tested prismGlitch2.exe against a single production detection stack. I don't know what Defender does with it. I don't know what a modern EDR does with it. I don't know what any commercial sandbox does with it. If someone runs those tests, I would be interested in the results either way, but that work is not this post's work. This post is a description of a construction and its receipts. Nothing more.

And there is one honest thing worth naming before closing, because the construction can read like it's larger than it is: the executed view still runs on Windows, and Windows is telemetered. Whether the invoker asks for arm64 or amd64, the mapped image reaches through the same Win32 APIs every other process uses, and as each API is resolved and called along the way, the activity surfaces through ETW providers (Microsoft-Windows-Kernel-Loader, Microsoft-Windows-Kernel-Process, Microsoft-Windows-Threat-Intelligence, and their peers) whether anyone happens to be subscribed at the moment or not.

For a defender, the concrete places to watch are three. First, PROC_THREAD_ATTRIBUTE_MACHINE_TYPE requests at CreateProcess: attribute value 25 with a non-native machine type is the lever this whole post rotates on; any process setting it is worth logging. Second, CHPEv2 metadata presence at load-config parse time, which flags a hybrid image at the moment the loader learns of one. Third, ARM64X-typed DVRT entries in the load config, which flag a polyglot container before fixup even runs. The signals exist. The question is whether the SIEM knows to ask.

So the honest sentence to close on is this. Prism Bleed hides authorship (which of the two views encoded in the file will run) from anything sampling the file on disk. It does not hide the fact that a hybrid image was loaded, that a particular machine type was requested at CreateProcess, or that the executed view called into a sequence of Windows APIs. All of that is on the wire, in ETW, whether anyone is listening or not.

The construction is the disclosure. The two views exist, the loader will honor whichever the caller asks for, the signature will cover both, and the telemetry that describes it is already flowing.

The VirusTotal receipt

I uploaded it. One file, prismGlitch2.exe, submitted to VirusTotal, no tricks.

0 out of 68 engines flagged it as malicious.

VirusTotal analysis of prismGlitch2.exe: the summary bar reports 0/68 vendors flagged the file, with tags peexe, corrupt, 64bits, arm.

The tags are worth reading before the score. peexe, corrupt, 64bits, arm. corrupt is honest labeling: some engine's PE parser hit a structure it couldn't reconcile with the declared Machine and gave up. That's a reasonable response for a parser confronted with an ARM64X container it wasn't built to understand.

The score itself is where the interpretive question lives, and it's the question worth ending on. A 0/72 (or 0/68) result on VT tends to circulate as if the number self-evidently means no vendor found the file to be malicious. That is one reading. The other reading is quieter: no vendor's static engine successfully finished analyzing a container it wasn't built to model, and no vendor's sandbox happened to spawn the binary with a PROC_THREAD_ATTRIBUTE_MACHINE_TYPE set to a machine type that would exercise the EC view. Under the first reading, 0/68 is a verdict. Under the second, it is a silence.

I don't know which reading is closer to the truth for any specific engine on this specific submission, and I'm not going to speculate. What I do know is that 0/68 on VT is a statement about what those 68 engines chose to report, not a statement about what the file will do when a process is spawned from it. Prism Bleed makes the gap between those two things concrete: same file, two behaviors, one signature, and, on this measurement, zero detections.

One more, for texture

Same primitive, different payloads. prismGlitch.exe (SHA-256 EDF309DA03F629AFF3C2FD6635E67AA9934FABC1119E241FF7BDA182D0BEBB8C) is another link /machine:arm64x build in the same shape: the ARM64 view launches Notepad, the ARM64EC view launches Calculator, and each view announces itself with a MessageBox naming which view reached it. Same file bytes across both runs; Get-FileHash prints the same SHA-256 before and after each invocation.

ARM64 invocation of prismGlitch.exe. .\1nvoke.exe arm64 .\prismGlitch.exe requests machine type 0xAA64, CreateProcess returns PID 19132, and the ARM64 native view pops a MessageBox reading "ARM64 Native View - Notepad Launched" with a fresh Notepad window on the desktop.

AMD64 invocation of the same file. .\1nvoke.exe amd64 .\prismGlitch.exe requests machine type 0x8664, CreateProcess returns PID 17320, and the ARM64EC / emulated view pops a MessageBox reading "ARM64EC / Emulated View - Calculator Launched" with a fresh Calculator window on the desktop. Get-FileHash between the two runs reports the same SHA-256 both times.

That's the fork, closed.


Prism Bleed is an ongoing research program into the emulation layer in Windows. More acts to come.

*Research, reproduction, and OPSEC analysis conducted by me, with AI assistance used to accelerate the workflow.