把停服七年的手游变成单机版:REA 逆向实战
前言
最近拿到一个 2015 年的手游 APK——《高达战争》,百度渠道包,207MB。这类老网游的宿命都一样:服务器早就停了,域名解析都没有了,但游戏的引擎、美术、玩法逻辑还完整地躺在安装包里。这篇文章记录的是一次完整的目标:把这个包变成本地可运行的单机版,包括破解脚本加密、还原通信协议、搭本地服务、修改存档、注入兑换码,以及最后治理"概率性闪退"的全过程。
全部工作只用了 Python 标准库、jadx、adb 和一个安卓模拟器。没有 Frida,没有 IDA。
方法论上依赖的是自建的 REA(Reverse Engineering Assistant,通用逆向助手工具集)。REA 最初为逆向一款 Electron 桌面应用而建:一个纯 Python 实现的 ASAR 解包器,加上几十个单文件分析脚本——域名扫描、关键词上下文检索、IPC 渠道分析、凭据编解码器与硬编码密钥扫描等等,约定"改脚本第一行的 root 变量即可对新目标开跑"。随着项目积累,REA 从脚本集合演化成一套按目标类型沉淀的 playbook 体系:Electron 应用一条、加固壳 Cocos 游戏一条,每打完一个目标就把可复用的流程、判据和踩坑清单写回对应的 playbook。这次的项目,就是照着 REA 的套路从头走到尾,最后又把经验沉淀成了它的第三条 playbook。
一、五分钟定引擎:看文件指纹
拿到一个游戏 APK,第一件事不是打开反编译器,而是解包看两个地方:lib/ 里的 so 名字和 assets/ 的目录形态。引擎指纹互不混淆:
| 看哪里 | Cocos2d-x + Lua | Cocos Creator JS | Unity |
|---|---|---|---|
| lib/*.so | libcocos2dlua.so | libcocos2djs.so | libil2cpp.so + libunity.so |
| assets | src/*.lua + res/ | src/*.jsc + _CCSettings | bin/Data + Assembly-CSharp |
这个包是标准的 Cocos2d-x Lua 架构:708 个 Lua 脚本、3256 个资源文件。更有意思的是 assets/config.json 里有 simulator_screen_size 和 consolePort——这是 Cocos Code IDE 时代 runtime 的标志格式,说明这游戏原生支持桌面模拟器运行。后面会看到,这个细节成了整个本地化工作的钥匙。
二、破解脚本加密:钥匙就藏在引擎隔壁
打开 src/main.lua,满屏乱码——但头部 10 个字节是可读的 51hutongnb。这是 cocos2d-x 脚本加密的标准形态:签名头 + XXTEA 密文。
密钥在哪?不需要动态调试。cocos 引擎启动时通过 setXXTEAKeyAndSign 把密钥和签名传进去,两者都是字符串常量,必然并排躺在 libcocos2dlua.so 的只读数据区里。于是:
idx = so.find(b'51hutongnb') # 先搜签名 seg = so[idx-300:idx+300] # 挖前后 600 字节 # 输出: ... 923e0288f6673ff08d4bc41d8e7aea12 / 51hutong / 51hutongnb ... # → key = "51hutong"(8字节), sign = "51hutongnb"(10字节)
那个 32 位 hex 字符串是干扰项。真正的坑在 XXTEA 实现本身:cocos 用的是 xxtea-c 的变体,加密前会把明文补齐到 4 字节边界,然后在密文末尾追加 4 字节的原始长度。我第一版解密脚本把长度当成了前缀,解出来全是乱码。正确做法是解密后校验尾部长度字段——顺便这也是验证密钥对不对的免费手段。修正后,708 个 Lua 全部解密成明文。
三、读明文:339 个接口和一条登录链
Lua 变成明文之后,剩下的就是 grep 的艺术。几条命令下来,整个游戏的架构就清楚了:
grep -rho '"gundam/[a-zA-Z/_]*"' src --include="*.lua" | sort -u # → 339 个服务端接口:fuben(副本)/union(军团)/pvp/kuaFuTeam(跨服)…
这是一个典型的客户端-服务器网游:HTTP POST 表单 + MD5 签名(密钥 GUNDAM_2015 硬编码在 global.lua 里),JSON 响应。登录链是:
机型检测 → 版本检查 → 服务器列表 → 登录 → 玩家初始化(返回 201 表示新号)→ 建角色 → 12 个数据同步接口 → 主城
最关键的发现藏在 system.lua:游戏按渠道号决定登录方式,而渠道 00000000_ADWUYOU 走的是 loginType=2 设备 ID 登录——完全绕开百度 SDK。也就是说,游戏自己就带着"无账号登录"的后门,只差有人把渠道号指过去。
四、本地化改造:改一个文件,重签一次名
方案变得非常薄:只改 src/system.lua 一个文件——把渠道强制为 00000000_ADWUYOU,把四个服务器地址全部指向本地——然后用 Python 的 zipfile 把它替换回 APK(顺便丢掉旧签名),jarsigner 重新签名。不需要 apktool,不需要碰引擎 so。
本地服务用 Python 标准库写一个 mock:实现启动链的 20 个接口,其余 319 个统一返回空成功兜底。几个踩过的坑值得记:
模拟器连宿主机:LDPlayer 里
10.0.2.2不通,要用宿主机的局域网 IP;阵容(zhenrong)数据结构里
formMap缺失会在战力计算里报空指针——这种报错从 logcat 的TestinAgent: reason里直接能看到完整 Lua 堆栈;guide.stepId="end"可以跳过强制新手引导。
验证结果:登录、建号、教学战斗、主城、仓库全链路跑通。一款死了七年的网游,在本地活了过来。
五、转折:一份现成的离线包
故事到这里有个转折:又拿到一份别人做好的"离线版"APK。对这种包,正确姿势不是从头分析,而是先 diff 文件清单:
find assets/src -type f | sort > new.txt diff old.txt new.txt # → 多出 13 个文件,全在 offline/ 目录,且是明文
读下来发现是一套相当工整的实现:它在场景启动前劫持了 system 模块的全部设备/渠道方法,替换了 network/http_manager.lua,把所有 HTTP 请求转进同进程的 offline/server.lua——一个 300 多行的"进程内游戏服务器",读写本地 JSON 存档,带原子提交和备份。Lua 异常全部被 xpcall 接住写进 offline_error.log。这个细节后面排查闪退时帮了大忙:错误日志为空,直接证明崩溃不在脚本层。
六、改存档与造兑换码
离线包的存档是 /data/data/<包名>/files/gundam_offline_save.json。root 模拟器上直接拉出来用 Python 改:VIP、等级、改造、潜能、关卡星级、每日次数,都是一目了然的字段。唯一要注意的是重装应用后 uid 会变,push 回去必须 chown 回新 uid,否则游戏读不到。
更有意思的是兑换码。游戏原本就有 CDKey 系统(主城 → 头像 → 兑换码按钮),离线包里也实现了处理器。往里面加自己的码只需要几行 Lua:
if code=='VIP666' then
s.player.vip = math.max(n(s.player.vip), 10)
r.csSyncMap = {PlayerOut={vip=s.player.vip}} -- 客户端即时刷新
return r
end两个细节:失败提示要复用客户端已有错误码的文案(14001=码不存在、14006=已使用过),自定义 message 客户端根本不显示;通关类的码要"只补未通关的关卡",避免覆盖玩家已有星级。最终交付了四个码:VIP666(VIP10)、SHUX666(等级/改造/潜能满配+百万资源)、TG666(直接通关前 10 关)、GD666(三合一),全部实测可用。
七、最难的部分:概率性闪退
离线包有个遗留问题:某些关卡会闪退,时闪时不闪。这是整个项目里最像侦探工作的一段。
第一步,取证。常规 logcat 抓不到现场,就写了个持续监控脚本挂在 logcat 上,维护一个"崩前 40 行"的环形缓冲,SIGSEGV 一出现就落盘。配合 root 从 /data/tombstones/ 拉 tombstone,凑齐了证据链:
| 证据 | 推论 |
|---|---|
| offline_error.log 为空 | 不是 Lua 层错误 |
| signal 11 空指针,线程名 GLThread,PC 落在转译层代码区 | x86 模拟器上 ARM 转译层的渲染崩溃 |
| 同一动画 7 次安全加载、7 次出现在崩溃前 | 概率性问题,"拉黑特定资源"方案否决 |
| 两个包的资源 MD5 完全一致 | 排除资源损坏 |
| 崩溃时内存映射已达 0xffffdfff(4GB 顶满),2301 个映射 | 32 位地址空间耗尽是诱因 |
结论:2015 年的 32 位游戏 + 大量 Cocos Studio 骨骼动画图集,在 x86 模拟器的转译层上执行,地址空间被挤满后 GL 分配拿到空指针,老引擎不判空直接崩。真机上反而不会发生——转译层是模拟器特有的。
第二步,缓解。根因在环境层修不掉,就打组合拳把概率压下来:每场战斗的敌人统一为同一机体(单套骨骼动画)、上限 3 台;启动时强制低画质(RGBA4444,纹理内存减半);最后在模拟器上跑一个 root 看门狗——进程死亡 2 秒内自动重启,战斗状态有持久化、自动恢复到崩溃的那一场。修复前同类战斗约 50% 崩溃率,之后连续压测 6 场零崩溃。
八、方法论:REA 的三段式闭环
回头看,这次的项目之所以顺,是因为每一步都踩在 REA playbook 已经验证过的判据上。REA 的核心是一个三段式闭环:
① 指纹识别定路线(引擎/壳/容器一张对照表,前五分钟决定后面五小时)→ ② 通用脚本组开路(字符串提取、上下文检索、批量解密、文件清单 diff——全是"改一行 root 就能跑"的单文件 Python 脚本)→ ③ 证据驱动的深挖(hook、tombstone、日志相关性,先建证据链再动手)
本项目的完整流水线就是这三段的具体化:
指纹定引擎 → 字符串区挖密钥 → 明文 grep 还原逻辑 → 对新包先 diff 再通读 → 崩溃用 tombstone + 日志相关性说话
几条个人经验:
解包后的前五分钟决定后面五小时——指纹对了,后面全是套路;
密钥、签名、配置这类字符串常量不用逆向二进制,它们总在 so 的字符串区里抱团出现;
对"别人改过的包",diff 文件清单是最快的理解方式;
概率性崩溃一定要先建证据链再动手,黑名单式的直觉方案(拉黑 war_131)就被数据直接否决过;
修不掉的环境问题,把"降低概率 + 快速自动恢复"做扎实,体验上等于修好了。
尾声
最终的交付物是一个 220MB 的离线 APK:VIP/属性/通关进度可调,内置自助兑换码,模拟器上带自动复活。一款停服七年的游戏,现在拔掉网线也能从建号玩到主城。
项目收尾时,整个流程被写回了 REA 的 playbook 库(《Cocos Lua 网游 APK 本地化》篇,与前两篇并列):XXTEA 长度藏在密文末尾、LDPlayer 连宿主要用局域网 IP、重装后存档要 chown 回新 uid、兑换码失败提示要复用客户端错误码、概率性崩溃的三层缓解组合……下次再遇到同类目标,这些坑就可以直接跳过了。工具会过时,但带着判据的流程沉淀不会——这大概就是维护一套 REA 这种"逆向助手"最大的意义。
最后是老生常谈但必须说的:这类操作属于对已停运作品的学习研究与技术存档,不触碰支付、不复原服务器数据、不分发原始资源。游戏代码与美术的版权归原厂商所有。