某直播工具 两代保护机制逆向研究:从单点 nop 到 14 层洋葱
目标:某直播工具 28.x.x.42 和 29.x.x.49,看这两代的授权保护都做了什么,顺便记一下折腾过程。
环境:Windows,.NET 逆向那一套,dnSpy / ILSpy 加自己写的脚本。
结果:28 基本是白给,一个 nop 就能完事;29 换了个思路,搞了 14 层洋葱,最里面那层密钥直接从主程序自己算出来,改主程序一个字节就全盘崩。
只写研究思路、代码实例和踩坑。破解文件、密钥、补丁偏移这些自行处理。请支持正版。
关于试炼程序:本文分析的是商业软件,所以不提供原版,但放了一个我自制的教学 CrackMe「洋葱试炼 · OnionTrial」。本文讲到的 14 层洋葱、CRC64 闸门、RSA 加固分支、两条自毁链、fieldrefs 陷阱,在里面都做成了能亲自踩的活陷阱,练手顺序见第十一节。文中的具体补丁点位做了脱敏,只留研究思路、通用技术原理和"为什么这么想"的过程。
目录
- 28:一个 nop 就完事
- 29:套路全换了
- 打点三板斧
- 四次翻车
- 我学到的七招加密手法[index]
- 方法论:思路是怎么长出来的
- AI 时代:逆向工程正在被改写
- 28 到 29,变了什么
- 写在最后
- 给学习者:把这篇装进 AI
- 试炼程序:洋葱试炼 OnionTrial
一、28:一个 nop 就完事,但主要研究不是28,所以只是简单过一下
先看数字
拿到 28 第一件事就是数数:
| 指标 |
数值 |
说明 |
| 方法总数 |
34,312 |
|
| 4 字节空壳 |
28,454(83%) |
nop; nop; nop; ret |
| #US 堆 |
1,896 B → 2,503,232 B |
差 1320 倍 |
| 反篡改调用 |
4,849 处 |
|
| 授权触发点 |
1 处 |
LicenseManager.Validate |
83% 的方法体都是 nop; nop; nop; ret 这种四字节空壳。说明方法体根本不在 IL 里,被抽到原生资源里去了,运行时靠 JIT Hook 填回来。#US 堆那个 1320 倍的差距也很说明问题:字符串被加密抽走了,正常程序不可能差这么多。
空壳长什么样
抽一个真实方法对比,某直播工具/Countdown.cs 里的 StartCountdown:
// 原版:方法体被抽空
public void StartCountdown(CompleteDelegate completedCallback) { }
// 从原生资源还原之后:完整 14 条 IL
public void StartCountdown(CompleteDelegate completedCallback)
{
if (this.m_a != null)
{
if (this.m_b) { this.m_b = false; this.m_d.Start(); }
}
else
{
StopCountdown();
this.m_d = null;
TimerCallback callback = a;
this.m_a = new System.Threading.Timer(callback, null, 0, 250);
h = completedCallback;
}
}
空壳形态分类也很说明问题:
| Code size |
方法数 |
形态 |
| 4 |
29,949 |
nop; nop; nop; ret / nop; nop; ldc.i4.0; ret |
| 6 |
4,731 |
空 setter / 返回常量的 getter |
| 8 |
619 |
简单委托转发 |
| >200 |
仅 30 |
真实 IL,几乎全是壳自己 |
真实 IL 只有 30 个方法有,还全是壳自己的东西。这壳的"保护"就是抽方法体,抽完把真实 IL 藏进原生资源。
图 1:方法体 Code size 分布。83% 是 4 字节空壳,真实 IL 只剩 30 个方法。

字符串是这么藏的
每个字符串常量都要走两段解密,拿构造一个 GUID 字符串举例:
IL_000a: ldsflda valuetype [mscorlib]Guid lh::a
IL_000f: ldc.i4 -1974737572 ; 常量 A
IL_0014: ldc.i4 -743729822 ; 常量 B
IL_0019: xor ; A ^ B
IL_001a: ldsfld class '<Module>{...}'::m_fb3c...
IL_001f: ldfld int32 '<Module>{...}'::m_3f30... ; 运行时状态 C
IL_0024: xor ; (A^B)^C → 资源内偏移
IL_0025: call string gBMthepoZSL1ZVKpeA::wsO7HTGYd(int32) ; ① 读密文
IL_002a: ldc.i4 60000 ; XOR 密钥索引
IL_002f: call string tzzzzzi::a(string, int32) ; ② 解密
IL_0034: call instance void [mscorlib]Guid::.ctor(string)
三层结构:
ldc.i4 A; ldc.i4 B; xor; ldsfld 状态; ldfld 字段; xor 算出资源内偏移,防止静态定位
wsO7HTGYd(int) 从嵌入资源里读密文
tzzzzzi.a(str, keyIdx) 用 256 字节资源密钥做 XOR 解密
前两步是纯算术,离线就能静态求值。比如上面那段:
A = -1974737572 = 0x8A4BE55C
B = -743729822 = 0xD3AB9562
A ^ B = 0x59E0703E = 1507881022 ← 就是 wsO7HTGYd 的参数
折叠成一条 ldstr 之后,保护就退化成了单段 XOR。说白了就是花里胡哨了三步,其实一步就能搞定。
图 2:字符串三段式解密链。算术部分可以离线复算,不需要跑程序。

顺带说一句,这个 XOR 链不止支持 xor,shl、not 都有。比如某个方法是 (1540607747<<1) ^ -1574414899,另一个是 (~2075507222) ^ -1822064480。都是整数运算,编译器能算,脚本也能算。
授权就一个点
- 触发点只有一个:
LicenseManager.Validate(typeof(a))
- 业务代码对授权子系统的引用数是 0
- 把这一处处理掉,整个授权体系直接失效
补丁前后的 IL 对比,打点:
// ============ 原版 a::Main(Code size 48)============
IL_0000: call bool [...]WindowsFormsApplicationBase::get_UseCompatibleTextRendering()
IL_0005: call void [System.Windows.Forms]Application::SetCompatibleTextRenderingDefault(bool)
IL_000a: ldtoken a
IL_000f: call class [mscorlib]System.Type [mscorlib]Type::GetTypeFromHandle(valuetype [mscorlib]RuntimeTypeHandle)
IL_0014: call void [System]System.ComponentModel.LicenseManager::Validate(class [mscorlib]Type) ← 授权闸门
IL_0019: call void g8T6n6RbLPwmR1ScAJ.dil2BPgckjnUlJwuku::C7NWPVSTiq() ← 反篡改
IL_001e: leave IL_0024
finally { IL_0023: endfinally }
IL_0024: call class a c::get_b()
IL_0029: ldarg.0
IL_002a: callvirt instance void [...]WindowsFormsApplicationBase::Run(string[])
IL_002f: ret
// ============ 打点的版本(Code size 33)============
IL_0000: call bool [...]WindowsFormsApplicationBase::get_UseCompatibleTextRendering()
IL_0005: call void [System.Windows.Forms]Application::SetCompatibleTextRenderingDefault(bool)
IL_000a: ldtoken a ← 留着,维持 maxstack 和栈平衡
IL_000f: nop ← 替换 GetTypeFromHandle
IL_0010: nop ← 替换 LicenseManager.Validate
IL_0011: nop ← 替换 C7NWPVSTiq
IL_0012: leave.s IL_0015 ← 短跳转,把代码段压到 33 字节
finally { IL_0014: endfinally }
IL_0015: call class a c::get_b()
IL_001a: ldarg.0
IL_001b: callvirt instance void [...]WindowsFormsApplicationBase::Run(string[])
IL_0020: ret
技术要点:
ldtoken a 必须留,删了 maxstack 和 leave 目标会错位
Validate 返回 void,不用弹栈,直接 nop 替换就保持栈平衡
- 三条 5 字节
call(15 字节)换成三条 1 字节 nop(3 字节),省下的 12 字节用 leave.s 短跳转吸收
.try/finally 没破坏,只是语义掏空
28 失败就失败在这:整个体系押在一个点上,堵死那一个点就完事了。
二、29:套路全换了,补上了之前的缺陷
架构
某直播工具64.exe (Babel 3.5, 方法体完整)
└─ 加载器:读外部 DLL → RSA-2048 验签 → XOR-32 → 内存加载
└─ layer1 → layer2 → ... → layer13 → layer14 (EC.EC)
注意 Babel 是混淆器,不加壳。方法体是完整的,不用再去找什么"原始程序集"。授权逻辑从主程序里搬出去了,放到外部 DLL 里,还带 RSA 签名。主程序里根本没有可替换的授权分支。
14 层洋葱
| 层 |
密钥 |
来源 |
| 外层 |
32 B |
主程序硬编码 |
| layer1-12 |
512 B × 12 |
各层 loader 内嵌 |
| layer13 |
64 B |
主程序自身派生 |
每层结构都一样:loader 读资源 → XOR → 加载下一层。每层缩减大概 12,800 B。
图 3:14 层洋葱。密钥来源决定强度,第 13 层把主程序绑进密钥。

第 13 层是反替换绑定
// 读主程序自己,按 64 槽累积 XOR 派生密钥
byte[] exe = File.ReadAllBytes(Assembly.GetEntryAssembly().Location);
for (int i = 0; i < exe.Length; i++) key[i % 64] ^= exe[i];
主程序改一个字节,密钥就变,layer14 解不出来,整条链自锁。这一手把"替换主程序"这条路彻底堵死了,是 29 最漂亮的设计,可惜了,再漂亮的设计,在新的AI时代,也挡不住算力之力了;www 。
打开 14 层的完整流程
外层加载器 tw::a 的 IL 流程,按顺序读一遍就明白整条链怎么走的:
IL_0000 br.s IL_006B
IL_006B ReadAllBytes(arg0) -> V_1 // 读 plr64.dat
IL_0061 ldarg.0; ldstr 密文; tzzzzzu::a -> 解密文件名
IL_0006 ReadAllText -> FromBase64String -> V_2 // 读签名文件
IL_0072 RSA 验签(补丁版在此 pop;br.s 跳过 23 字节)
IL_0089..IL_00B0 XOR 循环 V_1[i] ^= arg1[i % arg1.Length]
IL_00B2 Assembly.Load(V_1)
IL_00B9 GetTypes()
IL_00C1 V_7 = V_8[V_8.Length-1] // 取最后一个类型
IL_00CC Activator.CreateInstance(V_7)
IL_00DA..IL_00F5 LateGet(obj, null, "GetAssembly", ...) -> castclass Assembly
几个关键点:
- 没有 try/catch,任何一步失败直接往上抛,最后被外层 catch 成 "This program has been modified"
- 取的是
GetTypes() 的最后一个类型,不按名字找,名字是混淆的
LateGet 走反射拿 GetAssembly,再 castclass 成 Assembly
- 验签那段在补丁版里被
pop; br.s 跳过 23 字节,这是全链唯一的"跳过点"
每层缩减大概 12,800 B,这个规律很稳定,可以当进度条用。
字节码调度器
layer14 的 EC.EC 方法体不在 IL 里,是加密字节码,运行时解释执行。
- 方法名:base-85 编码(
GetE → EM9,%UnssT,解出 8 字节标识符)
- 字节码流:整数读取做了字节序重排,标准解析器拿到的是错误偏移
- 操作码表:100 多条,(哈希值,处理函数索引)
静态分析基本失效。想要真实逻辑,要么逆向解释器协议,要么运行时动手。
三、打点三板斧
动手之前先找点。
第一斧:RVA 换文件偏移
IL 里看到的是 RVA(相对虚拟地址),文件里存的是文件偏移,两者差一个节的基址。换算公式:
文件偏移 = RVA - 节的 VirtualAddress + 节的 PointerToRawData
从 PE 头里把每个节的 VirtualAddress 和 PointerToRawData 读出来,找到 RVA 落在哪个节,代进去就行。示意代码:
def rva_to_offset(pe, rva):
for sec in pe.sections:
va = sec.VirtualAddress
vsz = sec.Misc_VirtualSize
if va <= rva < va + vsz:
return sec.PointerToRawData + (rva - va)
raise ValueError("RVA out of range")
字节补丁就是:读整个文件 → 换算 → 改那几字节 → 写回。不重序列化,原因见翻车二。
第二斧:fieldrefs 列写入点
遇到空引用(NRE)别急着跳代码块,先问一句:是谁把这个全局对象置成 null 的?
把某个字段的所有写入点列出来:
dump fieldrefs <程序集> <类型>::<字段>
然后逐个看:
newobj ...::.ctor() 后面的 stsfld → 正常初始化,别动
- 比较不等分支里的
stsfld null → 自毁动作,这就是要打的点
这一步能解决大部分 NRE 误判。我这次就是靠它把设置面板的 NRE 从"图形问题"拉回"保护壳自毁"。
第三斧:dry-run 列命中
补丁工具先跑一遍 --dry,只列命中不真改。逐条判断是"自毁"还是"正常初始化"。
我这次就踩过:stsfld rzzzzzm::e 实际有 12 处(11 处自毁 + 1 处正常初始化)。一开始只列了 11 处,漏了 rzzzzzm::z 那处合法初始化。要是直接全打,授权对象永远为 null,设置面板 NRE 立刻复现。
所以工具必须能排除指定的 类型::方法,把合法初始化保住。
四、四次翻车
翻车一:改一个字节,重建 13 层
第 13 层用主程序派生密钥。改主程序之后原链失效,启动报 Installation is corrupt。
应对:逐层回填 → 重新生成外层文件 → 逐层校验。
校验方法也简单:每层解密完看是不是 MZ 头,末层跟基准文件比对。中间任何一层对不上,就是这层没配对。
翻车二:Cecil 不能重写程序集
Mono.Cecil 改完 IL 调 asm.Write(),启动报 InvalidProgramException。
原因是 Babel 依赖字节级结构,重序列化会重排 token、改写方法体偏移。正确做法是字节补丁:读字节 → RVA 换算文件偏移 → 改 → 写回,不重序列化。
翻车三:设置面板 NRE,两条自毁链
现象:点设置就空引用,关不掉。
弯路一:判成 D3D 设备丢失。拿原版配对复现,证明是保护壳行为。
弯路二:整段跳过 UI 初始化。设置能开了,但出现了各种杂七杂八的问题,什么主题色彩变蓝、关于页缺值。所以还需要绕过。
真根因是两条独立的链:
链 A:授权对象被清空(直接致崩)
rzzzzzm::z() → tx::a() 等校验
→ 11 处 stsfld rzzzzzm::e 把全局授权对象置 null
→ frmSettings.q 的 ldsfld rzzzzzm::e; callvirt ezzzzzz::zb() → NRE
链 B:渲染器被清空(8 秒后刷炸了)
frmSettings.q:
call dzzzzzy::a() // 程序集 IL 哈希
call yzp::a() // 期望常量
beq.s IL_000E // 相等则跳过
call fzzzzza::a() // 不等 → 安排杀渲染器
ldsfld rzzzzzm::e
callvirt ezzzzzz::zb()
ThreadPool.QueueUserWorkItem → 回调:Thread.Sleep(8000) → stsfld 渲染器 = null → 主循环空引用风暴。
时间证据:设置打开后整 8 秒,风暴开始,跟 Sleep(8000) 吻合。
图 4:两条自毁链。链 A 直接致崩,链 B 延迟 8 秒,时间戳对得上。

要打的点一共 12 处,全部是 stsfld 把全局对象置 null。按位置分三类:
| 位置特征 |
处数 |
动作 |
判定 |
| 校验失败分支 |
11 |
stsfld 授权对象 = null |
自毁,要打 |
| 线程池回调(8 秒链) |
1 |
stsfld 渲染器 = null |
自毁,要打 |
newobj 之后 |
1 |
stsfld 授权对象 = 实例 |
正常初始化,必须保留 |
第三类最容易漏。它是对象创建之后的赋值,跟自毁动作长得一模一样,但性质相反。补丁前不逐条判断,就会把授权对象永远置 null,设置面板 NRE 立刻复现。
图 5:修复前 / 修复后日志对比。同一台机器、同一操作,只差 12 处补丁。

