Check if an EXE or DLL Is 32-bit, 64-bit or ARM64 (Incl. .NET)
On this page
Short answer: every Windows EXE and DLL is a PE file, and two header fields decide its architecture. The COFF Machine field says which CPU the code is for (0x014C x86, 0x8664 x64, 0xAA64 ARM64), and the optional header Magic says whether the file is PE32 (32-bit layout) or PE32+ (64-bit layout). Drop the file into this site’s native binary overview and read Architecture and Bitness in the Identity block. Nothing is run or uploaded.
There is one big exception. A .NET assembly built as AnyCPU has a PE32 header with Machine x86, yet it runs as a 64-bit process on 64-bit Windows. For managed code you have to read the CLI flags too, and this guide shows real examples of each case.
Who this is for: developers chasing BadImageFormatException or “is not a valid Win32 application”, people packaging plug-ins that have to match their host (an Office add-in, a native Node or Python module, a driver), and anyone preparing builds for Windows on ARM.
What you will answer:
- Is this EXE or DLL x86, x64 or ARM64?
- Is a .NET assembly AnyCPU, x86-only or platform-specific?
- How do I check the same thing for ELF and Mach-O files?
- Which command-line tools confirm it?
Samples used: a minimal Windows program and DLL of my own, built three times on macOS with the Android NDK r29’s clang and lld-link (no C runtime, importing only kernel32.dll):
for t in x86:i686 x64:x86_64 arm64:aarch64; do
n=${t%%:*}; a=${t##*:}
clang -target $a-pc-windows-msvc -O2 -c -o $n.obj win.c
lld-link /nologo /nodefaultlib /entry:mainCRTStartup /subsystem:console \
/dynamicbase /nxcompat /out:hello-$n.exe $n.obj kernel32-$n.lib
done
With Visual Studio you would get the same three outputs by building for the Win32, x64 and ARM64 platforms. For .NET, I used open-source packages from nuget.org: Newtonsoft.Json 13.0.3, xunit.runner.console 2.4.2 (it ships both xunit.console.exe and xunit.console.x86.exe), and System.Text.Json.dll from the .NET 8.0.10 runtime packs for win-x64 and win-arm64.
Why the architecture matters
A Windows process has one architecture, and every DLL it loads directly has to match it. A 64-bit process cannot load a 32-bit DLL, or the other way round. That rule is behind most of the errors people search for:
BadImageFormatExceptionin .NET, when a process loads an assembly or native DLL built for a different architecture.- “%1 is not a valid Win32 application” (Win32 error 193) when Windows is asked to start or load an image it cannot use.
- Plug-ins that “do nothing”. Hosts such as Office, Explorer shell extensions or audio hosts only load add-ins that match the host process.
- Drivers. Kernel drivers must match the architecture of Windows itself; there is no emulation for them.
On Windows 11 on ARM, x86 and x64 applications run under emulation, but that does not relax the rule inside a process: an emulated x64 process still needs x64 DLLs. (ARM64EC, a special ABI designed for mixing ARM64 and x64 code in one process, is outside the scope of this guide.)
Step 1: Read Machine and Magic in the browser
Open the EXE or DLL in the native binary overview. For my six native builds, the Identity block reported:
| File | Format | Architecture | Bitness | File type |
|---|---|---|---|---|
hello-x86.exe |
PE32 | x86 (0x14c) | 32-bit | EXE |
hello-x64.exe |
PE32+ | x64 (0x8664) | 64-bit | EXE |
hello-arm64.exe |
PE32+ | ARM64 (0xaa64) | 64-bit | EXE |
add-x86.dll |
PE32 | x86 (0x14c) | 32-bit | DLL |
add-x64.dll |
PE32+ | x64 (0x8664) | 64-bit | DLL |
add-arm64.dll |
PE32+ | ARM64 (0xaa64) | 64-bit | DLL |
Two smaller hints line up with this. The 64-bit images got the image base 0x140000000 (EXE) or 0x180000000 (DLL), the 32-bit ones 0x400000 and 0x10000000. And the Security features table marks High-entropy ASLR (64-bit) as n/a for PE32 files. Do not rely on those alone, though: an image base is just a linker setting.
Notice that ARM64 and x64 are both PE32+. The Magic only tells you 32-bit vs 64-bit layout, so always read the Machine field to know which 64-bit CPU.
Step 2: Read the two fields yourself
Both fields are at fixed places, which makes them easy to script. The DOS header stores the offset of the PE\0\0 signature at 0x3C. The 2-byte Machine field follows the signature, and the optional header (starting with Magic) begins 24 bytes after the signature:
import struct, sys
MACHINES = {0x14C: "x86", 0x8664: "x64", 0xAA64: "ARM64", 0x1C4: "ARM (Thumb-2)"}
MAGIC = {0x10B: "PE32", 0x20B: "PE32+"}
for path in sys.argv[1:]:
with open(path, "rb") as f:
data = f.read(4096)
if data[:2] != b"MZ":
print(f"{path}: not a PE file"); continue
pe = struct.unpack_from("<I", data, 0x3C)[0] # e_lfanew
if data[pe:pe + 4] != b"PE\0\0":
print(f"{path}: no PE signature"); continue
machine = struct.unpack_from("<H", data, pe + 4)[0] # COFF Machine
magic = struct.unpack_from("<H", data, pe + 24)[0] # optional header Magic
print(f"{path}: {MACHINES.get(machine, hex(machine))} ({machine:#06x}), {MAGIC.get(magic, hex(magic))}")
$ python3 pearch.py *.exe *.dll
hello-arm64.exe: ARM64 (0xaa64), PE32+
hello-x64.exe: x64 (0x8664), PE32+
hello-x86.exe: x86 (0x014c), PE32
xunit.console.exe: x86 (0x014c), PE32
xunit.console.x86.exe: x86 (0x014c), PE32
add-arm64.dll: ARM64 (0xaa64), PE32+
Look at the two xunit files. Both report x86 and PE32, but only one of them is restricted to 32-bit. The header cannot tell them apart. That is the .NET case.
Step 3: .NET assemblies and AnyCPU
A .NET assembly is a PE file with a CLI header (data directory 14). When it contains only IL, the PE Machine field is just a container default. What the runtime actually looks at is the set of CLI flags in that header:
ILONLY: the image contains only IL, no native code.32BITREQUIRED: the image must run in a 32-bit process.32BITPREFERRED: together with32BITREQUIRED, this means “Prefer 32-bit”: run as 32-bit where possible, otherwise as 64-bit.
The binary overview tells you that the file is a .NET assembly and links to the .NET metadata and IL viewer, which shows the PE header and the CLI flags. Here is what it reported for my samples, with REA 6.4.0’s managed_architecture for the same files:
| File | PE (viewer) | CLI flags (viewer) | REA managed_architecture |
|---|---|---|---|
Newtonsoft.Json net45 |
PE32 x86 | ILONLY STRONGNAMESIGNED | anycpu |
xunit.console.exe |
PE32 x86 | ILONLY STRONGNAMESIGNED | anycpu |
xunit.console.x86.exe |
PE32 x86 | ILONLY 32BITREQUIRED STRONGNAMESIGNED | x86 |
| System.Text.Json (win-x64 pack) | PE32+ x64 | ILLIBRARY STRONGNAMESIGNED | x86_64 |
| System.Text.Json (win-arm64 pack) | PE32+ ARM64 | ILLIBRARY STRONGNAMESIGNED | arm64 |
So the rules are:
- PE32, Machine x86,
ILONLY, no32BITREQUIRED: AnyCPU. It runs as 64-bit in a 64-bit process and as 32-bit in a 32-bit one. - The same plus both
32BITREQUIREDand32BITPREFERRED: AnyCPU, Prefer 32-bit. This option only applies to executables. I did not find a published sample with it, so it is not in the table (see the note below). 32BITREQUIREDwithout32BITPREFERRED: x86 only. Loading it into a 64-bit process fails.- PE32+ with Machine x64 or ARM64: platform-specific. The runtime-pack builds above are ReadyToRun images with precompiled native code for one CPU.
REA 6.4.0 derives managed_architecture in managedArchitectureFor (dist/dotnet/ManagedArtifactInspector.js). When the PE machine is not x86, or ILONLY is clear, it returns the PE machine. Otherwise it checks 32BITREQUIRED first (x86), then 32BITPREFERRED (anycpu-prefer-32), and falls back to anycpu. Because of that order, a Prefer 32-bit EXE, which has both flags set, comes back from 6.4.0 as x86 rather than anycpu-prefer-32. To check, I set both bits on a scratch copy of xunit.console.exe (flags 0x9 to 0x2000b, the combination the C# compiler writes for /platform:anycpu32bitpreferred). The viewer listed ILONLY 32BITREQUIRED STRONGNAMESIGNED 32BITPREFERRED, and REA returned "managed_architecture":"x86". So for Prefer 32-bit, trust the raw flags the viewer lists.
One caveat on the ReadyToRun rows. Those images have ILONLY cleared, just like a C++/CLI mixed-mode image. The viewer tells them apart by the ReadyToRun header (the RTR signature in the managed native header), so these files get only the ReadyToRun tag, not Mixed mode. REA 6.4.0 checks ILONLY first and reports implementation: "cpp-cli-mixed" for them. The architecture answer is still right, but for these framework files ReadyToRun is the accurate label, not C++/CLI.
With REA on the command line
rea inspect-managed-artifact takes one argument, the path. I used --filter-output to keep the result short:
rea inspect-managed-artifact ./xunit.console.x86.exe --format json \
--filter-output normalized_result.classification.status,normalized_result.classification.implementation,normalized_result.classification.managed_architecture
{"normalized_result":{"classification":{"status":"managed","implementation":"cil","managed_architecture":"x86"}}}
xunit.console.exe returned "anycpu" with the same command. On a native PE such as hello-arm64.exe, REA does not guess. It returns status: "not-managed", managed_architecture: "unknown" and the evidence code cli-directory-absent. For native files, use the Machine field from Step 1. More about the managed side is in Check a .NET DLL’s Target Framework, Version and References.
Step 4: The same question for ELF and Mach-O
ELF (Linux, Android) stores the class in byte 4 of the file (1 = 32-bit, 2 = 64-bit) and the CPU in e_machine. The binary overview shows them as Format and Architecture: my 32-bit Android build came out as ELF32, ARM, the 64-bit one as ELF64, AArch64. llvm-readelf agrees:
$ llvm-readelf -h hello-arm-android | grep -E "Class|Machine"
Class: ELF32
Machine: ARM
This matters for Android apps: a 64-bit-only phone cannot load a 32-bit .so. The APK analyzer checks the libraries in each lib/<abi>/ folder for you.
Mach-O can hold several architectures in one universal file. The binary overview lists the slices and lets you pick one; on the command line, lipo does the same:
$ lipo -archs hello-macos-universal
x86_64 arm64
See ELF, PE and Mach-O Viewer Online for a full walk through the identity block of all three formats.
Step 5: Command-line equivalents
On macOS and Linux, file and llvm-readobj read the same headers. I ran both on the samples:
$ file hello-arm64.exe xunit.console.exe xunit.console.x86.exe
hello-arm64.exe: PE32+ executable (console) Aarch64, for MS Windows
xunit.console.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
xunit.console.x86.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
$ llvm-readobj --file-headers hello-arm64.exe | grep -E "Machine|Magic"
Machine: IMAGE_FILE_MACHINE_ARM64 (0xAA64)
Magic: 0x20B
file recognizes the .NET assemblies but, like the header, it cannot tell the AnyCPU build from the x86-only one.
On Windows, the usual tools are dumpbin /headers from a Visual Studio Developer Command Prompt (read its machine line and the magic # line under OPTIONAL HEADER VALUES) and corflags (also available in the Developer Command Prompt) for the .NET flags. I did not run them for this article, since all the samples here were built and checked on macOS. They read the same fields.
Troubleshooting
BadImageFormatException when loading a DLL. Check the DLL and the host process. If the DLL is x86-only (32BITREQUIRED) or native x86, the host must be 32-bit. If it is x64 or ARM64, the host must match. For an AnyCPU EXE, check whether Prefer 32-bit is set.
“Not a valid Win32 application”. The image’s Machine does not match what the system can run (for example, an ARM64 EXE on x64 Windows), or the file is not a PE at all. The binary overview tells you which.
Everything is AnyCPU, but a native dependency is x64. AnyCPU only covers IL. A P/Invoke target or a native NuGet asset still has a fixed architecture, so open it in the binary overview as well.
The folder says Program Files (x86). The folder is a hint about the installer, not the file. Read the header.
FAQ
Can I check without installing anything? Yes. The binary overview and the .NET viewer run in your browser, in a Web Worker without network access, and they never run the file.
Does PE32+ always mean x64? No. ARM64 images are PE32+ too. PE32+ only says “64-bit layout”; the Machine field names the CPU.
Why does my AnyCPU DLL show Machine x86? That is the default container for IL-only assemblies. The CLI flags decide how it runs.
Can a 64-bit app use a 32-bit DLL? Not in the same process. You need a 64-bit build of the DLL, or an out-of-process bridge (a separate 32-bit process you talk to through COM or IPC).
Only inspect files you built or are allowed to examine. If you go on to run or debug other people’s software, read REA’s DISCLAIMER.md first.
Next steps
- Check your own builds: native binary overview and .NET metadata and IL viewer
- Identify any native binary and check if it is stripped: ELF, PE and Mach-O Viewer Online
- Go deeper into a Windows binary: Reverse Engineer a Windows EXE or DLL with AI, Ghidra and REA
- Managed code in depth: .NET Assembly Static Analysis with REA
- Reference: Microsoft’s PE Format specification and ECMA-335 (CLI header flags)