这个 EXE 是不是 .NET 写的?分清 Framework、单文件、ReadyToRun 和 NativeAOT
本页目录
结论先行: 只有 PE 头里带 CLI 头(第 14 个数据目录)的 EXE,才是 .NET 程序集。.NET Framework 的 EXE 有 CLI 头;现代 .NET 应用的 EXE 往往没有。那个 .exe 只是一个原生启动器,叫 apphost,真正的程序集是旁边的同名 .dll,或者被打包塞进 EXE 尾部(单文件发布)。NativeAOT 的 EXE 既没有 CLI 头,也没有任何 IL。ReadyToRun 则不是一种独立的 EXE,它是在普通 IL 程序集上额外附加的一份原生代码。
所以“这是不是 .NET”其实有五种答案,每种最快的判断方法都不一样。本文用同一个 Hello World 项目构建出全部五种形态,对照展示本站工具、REA(morluto 的开源项目)以及常用命令行工具各自报告了什么。
适合谁读: 在选调试器、反编译器或性能分析器之前,需要先弄清手里是哪种构建的开发和技术支持人员;或者需要回答“这个程序要装哪个运行时”的人。
读完你能分清:
- .NET Framework EXE(IL,需要系统装有 Framework)
- .NET apphost + DLL(依赖框架发布)
- 单文件发布(apphost 尾部追加了程序集)
- ReadyToRun 程序集(IL 加预编译原生代码)
- NativeAOT 程序(只有原生代码)
除 NativeAOT 外,所有样本都是我自己构建的;NativeAOT 样本取自 nuget.org 上微软以 MIT 许可证发布的 runtime.win-x64.Microsoft.DotNet.ILCompiler 10.0.0 包。你可以对自己的构建做同样的检查。如果之后要调试或运行不是你自己写的软件,请先确认有授权,并阅读 REA 的 DISCLAIMER.md。
五种构建
我在 macOS(Apple Silicon)上用 .NET SDK 10.0.402,从 dotnet new console 开始,发布到 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
.NET Framework 版本用的是同一份 Program.cs,把目标框架改成 <TargetFramework>net48</TargetFramework> 后执行 dotnet build。SDK 会自动拉取引用程序集,所以在 macOS 上也能编译。
唯独 NativeAOT 在这台机器上编不出来。在 macOS 上执行 dotnet publish -r win-x64 -p:PublishAot=true,直接报错:
error : Cross-OS native compilation is not supported.
微软的 Native AOT 概述 把 Visual Studio 的 C++ 工作负载列为 Windows 上的前置条件,所以 Windows 版 NativeAOT EXE 只能在 Windows 上构建。我用上面那个 ILCompiler 包里的 tools/ilc.exe 代替,它带有下文要讲的 NativeAOT 特征。
构建结果:
| 构建 | 文件 | 大小 | CLI 头 |
|---|---|---|---|
| .NET Framework 4.8 | Fx.exe |
5,120 字节 | 有 |
| apphost + DLL | Hello.exe + Hello.dll |
162,304 + 4,096 | EXE 无,DLL 有 |
| 单文件,依赖框架 | Hello.exe |
168,897 | 无 |
| 单文件,自包含 | Hello.exe |
73,544,671 | 无 |
| ReadyToRun | Hello.exe + Hello.dll |
DLL 16,384 | DLL 有 |
| NativeAOT(ilc.exe) | ilc.exe |
14,639,360 | 无 |
ReadyToRun 的 DLL 是纯 IL 版的四倍大。微软的 ReadyToRun 文档 给出了原因:R2R 二进制同时包含 IL(某些场景仍需要)和同一份代码的原生版本。
第 1 步:把 EXE 拖进 .NET 查看器
最快的第一步,是用本站的 .NET 程序集元数据与 IL 查看器。文件在浏览器本地解析,不会上传。它的 PE 解析器(src/tools/dotnet/pe.ts)先找 CLI 头;找不到时,在两个错误码之间二选一:
NOT_DOTNET:这是原生 PE,不是 .NET 程序集(没有 CLI 头)。NATIVE_AOT:同样没有 CLI 头,但文件里有.managed或hydrated节,或者含有字符串DotNetRuntimeDebugHeader。
六个文件的结果:
| 文件 | 查看器结果 |
|---|---|
Fx.exe |
.NET,PE32 x86,标志 ILONLY,目标 .NETFramework,Version=v4.8,只引用 mscorlib 4.0.0.0 |
apphost Hello.exe |
NOT_DOTNET |
旁边的 Hello.dll |
.NET,PE32+ x64,ILONLY,目标 .NETCoreApp,Version=v10.0,引用 System.Runtime 和 System.Console 10.0.0.0 |
两个单文件 Hello.exe |
NOT_DOTNET |
ReadyToRun Hello.dll |
.NET,标志 ILLIBRARY,标记为 ReadyToRun |
ilc.exe |
NATIVE_AOT |
有两点值得细看。
apphost 和两个单文件 EXE 得到的都是 NOT_DOTNET,跟一个 C++ 程序的结果一样。这个判断没错,三者确实都没有 CLI 头,但信息不全。第 2 步会把它们区分开。
ilc.exe 是靠字符串特征识别出来的,不是靠节名:它的节只有普通的 .text、.rdata、.data、.pdata、.rsrc、.reloc。在 二进制概览工具 里看,它只导出一个符号,名字正是 DotNetRuntimeDebugHeader。自包含单文件 EXE 不会被误判:它导出 44 个符号,包括来自静态链接的 CoreCLR 运行时的 DotNetRuntimeInfo、g_dacTable、CLRJitAttachState,但没有 DotNetRuntimeDebugHeader。
第 2 步:apphost 还是单文件包?
每个 apphost 里都有一段固定的 32 字节 bundle 签名,签名前面 8 个字节存着 bundle 清单的偏移量。普通 apphost 里这个偏移是 0;单文件包里它指向文件末尾附近的一个小清单,清单列出所有内嵌文件。ILSpy 用的是同样的判断方法(ICSharpCode.Decompiler/SingleFileBundle.cs 里的 IsBundle)。
下面这个只读脚本告诉你属于哪种情况。它只解析字节,不会运行文件:
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}")
在我的构建上的输出(自包含版本只保留前三项):
== 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
依赖框架的单文件包里,偏移 163,840 处的 4,096 字节,和非单文件发布写出的 Hello.dll 完全一致(SHA-256 相同)。所谓单文件包,就是 apphost 后面追加了这些文件。
如果拿到的是普通 apphost,就去找它要启动的 DLL:我的 apphost 里在文件偏移 151,904 处有一次字符串 Hello.dll,这是 SDK 发布时写进去的。apphost 并不导入 hostfxr.dll,它的导入表里只有 Windows 和 CRT 的 DLL(KERNEL32、ADVAPI32、SHELL32、USER32 以及一组 api-ms-win-crt-*),hostfxr 是运行时再查找加载的。
要把单文件包里的程序集取出来,用 ILSpy。ilspycmd 11.1 不会替你猜,而是列出可选条目:
$ 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 可以直接反编译它,ilspycmd -d -o extracted sf-fdd/Hello.exe 会把三个文件全部写到一个目录里。解出来的 Hello.dll 和原文件 SHA-256 相同。微软的 单文件部署概述 也推荐用 ILSpy 查看单文件包。那一页还提到两点:原生库默认不打进包里,除非设置了 IncludeNativeLibrariesForSelfExtract;EnableCompressionInSingleFile 会压缩内嵌程序集。
第 3 步:ReadyToRun 仍然是 IL
ReadyToRun 程序集就是普通 .NET 程序集,外加一份原生代码。托管原生头里带有 RTR 签名,本站查看器会显示 ReadyToRun 标签,并说明这里展示的 IL 仍是准确的逻辑来源。ILSpy 把我的 R2R Hello.dll 反编译出了和纯 IL 版本一样的 C#,因为它读的就是 IL。
有个细节很容易误读:R2R DLL 的 CLI 标志只有 ILLIBRARY,ILONLY 被清掉了。而 C++/CLI 混合模式程序集也长这样。REA 6.4.0 就在这里判断错了:
{ "status": "managed",
"runtime_family": "mixed-clr-native",
"implementation": "cpp-cli-mixed",
"evidence": ["cli-header-observed", "cli-il-only-clear", "managed-architecture-derived"] }
同一份输出的 pe.cli 下面明明写着 "ready_to_run_signature": true。6.4.0 的 ManagedArtifactInspector.js 先检查 ILONLY 是否被清除,再检查 ReadyToRun,于是 crossgen 过的程序集被归成了 C++/CLI。用 REA 的话,先看 ready_to_run_signature。本站查看器的顺序相反:只有在不是 ReadyToRun 的前提下,ILONLY 被清除才算混合模式。
另外,我在 macOS arm64 上把 win-x64 的 ReadyToRun 代码编译出来了,没有报错,尽管微软 ReadyToRun 文档里的交叉编译表并没有列出 macOS 主机可以面向 Windows。我只确认了输出带有 R2R 头,没有在 Windows 上运行它。
第 4 步:NativeAOT 没有 IL 可读
NativeAOT 程序是原生代码加一个精简过的运行时。微软文档说这类应用运行时不使用 JIT 编译器,还列出了限制,比如不能 Assembly.LoadFile、不能用 Reflection.Emit。对分析来说结论很简单:没有元数据也没有 IL,.NET 反编译器无从下手。ilspycmd 打开 ilc.exe 会报错:
ICSharpCode.Decompiler.Metadata.MetadataFileNotSupportedException: PE file does not contain any managed metadata.
打开普通 apphost 也是同样的错误。把 NativeAOT EXE 当原生程序处理:先用 二进制概览工具 看,再换原生反汇编器,参考 用 REA 和 Ghidra 免费分析原生二进制。
第 5 步:命令行里做同样的检查
REA 6.4.0。 rea inspect-managed-artifact <path> 只接受一个路径参数,不会加载或执行文件。它对我这几个构建给出的 classification:
| 文件 | 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、两个单文件包、ilc.exe |
not-managed |
unknown |
not-managed |
最后一行唯一的证据是 cli-directory-absent。REA 6.4.0 分不出 apphost、单文件包和 NativeAOT,也没有单文件解包功能,这部分交给 ILSpy 或上面的脚本。REA 处理程序集的完整流程见 用 REA 做 .NET 程序集静态分析。
System.Reflection.Metadata。 .NET 10 SDK 可以用 dotnet run isnet.cs 直接运行单个 .cs 文件。下面是我对所有文件跑的检查:
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
托管原生头非空只是很强的提示,不是证据;像查看器和 REA 那样再核对里面的 RTR 签名更严格。单独调用 AssemblyName.GetAssemblyName() 是经典的一行判断法,在 PowerShell 里写作 [Reflection.AssemblyName]::GetAssemblyName($path)。我在 .NET 10 上实测,它对 apphost、单文件包和 ilc.exe 抛出 BadImageFormatException,对其余文件返回程序集名。PowerShell 写法我没有实际运行。
file 命令。 file 5.41 只给 Fx.exe 和两个 Hello.dll 加上了 “Mono/.Net assembly”,和 CLI 头的判断一致。
Windows SDK 工具。 不带选项运行 corflags <file> 会打印 CLI 标志,dumpbin /clrheader 会打印 .NET 头。按微软文档,CorFlags 随 Visual Studio 安装,DUMPBIN 属于 MSVC 工具。这两个我都没有在本文中实际运行。参见微软的 CorFlags.exe 和 DUMPBIN /CLRHEADER。
速查表
| 你看到的 | 它是 | 下一步 |
|---|---|---|
有 CLI 头,目标 .NETFramework,...,只导入 mscoree.dll |
.NET Framework EXE | 查看目标框架 |
| 无 CLI 头,有 bundle 签名且偏移为 0 | apphost | 打开同名 DLL |
| 无 CLI 头,有 bundle 签名且偏移非 0 | 单文件包 | ilspycmd --bundle-entry 或 -d |
有 CLI 头,ReadyToRun 标签,ILONLY 被清除 |
ReadyToRun 程序集 | 照常读 IL |
无 CLI 头,导出 DotNetRuntimeDebugHeader |
NativeAOT | 改用原生工具 |
常见问题
没有 CLI 头的 EXE 一定是原生代码吗? apphost 和单文件包的 EXE 本身确实是原生的,但它会启动放在旁边或者藏在自己体内的 .NET 代码。只有 NativeAOT 是哪里都没有 IL。
看 32 位还是 64 位能说明什么吗?
跟是不是 .NET 无关。Fx.exe 是 PE32 x86,因为 AnyCPU 程序集使用 32 位 PE 头,详见 判断 EXE/DLL 是 32 位、64 位还是 ARM64。
NativeAOT 程序能还原回 C# 吗? .NET 反编译器做不到,因为没有 IL。原生反编译器能看到的是程序和运行时在机器码层面的逻辑。
为什么我的单文件应用旁边还有别的文件? 按微软单文件文档的说法,原生库和 PDB 默认不打进包里。
下一步
- 在浏览器里检查任意 EXE 或 DLL:.NET 元数据与 IL 查看器、二进制概览工具
- 为找到的 IL 选一个反编译器:ILSpy、dnSpyEx、dotPeek 怎么选
- 查看框架、版本和引用:查看 .NET DLL 的目标框架
- 用 REA 深入分析:.NET 程序集静态分析