补丁方式是等长替换,保持栈平衡:
stsfld = 0x80 + 4 字节 token(共 5 字节)
改成 = 0x26(pop) + 0x00 × 4(共 5 字节)
为什么用 pop 不用 nop:stsfld 会弹一个值,nop 不弹,栈就多了一项,可能 InvalidProgramException。pop 弹掉,栈平衡不变。
ldsfld 版本同理:0x7E + 4 字节 token 改成 0x2A(ret) + 0x00 × 4。
还有一个坑:这个"安排杀渲染器"的函数有 7 个调用点,分布在录制、外部输出、录制设置、设置面板等位置,每一处都是"比较不等 → 调用"的反篡改开关。只补设置面板那一处不够,必须中和线程池回调里的置 null 动作。
翻车四:GT 黑屏,DLL 层 CRC64 闸门
现象:GT 字幕输入的缩略图、预览、输出全黑,GT Designer 里正常。
图 6:修复前 / 修复后界面对比。只补 DLL 里那一个分支,闸门保持失败。

根因:D2DEngine.dll 构造函数读主程序全文件算 CRC64,跟常量比对,不等则开关恒关,每帧跳过拷贝。IL 长这样:
if (!m_Init) {
m_Init = true;
if (CUtils.CalcO() == <常量>) m_E = true;
}
CalcO() 对主程序全文件做 CRC64:表驱动、init=0、xorout=0、refin 表(256 项直接夹在 IL 里)。补丁版 exe 的 CRC 跟常量不等 → m_E=false → 每帧跳过拷贝 → 共享纹理停在初始状态 → 黑屏。
发现:从 IL 里挖出 CRC64 表(256 项),复算三份 exe 全部命中实测值,算法确认。
正解:只补 DLL 里那一个分支。定位方法是找 IL 模式:
ldc.i8 <常量> (0x21 + 8 字节立即数)
bne.un.s <偏移> (0x33 + 1 字节偏移)
ldc.i4.1; stsfld m_E (0x17 0x80)
改法:
33 xx → 26 26 (pop / pop)
栈平衡:bne.un 本来就弹两个值,pop pop 同样弹两个,顺序落到 ldc.i4.1; stsfld,开关恒 true。
不能改成 00 00(nop nop):会残留两个栈项,方法栈不平衡,可能 InvalidProgramException。
图 7:CRC64 闸门。正解是只补分支,伪造 CRC 会激活加固分支。

弯路(别再走):伪造主程序 CRC 让校验通过。结果启动即崩:
STARTUP ERROR: 程序已被修改,即将退出
未能加载文件或程序集"218624 bytes loaded from tmpC19B.tmp...",试图加载格式不正确的程序
含义是:CRC 一命中就切进"文件可信"分支,该分支要用原始文件派生的方式把受保护程序集解到临时文件再 LoadFrom。补丁版字节已经改了,解出来是垃圾 → BadImageFormatException。为过校验伪造 CRC,等于把原本被跳过的加固分支激活。
反向结论:另一个原生库也有同款闸门。改成永远通过后输出窗格全黑,改回立刻恢复。闸门保持失败时走的是可用的托管回退路径,别动。
五、可以从中学到的七招加密手法
| 手法 |
说明 |
弱点 |
| XOR 滚动密钥 |
固定密钥循环异或 |
密钥在代码里,能提取 |
| 分层密钥 |
外层、中间、最内层来源不同 |
逐层攻破,工作量大 |
| 主程序自身作密钥 |
派生密钥,改主程序即失效 |
派生逻辑可被 Hook |
| RSA 文件签名 |
公钥硬编码,防文件篡改 |
主程序可改,验签可绕 |
| 字节码解释执行 |
逻辑不以 IL 存在 |
密钥内嵌,可离线还原 |
| base-85 + 字节序混淆 |
消除字符串特征 |
算法可逆 |
| CRC64 全文件校验 |
改动即失效 |
静态常量比对,能找到分支 |
这些手法有效的原因就两个:提高攻击者成本,让静态分析失效。防护的本质是成本博弈,让攻击成本高于收益,但是现在嘛,算力爆灌,基本没悬念了,时代过去咯。
六、方法论:思路是怎么出来的
这一节是全文最该读的。前面写的都是"我做了什么",这一节写"我为什么这么做"。
6.1 先把"观察"和"结论"分开
逆向这行最容易犯的错,是把"猜对了"当成"会了"。猜对一次不代表下次还对。
严谨的做法是把每件事拆成四步:观察 → 假设 → 验证 → 结论。每一步都要能被别人独立复算。
拿 #US 堆举例。我观察到的只是两个数字:1,896 B 和 2,503,232 B,差 1320 倍。这是个异常,还算不上结论。假设是"字符串被加密抽走了"。怎么验证?看字符串解密调用的数量:原版 216 处,还原后 21,827 处。两个独立证据指向同一结论,才去写进文档。
如果只看 #US 堆就下结论,那是猜。加上调用数这条独立证据,才叫验证。
6.2 证伪比证实重要
设置面板 NRE 那次,我走了两条弯路。现在回头看,正好是证伪的教科书。
| 假设 |
依据 |
证伪方法 |
结果 |
| 一:D3D 设备丢失 |
日志里 DeviceLostTimer 在报 |
拿原版配对跑同样操作 |
原版也报,假设死 |
| 二:UI 初始化代码有问题 |
设置面板加载时报错 |
整段跳过后看结果 |
主题变蓝、About 缺值,假设死 |
| 三:有人把全局对象置 null |
堆栈指向全局字段 |
fieldrefs 列写入点 |
找到 11 处自毁分支,假设活 |
关键点:前两个假设我都能提出,也都能被简单实验证伪。如果我不做证伪,直接按假设二改,就会留下"主题变蓝"的后遗症,还以为是成功了。
证伪的成本很低(跑一次原版配对),不复原的成本很高(留后遗症)。这笔账很划算。
6.3 每个方法都要问"为什么"
为什么用字节补丁,不用 Cecil?
Cecil 是高级 API,改 IL 很方便,但它 Write() 的时候会重排 token、改写方法体偏移。29 的 Babel 保护依赖字节级结构,第 13 层拿主程序自身派生密钥,任何字节变化都会顺着链传导。
所以工具选择得看保护机制对什么敏感:
- 保护对语义敏感 → 改 IL 语义就行
- 保护对字节偏移敏感 → 必须降到字节层,等长替换
判断依据是观察:Cecil 写完就报 InvalidProgramException,说明保护在读字节级结构。那就别在高层工具上纠缠,直接降层。
为什么不能伪造 CRC?
直觉上 CRC 闸门就是"过校验"的障碍,伪造 CRC 让它通过就完事了。但 29 里 CRC 校验值和分支逻辑是耦合的:CRC 通过会切进"文件可信"分支,那条分支会用原始文件派生的方式解受保护程序集。补丁版字节已经改了,解出来是垃圾 → BadImageFormatException。
结论:校验值和它守护的分支是一体的,改校验值等于改控制流。 正确做法是只改分支本身,不动校验值。
为什么用 fieldrefs,不直接看异常堆栈?
异常堆栈告诉你"在哪里崩",不告诉你"为什么崩"。ql::a 的 NRE 堆栈指向 ozzzzzq::b(),但那个方法只是后果,它自己也是读到 null 才崩的。fieldrefs 列的是"谁写的",直接定位到因。
所以:堆栈是结果,写入点是原因。
6.4 分层防御的成本分析
28 是单点防御:授权触发点 1 处,堵死即失效。攻击成本是常数级,O(1)。
29 是纵深防御:14 层嵌套,每层失败不影响其他层,第 13 层还把主程序绑进密钥。攻击成本是线性级 O(n),而且"改一处动全身"的耦合让成本进一步上升。
说白了,纵深防御就是把攻击成本一级一级往上抬:从 O(1) 到 O(n),再到耦合级。防护方要做的就一件事,把攻破成本抬到比收益高。抬上去了,防护就成立。
反过来说,评估一个保护强不强,别看它用了多少招,看它把攻击成本抬到了哪一级。28 的招数不少(抽方法体、字符串加密、反篡改),但成本还是 O(1),因为全押在一个点上。
6.5 验证要三个独立证据
单一证据容易出错,我要求每个结论至少三个独立证据:
| 结论 |
证据一 |
证据二 |
证据三 |
| 方法体被抽空 |
83% 是 4 字节空壳 |
真实 IL 仅 30 个方法 |
#US 堆 1320 倍异常 |
| 字符串被加密 |
三段式 IL 存在 |
A^B 可算术复算 |
调用数 216 → 21,827 |
| 授权是单点 |
触发点仅 1 处 |
业务代码引用数 0 |
补丁后体系失效 |
| 第 13 层绑定主程序 |
派生代码存在 |
改字节即崩 |
重建链后恢复 |
三个证据相互独立,指向同一结论,才立得住。少一个都要标注"待验证"。
6.6 反例与边界
方法都有失效条件,写清楚比藏着掖着强:
- 字节补丁不是万能的。 如果保护不依赖字节偏移,Cecil 就够用,还能少写很多代码。选工具前先判断敏感维度。
- fieldrefs 只适用于"全局对象被置 null"型 NRE。 空数组、越界、类型转换失败都不适用,别硬套。
- 伪造校验值在有些保护里可能有效(校验值和分支逻辑不耦合时),在 29 里无效。别把个例当通例。
- 分层密钥的弱点:只要有一层密钥内嵌在代码里,就能提取。层数只抬高成本,不改变本质。
- "绕过症状"和"消除根因"是两回事。 跳过 UI 初始化能让设置打开,但主题变蓝。凡是"改了之后有别的副作用",都要怀疑是不是只绕过了症状。
6.7 给后来者的学习路径
按这个顺序练,别跳:
- IL 基础:opcode、栈平衡、token、RVA。看不懂
stsfld 弹几个值,后面全白搭。
- 工具:dnSpy / ILSpy 看 IL,pefile 看 PE 结构,能自己写脚本批量分析。
- 空壳识别:数 Code size 分布,找异常。83% 是 4 字节,这个数字本身就是线索。
- 字符串分析:找解密器,把多步算术静态求值成一步。
- 字段追踪:fieldrefs 列写入点,区分初始化和自毁。
- 字节补丁:等长替换,永远先想栈平衡。
- 最后练"为什么":每个方法问三个为什么,问到自己能答上来为止。
前三步是基本功,四到六步是手法,第七步是分水岭。只会手法的人能干活,会问为什么的人能应变。
6.8 排查 SOP
- 看日志,记堆栈和时间戳。时间戳常是关键证据。
fieldrefs 列全局字段的所有写入点,逐个判断。
- 拿原版配对复现,确认是不是保护壳行为。
- 补丁前 dry-run 列命中点,区分"自毁"和"正常初始化"。
- 补丁后重建链,逐层校验。
- 验证要够长。有 8 秒延迟自毁,静置 30 秒以上,多轮开关。
- 记录。同一个坑不踩第二次。
七、AI 时代:逆向工程正在被改写
7.1 先交代:这篇文章本身就有 AI 深度参与。
诚实说,这篇笔记的分析和写作借助了 AI。AI 帮我批量转储和比对 IL、统计方法体分布、定位字符串解密链、检索文献。人工做的是判断:哪个异常值得追、哪个假设该证伪、哪个结论能立住。
这也是现在逆向工作的常态。开源项目 REA(Reverse Engineer Anything)一周涨了约 7.88 万 GitHub 星,冲到当周所有项目第一。它做的事说穿了不复杂:把 Ghidra、IDA Pro、Hopper 通过 MCP 协议封装成 AI Agent 能调用的接口,让 AI 自己去翻二进制、追函数、给结论。证据它也会一起带回来,之前复杂重复的工作量可以交给ai了,好多研究的乐趣都没了,但是效率极大提高了,也就是说,现在受限的其实是天马行空的思路,不再是局限技术了,任何人都能借ai的力量,做到想做的事情。
REA 不是第一个做这件事的(GhidraMCP、IDA Pro MCP 去年就有了)。它的突破在把操作门槛降到了接近零,让普通人都能轻易上手,技术本身还是已有工具的重新包装。有人评价它"炒作",这评价只对了一半:工具本身确实是已有技术的重新包装,但门槛降低带来的冲击是真的。
7.2 AI 对逆向的冲击
门槛低太多了 以前要练几年才敢上手,现在会写 Prompt 的初级攻击者借 AI 就能完成大部分分析,甚至有些人连逆向的概念都不懂,直接就和ai要,反而更快的达到目的,啼笑皆非。据说一个说法哈,安全研究机构的测试数据研究,在未保护的代码上,顶级 AI 模型的代码还原成功率能到 72%-86%。
效率也实打实。 人工分析几小时的函数,AI 几十秒给结果。EURECOM 那篇《Decompiling the Synergy: An Empirical Study of Human–LLM Teaming in Software Reverse Engineering》观察了 48 名参与者、累计 6586 分钟的逆向工作,发现 AI 让新手的性能提升了 98%,标准算法的分析时间缩短了 238%,还能多恢复至少 66% 因编译丢失的信息。
攻击者还能同时扫一片目标,这是人工做不到的,毕竟人的精力和注意力是有限的。
7.3 但 AI 有明确的边界
同一篇研究也给了相反的结论:真正的顶尖高手用 AI 几乎没有提升,某些情况下还会被AI模型的幻觉干扰,反而多一步排查。 这数据挺反直觉。
为什么?因为本来就会,AI 的提速对他们不构成边际收益;而 AI 一旦给出看似合理实则错误的解释,如果真信了,反而很容易带偏了。
DecompileBench 基准测试的结论更直接:AI 方法在代码可读性上确实超过了传统商业反编译器,但在功能正确性上仍然低 52.2%。翻译一下:AI 生成的伪代码"看起来很对",直接编译运行可能行为不一致。
还有几个限制:
- 幻觉:AI 面对看不懂的代码,倾向于"合理化"地描述成某种常见模式,而不是承认不理解,但是现在有很多解决方法了。
- 上下文窗口:一个典型二进制有几十万个函数,再大的窗口也装不下整个程序,AI 只能逐函数理解,建立不了全局架构视图。
- 复杂混淆:控制流平坦化、虚拟化保护(VMProtect、Themida)会让 AI 的理解能力明显下降。商业级虚拟化保护,AI 目前还是啃不动。
7.4 回到这个案例:AI 帮了什么,没帮什么
拿这篇笔记里的具体环节说:
AI 帮上忙的:
- 批量统计 34,312 个方法的 Code size 分布,几秒钟出结果,批量的活儿ai干的确实快
- 比对 660,298 行 IL,找出方法体差异
- 算术验证字符串 XOR 链(A^B 这类计算)
- 检索和整理文献
AI 帮不上忙的:
- 判断"设置面板 NRE 是不是保护壳行为",这需要拿原版配对做实验,AI 没法替,
- 判断"12 处 stsfld 里哪 11 处是自毁",这需要理解业务语义,AI 只能列出候选
- 判断"伪造 CRC 会不会激活加固分支",这需要预判控制流的连锁反应,AI 给不出可靠答案
- 决定"哪个假设值得追",这是品味问题
在直觉判断上,AI还差的十万八千里,但是穷举暴力测算,人类拍马也比不上算力机器
7.5 对防护方的启示
如果攻击者都用 AI 了,防护方怎么办?
先把 AI 的输入成本抬上去。 AI 的分析质量高度依赖输入质量。反编译输出越难读,AI 越容易出错。所以混淆、虚拟化、控制流平坦化这些"让代码难读"的手段,在 AI 时代反而更重要,它们同时提高人和 AI 的成本。
然后别指望"挡住 AI",要算成本账。 AI 让攻击成本下降,但防护的本质是"攻破成本高于收益"。29 的 14 层洋葱就是在干这个:层数抬高成本,主程序绑定抬高耦合成本。AI 能加速逐层分析,但加速不了"改一处动全身"的耦合。
防护方自己也该用 AI。 这一点很重要,把自己的保护壳丢给 AI 分析,看它多久能出结果、结论对不对。这比雇人盲测便宜得多。
按最悲观的方向,或许该抛弃过度防护了。将注意力放到产品自身的竞争力上来,让工具让更多人用好,工具本身才是根本,用户体验才是根本。
7.6 对人的要求反而更高了
AI 把"执行层"的价值打薄了:批量分析、格式转换、初步翻译,这些活 AI 干得比人快。剩下的价值集中在判断层:
- 提假设的能力(从异常里看出问题)
- 设计证伪实验的能力(怎么花最小成本否定一个假设)
- 预判连锁反应的能力(改一个字节会牵连什么)
- 对结论的审慎(三个独立证据才敢立)
这篇笔记里,真正体现价值的是"为什么不信第一个假设""为什么没去伪造 CRC"。
AI 是放大器。会判断的人用它更快,不会判断的人用它更快地犯错。门槛降低了,天花板没降。以前不会逆向的人现在能借 AI 入门,但要从入门走到能独立做判断,该吃的苦一点没少,用的算力只会越来越多,成本也会越来越高。
7.7 一句实话
这篇文章能写出来,AI 帮了很多。工具会越来越强,想清楚"为什么"这件事,暂时还是人的活儿啊。
八、28 到 29,变了什么
| 维度 |
28 |
29 |
| 授权位置 |
程序集内 |
外部 DLL |
| 文件签名 |
无 |
RSA-2048 |
| 方法体 |
抽空成 stub |
加密字节码 + 调度器 |
| 密钥 |
固定 |
分层,最内层由主程序派生 |
| 授权与业务 |
解耦 |
绑定 |
| 反替换 |
无 |
第 13 层 |
四条经验:
- 从单点决策到纵深防御
- 从纯托管到托管加原生
- 从静态保护到运行时解释
- 从授权与业务解耦到授权与主程序绑定
三条可复用:
- 保护点要分层,单层被攻破不波及整体
- 避免单点闸门
- 密钥要分散,最好跟被保护对象绑定
29 的新攻击面:验签公钥和逻辑在主程序里、字节码密钥内嵌、托管层缺原生校验。
图 8:28 到 29 的六维对照。攻击成本从 O(1) 抬到 O(n) 加耦合。

