记一次商业级 .NET 保护壳完整脱壳实战
一篇从零开始的 .NET 逆向工程实战分享,涵盖虚拟机、JIT Hook、Native花指令、控制流混淆的原理分析与脱壳脚本编写,最终实现全自动脱壳。
目录
- 一、背景与目标
- 二、初步侦察
- 三、Layer 1:PE 段加密
- 3.1 现象发现
- 3.2 GStruct8 参数定位
- 3.3 解密流程与脚本实现
- 四、Layer 2:嵌入式原生 DLL
- 4.1 GStruct1 / GStruct0 结构发现
- 4.2 KDAR 紧凑 PE 格式还原
- 4.3 jithook.dll 提取与去混淆
- 五、Layer 3:JIT Hook 方法体加密(核心保护)
- 5.1 JIT Hook 工作原理
- 5.2 FOAP / NSWF 数据结构
- 5.3 IL 方法体解密与 RVA 修补
- 六、Layer 4:字符串混淆
- 6.1 混淆方式与加密算法还原
- 6.2 基于 dnlib 的自动化解密
- 七、de4dot 符号标准化
- 八、隐藏程序集提取:Lain.dll
- 8.1 SummerDay 动态代理发现
- 8.2 TestBB.qk 脚本解密与分析
- 8.3 Bit-Flip 密码与 KDAR 变体
- 九、总结与思考
一、背景与目标
在一次逆向工程任务中,我们遇到了一个被「乾坤引擎 (Qiankun Engine)」保护的程序集套件。该套件包含一个主程序 Main.exe 和多个 DLL 模块,使用了多层保护策略,使得传统的 dnSpy / ILSpy 无法直接反编译。
我们的目标是:
- 理解 乾坤引擎的每一层保护机制
- 开发 通用脱壳工具,一键还原所有受保护的程序集
- 提取 被隐藏加密的动态加载程序集
最终我们成功处理了主WPF+DLL共10程序集,全部还原为可正常反编译的状态
二、初步侦察
拿到 Main.exe (493,056 字节) 后,首先用 dnSpy 打开,发现:
- 大部分方法体无法反编译 — dnSpy 显示方法体为抛出运行时异常
- 字符串全部乱码 — 所有用户可见的字符串以及标签都被加密替换
- PE 段结构异常 — 使用 PE 工具检查,发现 .text 段的 SizeOfRawData = 0(虚拟段),说明原始代码被移到了别处
这些特征暗示至少存在三层保护。让我们逐层突破。
PE 段异常特征
- Section VA VS RO RS 特征
- .text 0x2000 0x48200 0x0 0x0 [VIRTUAL] ← 异常!
- .text 0x4C000 0x400 0x0 0x0 [VIRTUAL] ← 异常!
- .rsrc 0x4E000 0x29254 0x200 0x29400 正常
复制代码 注意两个 .text 段都是虚拟的(RS = 0),但有非零的 VirtualSize。这意味着 PE 加载器会为它们分配内存,但文件中没有数据 —— 数据必定在运行时被动态填充。
三、Layer 1:PE 段加密
3.1 现象发现
在 PE 文件中搜索数据段,发现 .rsrc 段之后还有额外的数据区域。通过交叉引用 .NET 模块初始化代码(ns2/GClass107.erhe34n3),我们发现了第一层保护的实现:
原理:将 .NET 程序集的关键 PE 段(.text)内容用 RC4 加密 + GZip 压缩后存放到一个独立的数据段中,原始段变为虚拟段(RawSize = 0)。程序启动时由模块初始化代码解密还原到内存中。
3.2 GStruct8 参数定位
加密参数存储在一个名为 GStruct8 的结构中:- ┌─────────────────────────────────┐
- │ GStruct8 │
- ├─────────────────────────────────┤
- │ byte[16] key RC4 密钥 │
- │ uint32 count 加密段数 │
- │ GStruct7[count] entries 段描述表 │
- └─────────────────────────────────┘
- ┌─────────────────────────────────┐
- │ GStruct7 (每条 16 字节) │
- ├─────────────────────────────────┤
- │ uint32 dest_rva 目标段 RVA │
- │ uint32 dest_size 解压后大小 │
- │ uint32 src_rva 加密数据 RVA │
- │ uint32 enc_size 加密后大小 │
- └─────────────────────────────────┘
复制代码 定位策略:扫描有数据的段,寻找满足以下条件的偏移:
- 偏移处有 16 字节非全零数据(RC4 密钥)
- 紧跟的 uint32 值等于虚拟段数量
- 后续的 GStruct7 表中 dest_rva 恰好匹配虚拟段的 VirtualAddress
本样本的参数:- RC4 密钥: a2d40244a915d403c06ebe4793e74809
- 段数量: 2
- 段 1: 加密数据 RVA 0xA2000 (165,414 字节)
- → 目标 RVA 0x2000 (解压后 295,424 字节 = .NET 核心代码段)
- 段 2: 加密数据 RVA 0xCA228 (647 字节)
- → 目标 RVA 0x4C000 (解压后 1,024 字节 = 辅助代码段)
复制代码 3.3 解密流程与脚本实现
关键发现:RC4 状态在多个段之间是连续的 —— 第一个段解密后,RC4 密钥流不重置,第二个段接着使用当前状态继续解密。这一点很重要,如果错误地为每个段重新初始化 RC4,第二个段将无法正确解密。- def decrypt_outer_sections(pe, gstruct8):
- """Layer 1 解密 — 注意 RC4 共享状态"""
- result = {}
- key = gstruct8['key']
- rc4 = RC4(key) # ★ 单实例,共享密钥流
- for entry in gstruct8['entries']:
- # 1. 从 src_rva 读取加密数据
- src_off = pe.rva_to_offset(entry['src_rva'])
- encrypted = pe.data[src_off:src_off + entry['enc_size']]
- # 2. RC4 解密(连续密钥流)
- decrypted = rc4.crypt(encrypted)
- # 3. GZip 解压
- decompressed = gzip.decompress(decrypted)
- result[entry['dest_rva']] = decompressed
- return result
复制代码 解密后需要重建 PE 文件,将解密的段数据填入正确的文件偏移,并更新段表中的 SizeOfRawData 和 PointerToRawData:- def build_decrypted_pe(pe, decrypted_sections):
- """重建 PE 文件 — 将虚拟段转为有数据的实段"""
- FILE_ALIGN = pe.file_alignment
- cur_offset = align_up(pe.size_of_headers, FILE_ALIGN)
- # 为每个段计算新的文件偏移
- new_pe = bytearray(...)
- for sec in pe.sections:
- if sec.virtual_address in decrypted_sections:
- data = decrypted_sections[sec.virtual_address]
- # 更新段头: SizeOfRawData, PointerToRawData
- # 写入解密数据
- return new_pe
复制代码 Layer 1 完成后,Main.exe 从 493,056 字节展开为 789,504 字节的完整 PE 文件。
四、Layer 2:嵌入式原生 DLL
4.1 GStruct1 / GStruct0 结构发现
第一层解密后,PE 内部暴露出更多数据。通过分析 ns0/GClass21 和 ns0/GClass22,我们发现 .NET 程序集内嵌入了原生 DLL(用于 JIT Hook 注入)。
GStruct1(主控结构,48 字节):- 偏移 大小 内容
- 0x00 4 int32 = 48(结构大小标识)
- 0x04 12 保留字段
- 0x0C 4 uint32 dll_table_rva — GStruct0 表的 RVA
- 0x10 16 保留字段
- 0x20 16 byte[16] key — 全局 RC4 主密钥
复制代码 GStruct0(DLL 描述表,56 字节):- 偏移 大小 内容
- 0x00 12 byte[12] name — 混淆的 DLL 名称
- 0x0C 4 uint32 bitness — 32 或 64
- 0x10 16 byte[16] key — 该 DLL 专属的 RC4 密钥
- 0x20 8 uint64 data_rva — 加密数据的 RVA
- 0x28 8 uint64 config_rva — 配置数据 RVA(0 = 无)
- 0x30 8 保留
复制代码 DLL 名称的解混淆算法:- def decode_dll_name(gs0):
- """name[i] = ((name[i] - 1) & 0xFF) ^ key[i]"""
- decoded = []
- for i in range(12):
- b = ((gs0['name'][i] - 1) & 0xFF) ^ gs0['key'][i]
- if b == 0: break
- decoded.append(b)
- return bytes(decoded).decode('ascii')
复制代码 定位到的嵌入 DLL:- 名称: jithook
- 位数: 32
- Per-DLL Key: 2c9cf37e45dc022494ca4e41cebb4948
- 数据 RVA: 0x2E200
- 配置 RVA: 0x0(表示方法体映射嵌入在主 PE 中)
复制代码 4.2 KDAR 紧凑 PE 格式还原
在 data_rva 处读取的加密载荷结构:- 偏移 大小 内容
- 0x00 4 enc_size — 加密数据总长度
- 0x04 4 padding
- 0x08 16 block_key — 块级 RC4 密钥
- 0x18 N encrypted_payload — RC4 加密的紧凑 PE 数据
复制代码 解密后得到的不是标准 PE 文件,而是乾坤引擎自定义的 KDAR 紧凑格式:- ┌───────────────────────────────────────────┐
- │ KDAR Compact PE Format │
- ├───────────────────────────────────────────┤
- │ [0:4] format_indicator (> 8, 标识紧凑模式) │
- │ [4:6] Machine │
- │ [6:8] NumberOfSections │
- │ [8:10] SizeOfOptionalHeader │
- │ [10:12] Characteristics │
- │ [12:14] Magic (0x10B=PE32, 0x20B=PE32+) │
- │ [14:18] AddressOfEntryPoint │
- │ [18:22] ImageBase │
- │ [34:38] SizeOfImage │
- │ [42:44] DllCharacteristics │
- │ [46:70] DataDirectories (Export,Import,Reloc)│
- │ [94:94+36*N] Section Headers (36字节/个): │
- │ [+0:+4] VirtualAddress │
- │ [+4:+8] VirtualSize │
- │ [+8:+12] RawSize (加密) │
- │ [+12:+16] RawPointer (紧凑数据内偏移) │
- │ [+20:+36] RC4 Key (16字节, 每段独立) │
- └───────────────────────────────────────────┘
复制代码 每个段的数据都使用独立的 RC4 密钥单独加密,需要逐段解密后拼装成标准 PE 文件。还原流程:
- 解析紧凑头 → 获取段数量、机器类型等
- 依次解析 36 字节的段头 → 获取每段的 VA/VS/RS 和 16 字节 RC4 密钥
- 对每个段:从紧凑数据的 RawPointer 处读取 RawSize 字节 → RC4 解密
- 构建标准 DOS Header + PE Header + Optional Header + Section Headers
- 按 0x200 文件对齐写入各段数据
最终提取出 jithook.dll(115,200 字节)。
4.3 jithook.dll 提取与去混淆
提取出的 jithook.dll 本身也被混淆了。混淆方式是将函数逻辑拆分到 .text 和 .rsrc 两个段之间:- 执行流: .text → CALL → .rsrc (Level 1 跳板)
- ↓
- .rsrc (Level 2 代码块: 真实逻辑 + 垃圾指令)
- ↓
- JCC/JMP → .text (返回)
复制代码
- Level 1 跳板:lea esp,[esp+4]; call $+5; add [esp],delta; ret — 纯粹的地址跳转,无实际功能
- Level 2 代码块:包含真实的 CMP/JCC/MOV 等指令,但夹杂大量垃圾寄存器操作(PUSH+POP、XCHG、无效 MOV 等)
我们编写了 deobfuscate_jithook.py 去混淆脚本:
- 扫描 .text 段中所有 CALL → .rsrc 的指令
- 解析 Level 1 跳板的 call $+5 + add [esp], delta 模式确定跳转目标
- 将 .text 段中 CALL 之间的垃圾字节替换为 NOP
- 将 .rsrc 段标记为可执行(添加 IMAGE_SCN_MEM_EXECUTE)
- 生成 IDA Python 辅助脚本,自动标记 .rsrc 中的代码区域
去混淆后在 IDA Pro 中成功还原出关键函数 sub_100013B0(JIT Hook 主处理函数,0x93A 字节),这是理解 Layer 3 的关键。
五、Layer 3:JIT Hook 方法体加密(核心保护)
这是乾坤引擎最核心的保护层,也是最难逆向的部分。
5.1 JIT Hook 工作原理
.NET 运行时通过 JIT (Just-In-Time) 编译器将 IL 代码编译为本机代码。乾坤引擎通过以下方式劫持此过程:- 正常 JIT 流程:
- CLR 调用 compileMethod(info)
- → info->ILCode 指向原始 IL
- → JIT 编译为机器码
- 被 Hook 的 JIT 流程:
- CLR 调用 compileMethod(info)
- → jithook.dll 拦截
- → 发现 info->ILCode 指向通用存根
- → 在 NSWF 表中查找方法 Token
- → RC4 解密真实 IL → 验证 VBPD 标签
- → 回写 info->ILCode / ILCodeSize / EHcount
- → 调用原始 compileMethod 编译还原后的 IL
复制代码 存根方法体:所有受保护方法的 MethodDef 表 RVA 被修改为指向同一个 Tiny 方法体存根:- RVA 0x636A4: 2E 72 AE 2E 00 70 73 30 02 00 0A 7A
复制代码 这是一个 11 字节的 Tiny Header 方法体,功能为加载一个字符串 "Runtime Exception" 并抛出异常。如果没有 JIT Hook 介入,调用受保护方法会直接以异常方式崩溃。
5.2 FOAP / NSWF 数据结构
通过逆向 jithook.dll 的主处理函数 sub_100013B0,我们还原了两个关键数据结构:
FOAP 块(紧接在存根方法体之后):- 偏移 大小 描述
- 0x00 4 magic = 0x50414F46 ("FOAP")
- 0x04 4 relative_offset
- 0x08 4 NSWF 的绝对 RVA
复制代码 NSWF 方法查找表:- 偏移 大小 描述
- 0x00 4 magic = 0x4E535746 ("NSWF")
- 0x04 4 count — 受保护方法数量
- 0x08 ... entries[count],每条 24 字节
复制代码 NSWF 条目格式(24 字节):- 偏移 大小 类型 描述
- 0x00 4 uint32 method_token MethodDef 令牌 (0x06XXXXXX)
- 0x04 4 int32 relative_offset 相对偏移(运行时导航用)
- 0x08 4 uint32 original_rva 加密 IL 体在 PE 中的原始 RVA
- 0x0C 4 uint32 encrypted_size 加密数据长度(含 4 字节 VBPD)
- 0x10 4 uint32 extra_data 异常处理元数据
- 0x14 4 uint32 reserved 保留(始终为 0)
复制代码 5.3 IL 方法体解密与 RVA 修补
关键发现:加密后的 IL 方法体存放在 PE 中的原始位置(即原方法体所在的 RVA 处),只是就地加密了。加壳流程为:- 加壳:
- 1. 记录原始方法体的 RVA 和大小
- 2. 在方法体末尾追加 4 字节 VBPD 验证标签
- 3. 使用 RC4 整体加密 [方法体 + VBPD]
- 4. 将加密结果写回原位
- 5. 将 MethodDef RVA 改为通用存根 RVA
复制代码 RC4 密钥(从 jithook.dll sub_100013B0 + 0x222 处提取):- 5F 21 B1 1A EA DB 17 23 B5 87 9F 1C 2D 77 FC 9B
复制代码 对应的 x86 汇编:- mov [esp+0x4c], 0x1AB1215F ; 5F 21 B1 1A
- mov [esp+0x58], 0x2317DBEA ; EA DB 17 23
- mov [esp+0x5c], 0x1C9F87B5 ; B5 87 9F 1C
- mov [esp+0x60], 0x9BFC772D ; 2D 77 FC 9B
复制代码 解密流程:- JITHOOK_RC4_KEY = bytes.fromhex('5F21B11AEADB1723B5879F1C2D77FC9B')
- VBPD_TAG = b'\x56\x42\x50\x44' # "VBPD"
- def restore_method_bodies(pe_data, pe):
- # 1. 定位 FOAP → NSWF
- loc = find_nswf_foap(pe_data, pe)
- entries = parse_nswf_entries(pe_data, loc['nswf_off'])
- # 2. 逐条解密 IL 方法体
- for entry in entries:
- file_off = pe.rva_to_offset(entry['orig_rva'])
- encrypted = pe_data[file_off:file_off + entry['enc_size']]
- # ★ 每个方法体独立初始化 RC4(与 Layer 1 不同!)
- decrypted = RC4(JITHOOK_RC4_KEY).crypt(encrypted)
- # 3. 验证 VBPD 标签
- assert decrypted[-4:] == VBPD_TAG, "VBPD 验证失败!"
- # 4. 写回解密的方法体(去除 VBPD 标签)
- pe_data[file_off:file_off + len(decrypted) - 4] = decrypted[:-4]
- # 5. 修补 MethodDef 表 RVA
- # 找到存根 RVA(出现次数最多的 RVA),然后根据 NSWF 条目将每个
- # 指向存根的 MethodDef RVA 修改回 original_rva
- patch_methoddef_rvas(pe_data, pe, entries)
复制代码 RVA 修补的核心思路:
解析 .NET 元数据的 #~ 流,定位 MethodDef 表(Table 6),遍历所有方法行。找到出现次数最多的 RVA(即通用存根 RVA,本样本中为 0x636A4,被 457 个方法引用),然后根据 NSWF token→original_rva 映射,将 MethodDef 行中的 RVA 恢复为原始位置。
计算 MethodDef 表的偏移需要正确跳过 Table 0-5:
Table名称行大小计算0Module2 + str_idx + guid_idx × 31TypeRefResolutionScope + str_idx × 22TypeDef4 + str_idx × 2 + TypeDefOrRef + field_idx + method_idx3FieldPtrfield_idx4Field2 + str_idx + blob_idx5MethodPtrmethod_idx6MethodDef4 + 2 + 2 + str_idx + blob_idx + param_idx其中索引大小取决于 HeapSizes 标志和各表的行数(超过 0xFFFF 则使用 4 字节索引)。
Layer 3 验证结果:
指标值总 MethodDef1206抽象/接口方法 (RVA=0)142受保护方法 (NSWF)456VBPD 验证通过456 (100%)RVA 修补成功456最终可反编译方法1063 / 1063 (100%)六、Layer 4:字符串混淆
6.1 混淆方式与加密算法还原
经过 Layer 1-3 的脱壳,dnSpy 已经可以看到方法体,但所有字符串仍然是乱码。分析发现:
每个加密字符串的使用模式为:- // 混淆前
- string text = "正常文本";
- // 混淆后
- string text = Class0.smethod_14("\u5a3c\u2b18\u093f...");
复制代码 即 ldstr 后紧跟 call smethod_14 的模式。smethod_14 内部是一个 DynamicMethod 代理,运行时 RC4 解密一段 IL 字节码,构建动态方法,然后调用它来解密字符串。
我们通过动态调试或静态分析还原了核心解密算法:- def decrypt_string(s):
- """基于长度的 XOR 解密"""
- key = (len(s) ^ 88) & 0xFF
- return ''.join(
- chr(((ord(s[i]) + 10 + i) ^ key) & 0xFFFF)
- for i in range(len(s))
- )
复制代码 算法很简单:
- Key 取自字符串长度异或 88 的低 8 位
- 每个字符:decrypted = (encrypted + 10 + i) ^ key
6.2 基于 dnlib 的自动化解密
使用 dnlib 可以非常优雅地完成字符串去混淆。思路是:
- 找到解密方法:遍历所有方法体,统计 ldstr + call 模式中被调用最多的方法(出现 ≥ 3 次即认定为解密函数)
- 解密并替换:对每个 ldstr + call decrypt 的位置,解密字符串后直接修改 ldstr 的操作数,然后将 call 指令改为 nop
- 保存:使用 MetadataFlags.PreserveAll | MetadataFlags.KeepOldMaxStack 写入,确保不改变元数据布局
- static void Layer4_DeobfuscateStrings(string inputPath, ...)
- {
- using var mod = ModuleDefMD.Load(inputPath);
- // 1. 自动检测解密方法
- var callCounts = new Dictionary<uint, int>();
- foreach (var type in mod.GetTypes())
- foreach (var method in type.Methods)
- {
- if (!method.HasBody) continue;
- var instrs = method.Body.Instructions;
- for (int i = 0; i < instrs.Count - 1; i++)
- if (instrs[i].OpCode == OpCodes.Ldstr &&
- instrs[i + 1].OpCode == OpCodes.Call &&
- instrs[i + 1].Operand is MethodDef calledMethod)
- callCounts[calledMethod.MDToken.Raw]++;
- }
- uint decryptToken = callCounts.MaxBy(kv => kv.Value).Key;
- // 2. 解密并替换
- foreach (var type in mod.GetTypes())
- foreach (var method in type.Methods)
- {
- if (!method.HasBody) continue;
- var instrs = method.Body.Instructions;
- for (int i = 0; i < instrs.Count - 1; i++)
- {
- if (instrs[i].OpCode != OpCodes.Ldstr) continue;
- if (instrs[i + 1].Operand is not MethodDef cm ||
- cm.MDToken.Raw != decryptToken) continue;
- string enc = (string)instrs[i].Operand;
- instrs[i].Operand = DecryptString(enc); // 直接替换
- instrs[i + 1].OpCode = OpCodes.Nop; // NOP 掉 call
- instrs[i + 1].Operand = null;
- }
- }
- // 3. 保存
- var opts = new ModuleWriterOptions(mod)
- {
- MetadataOptions = {
- Flags = MetadataFlags.PreserveAll
- | MetadataFlags.KeepOldMaxStack // ★ 关键!
- }
- };
- mod.Write(outputPath, opts);
- }
复制代码踩坑记录:如果不加 MetadataFlags.KeepOldMaxStack,dnlib 会在写入时重新计算 MaxStack,对于某些特殊的 ::.cctor() 方法会因为无法正确推断栈深度而抛出异常。加上 KeepOldMaxStack 可以保留原始的 MaxStack 值,安全绕过此问题。
七、de4dot 符号标准化
经过四层脱壳后,程序集已经可以正常反编译,但类名和方法名仍然是混淆后的名称(如 GClass107、smethod_14 等)。使用 de4dot 进行符号标准化,使代码更易读:- $de4dot = "..\de4dot.exe"
- # 对所有解密后的程序集执行 de4dot
- $assemblies = @(
- "Main_unpacked\Main_restored_deobf.exe",
- "SummerDay_unpacked\SummerDay_restored_deobf.dll",
- # ... 其他 7 个程序集
- )
- foreach ($asm in $assemblies) {
- & $de4dot $asm --dont-rename
- }
复制代码注意使用 --dont-rename 参数可以在不重命名符号的情况下进行其他清理,或者不加此参数让 de4dot 自动重命名混淆符号为更友好的名称。
清理后的程序集收集到 cleaned_assemblies 文件夹中,使用原始文件名:- cleaned_assemblies/
- ├── Main.exe (304 KB)
- ├── SummerDay.dll (176 KB)
- ├── <REDACTED>.Lain.dll (2,437 KB)
- ├── <REDACTED>.Common.dll (148 KB)
- ├── <REDACTED>.CommonView.dll (460 KB)
- ├── <REDACTED>.DDFXY.dll (142 KB)
- ├── <REDACTED>.DDFXYRJ.dll (586 KB)
- ├── <REDACTED>.JXB.dll (1,438 KB)
- ├── <REDACTED>.SSKZY.dll (3,856 KB)
- └── <REDACTED>.SSZKS.dll (11,675 KB)
复制代码 八、隐藏程序集提取:.Lain.dll
在完成常规脱壳后,我们发现了一个有意思的隐藏机制 —— SummerDay.dll 并不是一个普通的功能模块,它是一个动态代理,在运行时会加载并解密一个伪装成 PNG 的 .NET 程序集。
8.1 SummerDay 动态代理发现
在 dnSpy 中分析 SummerDay.dll 的入口点 BegionWork 方法(注意拼写,是作者的笔误),发现它引用了一个文件 .Lain.png。顺藤摸瓜,发现 SummerDay 实际上是一个完整的脚本引擎:
- Engine 类:脚本解释器核心
- Runtime 类:脚本运行时环境
- FileLib:文件操作库(readb = 读取二进制文件)
- StandardLib:标准库(log 等)
- SummerDay 库:加密/解密函数(getbytesc、getcond、upc、loadass)
脚本引擎读取 TestBB.qk 文件,解密后执行其中的脚本逻辑来完成 Lain 程序集的解密和加载。
8.2 TestBB.qk 脚本解密与分析
TestBB.qk 文件(222 字节)也是被加密的,使用的正是同一个 KDAR 格式,但属于 EngineQt 模式(CodeMod=0,10 字节头)。
解密后得到脚本内容:- var heard = 0x4B444152
- var CodeMod = 0x01
- var orifile = file.readb(OriPth)
- var cc = getbytesc(orifile)
- var oric = getcond(cc)
- set orifile = upc(orifile,oric,heard)
- set OutPth = loadass(orifile)
- log(OutPth)
复制代码 脚本解读:
- heard = 0x4B444152 → KDAR 标识
- CodeMod = 0x01 → 使用 SummerDay 模式(与 EngineQt 不同)
- file.readb(OriPth) → 读取 .Lain.png 的原始字节
- getbytesc(orifile) → 从文件中提取 3 个编码字节(偏移 6、9、10)
- getcond(cc) → 根据 3 字节生成编码表(Coding Table)
- upc(orifile, oric, heard) → 使用编码表解密文件
- loadass(orifile) → 将解密结果作为 .NET 程序集加载
8.3 Bit-Flip 密码与 KDAR 变体
深入分析 SummerDay 库中的 upc 函数(对应 SummerDayLib.Libs.SummerDay.GoStart 方法),发现它使用了一种比特翻转密码 (Bit-Flip Cipher):
- 将输入数据的每个字节展开为二进制字符串
- 使用编码表(8 组 3 位二进制码,一一映射到 000-111)进行替换
- 将替换后的二进制字符串重新组装为字节
这里有一个非常重要的差异:
特性EngineQt (CodeMod=0)SummerDay (CodeMod=1)头部大小10 字节11 字节GoStart 方向正向(Max=false, i++ 递增)反向(Max=true, i-- 递减)编码表来源硬编码 ["111","110","010","100","000","001","101","011"]从文件数据动态生成动态编码表生成:- def getcond(cc):
- """从 3 个字节生成 8 组 3 位编码表"""
- # cc = [byte_at_6, byte_at_9, byte_at_10]
- # 将 3 字节转为 24 位二进制串
- bits = ''.join(format(b, '08b') for b in cc)
- # 分为 8 组,每组 3 位
- return [bits[i*3:(i+1)*3] for i in range(8)]
复制代码 GoStart 反向遍历(SummerDay 模式):
在 EngineQt 模式中,GoStart 从替换后的第一个字符开始正向扫描二进制字符串;而在 SummerDay 模式中,从最后一个字符开始反向扫描,每次从尾部截取对应长度的子串进行解码。这是两种模式最关键的区别。
8.4 解密脚本与结果验证
将以上分析汇总为 Python 解密脚本:- def decrypt_lain(input_path, output_path):
- data = open(input_path, 'rb').read()
- n = len(data)
- # 1. 读取头部(SummerDay 模式:11 字节头)
- header = data[:11]
- # 2. 提取编码条件字节
- cc = [data[6], data[9], data[10]]
- # 3. 生成编码表
- bits_24 = ''.join(format(b, '08b') for b in cc)
- coding = [bits_24[i*3:(i+1)*3] for i in range(8)]
- # Coding 表建立: value → encoded_bits 的映射
- coding_map = {}
- for i, code in enumerate(coding):
- coding_map[code] = format(i, '03b') # i → binary(i)
- # 4. 将载荷(去掉头部)转为二进制字符串
- payload = data[11:]
- bin_str = ''.join(format(b, '08b') for b in payload)
- # 5. 反向解码(SummerDay 模式的 GoStart)
- decoded_bits = []
- while len(bin_str) >= 3:
- # 从尾部截取 3 位
- chunk = bin_str[-3:]
- bin_str = bin_str[:-3]
- if chunk in coding_map:
- decoded_bits.insert(0, coding_map[chunk])
- else:
- decoded_bits.insert(0, chunk) # 无映射则原样保留
- decoded_bin = ''.join(decoded_bits)
- # 6. 重组为字节
- result = bytearray()
- for i in range(0, len(decoded_bin) - 7, 8):
- result.append(int(decoded_bin[i:i+8], 2))
- # 7. 验证
- assert result[:2] == b'MZ', "输出不是有效的 PE 文件!"
- with open(output_path, 'wb') as f:
- f.write(result)
复制代码 运行后成功从 .Lain.png(2,495,499 字节)中提取出 .Lain.dll(2,495,488 字节),差值 11 字节恰好是 SummerDay 模式的头部大小。
在 dnSpy 中验证:
- 程序集名称:.Lain, Version=2.0.0.0
- 25 个命名空间,包括 Lain、Codaxy.Xlio、ICSharpCode.SharpZipLib 等
- 所有类型和方法均可正常反编译
- 没有乾坤引擎保护(无 NSWF/FOAP,无虚拟段)—— 这个 DLL 只使用了 SummerDay 的比特翻转加密,未被乾坤加壳
九、完整自动化工具(C# / dnlib 版)
最终,我们将所有脱壳逻辑整合为一个 C# 命令行工具,使用 dnlib 处理 .NET 元数据操作:
核心架构
- static void UnpackAssembly(string inputPath, string outputDir)
- {
- byte[] data = File.ReadAllBytes(inputPath);
- var pe = new PEHelper(data);
- // Layer 1: 段解密 (RC4 + GZip)
- data = Layer1_DecryptSections(data, pe, outputDir, ...);
- pe = new PEHelper(data);
- // Layer 2: 提取嵌入式原生 DLL (KDAR)
- Layer2_ExtractNativeDlls(data, pe, outputDir);
- // Layer 3: 方法体还原 (NSWF/FOAP + RC4 + VBPD)
- data = Layer3_RestoreMethodBodies(data, pe, outputDir, ...);
- // Layer 4: 字符串去混淆 (dnlib)
- Layer4_DeobfuscateStrings(restoredPath, outputDir, ...);
- }
复制代码 九、总结与思考
保护层总览
- ┌─────────────────────────────────────────────────────┐
- │ 乾坤引擎保护架构 │
- ├─────────────────────────────────────────────────────┤
- │ │
- │ Layer 1: PE 段加密 │
- │ ├── 关键 .text 段 → RC4 + GZip → 独立数据段 │
- │ └── 原始段 RawSize = 0(虚拟段) │
- │ │
- │ Layer 2: 嵌入式原生 DLL │
- │ ├── jithook.dll → KDAR 紧凑格式 + 多层 RC4 加密 │
- │ └── DLL 自身还有 .text↔.rsrc 代码混淆 │
- │ │
- │ Layer 3: JIT Hook 方法体加密 ★(核心) │
- │ ├── compileMethod 拦截 │
- │ ├── NSWF 方法查找表 (456 条) │
- │ ├── RC4 方法体就地加密 + VBPD 验证 │
- │ └── MethodDef RVA → 通用存根 │
- │ │
- │ Layer 4: 字符串混淆 │
- │ ├── DynamicMethod 代理解密 │
- │ └── XOR + 增量 字符算法 │
- │ │
- │ [隐藏层] SummerDay 比特翻转加密 │
- │ ├── 脚本引擎 + 自定义加密 │
- │ └── <REDACTED>.Lain.png → DLL │
- │ │
- └─────────────────────────────────────────────────────┘
复制代码 技术要点回顾
- RC4 状态管理:Layer 1 跨段共享 RC4 状态(连续密钥流),Layer 3 每个方法体独立初始化。混淆是否重用加密状态是一个经典的陷阱。
- 元数据表偏移计算:正确遍历 .NET #~ 流需要按 HeapSizes 标志和各表行数精确计算索引大小。这部分很容易出错,建议使用 dnlib 等成熟库。
- KDAR 自定义格式:乾坤引擎设计了自己的紧凑 PE 封装格式,每个段独立加密,需要完全还原 PE 头结构才能得到可用的 DLL。
- JIT Hook 分析:去混淆 jithook.dll 的 .text↔.rsrc 跳板是理解 Layer 3 的前提。需要追踪 call $+5 + add [esp],delta 的 trampoline 链。
- dnlib 的 KeepOldMaxStack:使用 dnlib 保存修改后的程序集时,如果包含特殊的模块初始化代码(如 ::.cctor()),可能触发 MaxStack 计算异常。添加 MetadataFlags.KeepOldMaxStack 可以安全规避。
- SummerDay 双模式差异:EngineQt 和 SummerDay 虽然共用 KDAR 标识,但 GoStart 方向相反(正向 vs 反向),编码表来源不同(硬编码 vs 动态生成)。忽略这个差异会导致解密完全错误。
声明:本文仅用于技术研究和学习目的,所有分析均基于合法的安全研究。请勿将本文中的技术用于非法用途。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |