记一次对NativeAOT程序的逆向分析
前言
NativeAOT 是 .NET 框架自 .NET 8 正式发布以来逐渐走向成熟的一项关键技术。它通过在编译期将 IL 代码直接编译为本地机器码,生成一个不依赖 外部 CLR / CoreCLR 的原生可执行文件,从而在启动速度、程序大小方面带来了显著提升。
正文
编译
本篇文章采用 .NET 9 运行时进行编译,
代码:
namespace NativeAOTSample
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Welcome to .NET Test!");
#if NATIVE_AOT
Console.WriteLine("This application is compiled with Native AOT.");
#else
Console.WriteLine("This application is running under .NET Framework.");
#endif
string Password = "Password";
Console.WriteLine("Enter the 'Password'...");
string? input = Console.ReadLine();
if (input == Password)
{
Console.WriteLine("Access Granted!");
}
else
{
Console.WriteLine("Access Denied!");
}
}
}
}
编译参数为:
<!-- if you want compiles with .Net Framework, comment this 'TrimmerRootConfiguration' -->
<PropertyGroup Label="TrimmerRootConfiguration" Condition="$(Configuration) == 'Release'">
<PublishAot>true</PublishAot>
<StackTraceSupport>false</StackTraceSupport>
<TrimMode>full</TrimMode>
<BuiltInComInteropSupport>false</BuiltInComInteropSupport>
<StaticExecutable>true</StaticExecutable>
<DefineConstants>$(DefineConstants);NATIVE_AOT</DefineConstants>
</PropertyGroup>
程序外部分析
如果我们不使用NativeAOT编译,携带框架的程序将会达到如图大小:
而如果我们使用NativeAOT编译,其程序大小会显著降低:
而很明显的,你可以注意到 PDB 变大了,从 11 KB 变成了 6.38 MB。
这正是说明,在进行 NativeAOT 之后,其程序确确实实的脱离了 .NET 运行时环境,最后的结果也是用 Linker 进行链接的 Native Code 产物。
程序内部分析
使用的工具 : IDA 9.2 , x64dbg
我们现在使用 IDA 带PDB对程序进行分析。
在加载PDB后,我们注意到程序的入口点如图所示:
其Main函数很明显是在建立一些东西,简言之:
native main()
├─ RhInitialize // 启动 .NET Runtime
├─ Get module handle // 找到当前模块
├─ RhRegisterOSModule // 注册托管代码段
├─ InitializeModules // 初始化 CoreLib + 程序
└─ call managed Main() // 进入 C#
也就是说,对于所有未加壳,未混淆,未魔改的 .NET 9 NativeAOT x64程序,其真正的入口点都是main函数的最后一个函数,前面的我们一般来说不关注。
当我们进入这个 _managed__Main 后,我们又可以发现地狱绘图:
这个托管的Main函数流程示意图:
┌────────────────────────────────────┐
│ 进入托管世界(Reverse P/Invoke) │
│ RhpReversePInvoke │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Runtime 基础准备 │
│ • 预分配 OutOfMemoryException │
│ • ClassConstructorRunner.Init │
│ • RuntimeAugments / TypeLoader │
│ • ReflectionExecution.Init │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 命令行参数初始化 │
│ argc / argv → string[] args │
│ InitializeCommandLineArgs │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 当前模块与类型系统绑定 │
│ typeof(<Module>) RuntimeTypeHandle │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 线程系统初始化 │
│ • Thread.CurrentThread │
│ • ThreadStatic / TLS │
│ • ApartmentState(STA) │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Module Initializer │
│ [ModuleInitializer] │
│ RunModuleInitializers │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Main 参数构造 │
│ 根据 Main 签名构造参数对象 │
│ GetMainMethodArguments │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ MainMethodWrapper │
│ • 适配不同 Main 签名 │
│ • async / Task / int / void │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ ★★★ 用户 C# Main ★★★ │
│ Program.Main(...) │
│ 你的业务逻辑 │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Main 返回处理 │
│ • await Task │
│ • 设置 Environment.ExitCode │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 前台线程收尾 │
│ Thread.WaitForForegroundThreads │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 进程退出事件 │
│ AppContext.OnProcessExit │
│ ProcessExit / Dispose │
└───────────────┬────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 托管 → Native 返回 │
│ RhpReversePInvokeReturn │
└────────────────────────────────────┘
也就是说,到了这一层真正对我们有用的被封装在了 MainMethodWrapper 内,我们应当去查看 MainMethodWarpper 才能看到程序的真实逻辑。
经过观察,这个 MainMethodWarpper 通常在 _managed__Main 的倒数段,且传入一个参数,
我们放一张没有加载 PDB 的图作为对比:
现在我们就可以进入我们Warpper了,Warpper浅显易懂,我们一笔带过。
可以注意到,此时,Main函数的参数被创建成了个托管对象,传入我们真正的逻辑。
进入用户 Main 真实逻辑层,我们按有 PDB 信息和无 PDB 信息放图:
这里的逻辑很像C层,但是好像又不太一样,尤其是当我们点击这些 unk_xxxxx 变量,我们会发现其实他们在静态是空的:
这是因为这些引用并非真正的指针,而是逻辑引用,引用的是C# System.String 对象,那我们没办法了,静态我们拿不到信息,只能去动态碰碰运气了。
因此我们启动 x64dbg 对程序进行调试。
我们按照IDA中的进入顺序,按照如下函数进行:
wmain
SetupCodeMain
然后我们终于看到熟悉的 MainMethodWarpper 了
然后就能进Main了,因为MainWarpper就一个Call,直接进去即可,所以不给图。
Main函数上下文汇编:
我们断在这个 lea 上,然后步进,可以注意到此时的rcx指针指向了我们在IDA看到的那个引用地址:
我们查看该内存区域:
可以发现一个规律:
对于所有的托管对象,其相同类型的MethodTable指针必然相同,
图示为System.String托管对象,且在 .NET 中,数据存储按照小端排列。
等价 C++ 代码:
namespace System {
// 所有托管引用类型的对象头
struct Object
{
void* MethodTable; // +0x00 类型信息(CLR MethodTable)
};
struct String : Object
{
int32_t Length; // +0x08 UTF-16 字符数
int32_t Padding; // +0x0C 对齐填充
char16_t FirstChar; // +0x10 UTF-16 字符起点(柔性数组)
// 逻辑上存在的柔性字符数组,实际长度由 Length 决定;
// 该字段在运行期紧随对象头存储,
// 但 C++ 标准不支持以运行期变量作为数组长度,因此此处仅作概念性标注。
// char16_t Chars[Length];
};
} // namespace System
其他说明:
Console.ReadLine 内部实现涉及多层封装,其核心流程为:
调用操作系统提供的标准输入接口(Windows 下通常为 ReadFile),
将读取到的字节序列按当前控制台编码(如 UTF-16 / OEM Code Page)进行解码,
并在托管堆上构造对应的 System.String 对象作为返回值。
总结
通过以上分析可以看出,NativeAOT 程序在表现形式上已经完全脱离了传统的 .NET Framework / CoreCLR 运行模式,其最终产物更接近一个包含精简运行时的纯原生程序。
在逆向视角下,虽然其代码形式与普通 C/C++ 程序极为相似,但其对象模型、类型系统以及字符串等基础结构仍然严格遵循 CLR 的设计规范。
在本文中,我们以 System.String 为切入点,从静态反编译到运行期调试,验证了托管对象在 NativeAOT 下的真实内存布局,并总结了通过 MethodTable 判定托管对象类型的通用方法。这一思路同样适用于 System.Array、object[] 以及其他常见托管类型,虽然这些东西如果有RTTI,那你基本上睁一只眼闭一只眼都能知道是个啥。
需要注意的是,本文所分析的示例仅涉及同步 Main 逻辑。在实际应用中,若程序使用 async Main、Task、线程池或更复杂的异步模型,其在 MainMethodWrapper 层我还没分析过,所以我不知道。
全文完。
下附示例代码与编译示例程序文件:
https://gitee.com/MartionSimon/workshop/releases/download/NAOTSample/NativeAOTSample.7z