九、写在最后
真正记住的是这几个瞬间:
- XOR 出
MZ 头
-
US 堆 1320 倍
- 13 层全部解密通过
- 从 IL 挖出 CRC64 表,复算三份全部命中
- base-85 解出方法名
- 时间戳精确对上 8 秒
逆向这行最上头的时刻,是"看懂了它",比破解成功那一秒还满足。第 13 层那个设计让我多花了好几天,但不憋屈。好的保护机制抬高理解的门槛,不锁死理解的路。
踩坑合集
给想走同一条路的人,这六个坑我都踩过:
- 改字节必重建链,Cecil 重序列化会炸
- 别信第一个假设,先证伪再动手
- fieldrefs 列写入点,能省掉一半瞎猜
- 补丁前 dry-run 列命中,区分自毁和正常初始化
- 校验值别伪造,它跟分支是一体的
- 验证要够长,8 秒延迟自毁静置 30 秒以上
参考
- EURECOM《Decompiling the Synergy》AI 逆向协同研究
- DecompileBench 反编译基准
- REA(Reverse Engineer Anything)开源项目
- dnSpy / ILSpy / Mono.Cecil 文档
细节可能有记岔的地方,因为用AI修饰过了,时间比较晚了,眼花了有忽略的也有可能,欢迎拍砖。觉得有用,点个赞或者评论区聊聊都行。
试炼程序已经做好了,文末附件里的「洋葱试炼 · OnionTrial」。上面这些方法在里面做成了一个个能亲自踩的陷阱,练手顺序见第十一节。
另外交代一句:这篇稿子的分析和写作借助了 AI(批量转储 IL、统计分布、检索文献),但每个"为什么"都是人想清楚的,验证也是人跑的。这点第七节写过,不藏着。
十、给学习者:把这篇装进 AI
读一遍不等于会。这一节给你两样东西:一段能直接复制的提示词,和一个能装进 AI 的技能包。把它们用起来,这篇的知识点就从「看过了」变成「陪你练」。
10.1 先说清楚能干什么、不能干什么
能干的:把文章里的概念讲透、陪你练分析手法、出题考你、卡住时给提示、帮你复盘思路。
不能干的:替你跑程序、替你判断哪个假设值得追、替你承担"这个结论靠不靠谱"的责任。
第七节说过,AI 是放大器。会判断的人用它更快,不会判断的人用它更快地犯错。这个技能包也一样,陪练可以,替你不行。
10.2 一段提示词,任何 AI 都能用
下面整段复制,发给 ChatGPT / Claude / WorkBuddy / Kimi 都行。它把文章的知识框架写进去了,AI 不用读原文也能带你学。
你现在的角色是「.NET 保护机制逆向」学习教练。我读完了一篇题为《某直播工具两代保护机制逆向研究:从单点 nop 到 14 层洋葱》的技术文章,想把它里的知识点真正学到手。请你以这篇文章为知识边界,不要跑出这个范围瞎编。
【文章核心知识点】
一、两代保护机制
1. 第一代(28,.NET Reactor):把方法体抽空成 4 字节空壳,83% 的方法都是 nop; nop; nop; ret,真实 IL 只剩 30 个方法;方法体被抽到原生资源,运行时靠 JIT Hook 填回来;字符串走三段式解密;反篡改调用 4849 处;但授权触发点只有 1 处(LicenseManager.Validate),业务代码对授权子系统的引用数是 0。
2. 第二代(29,Babel 3.5):混淆器不加壳,方法体完整;授权逻辑搬到外部 DLL,带 RSA-2048 签名;14 层洋葱结构,密钥分层(32 B / 512 B×12 / 64 B);最内层密钥由主程序自身派生(key[i % 64] ^= exe[i]),改主程序一个字节整条链自锁;最内层是加密字节码 + 解释器,方法名用 base-85 编码,字节码流做了字节序重排。
二、分析方法
1. Code size 分布统计:数空壳占比,83% 是 4 字节,这个数字本身就是线索。
2. #US 堆对比:1,896 B vs 2,503,232 B,差 1320 倍,说明字符串被加密抽走了。
3. 字符串三段式解密链:ldc A; ldc B; xor; ldsfld 状态; ldfld; xor 算出资源内偏移 → 从嵌入资源读密文 → 用 256 字节资源密钥做 XOR 解密。前两步是纯算术,离线就能静态求值。
4. RVA 换文件偏移:文件偏移 = RVA - 节的 VirtualAddress + 节的 PointerToRawData。
5. fieldrefs 列写入点:newobj 之后的 stsfld 是正常初始化,比较不等分支里的 stsfld null 是自毁动作。
6. dry-run 列命中:补丁前先跑一遍只列命中不真改,逐个判断,还要能排除合法初始化。
7. 字节补丁:等长替换保持栈平衡。stsfld(0x80 + 4 字节 token)改成 pop(0x26)+ 0x00×4;ldsfld(0x7E + 4 字节 token)改成 ret(0x2A)+ 0x00×4。
8. CRC64 闸门:ldc.i8 常量; bne.un.s; ldc.i4.1; stsfld 这个模式,正解是只改分支本身,不能伪造校验值(伪造会激活加固分支)。
三、方法论
1. 观察 → 假设 → 验证 → 结论,每一步都要能被别人独立复算。
2. 证伪比证实重要:先花小成本否定一个假设。
3. 每个方法都要问「为什么」:工具选择看保护对什么敏感,对语义敏感就改 IL 语义,对字节偏移敏感就必须降到字节层做等长替换。
4. 分层防御成本:单点防御是 O(1),纵深防御是 O(n),再把密钥跟被保护对象绑定就升到耦合级。评估保护强弱看它把攻击成本抬到哪一级。
5. 每个结论至少三个独立证据。
6. 排查 SOP:看日志记时间戳 → fieldrefs 列写入点 → 拿原版配对复现 → 补丁前 dry-run → 补丁后重建链逐层校验 → 验证要够长(有 8 秒延迟自毁,静置 30 秒以上)→ 记录。
四、踩坑
1. 改字节必重建链,用 Cecil 重写程序集会报 InvalidProgramException。
2. 别信第一个假设,先证伪再动手。
3. fieldrefs 列写入点,能省掉一半瞎猜。
4. 补丁前 dry-run 列命中,区分自毁和正常初始化。
5. 校验值别伪造,它跟它守护的分支是一体的。
6. 验证要够长,8 秒延迟自毁静置 30 秒以上。
7. 绕过症状不等于消除根因。
五、合规边界
只讲原理、方法和思路,不给具体补丁偏移、密钥、破解步骤或可运行产物。商业软件请使用正版。
【请这样带我学】
1. 先问我想练哪个方向:概念理解 / 分析手法 / 字节补丁 / 方法论 / 实战出题。
2. 用费曼学习法:让我用自己的话讲,你指出漏洞。
3. 我答错时不要直接给答案,先给提示,让我再想一层。
4. 每讲完一个知识点,配一个「如果是你,下一步怎么想」的问题。
5. 我卡住时才展开讲,讲完再出题巩固。
10.3 一个技能包,装进 WorkBuddy 就能复用
如果你们用 WorkBuddy,把 dotnet-protection-reversing 整个文件夹放进技能目录,就能反复调用:
- 用户级(所有项目可用):
C:\Users\Administrator\.workbuddy\skills\
- 项目级(只当前项目):
<项目>\.workbuddy\skills\
技能包结构:
dotnet-protection-reversing/
├── SKILL.md
└── references/
├── 01-protection-mechanisms.md 两代保护机制全解
├── 02-analysis-techniques.md 分析方法与字节补丁
├── 03-methodology.md 方法论
├── 04-pitfalls-and-sop.md 踩坑与排查 SOP
├── 05-case-notes.md 本案数据(脱敏)
└── 06-glossary.md 术语表
装好后直接说「用 .NET 保护机制逆向技能带我学」,它会先问你练哪个方向,再按费曼学习法带你过。
10.4 一个完整的使用示例
你:用 .NET 保护机制逆向技能带我学
AI:好。先问一句,你想练哪个方向?
1. 概念理解(两代保护机制到底差在哪)
2. 分析手法(Code size、字符串链、fieldrefs)
3. 字节补丁(等长替换、栈平衡)
4. 方法论(观察→假设→验证→结论)
5. 实战出题
你:2
AI:那我们从 Code size 开始。先别看我这边的答案,你自己说:
一个程序集里有 34,312 个方法,其中 28,454 个是 4 字节。
你第一反应是什么?为什么这个数字本身就是线索?
你:说明大部分方法体被抽空了?
AI:方向对了。再想一层:抽空之后,真实的方法体去哪了?运行时又怎么恢复?
...