从手搓工具链到调用编排引擎:一次加壳客户端的逆向路线复盘
一次真实项目的复盘。目标是一款 2013 年前后停更的单机回合制游戏改版《雾隐山河》:主程序加了 VMProtect 壳,存档是 SQLite 每页 AES 加密、列内再套 zlib + Blowfish 的双层结构。我要做的事很简单------改掉一件装备上的一个词条,顺便搞清楚宠物面板为什么是空的。
结果我做完了,但过程中走了两条完全不同的路:前半程全靠自己写脚本硬啃,后半程切到一套基于 Ghidra 的编排工具(下称 REA)。这篇复盘讲清楚两条路的差别、代价,以及我最后固化下来的分工原则。
一、两条路的起点,其实差在一个动作
前半程我的做法很"古典":遇到问题 → 判断需要什么能力 → 自己写一个脚本补上这个能力。
于是项目里慢慢长出了一堆东西:
- 一个导进程内存镜像的脚本(应对加壳,静态看不到代码)
- 一个把内存镜像重新封成 PE 的脚本(因为工具链不认裸二进制)
- 一个 capstone 反汇编器(想看清某个函数的指令流)
- 一个自己写的 Lua 5.1 字节码反汇编器(想定位脚本里的加密函数)
- 一个 32 位进程内存扫描器(想从堆里捞出运行时才生成的密钥)
- 一套列级编解码器(
zlib + Blowfish的正向/反向实现)
每一个单独看都合理,合起来是六七个半成品工具,每个都只覆盖我当下卡住的那一步。
后半程的转折点很朴素:我被告知"机器上早就装好了现成的编排工具,你为什么不用"。我去磁盘上搜了一下------确实装了,Ghidra 12.1.4 + JDK21,外加一套把 Ghidra 包装成 CLI/UI 的编排层。之前我只查了 IDE 的插件目录和技能目录,从未在磁盘上找过。
这件事本身就是第一个教训:"没有现成工具"这个判断,需要先证明。 我当时的"没有",其实只是"我没找到"。
二、路线 A:自己造轮子的真实收益与真实代价
先说公道话,自建工具链不是一无是处。它在这几件事上赢得很干脆:
1. 业务格式只有自己懂。 存档列是 [魔数][长度][zlib(Blowfish(长度+文本+补零))],hex 还必须全大写。这种私有格式,任何通用逆向工具都不会替你实现,只能自己写。这部分代码我至今保留,一次写对,长期复用。
2. 卡在"工具不支持"的边界时,自建是唯一出路。 比如工具只接受 PE 和某几种裸格式,而手里只有内存镜像;比如进程是 32 位的、地址空间要一段段 VirtualQueryEx 扫过去。这些缝隙,通用工具通常不覆盖。
3. 反馈回路极短。 改一行、跑一次、看结果,几秒钟。想验证"大写还是小写"这种假设,自建脚本是最快的实验台。
但代价同样真实,而且往往在后期才显形:
代价一:结论没有证据链。 我用反汇编看着像 Blowfish,就写下"这是标准 Blowfish"。它是对的,但支撑它的是我的阅读,不是工具的判定。别人(或者三个月后的我)要复核,得把同样的推理重走一遍。
代价二:覆盖面积太小。 我为了找一个函数,手动反汇编了那一个函数。整个二进制里有近两千个函数,我一个都没系统看过。于是"某段代码到底有没有被调用"这种问题,我永远只能回答"我看到的那部分没有"。
代价三:每个脚本都是一次性投资。 换一个目标、换一种壳,六七个脚本里有五个要重写。它们不是工具,是当时那一刻的解题草稿。
代价四:自己写的解析器会骗自己。 这点最要命。我写列编解码器时,往返校验里用了 .lower() 做比较------于是"大小写不同"这个唯一的差异被我自己抹掉了,42/42 全绿,一写进存档就闪退。工具不会替我犯这种错,我会。
三、路线 B:REA 的用法与它真正改变的东西
REA 的形态是一层编排:底下是 Ghidra 的 headless 分析器,上面暴露成 153 个命令,既能命令行调用,也能起一个本地服务用 HTTP 打。
它改变的不是"能不能反编译",而是三件事:
1. 一次会话,全量产出。 项目里自带一个批量反编译工作流:给一个目标,它在同一个 Ghidra 会话 里把全部函数导出成"一个函数一个 .c 加一个 .asm"。我这次拿到 1832 个函数、3664 个文件。这跟"我怀疑某个地址像什么,就去反汇编那个地址"是完全不同的量级------前者是地图,后者是手电筒。
2. 结论自带身份。 每次分析返回一个 evidence_id 和目标文件的 sha256,伪代码里保留原始函数名与地址。这意味着"我判断这里是标准 Blowfish 密钥扩展"这句话,从一个观点变成了一条可复核的记录:谁都可以拿着同一个 id 回到同一份产物上重看。
3. 分析器替我兜住了低级错误。 大端小端、循环边界、参数传递约定,这些我手写反汇编时最容易看错的地方,交给反编译器之后错得少了。最有代表性的一次:闪退根因最后是靠它给出的三行代码定死的------
c
if (cVar1 < '0' || '9' < cVar1) {
if (cVar1 < 'A') return;
if ('F' < cVar1) return; /* 小写 a-f 在这里被拦下,半个字节被丢弃 */
一行 'F' < cVar1 就解释了"为什么小写 hex 会让整列密文错位、进而让道具表变 nil、进而闪退"。这条结论我此前只有实验证据(改了会崩),没有代码证据(为什么会崩)。差别就在这里。
四、改动真正落地:四层结构,逐层拆
前面两条路解决的都是"看懂"。真正的交付是:把一个属性值改进去,进游戏能看到。这件小事要穿过四层结构,每层都有独立的坑。
第一层,文件层。 存档是 SQLite,但不是明文------每个 1024 字节的页单独做 AES-128-CBC,密钥固定写在主程序里。做法不是"解密整个库",而是按页切开逐页解密到临时文件,让标准 SQLite 正常读写,改完再逐页加密写回。这样 B 树、索引、页分配全部不用自己管。
第二层,列层。 要改的字段并不以文本存在列里,而是一串 hex,解开后是:
[魔数 4B][密文长度 u32 小端][ zlib level 6 ]
└─ Blowfish-ECB( u32 文本长度 ‖ 文本 ‖ 补零 )
解码顺序:验魔数 → 读长度 → 解压 → 按 8 字节块解密 → 从头部读真实长度 → 切出文本。编码是严格逆过程,两处特别容易错:
- 补零必须补满整块。 文本长度不是 8 的倍数时,要先补到整块再加密;只在头部塞长度、尾部不补,解出来会掉字节。
- hex 必须大写。 上一节已经用三行 C 代码把这条定死了,落到数据侧还有一个极好用的旁证:游戏自己写出的每一份存档,hex 里大写字符三万余、小写为 0;而我写坏的那份恰好相反------大写 0、小写三万余。于是"写入后先数大小写比例"就成了一个能在启动游戏之前判断这份档是死是活的体检项。
第三层,记录层。 解出来的文本是一个 [n]={...} 嵌套的类 Lua 表,一条记录就是一件装备、一只宠物或一个道具。定位记录不能直接找 [6]={------记录内部还有 [1]=、[2]= 这类嵌套下标,会撞车。正确做法是找到起点后按花括号深度配平,并跳过字符串字面量里的括号和转义。
这个坑我自己踩过:早期版本就是"找到第一个 [n]={ 就切",单条修改时从没暴露,一旦做批量统计就切错记录、算出一片 0。后来改成按层级只在顶层匹配才对。
第四层,字段层。 几乎每个字段都跟一个 <字段名>_md5 影字段。规则反推出来是:
python
md5( GBK(值文本) + b"<固定盐>" ).hexdigest()
盐是主程序里一个固定常量。两个附加约束:文本按 GBK 编码而不是 UTF-8;浮点必须写成最短十进制 (32.9,不能是 32.900000),否则文本一变、校验值立刻对不上。只有极少数键没有影字段(装备类型、归属角色这类枚举),改它们不用重算。
顺序比代码更重要。 这个客户端每隔十几分钟自动存盘一次,会把写进去的内容覆盖掉。所以固定流程是:
- 正常退出游戏,让它自己存最后一次
- 备份存档,记下哈希
- 改 → 回读校验 → 重算整库哈希
- 启动游戏、进角色、看属性面板
跳过任何一步,得到的都是"改了没生效"或者"改完就闪退"这类假象。
结果: 一件足部装备上的一个词条,从"反击类"换成"防御类",数值 32.9。进游戏后属性面板显示正常、数值正确,同角色的其他数据无变化。
顺带确认了这条通路的复用性:同一套四层结构,改现金、改道具数量、改宠物成长值走的是完全相同的路径,差别只在"改哪个键、算哪个校验值"。
五、两条路的对照
| 维度 | 自建脚本 | REA(编排 + Ghidra) |
|---|---|---|
| 起步成本 | 极低,想到就写 | 中,要先摸清命令与目标格式要求 |
| 单点问题响应 | 最快,秒级验证 | 慢,一次分析按分钟计 |
| 全量覆盖 | 做不到,只能一个点一个点看 | 一次会话导出全部函数(本次 1832 个) |
| 结论可信度 | 依赖个人阅读,容易自我说服 | 带 evidence id 与哈希,可复核 |
| 复现性 | 差,脚本是草稿 | 好,命令 + 参数即可重跑 |
| 换目标成本 | 高,多数要重写 | 低,换个路径重跑 |
| 私有业务格式 | 唯一可行 | 不支持,也不该支持 |
| 犯错概率 | 高(我确实犯了大写那次) | 低(分析器兜底) |
| 产物形态 | 一堆一次性脚本 | 结构化产物 + 索引 + 证据 |
一句话概括:自建赢在"此刻就能动",编排工具赢在"动完之后还站得住"。
六、接工具时踩的四个坑(这部分最有普适价值)
把 REA 接上去花了比预期久得多的时间,原因全在环境上,不在工具本身。四个坑,按踩到的顺序:
坑一:中文路径。 工具遇到非 ASCII 路径时会先把目标复制到临时目录,但复制出的路径带了个前导 /,Ghidra 直接报 File [/C:/...] not found。换成纯 ASCII 路径立刻正常。
坑二:目录联接被拒。 想用目录联接(junction)把中文目录映射成英文名,避免复制。工具明确拒绝:Reparse path component is unsupported。这条路走不通。
坑三:杀软静默删文件。 复制出来的 .exe 副本会在 5 秒内消失,系统事件里没有任何查杀记录,是第三方杀软按扩展名干的。换成 .dat 扩展名就活下来了。结论:分析用的副本,别用 .exe/.bin 命名。
坑四:Windows 端只认 PE。 想直接喂裸的 x86 内存镜像,会被判 target_unsupported。只能先封成 PE 容器------而这一步,恰恰需要路线 A 里那个自建的封装脚本。
这四个坑没有一个是"工具不好用",全是"我没读它的边界"。踩完之后我明白一件事:接一个新工具,成本主要不在学习它的功能,而在摸清它拒绝什么。
七、最后固化的分工原则
项目收尾时,我把两边的东西重新分了一次工,规则很简单:
二进制归工具,业务格式归脚本。
- 反汇编、交叉引用、函数边界、字符串归属、调用关系------全部交给 REA。这类问题需要"全局视野 + 可复核证据",自建脚本永远给不了。
- 存档列的加解密、字段定位、校验值重算、按业务规则改写------自己写。这类问题需要"懂这个游戏的数据模型",通用工具不碰,也不该碰。
还有就是喂料环节的定位要清楚:加壳目标必须先导出运行时的解包镜像、再封成工具能吃进去的 PE。这两步是"把食物做熟",不是"替工具吃饭"。我一度把这两件事混在一起,导致自己又写了一堆脚本,然后误以为"工具没用"。
八、给下次自己的清单
- 判断"没有现成工具"之前,先在磁盘上搜一遍。 我漏掉的不是能力,是搜索。
- 先读工具"拒绝什么",再学它"能做什么"。 边界比功能更省时间。
- 分析副本永远用非可执行扩展名。 一条能省半小时的迷信。
- 加壳目标的第一步是"派生镜像",不是"硬刚壳"。 导内存 → 封 PE → 交给分析器,比静态啃壳快一个数量级。
- 全量批处理和单点反编译,是两种完全不同的工作方式。 前者建立地图,后者查证细节。先建地图。
- 往返校验不要自己降低标准。 那次
.lower()教会我:校验代码里的每一处"宽容",都可能是在掩护一个真 bug。 - 私有格式老老实实自己写,别指望通用工具。
九、一句话结论
我一开始以为这是一道"要不要自己写工具"的选择题。做完才发现它是一道分工题:通用能力交给编排工具,专有能力留给自己,中间用一个干净的喂料环节接起来。 手搓的那六七个脚本没有白写------它们后来正好成了那个喂料环节和业务层的零件。真正白花的,是"以为没有工具"的那几个小时。