Is This EXE a .NET App? Framework, Single-File, R2R or NativeAOT
On this page
Short answer: an EXE is a .NET assembly only if its PE header has a CLI header (data directory 14). A .NET Framework EXE has one. A modern .NET app usually does not: the .exe is a native launcher called the apphost, and the real assembly is the .dll next to it, or is packed inside the EXE as a single-file bundle. A NativeAOT EXE has no CLI header and no IL at all. ReadyToRun is not a separate kind of EXE. It is native code added to a normal IL assembly.
So “is this .NET?” has five possible answers, and the fastest checks are different for each. This guide builds all five from one Hello World project and shows what this site’s tools, REA (the open-source project by morluto) and standard tools report for each one.
Who this is for: developers and support engineers who need to know what kind of build they are holding before they pick a debugger, a decompiler or a profiler, or before they answer “which runtime does this need?”.
What you will be able to tell apart:
- a .NET Framework EXE (IL, needs the Framework installed)
- a .NET apphost plus DLL (framework-dependent)
- a single-file bundle (apphost with the assemblies appended)
- a ReadyToRun assembly (IL plus precompiled native code)
- a NativeAOT binary (native code only)
All samples are my own builds, except the NativeAOT one, which comes from Microsoft’s MIT-licensed runtime.win-x64.Microsoft.DotNet.ILCompiler 10.0.0 package on nuget.org. Use the same checks on your own builds. If you go further and debug or run software you did not write, make sure you are authorized to, and read REA’s DISCLAIMER.md.
The five builds
I used the .NET SDK 10.0.402 on macOS (Apple Silicon), started from dotnet new console, and published for Windows x64:
dotnet publish -c Release -r win-x64 --self-contained false -o out/fdd
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o out/sf-fdd
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o out/sf-sc
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishReadyToRun=true -o out/r2r
The .NET Framework build is the same Program.cs with <TargetFramework>net48</TargetFramework>, built with dotnet build. The SDK pulls in the reference assemblies, so this works on macOS too.
NativeAOT is the one I could not build here. dotnet publish -r win-x64 -p:PublishAot=true on macOS stops with:
error : Cross-OS native compilation is not supported.
Microsoft’s Native AOT overview lists Visual Studio’s C++ workload as the Windows prerequisite, so a Windows NativeAOT EXE has to be built on Windows. As a stand-in I used tools/ilc.exe from the ILCompiler package above, which carries the NativeAOT markers described below.
Here is what came out:
| Build | File | Size | CLI header |
|---|---|---|---|
| .NET Framework 4.8 | Fx.exe |
5,120 bytes | yes |
| apphost + DLL | Hello.exe + Hello.dll |
162,304 + 4,096 | EXE no, DLL yes |
| single-file, framework-dependent | Hello.exe |
168,897 | no |
| single-file, self-contained | Hello.exe |
73,544,671 | no |
| ReadyToRun | Hello.exe + Hello.dll |
DLL 16,384 | DLL yes |
| NativeAOT (ilc.exe) | ilc.exe |
14,639,360 | no |
The ReadyToRun DLL is four times the size of the IL-only one. Microsoft’s ReadyToRun page explains why: R2R binaries “contain both intermediate language (IL) code, which is still needed for some scenarios, and the native version of the same code.”
Step 1: Drop the EXE into the .NET viewer
The fastest first check is this site’s .NET metadata and IL viewer. It parses the file in your browser and never uploads it. Its PE reader (src/tools/dotnet/pe.ts) looks for the CLI header and, when there is none, decides between two error codes:
NOT_DOTNET: “This is a native PE, not a .NET assembly (no CLI header).”NATIVE_AOT: the same, but the file has a.managedorhydratedsection or contains the stringDotNetRuntimeDebugHeader.
Results for the six files:
| File | Viewer result |
|---|---|
Fx.exe |
.NET, PE32 x86, flags ILONLY, target .NETFramework,Version=v4.8, one reference: mscorlib 4.0.0.0 |
apphost Hello.exe |
NOT_DOTNET |
Hello.dll (next to it) |
.NET, PE32+ x64, ILONLY, target .NETCoreApp,Version=v10.0, references System.Runtime and System.Console 10.0.0.0 |
single-file Hello.exe (both) |
NOT_DOTNET |
ReadyToRun Hello.dll |
.NET, flags ILLIBRARY, tagged ReadyToRun |
ilc.exe |
NATIVE_AOT |
Two results deserve a closer look.
The apphost and both single-file EXEs get the same NOT_DOTNET answer as a C++ program would. That is correct, since none of them has a CLI header, but it is not the whole story. Step 2 separates them.
ilc.exe was flagged through the string marker, not the section names. Its sections are plain .text, .rdata, .data, .pdata, .rsrc and .reloc. In the binary overview it has exactly one export, named DotNetRuntimeDebugHeader. The self-contained single-file EXE does not trip this check: it exports 44 symbols, among them DotNetRuntimeInfo, g_dacTable and CLRJitAttachState, which come from the statically linked CoreCLR runtime, but not DotNetRuntimeDebugHeader.
Step 2: Apphost or single-file bundle?
Every apphost contains a fixed 32-byte bundle signature. The 8 bytes in front of it hold the offset of the bundle manifest. In a plain apphost that offset is zero. In a single-file bundle it points near the end of the file, where a small manifest lists the embedded files. ILSpy uses the same test (IsBundle in ICSharpCode.Decompiler/SingleFileBundle.cs).
This read-only script reports which case you have. It only parses bytes and never runs the file:
import struct, sys
MARKER = bytes.fromhex("8b1202b96a612038727b930214d7a03213f5b9e6efae3318ee3b2dce24b36aae")
KINDS = {1: "Assembly", 2: "NativeBinary", 3: "DepsJson", 4: "RuntimeConfigJson", 5: "Symbols"}
def read_str(d, p): # 7-bit length prefix, as BinaryWriter writes it
n = shift = 0
while True:
b = d[p]; p += 1; n |= (b & 0x7F) << shift; shift += 7
if b < 0x80: return d[p:p + n].decode(), p + n
d = open(sys.argv[1], "rb").read()
at = d.find(MARKER)
if at < 8: sys.exit("no bundle signature: not an apphost")
hdr = struct.unpack_from("<q", d, at - 8)[0]
if hdr == 0: sys.exit("apphost, not a bundle: the app is a separate .dll")
major, minor, count = struct.unpack_from("<IIi", d, hdr)
_, p = read_str(d, hdr + 12)
p += 40 if major >= 2 else 0 # deps/runtimeconfig offsets and flags
print(f"single-file bundle v{major}.{minor}, {count} files")
for _ in range(count):
off, size = struct.unpack_from("<qq", d, p); p += 16
p += 8 if major >= 6 else 0 # compressed size
kind = d[p]; name, p = read_str(d, p + 1)
print(f" {KINDS.get(kind, kind):<17} @{off:<9} {size:>9} {name}")
Output on my builds (the self-contained list is cut after three entries):
== fx/Fx.exe
no bundle signature: not an apphost
== fdd/Hello.exe
apphost, not a bundle: the app is a separate .dll
== sf-fdd/Hello.exe
single-file bundle v6.0, 3 files
Assembly @163840 4096 Hello.dll
RuntimeConfigJson @167936 330 Hello.runtimeconfig.json
DepsJson @168266 440 Hello.deps.json
== sf-sc/Hello.exe
single-file bundle v6.0, 175 files
Assembly @9994240 4096 Hello.dll
RuntimeConfigJson @9998336 360 Hello.runtimeconfig.json
Assembly @10002432 980776 Microsoft.CSharp.dll
The 4,096 bytes at offset 163,840 of the framework-dependent bundle are byte-for-byte the same Hello.dll that the non-bundled publish wrote (same SHA-256). The bundle is just the apphost with the files appended.
If you have an apphost, look for the DLL it starts: my apphost contains the string Hello.dll once, at file offset 151,904, which the SDK wrote into it at publish time. The apphost does not import hostfxr.dll. Its import table lists only Windows and CRT DLLs (KERNEL32, ADVAPI32, SHELL32, USER32 and the api-ms-win-crt-* set); it finds and loads hostfxr at run time.
To get the assemblies out of a bundle, use ILSpy. ilspycmd 11.1 refuses to guess and lists the entries instead:
$ ilspycmd -l c sf-fdd/Hello.exe
error: single-file bundle; name an entry with --bundle-entry <name>. Managed entries:
Hello.dll (entry point)
ilspycmd --bundle-entry Hello.dll sf-fdd/Hello.exe decompiles it, and ilspycmd -d -o extracted sf-fdd/Hello.exe writes all three files to a folder. The extracted Hello.dll had the same SHA-256 as the original. Microsoft’s single-file overview also points to ILSpy for inspecting bundles. Two caveats from that page: native libraries are not bundled unless IncludeNativeLibrariesForSelfExtract is set, and EnableCompressionInSingleFile compresses the embedded assemblies.
Step 3: ReadyToRun is still IL
A ReadyToRun assembly is a normal .NET assembly with a second, native copy of the code. The managed native header carries the RTR signature, and the site’s viewer shows a ReadyToRun tag with the note that the IL “is still the source of truth”. ILSpy decompiled my R2R Hello.dll to the same C# as the IL-only build, because it reads the IL.
One detail is easy to misread. The R2R DLL’s CLI flags are ILLIBRARY only: ILONLY is cleared. That flag is also how C++/CLI mixed-mode assemblies look. REA 6.4.0 falls into this:
{ "status": "managed",
"runtime_family": "mixed-clr-native",
"implementation": "cpp-cli-mixed",
"evidence": ["cli-header-observed", "cli-il-only-clear", "managed-architecture-derived"] }
The same output reports "ready_to_run_signature": true under pe.cli. In 6.4.0’s ManagedArtifactInspector.js, the clear ILONLY check runs before the ReadyToRun check, so a crossgen’d assembly is labelled C++/CLI. If you use REA, read ready_to_run_signature first. The site’s viewer does the opposite: it treats a cleared ILONLY as mixed-mode only when the image is not ReadyToRun.
Note that I compiled win-x64 ReadyToRun code on macOS arm64 without errors, although the cross-compilation table on Microsoft’s ReadyToRun page does not list Windows targets for macOS hosts. I only checked that the output has the R2R header; I did not run it on Windows.
Step 4: NativeAOT means no IL to read
A NativeAOT binary is native code plus a trimmed runtime. Microsoft’s overview says these apps “don’t use a just-in-time (JIT) compiler” and lists limits such as no Assembly.LoadFile and no Reflection.Emit. For analysis the consequence is simple: there is no metadata and no IL, so .NET decompilers have nothing to work with. ilspycmd on ilc.exe stops with:
ICSharpCode.Decompiler.Metadata.MetadataFileNotSupportedException: PE file does not contain any managed metadata.
It prints the same error for the plain apphost. Treat a NativeAOT EXE as a native binary: start with the binary overview and continue in a native disassembler, as in free native binary analysis with REA and Ghidra.
Step 5: The same checks from the command line
REA 6.4.0. rea inspect-managed-artifact <path> takes the path as its only argument and does not load or execute the file. Its classification block for my builds:
| File | status |
runtime_family |
implementation |
|---|---|---|---|
Fx.exe |
managed |
dotnet-framework |
cil |
Hello.dll |
managed |
modern-dotnet |
cil |
ReadyToRun Hello.dll |
managed |
mixed-clr-native |
cpp-cli-mixed |
apphost, both bundles, ilc.exe |
not-managed |
unknown |
not-managed |
For the last row the only evidence is cli-directory-absent. REA 6.4.0 does not tell an apphost from a bundle or from NativeAOT, and it has no single-file extraction; that part stays with ILSpy or the script above. For the full REA workflow on assemblies, see .NET assembly static analysis with REA.
System.Reflection.Metadata. With the .NET 10 SDK, a single .cs file runs directly with dotnet run isnet.cs. This is the check I ran on all files:
using System.Reflection;
using System.Reflection.PortableExecutable;
foreach (var path in args)
{
using var pe = new PEReader(File.OpenRead(path));
var cor = pe.PEHeaders.CorHeader;
if (cor is null) { Console.WriteLine($"{path}: native PE, no CLI header"); continue; }
var r2r = cor.ManagedNativeHeaderDirectory.Size > 0;
Console.WriteLine($"{path}: .NET, flags {cor.Flags}, ReadyToRun header {r2r}");
Console.WriteLine($" {AssemblyName.GetAssemblyName(path).FullName}");
}
fx/Fx.exe: .NET, flags ILOnly, ReadyToRun header False
fdd/Hello.exe: native PE, no CLI header
r2r/Hello.dll: .NET, flags ILLibrary, ReadyToRun header True
../ilc/pkg/tools/ilc.exe: native PE, no CLI header
A non-empty managed native header is a good hint, not proof: checking the RTR signature inside it, as the viewer and REA do, is stricter. AssemblyName.GetAssemblyName() on its own is the classic one-liner test, also from PowerShell as [Reflection.AssemblyName]::GetAssemblyName($path). In my run on .NET 10 it threw BadImageFormatException for the apphost, the bundle and ilc.exe, and returned a name for the rest. I did not run the PowerShell form.
file(1). file 5.41 adds “Mono/.Net assembly” to Fx.exe, both Hello.dll files and nothing else, which matches the CLI header check.
Windows SDK tools. corflags <file> with no options prints the CLI flags, and dumpbin /clrheader prints the .NET header. Microsoft documents CorFlags as installed with Visual Studio, and DUMPBIN is part of the MSVC tools. I did not run either for this article. See Microsoft’s CorFlags.exe and DUMPBIN /CLRHEADER pages.
Quick decision table
| What you see | It is | Next step |
|---|---|---|
CLI header, target .NETFramework,..., imports only mscoree.dll |
.NET Framework EXE | check the target framework |
| No CLI header, bundle signature with offset 0 | apphost | open the DLL with the same name |
| No CLI header, bundle signature with non-zero offset | single-file bundle | ilspycmd --bundle-entry or -d |
CLI header, ReadyToRun tag, ILONLY cleared |
ReadyToRun assembly | read the IL as usual |
No CLI header, DotNetRuntimeDebugHeader export |
NativeAOT | native tools |
FAQ
Is an EXE without a CLI header always native code? For the apphost and bundles, the EXE itself is native, but it starts .NET code that lives next to it or inside it. Only NativeAOT has no IL anywhere.
Does 32-bit vs 64-bit tell me anything?
Not about .NET. Fx.exe is PE32 x86 because AnyCPU assemblies use a 32-bit header; see 32-bit, 64-bit or ARM64.
Can a NativeAOT binary be turned back into C#? Not with a .NET decompiler, since there is no IL. A native decompiler shows machine-level logic of the program and its runtime.
Why does my single-file app still have files next to it? Native libraries and the PDB stay outside the bundle by default, per Microsoft’s single-file page.
Next steps
- Check any EXE or DLL in the browser: .NET metadata and IL viewer and binary overview
- Choose a decompiler for the IL you found: ILSpy vs dnSpyEx vs dotPeek
- Read framework, versions and references: check a .NET DLL’s target framework
- Go deeper with REA: .NET assembly static analysis