6.6 KiB
.NET 逆向通用工作流
完整工作流细节、IL patch 可靠性、字符串解密器提取、状态机识别、dnlib 脚本化。
完整工作流(端到端)
1. Identify → 确认是 .NET 托管程序(不是 native)
2. Detect → DIE / de4dot --detect 识别混淆器
3. Deobf → de4dot 脱混淆(保留原样本)
4. Static → dnSpyEx 浏览 C# 视图定位,IL 视图看关键逻辑
5. Dynamic → dnSpyEx 调试器在关键方法下断,看运行时明文
6. Patch → IL 编辑器修改,Save Module
每一步的产物要落盘:原样本 target.exe → 脱壳 target-clean.exe → patch 后 target-patched.exe。
IL patch vs C# patch 可靠性
核心结论:关键修改用 IL 编辑器,不要用 C# 编辑器。
| 维度 | C# 编辑器 (Edit Method C#) | IL 编辑器 (Edit IL) |
|---|---|---|
| 编译失败风险 | 高(缺引用、语法、lambda 重写失败) | 几乎为零 |
| 信息保真 | 编译器重新生成 IL,可能与原 IL 不同 | 原样替换,逐指令改 |
| 适用 | 改个字符串、改个常量、简单逻辑 | 改判断、删校验、改控制流 |
| async/await/状态机 | 经常编译失败或扭曲 | 直接改状态机字段,可靠 |
dnSpyEx 的 C# 反编译器是基于只读反编译 + 尝试重编译,对编译器生成的代码(状态机、闭包、yield)重编译极易失败。IL 编辑器是逐指令编辑,所见即所得。
典型 IL patch 模式
改判断(if (check) → 永远 true):
原: call bool Foo::Check()
brfalse.s SKIP
改: ldc.i4.1 ; push true
brfalse.s SKIP ; 现在永远不跳,SKIP 不执行
或更直接:
ldc.i4.1
ret ; 方法直接返回 true
改判断(if (check) → 永远 false):
ldc.i4.0
ret
删整段校验:
全部 nop,或改成 ret + 正确返回值
改字符串常量:
C# 编辑器改字符串通常 OK(ldstr 直接换 token),但若字符串在资源/加密里则要改解密逻辑
改数字常量:
ldarg / ldc 指令直接改操作数
状态机识别(async/await / yield)
C# 的 async/await 和 IEnumerator yield 编译成状态机:编译器生成一个嵌套类,MoveNext() 里用 state 字段做 switch dispatch。dnSpyEx C# 视图会还原成 async,但反编译可能失真,IL 视图看 MoveNext 最准。
async/await 的 MoveNext 结构:
switch(this.<>1__state) {
case 0: ... await 前的逻辑; this.<>1__state = 1; await MoveNext;
case 1: ... await 后的逻辑;
}
要 patch async 逻辑:改 MoveNext 里的 state 转移或具体 case 里的判断。
C# 编辑器改 async 几乎必失败 → 必须用 IL。
字符串解密器提取
详见 obfuscators.md。这里补充 dnlib 脚本化批量解字符串:
// dnlib 脚本:扫描所有字符串解密器调用,运行时还原后写回
// 用法:dotnet script decrypt.csproj target.exe 0x06000012
using System;
using System.Reflection;
using dnlib.DotNet;
using dnlib.DotNet.Writer;
using dnlib.DotNet.Emit;
var module = ModuleDefMD.Load(args[0]);
var decryptorToken = uint.Parse(args[1], System.Globalization.NumberStyles.HexNumber);
// 找到解密方法,用反射调用它(需把 assembly 加载进 AppDomain)
// 遍历所有方法,把 call Decryptor(token) 替换成 ldstr "解密结果"
foreach (var type in module.GetTypes())
foreach (var method in type.Methods)
{
if (!method.HasBody) continue;
var instrs = method.Body.Instructions;
for (int i = 0; i < instrs.Count; i++)
{
// 识别 call 解密器模式,调用解密器拿明文,替换为 ldstr
// (此处省略反射调用解密器的样板,思路:加载原 assembly →
// MethodInfo.Invoke 拿明文 → instrs[i] = OpCodes.Ldstr + operand=明文)
}
}
var opts = new ModuleWriterOptions(module);
module.Write("target-decrypted.exe", opts);
dnlib 是 .NET 元数据编程的事实标准,de4dot 内部就是用它。写自定义脱混淆脚本时首选。
动态调试要点
dnSpyEx 调试器对 .NET 程序比 native 友好得多:
- 断点在方法入口:右键方法 → Add Breakpoint
- 看对象值:断住后 Locals / Watch 窗口直接看对象字段、字符串内容
- 内存写入:可以直接改运行时变量值(Edit Value)
- 异常断点:Debug → Exceptions,勾选要断的异常类型 —— 混淆器常用异常驱动控制流,断异常能看到真实路径
异常驱动控制流
部分混淆器把正常逻辑塞进 try,用 throw + catch 做跳转。静态看 IL 像异常处理,实际是控制流:
try { throw new CustomException(0x42); }
catch (CustomException e) {
switch(e.Code) {
case 0x42: 真实逻辑A; break;
case 0x43: 真实逻辑B; break;
}
}
下异常断点(断 CustomException),跟踪 Code 值流转,比硬啃 IL 快。
模块初始化器(Module .cctor)
.NET 模块的静态构造函数(<module> 的 .cctor)在 assembly 加载时最先执行,混淆器常把 anti-tamper / 解密初始化放这里。分析顺序:
1. 先看 <module>.cctor(Module .cctor)—— 解密/反调试初始化
2. 再看 Program.Main / Startup
3. anti-tamper 在 .cctor 里 → 先 patch .cctor 再脱壳
提取配置 / C2 / Key 的通用模式
红队工具和 loader 常把配置加密嵌在资源或字段里,运行时解密:
定位流程:
1. strings 看有无明文 URL/IP(混淆后通常没有)
2. 找 byte[] 字段 + 解密方法(AES/XOR)
3. 动态断在解密方法的返回点,dump 解密后的明文
4. 常见:AES-256-CBC with Key==IV(Codegate 2013 模式,见 reverse-engineering/tools.md .NET 段)
参考 references/sharp-tools.md 里红队工具的具体配置结构。
与 reverse-engineering 的边界
- IL2CPP / NativeAOT → 编译成 native,没有 CLR 元数据 → 走
reverse-engineering/(IDA/r2),本 skill 仅做识别 - 托管 .NET(标准 C# exe/dll、Mono/Unity 托管层、Xamarin)→ 本 skill
- 混合(native loader + .NET payload) → loader 部分走
reverse-engineering/,dump 出 .NET payload 后切本 skill
落盘产物清单
每次 .NET 逆向任务建议产出:
target-original.exe(原样本,不动)target-clean.exe(de4dot 脱壳后)notes.md(识别的混淆器、解密器 token、关键方法地址、配置/C2/key)target-patched.exe(patch 后,如需要)il-diff.txt(patch 前后 IL 对照,如做 patch)