逆向一款停服 15 年的手游:从 APK 到一刀满级
一、起因:一个十五年前的安装包
我小时候玩过一款手游,叫《RO 神秘的紫罗兰》。
仙境传说 RO 的横版动作手游版,盛大 2013 年发的。那会儿智能手机刚开始普及,我在上面反复刷第一张图,打波利,攒装备——大概是我最早"玩进去"的一款游戏。
后来它停服了。停了十五年。
前阵子我翻出一个 base.apk,11MB。装不上(targetSdk 只有 15,现在的手机基本不认),也进不去(要连的服务器早没了)。
但它是完整的。
我决定拆开看看。
二、先别急着改,先看清楚它是什么
解包之后,结构比我想的干净:
AndroidManifest.xml
classes.dex ← Java 层,824KB
lib/armeabi/libnostalgia.so ← 核心引擎,535KB
assets/img/ ← 2781 个文件
包名 com.shanda.violet,native 库叫 libnostalgia.so。
nostalgia。怀旧。
盛大给这个库起名字的时候大概不会想到,十五年后真有人对着它怀旧。
先解码 manifest,再反编译 Java 层——用 jadx:
package="com.shanda.violet"
android:label="@string/app_name" → "RO 神秘的紫罗兰"
android:targetSdkVersion="15"
Java 层读完了,结论很明确:它只是个壳。
VioletActivity 干三件事——加载 native 库、起渲染线程、调盛大的登录/充值 SDK。真正的游戏逻辑一行都不在这里。
有个细节让我停了一下:
static {
System.loadLibrary("nostalgia");
}
private native void nativeStart(String str, String str2);
nativeStart(apk路径, 包名)。引擎启动时拿到的是 APK 自己的路径——说明它运行时直接从 APK 里读资源,不走解压。
这一点后面会咬我一口。
三、那些还没消失的东西
动刀之前,我先把还能救出来的东西捞了出来。
剧情原文。 assets/img/ 里有 479 个 DIADATA*.dat,是对话脚本。格式很简单——4 字节长度头,后面全是 \0 分隔的字符串。写个十行的解析器就能读:
Clide!
Yeah Mom?!
Finally, it's your test day for the Violet Knights!
Don't worry Mom! I'll surely pass the test!
主角叫克莱德。今天是普隆德拉国王亲卫队——紫罗兰骑士团——的入学考试。
还有个 Localized.txt,是官方中文翻译表。开场白是这样的:
看着身为骑士的父亲,克莱德从小拥有成为骑士的梦想…克莱德梦想成为普隆德拉最强的骑士…
结局也在:
黑暗之王的复活被制止,耶墨徳的阴谋也被粉碎,伦米徳嘉慈王国重新找回了昔日的和平…丧失紫罗兰的伦米德嘉慈王国,未来究竟会怎么样?现在…仅剩一个的紫罗兰该给谁?
音乐。 res/drawable/ 底下藏着 9 首 BGM(b0.mp3 到 b8.mp3)和 17 个音效。全是标准 MP3,直接能播。
美术。 531 张 PNG,另有一批伪装成 .dat 的图——文件头是 00 01 71 06 89 50 4E 47,前 4 字节是长度,第 5 字节开始就是完整的 PNG。写个循环把 4 字节头切掉,114 张角色和装备图就出来了。
到这里,其实"怀旧"这件事已经完成了。剧本、音乐、立绘都在手里了。
但我没停。
四、我想要的是:砍一刀,升到满级
理由很幼稚——我小时候从来没打通过这游戏。
我想要的是:进去,砍一刀,满级。把所有当年没能力打过去的地方走完。
游戏是单机的,服务器早就没了,逻辑全在本地。能改。
工具链
- jadx —— 反编译 Java 层,看壳
- Ghidra(NSA 出的那个)—— 反编译
libnostalgia.so,ARM 代码还原成 C - capstone / keystone —— 反汇编和汇编,写补丁用
- jarsigner —— 改完重新签名
Ghidra 是这次的主力。这个 .so 是 ARM 32 位、ARM 和 Thumb 指令混排,纯手工看汇编基本没戏。Ghidra headless 模式可以脚本化——写个 Jython 脚本,让它把指定函数反编译成 C 打印出来。
第一轮脚本我让它找所有引用 EXP、LV:、MONEY 这些字符串的函数。
五、一个把我卡了很久的坑
跑第一轮脚本的时候出了个怪事。
Ghidra 报告说 LV: 这个字符串在地址 0x6e3cc。但我用 Python 直接读文件,它明明在 0x5e3cc。
差 0x10000。整数。
我一开始以为是脚本写错了,反复检查了好几遍。直到我让 Ghidra 打印它的内存布局:
Block: .data start=00079000 len=104668
而文件里 .data 段的偏移是 0x69000。
Ghidra 加载这个 .so 时把基址抬高了 0x10000。
于是所有 Ghidra 地址 = 文件偏移 + 0x10000。我用 capstone 按文件偏移反汇编,和 Ghidra 的反编译结果对不上——因为它们说的根本不是同一个地址。
这个坑吃掉了我相当长一段时间。教训很简单:当两个工具给出的结果矛盾时,先确认它们在同一个坐标系里。
六、找到升级逻辑
绕开地址问题之后,事情快起来了。我让 Ghidra 把全库函数反编译成 C,然后在输出里搜关键词,捞出来三个函数:
void checkLevelUp(int param_1) {
if ((*(char *)(param_1 + 0xa4) < 'c') && // 等级 < 99
(*(int *)(param_1 + 0xa0) <= *(int *)(param_1 + 0x9c))) { // 需求经验 <= 当前经验
do {
levelUp(param_1);
} while (*(int *)(param_1 + 0xa0) <= *(int *)(param_1 + 0x9c));
}
}
玩家结构体一下就清楚了:
| 偏移 | 含义 |
|---|---|
+0x9c | 当前经验 |
+0xa0 | 升级所需经验 |
+0xa4 | 等级(上限 99 = 'c') |
+0x6c ~ +0x8c | 九项属性(STR/AGI/VIT/INT/DEX/LUK…) |
levelUp 里还看到一行很有意思的东西:
*(int *)(param_1 + 0x104) = *(int *)(param_1 + 0x104) + 2; // 属性点 +2
每升一级给 2 点属性点。
到这里思路就很直白了:让 getExp 给一个天文数字的经验,升级循环自己会把等级推满。
我改了 20 个字节,把"当前经验 += 获得经验"换成"当前经验 = 16,777,216"。
打包装上手机。
闪退。
七、三次闪退,两个完全不同的原因
第一次:我漏了一行
看 getExp 的调用链:getExp → checkLevelUp → 循环调 levelUp。
我把 checkLevelUp 重写了一遍,加了个防死循环的保护——但重写的时候漏了一行:
loop:
...
adds r0, r4, #0 ; ← 重新把玩家指针装进 r0
bl levelUp ; ← 调用升级
b loop
原版代码在循环里每次调用 levelUp 之前都要重新把玩家指针装进 r0。我抄漏了。
后果是:第一次升级正常,第二次 levelUp 拿到的 r0 是上一次调用留下的垃圾值,当成玩家指针去读内存——直接崩。
这是我的 bug,实打实的。 读反汇编的时候我明明看到了那行,写补丁的时候没抄进去。
补上,重新打包。
还是闪退。 而且换了地方——卡在加载进度条满的那一刻。
第二次:我改了设计
我猜是升级循环的问题:加载阶段如果触发了经验检查,循环会跑 98 次 levelUp,而每次 levelUp 都会调 resetMyStat——那里面有一堆全局指针解引用,加载阶段这些全局变量可能还没初始化好。
听起来很合理。于是我换了个保守得多的设计:
不再走升级循环,直接写结构体。 等级 = 99,经验 = 0,一次写入,没有循环,没有函数调用,没有任何全局指针。
从"必然崩溃"的角度看,这个补丁理论上不可能出事。
还是闪退。还是卡在加载进度条满的那一刻。
三个版本,三次同样的位置。这时候我意识到:可能根本不是补丁的问题。
第三次:做一次对照实验
我做了个决定性的实验。
我打了一个包:原版游戏,一个字节都不改,只是用和我一样的流程重新打包、重新签名。
如果它也闪退 → 问题在打包流程,跟补丁无关。 如果它能玩 → 问题在补丁。
结果是:它能玩。
一个字节都没改,只是重新打包,游戏正常进。
问题出在我的打包流程上。
根因:压缩方式
对比原版和我打的包:
原版 assets/img/BODYIMAGE61.dat: compress=0 (STORED)
我的 assets/img/BODYIMAGE61.dat: compress=8 (DEFLATE)
原版 APK 里,小文件是不压缩的(STORED),大文件才 deflate。我用 Python 的 zipfile 重打包,默认把每个条目都设成了 DEFLATE——1923 个文件的压缩方式被我改了。
而这个游戏的引擎是从 APK 里直接读资源的(还记得 nativeStart 传的是 APK 路径吗)。它自己实现的 zip 读取代码对压缩方式有预期,我改了之后,加载阶段读到那些文件就出问题了。
顺带我还犯了第二个错:把分析过程产生的垃圾文件也打进去了(imports.json、libnostalgia_patched.so——后者是 500KB 的补丁中间产物)。
修法是"忠实重打包":复刻原版每个条目的 compress_type,只替换目标文件,其余原样拷贝,同时跳过旧的 META-INF。
for info in zin.infolist():
if info.filename.startswith('META-INF/'):
continue
data = zin.read(info.filename)
if info.filename == 'lib/armeabi/libnostalgia.so':
data = patched_so # 只换这一个
zout.writestr(zipfile.ZipInfo(info.filename, info.date_time),
data, compress_type=info.compress_type) # ← 关键
再打包。装上。
进去了。
八、最后的功能清单
游戏能跑之后,加功能就很快了。每个补丁都是先让 Ghidra 反编译出 C 逻辑,再用 capstone 找到精确的指令,用 keystone 汇编出新字节,替换。
| 功能 | 怎么做的 |
|---|---|
| 满级 + 满属性 | patch resetMyStat(属性重算函数),每次重算都强制等级 99、九项属性 99、HP/MP 9999、属性点 9999 |
| 金币 ×100 万 | getDropItemNew 里"金币 += 掉落"改成"金币 = 掉落 << 20" |
| 卡普拉传送免费 | GATEWAY_TouchPressed 的费用检查 ble 改成无条件 b |
| 全地点传送 | 传送点的"未解锁"检查直接 NOP 掉——没去过的地方也能传 |
| 角色改名"问荆" | patch nativeIMEText(键盘输入处理),把输入直接替换成"问荆"的 UTF-8 字节 |
| 技能无冷却 | useSlot 里两处:冷却检查 NOP 掉,冷却标记写入也 NOP 掉 |
| 背包扩容 | addElementItemHave 的容量检查 NOP 掉 |
写"问荆"那段有点意思——我不需要输入法,直接把字节写进缓冲区:
movw r0, #0x97e9 ; "问" 的 UTF-8: E9 97 AE
movt r0, #0xe8ae ; "荆" 的前半: E8
str r0, [r6] ;
movw r0, #0x868d ; "荆" 的后半: 8D 86
strh r0, [r6, #4]
中文直接以字节形态进了存档。
九、回头总结
这件事真正有价值的部分,不是"我做出了外挂"——那只是体力活。有价值的是那三次闪退。
第一条:写汇编补丁的时候,寄存器的状态是你要主动维护的东西。
高级语言里"变量还在这儿"是默认的,汇编里不是。bl 一个函数回来,r0~r3 就可能是垃圾了。我漏掉的那行 adds r0, r4, #0 就是这个——原版代码写它是有原因的,抄的时候不能凭感觉省。
第二条:当"理论上不可能出错"的补丁还是崩了,去做对照实验。
这是我这次最大的收获。第三个版本的补丁是单次结构体写入——没有循环、没有函数调用、没有全局指针。它在逻辑上真的不可能崩。
但它崩了。这时候正确的反应不是"再想想补丁哪里错了",而是**"假设补丁是清白的,去验证它的下游"**。
我打了个一个字节都没改的原版包。它跑起来了。五分钟就锁定了方向,比我前面两轮猜测加起来都管用。
第三条:逆向最大的开销,是坐标系。
那个 0x10000 的基址偏移、那个 compress_type 的差异——都不是"难"的东西。Ghidra 的内存布局是明摆着的,zip 条目的压缩方式也查得到。
难的是在找到它们之前,你不知道要去查那里。我花在"我的补丁哪里写错了"上的时间,远多于花在"我的假设是不是错了"上的时间。
最后说句实话。
这篇东西的技术含量,其实不如它看起来那么高。ARM 汇编、Ghidra、zip 格式,每一项都是公开的、有文档的。我做的事无非是耐着性子把这些东西串起来。
真正稀缺的是它的对象——一款 2013 年的、停服十五年的、服务器早就灰飞烟灭的手游。
游戏是单机的,我改的是我自己手机上、我自己那份安装包,用来打我自己小时候没打完的关。它的服务器不会再回来了,排行榜也不会再更新——我改不改,其实没有第二个人会受影响。
所以我更愿意把它当成一次修复,而不是一次破解。
那个叫 libnostalgia.so 的库,名字起得真好。
评论
本站已关闭评论。

