【逆向实战】
Cocos2d-Lua 手游从资源解密到打包破解 (下) ——
Lua 详细逆向:奖励视频获取与产出直装包[
目录
一、确定互动实现
二、确定视频来源
2.1 搜索
2.2 抓包
2.3 初步结论
三、卡壳
3.1 先猜规律
3.2 不知道谁是谁
四、顺藤摸瓜,域名定位
4.1 思路
4.2 grep xn--xxxxxxx.com
4.3 头大的文件名
4.4 解药
4.5 反查工具
五、读懂分包下载器
5.1 关键逻辑
5.2 寻找对应码表
5.3 URL 拼接
5.4 细节
5.5 最后一环:找到 csv.banlvxiaowu
六、落地方案一:视频下载
6.1 思路
6.2 写下载器
6.3 下载完自动解密
6.4 跑起来
七、落地方案二:改包
7.1 判断检查类型
7.2 证据一:视频下载无鉴权
7.3 证据二:进房间是纯本地跳转
7.4 证据三:协议层是干净的
7.5 结论
八、寻找切入点
8.1 关键词定位
8.2 全局搜索
8.3 知道 key 还不够
8.4 索引表对查
九、读懂门卫拦截逻辑
9.1 门槛表
9.2 找到按钮绑定函数
9.3 门卫一号:onClickFereRoom
9.4 门卫二号:onClickFereCard
9.5 门卫三号:列表锁定
9.6 暗哨?
十、改包、加密、回填
10.1 为什么不能直接改
10.2 四处修改
10.3 重新加密
10.4 测试
10.5 无解的闪退
10.6 排查
10.7 破局
10.8 重建索引
十一、翻车点总结
十二、防御侧反思与加固建议
12.1 加密层加固(上篇)
12.2 授权层加固(下篇)
十三、结语
开篇严正声明:本文仅用于学习逆向工程与网络安全相关技术,未掺杂任何不良目的。文中的软件已对其名字、图标及相关敏感信息做了模糊处理,本人也不会提供软件原包样品,内容仅作学习交流使用!同时,本人并未对该软件样本进行任何分享,下载仅做技术研究,均在个人设备上和虚拟设备中进行分析,并已在分析完后删除!
由于事务繁多,拖了这么久才出下篇
下篇相对来说较为简单,我会带大家一步一步剖析 Lua 代码,找出关键线索,对于小白来说较为友好。
如果还有没看过上篇的,可以先去把上篇看完,再来看本篇思路会更加清晰:
【逆向实战】Cocos2d-Lua 手游从资源解密到打包破解 (上)(https://www.52pojie.cn/thread-2122280-1-1.html)
上期我们提到,我们的分析对象是一个宝可梦二次元手游,玩家完成主线任务、成功闯关后,就有机会解锁人物进行互动,游玩方式非常简单。当我们未完成主线任务时,游戏就不让我们与角色互动。那么我们的任务就是绕过这个机制,实现获取互动资源或者做到游戏直装破解。
不过在正式开始之前,我先把上期的成果快速过一遍,免得有同学直接跳过来看,结果看天书。
1、引擎:包名 com.bkm2.js.android,Application 是 org.cocos2dx.lua.App,lib 下就一个 libcocos2dlua.so —— 标准 Cocos2d-Lua。
2、算法:SO 符号没 strip,函数表里直接搜到 tj_xxtea_decrypt / LZ4_decompress_safe,靠交叉引用定位到总解密入口 0x93FB20。
3、格式:文件头三字节定生死
tje = 纯 XXTEA,解完直接是明文;
tj! = XXTEA + LZ4,XXTEA 明文 = [4字节小端长度][LZ4 数据];
tjz = 纯 LZ4(代码里存在,包里没样本);
其它 = 原样透传。
文件头结构:魔数(3) + keymat(16) + plen(4) + 密文(...)
派生密钥 derived = 内置密钥 s_xxteaKey XOR keymat
4、密钥:静态推导 + Frida 动态验证,双保险
Key = 94e1e4efb4ddea414f6d65484680a829
5、工具:Python 验证 → C 重写 → ./decrypt,两万个仓库文件全解,秒级完成。
到这里,APK 里 /assets/repository/ 那 900 多兆的资源,已经全部变成明文了。
好,工具齐了,刀也磨快了。那……砍谁?
答案是先玩,先看现象。逆向永远从现象开始。
一、确定互动实现
我们重新打开游戏,进入“伴侣小屋”。
这个界面里有 11 个角色,但能互动的只有一个,即默认解锁的那位。其他 10 个锁着,点进去弹提示。
那我们就先玩能玩的那个。
点进去,触发互动。然后我就发现不对劲了。
那就是这个“互动”太流畅了。互动效果不是那种几张立绘来回切换、或者 Spine 骨骼动画扭来扭去的效果,而是真正的连续画面
这里先科普一下,二次元手游里的互动通常有三种实现:
1、Spine / DragonBones 骨骼动画(资源是 .skel / .atlas,代码里能搜到 addAnimation)
2、逐帧序列图(一堆 png,按帧播)
3、真正的视频文件(mp4,交给播放器组件播)
前两种是“动画”,第三种是“视频”。
结合我们刚才的互动,同时考虑到这种小游戏的制作成本,我们敢断定这里使用的就是第三种实现,即直接播放视频。
于是问题来了:这个视频,从哪来的?
二、确定视频来源
有同学可能会说:管它哪来的,反正能看
但我们是学逆向的,不能“管它”。
而且这个问题非常关键,因为它的答案决定了我们后面要干的事完全不同:
- 如果是本地加载:文件就在 APK 里,那它已经在我们解密好的 repository 目录里了,直接拿就行,收工。
- 如果是网络加载:那我们就得知道服务器地址、文件名规则、以及加密方式,才能把东西弄下来。
2.1 搜索
最直接的验证方式:既然是一个“视频”,那它落地到本地,大概率就是 .mp4 后缀。
我们在解密好的 repository 目录里全量搜一遍:
> find repository/ -iname "*.mp4" -printf "%p %s bytes\n"
repository/buzhen2/hero_jinshuguaic5ab.mp4 52708070 bytes
repository/img_fz3/fishing_book3cf9.mp4 45835592 bytes
两万个文件,mp4 只有两个。
名字依然是混淆的,看不懂没关系,因为数量已经足够说明问题了。
我们想一想:游戏里 11 个角色,每个角色至少 2 段互动,加起来至少 20 多段。而本地只放了 2 个视频。
结论:绝大多数的互动视频,是从网络加载的。
在打开新互动页面时出现的进度条也从侧面佐证了这一点,这可能就是软件在从网络下载资源:
2.2 抓包
既然走网络,那就好办了 —— 网络流量是藏不住的。
环境准备:模拟器 + 抓包工具 (我这里使用Reqable) + 可信证书。
然后在游戏里随便点一个能玩的互动,触发加载。抓包结果:
[26] request
HEAD /subpackage/pkm/video/video_b_1 HTTP/1.1
Host: xn--xxxxxxx.com
Connection: Keep-Alive
Accept-Encoding: gzip
[26] response
HTTP/1.1 200 OK
Last-Modified: Thu, 19 Mar 2026 03:31:13 GMT
Etag: "9cda1ec0a9a3bab9d443718b95aecf2d"
x-amz-server-side-encryption: AES256
Content-Type: application/octet-stream
Server: AmazonS3
Content-Length: 95813803
Accept-Ranges: bytes
[27] request
GET /subpackage/pkm/video/video_b_1 HTTP/1.1
Content-Length: 0
Host: xn--xxxxxxx.com
信息量大得吓人,我们一条条拆:
Ⅰ、先 HEAD 探活,再 GET 拉正文
这是典型的客户端缓存策略,先用 HEAD 拿 Content-Length 和 Etag,判断本地缓存是否过期;需要更新才发 GET 拉正文。
Ⅱ、域名 xn--xxxxxxx.com 是 punycode
xn-- 开头是 Punycode 编码,解码一下:
> python3 -c "print('xn--xxxxxxx.com'.encode().decode('idna'))"
XX.com
一个中文域名。
Ⅲ、路径里有个熟悉的词:pkm
/subpackage/pkm/video/ —— 还记得上期我们在密钥构造链里见的那个字符串吗?
base = "cocosTianJi,pkm" + "TianJi Information Technology Inc."
又是 pkm! 这个词根同时出现在客户端的内置密钥和服务器路径里,说明这套资源体系就是跟着 TianJi(天机)引擎走的。虽然对破解没什么直接帮助,但看到这种标记,说明我们方向没跑偏。
Ⅳ、视频是加密的,而且和我们仓库里同款
我们把响应的正文抠出来看一眼开头:
> xxd video_b_1 | head -1
00000000: 74 6a 65 bd 4b a1 4e 47 85 8a e5 c9 15 c6 07 37 ...
74 6A 65 = "tje"
各位,看到这三个字节你还慌吗?
这不就是上期我们逆出来的 tje 格式吗!纯 XXTEA 加密,解完直接是明文
也就是说,服务器的视频,和我们 APK 里的资源,是同一套加密体系。我们上期写的 ./decrypt 工具,可以直接拿来用。
2.3 初步结论
我们尝试使用解密工具解密视频,发现能够完美解密:
综合以上分析,我们得出:
1、互动视频确实是网络加载的,域名 XX.com,路径 /subpackage/pkm/video/
2、响应正文是 tje 加密,我们的 decrypt 工具可直接解密
3、文件名是 video_b_1,看起来有规律
好,那就顺着这个规律,把视频“枚举”出来不就行了?
三、卡壳
3.1 先猜规律
抓到的文件名是 video_b_1。
我们看着它琢磨:
video_ ? _ ?
↑ ↑
a/b/c 伴侣编号
- 中间的字母大概是“第几段”的意思(a、b、c)
- 最后的数字大概是“哪个角色”(1 = 第一个角色)
再结合前面:11 个角色,每个角色最多 3 段视频,那理论上把所有组合枚举一遍就完了:
for i in 1..11:
for x in [a, b, c]:
GET /subpackage/pkm/video/video_{x}_{i}
但是根据我实测发现,根本下不完所有的角色视频,有些槽位根本就不存在视频,命名十分混乱,并不像我们所想的那样具有规律。
3.2 不知道谁是谁
虽然下不全,但好歹下了些。
但是下面这点其实这才是最要命的:
我现在手里有一堆 video_xxx 的文件名,但——
哪个文件对应哪个角色?
每个角色到底有几段?
我下载下来的一堆 MP4,怎么归类到 11 个角色名下?
总不能下完再一个个打开看吧(那也太费时间了)。
所以问题的本质变了。我们需要的不是“猜文件名”,而是一张“对照表”。
这张表,肯定就在安装包中。因为它得靠这张表去拼 URL、去循环下载。我们的任务就是把这张表找出来。
四、顺藤摸瓜,域名定位
4.1 思路
那么如何下手呢?
让我们先想想,我们的仓库里有什么?
有 1804 个 Lua 脚本、18772 个资源文件,全部解密成明文了。
而服务器地址这种东西,程序员一般是写死在代码里的(尤其是这种换皮游戏,不会搞什么动态配置)。
所以:
4.2 grep xn--xxxxxxx
> grep -rl "xn--xxxxxxx" repository/
repository/box_gg/gljo2908.subpackage
repository/box_wddw2/logo_bxys2ae8
命中两个文件。
好,找到嫌疑人了。那我们现在打开看看?
4.3 头大的文件名
我们手里拿到的是 box_gg/gljo2908.subpackage 和 box_wddw2/logo_bxys2ae8。
请问:这两个文件是什么模块?
- gljo2908.subpackage —— 里面的 gljo2908 是什么鬼?
- logo_bxys2ae8 —— 连扩展名都没有
上期我们只解决了“能不能解密”,没解决“解出来是谁”。 两万个文件摆在你面前,文件名全是被随机后缀污染的,你等于在看一本没有目录的书。
这就是我们下篇要面对的第一个,也是最现实的问题:
仓库里的文件名,全部被混淆了。
4.4 解药
还记得上期我提过一句吗?仓库根目录躺着一个 2.8MB 的 repo.mapping,但是我没有细讲。
此时该文件已经被解密,我们打开看看:
{
"res/resources/city/pvp/cross_mine/buff/icon_buff5.png": {
"reposize": 4747,
"mapping": "repository/txt_xuanze/txt_swksjqqd8eb3.png",
"size": 4718
},
"src/app.views.city.house.fereView": {
"reposize": 7995,
"mapping": "repository/hunting/logo_ytz42a3.fereView",
"size": 7995
},
...
}
这就是一张“逻辑路径到混淆物理路径”的翻译表,我们可以简单把它类比理解成我们常见的老朋友 resources.arsc 。
游戏运行时的流程是这样的:
代码要读 res/resources/city/pvp/.../icon_buff5.png
↓ 查 repo.mapping
得到物理路径:repository/txt_xuanze/txt_swksjqqd8eb3.png
↓ 读文件、解密
交给渲染层
所以,文件名的混淆,本质上只要你有索引表,一切都能还原。
4.5 反查工具
每次手动翻 JSON 太累了,我们写个小脚本。只要给它一个物理文件名,它就会输出逻辑路径:
import json, sys
m = json.load(open('repository/repo.mapping'))
for k, v in m.items():
if v['mapping'] == sys.argv[1] or v['mapping'].endswith(sys.argv[1]):
print(f"{k}\n reposize={v['reposize']} size={v['size']}\n {v['mapping']}")
> python3 find.py gljo2908.subpackage
src/app.subpackage
reposize=4375 size=4375
repository/box_gg/gljo2908.subpackage
> python3 find.py logo_bxys2ae8
updater/main
reposize=10563 size=10563
repository/box_wddw2/logo_bxys2ae8
谜底揭晓:
box_gg/gljo2908.subpackage → src/app.subpackage(分包下载器)
box_wddw2/logo_bxys2ae8 → updater/main(热更新入口)
我们要找的分包下载器,就是它!
五、读懂分包下载器
现在正式打开 src/app.subpackage(物理文件 box_gg/gljo2908.subpackage)。
5.1 关键逻辑
-- 分包 下载
local SubPackDownload = class("SubPackage")
local subPackUrl = {
--cn = "https://r-bkm.xxx.com/subpackage/pkm/video/",
cn = "http://xn--xxxxxxx.com/subpackage/pkm/video/",
}
服务器地址,明文 HTTP,写死在客户端里。和抓包完全对上。
5.2 寻找对应码表
往下看 init 函数,这是整篇下篇的“命门”:
function SubPackDownload:init()
self.fileInfo = {}
self:setSubpackUrl()
self.tarPackPath = writablePath .. "package/video/xiaowu/"
self.tempPackPath = writablePath .. "package/_temp/"
local videoList = {}
for k, v in csvPairs(csv.banlvxiaowu) do -- 就是它!
for i = 1, 3 do
-- 第一个伴侣不下载
if v.id == 1 and i == 1 then
else
local videoname = v["video"..i]
if videoname and videoname ~= "" then
table.insert(videoList, videoname)
end
end
end
end
...
end
看到 csv.banlvxiaowu 了吗?
- banlv = 伴侣
- xiaowu = 小屋
这就是我们苦苦寻找的那张“角色→视频”对照表!
5.3 URL 拼接
继续往下读,这两行解释了为什么有些编号请求不到:
local basename = string.match(v, "([^/]+)%.%w+>") -- 从 'xiaowu/video_a_1.mp4' 取出 'video_a_1'
local targetFile = self.tarPackPath .. basename .. ".mp4"
-- 拼下载地址
local fileUrl = self.subPackUrl .. self.downInfo.file -- http://.com/subpackage/pkm/video/video_a_1
也就是说,表里写什么名字,就去下什么名字。所以,如果这张表里,角色3的第一段写的是 video_3 而不是 video_a_3,那 video_a_3 当然请求不到。这正好解释了我们为什么有许多视频无法下载的原因。
5.4 细节
再看这一行注释:
-- 第一个伴侣不下载
if v.id == 1 and i == 1 then
“第一个伴侣的第一段视频不下载”,因为它随包内置了!
还记得第二节我们全仓搜 mp4,只搜到两个文件吗?
其中那个 45.8MB 的 fishing_book3cf9.mp4,反查下来的身份就是:
> python3 find.py fishing_book3cf9.mp4
res/video/xiaowu/video_a_1.mp4
reposize=45835592 size=45835592
repository/img_fz3/fishing_book3cf9.mp4
所以,res/video/xiaowu/video_a_1.mp4 就是伴侣 1 的第一段互动视频,被直接打包进了 APK。
所以我们一开始能玩的那个角色,看的就是本地这个文件。伏笔回收。
再补一个细节,让我们来看播放页 fereVideo 里是如何找文件的:
local videoFullPath = cc.FileUtils:getInstance():fullPathForFilename(path)
if #videoFullPath == 0 then
local writablePath = cc.FileUtils:getInstance():getWritablePath()
videoFullPath = writablePath .. "package/video/" .. path
end
先在资源搜索路径(包含 res/video)里找,找不到就去可写目录 package/video/ 里找。而下载器下载完正好就存到 writablePath.."package/video/xiaowu/" 两边路径严丝合缝地对上了。
这也印证了之前我们的猜想。
5.5 最后一环:找到 csv.banlvxiaowu
好,现在只差最后一步:csv.banlvxiaowu 这张表在哪?
同样的套路,先 grep,拿到物理文件名;再反查,拿到逻辑路径。
> grep -rl "banlvxiaowu" repository/ | head
repository/icon_bb_4/icon_bb_15e344.banlvxiaowu
...
> python3 find.py icon_bb_15e344.banlvxiaowu
src/config.banlvxiaowu
reposize=1943 size=1943
repository/icon_bb_4/icon_bb_15e344.banlvxiaowu
打开它:
-- auto generated by csv2lua
-- DON'T EDIT THIS FILE !!!
csv['banlvxiaowu'] = {
[1] = {
id = 1, loveID = 1001, attrType = 9, gateID = 10102,
name = '物防大师·雪绫',
icon = 'config/xiaowu/icon_touxiang1.png',
image1 = 'spine/xiaowu/image1_1.skel',
video1 = 'xiaowu/video_a_1.mp4',
video2 = 'xiaowu/video_b_1.mp4',
video3 = 'xiaowu/video_c_1.mp4'
},
[2] = { id = 2, ..., gateID = 10205, name = '特防大师·夏音',
video1 = 'xiaowu/video_a_2.mp4', video2 = 'xiaowu/video_b_2.mp4' },
...
}
完美!可以看到,我们已经成功的挖出了整张表。
我们将表内信息整理一下(一共 11 个伴侣、29 段视频):
伴侣ID | loveID | gateID | 名称 | video1 | video2 | video3
1 | 1001 | 10102 | 物防大师·雪绫 | video_a_1 | video_b_1 | video_c_1
2 | 1002 | 10205 | 特防大师·夏音 | video_a_2 | video_b_2 | —
3 | 1003 | 10407 | 生命大师·夜莉 | video_3 | video_b_3 | video_c_3
4 | 1004 | 10609 | 物攻大师·猫丸 | video_a_4 | video_b_4 | video_c_4
5 | 1005 | 10809 | 特攻大师·珂甜 | video_5 | video_b_5 | —
6 | 1006 | 11009 | 速度大师·枭羽 | video_6 | video_b_6 | video_c_6
7 | 1007 | 11209 | 暴击大师·诛鸣 | video_7 | video_b_7 | —
8 | 1008 | 11509 | 治疗大师·诗梦 | video_a_8 | video_b_8 | video_c_8
9 | 1009 | 11709 | 必杀大师·浅汐 | video_a_9 | video_b_9 | video_c_9
10 | 1010 | 11909 | 破坏大师·玖岚 | video_a_10 | video_b_10 | video_c_10
11 | 1011 | 12109 | 闪避大师·晴雨 | — | video_b_11 | video_c_11
注意到,角色 3、5、6、7 的第一段,叫 video_3、video_5、video_6、video_7,根本没有那个 a!
这就是为什么我们枚举会失败。这几段视频的名字八成是策划手工填进 csv 的,不是程序按规则拼出来的,所以出现了这种不统一。
至此,第三节卡住我们的两个问题,全部解决
六、落地方案一:视频下载
6.1 思路
现在一切都清楚了:
6.2 写下载器
清单我们直接硬编码进脚本:
BASE_HOST = "xn--xxxxxxx.com" # punycode: XX.com
BASE_PATH = "/subpackage/pkm/video"
BASE_URL = f"http://{BASE_HOST}{BASE_PATH}"
COMPANIONS = [
(1, 1001, 10102, "物防大师·雪绫", ["video_a_1", "video_b_1", "video_c_1"]),
(2, 1002, 10205, "特防大师·夏音", ["video_a_2", "video_b_2", None]),
(3, 1003, 10407, "生命大师·夜莉", ["video_3", "video_b_3", "video_c_3"]),
...
] # 共 11 个伴侣、29 段视频
这里我们顺便把 UA 伪装成游戏客户端(其实不伪装也行,但礼貌一点):
USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; sdk_gphone64_arm64 Build/TE1A.220804.005)"
6.3 下载完自动解密
下载下来的文件是 tje 密文,直接改后缀成 .mp4 是播不了的。所以下载完成后,脚本自动调用上期的解密器批量解密:
cmd = [decryptor, "-o"] + downloaded_files # -o 原地覆盖
subprocess.run(cmd)
6.4 跑起来
用法设计得尽量顺手:
.../神奇宝贝/Tools $$$$ python video_fetcher.py --help
usage: video_fetcher.py [-h] [-o OUTDIR] [--no-decrypt] [--decryptor DECRYPTOR]
[--no-head] [-l]
[selections ...]
神奇宝贝 伴侣视频下载解密器
positional arguments:
selections 选择,如: all / 1 / 1,3 / 1:2 / 1:2,3 8:1
options:
-h, --help show this help message and exit
-o, --outdir OUTDIR 下载目录(默认 videos/,按伴侣名分子目录)
--no-decrypt 只下载,不解密
--decryptor DECRYPTOR
解密器路径(默认 ./decrypt)
--no-head 跳过 HEAD 探活
-l, --list 打印伴侣视频对照表后退出
神奇宝贝 伴侣视频下载解密器
=========================================
内置完整伴侣-视频对照表(csv.banlvxiaowu 提取),支持:
1. 按伴侣下载:一个或多个伴侣,每个伴侣自由选择 video1/2/3
2. 全量下载:一次下载所有伴侣的所有视频
3. 下载完成后自动补 .mp4 后缀,并调用编译好的解密器 ./decrypt 批量解密
视频按伴侣名存放在子目录:videos/<伴侣名>/video_a_1.mp4
解密使用 -o 覆盖选项,解密结果写回原文件(原地替换为可播放 MP4)
两种调用方式:
- 纯命令行:python3 video_fetcher.py <选择>...
- 交互模式:python3 video_fetcher.py (不带参数,逐项询问)
选择语法:
all 下载所有伴侣的全部视频
1 下载伴侣1的全部视频
1,3,8 下载伴侣1/3/8的全部视频
1:2 下载伴侣1的 video2
1:2,3 8:1 下载伴侣1的 video2/3 + 伴侣8的 video1
1:all 下载伴侣1的全部视频
选项:
-o, --outdir <目录> 下载保存目录(默认 videos/,按伴侣名分子目录)
--no-decrypt 只下载不解密
--decryptor <路径> 解密器路径(默认 ./decrypt)
--no-head 跳过 HEAD 探活
--list 列出全部伴侣视频对照表后退出
.../神奇宝贝/Tools $$$$
随便拿几个视频跑一下:
将所有视频下载完成后,最终目录:
videos/
├── 物防大师·雪绫/
│ ├── video_a_1.mp4
│ ├── video_b_1.mp4
│ └── video_c_1.mp4
├── 特防大师·夏音/
│ └── ...
└── 闪避大师·晴雨/
└── ...
完成!29 段视频全部到手。
这种方式的优点是:不碰游戏、零风险、门槛低。
不过也有缺点:你得跳出游戏去文件夹里看,可能会破坏游戏体验;而且如果游戏后续更新了新内容,也要重新走一遍这个流程。
那么,有没有办法在游戏里直接解锁呢?
有。这就引出方案二。
七、落地方案二:改包
7.1 判断检查类型
这个方案思路很直接,绕过游戏的资格检查,把它改掉。
但是,在动手之前,必须先明确一个问题:
如果是服务端鉴权,那改客户端就是白改。如果是这样,那就麻烦了。
7.2 证据一:视频下载无鉴权
经过试验,视频下载请求就是一个 明文的 HTTP GET,没有 token,没有签名头,没有时间戳校验,连个 referer 都不查。
而且我们在方案一里已经用 Python 成功下载视频。服务器根本不关心你是谁、你有没有解锁。
7.3 证据二:进房间是纯本地跳转
我们后面会看到 fereView 里的相关代码:
gGameUI:stackUI("city.house.fereRoom", nil, {full = true}, ...)
这是客户端 UI 栈操作,不发任何网络请求。
7.4 证据三:协议层是干净的
把所有跟“伴侣”相关的网络协议搜一遍:
> grep -rn "game/lover" repository/
fereView: gGameApp:requestServer("/game/lover/add/feels", ...) -- 加好感度(喂花)
fereView: gGameApp:requestServer("/game/lover/bless/card", ...) -- 伴侣祝福绑卡
只有两个业务接口,而且都是写数据的,没有任何一个接口是“查询/校验视频解锁状态”的。
7.5 结论
整条锁链 100% 在客户端 Lua 里,服务端完全不参与。
也就是说,我们通过修改 Lua 文件,理论上就能全部解锁。
八、寻找切入点
8.1 关键词定位
我们发现,点伴侣小屋里被锁的伴侣,屏幕上弹出:
这句话就是我们的第一线索。
8.2 全局搜索
> grep -rl "该玩法需好感度" repository/
repository/icon_wsj/ring_5_7_10_2b940.language
命中一个 .language 文件。打开它(是个 Lua 表,3778 条文案):
> grep -n -A3 "该玩法需好感度" ring_5_7_10_2b940.language
4728: [8732] = {
4729- id = 8732,
4730: key = 'yijiejiesuo',
4731- text = '该玩法需好感度一阶满级时解锁'
4732- },
把周围几条一起打出来,信息量就大了:
[8732] = { id = 8732, key = 'yijiejiesuo', text = '该玩法需好感度一阶满级时解锁' },
[8733] = { id = 8733, key = 'erjiejiesuo', text = '该玩法需好感度二阶满级时解锁' },
[8734] = { id = 8734, key = 'sanjiejiesuo', text = '该玩法需好感度三阶满级时解锁' },
一阶、二阶、三阶,这说明“好感度”被分成了三个等级,每一级对应一道门槛。
8.3 知道 key 还不够
现在我们手里有一个字符串常量 yijiejiesuo。可是谁在用它?
Lua 里相关逻辑通常是这样写的:
gGameUI:showTip(gLanguageCsv.yijiejiesuo)
它会以 yijiejiesuo 这个标识符出现在某个 Lua 文件里。那就继续 grep:
> grep -rln "yijiejiesuo" repository/
repository/hunting/logo_ytz42a3.fereView
repository/icon_wsj/ring_5_7_10_2b940.language
找到了:hunting/logo_ytz42a3.fereView。
8.4 索引表对查
这个文件名 logo_ytz42a3.fereView 又是混淆的。
不过不用担心,我们有索引表。反查:
> python3 find.py logo_ytz42a3.fereView
src/app.views.city.house.fereView
reposize=7995 size=7995
repository/hunting/logo_ytz42a3.fereView
翻译一下:
src/app/views/city/house/fereView.lua
= “城市 → 房子 → 伴侣小屋” 的主界面逻辑
至此,第一道门被打开了。
各位同学可以看看这个流程,是不是很清晰:
玩家看到的提示(现象)
→ grep 文案表,拿到 key(yijiejiesuo)
→ grep key,拿到物理文件名(logo_ytz42a3.fereView)
→ 反查 repo.mapping,拿到逻辑路径(src/app.views.city.house.fereView)
→ 打开源码,读逻辑
九、读懂门卫拦截逻辑
现在正式打开 fereView.lua。
9.1 门槛表
文件不大,600 行出头。第 32 行,我们一眼就能看到:
local FERE_FEEL_DATAS = {100, 200, 300}
这不就是游戏中好感度的三档阈值吗?
一阶满级 = 100,二阶满级 = 200,三阶满级 = 300。
凭什么这么肯定?我们可以看它在代码里怎么被用的。不难发现,全是拿 currPoints 去跟 FERE_FEEL_DATAS 比。而且别忘了我们刚才在语言表里发现的“一阶/二阶/三阶”三兄弟,一一对应。
9.2 找到按钮绑定函数
Lua 的界面代码有个特点,就是界面元素和回调函数是绑定在一起的。所以先看 RESOURCE_BINDING,它会告诉我们哪个按钮点了会调用哪个函数。
HousefereView.RESOURCE_BINDING = {
...
["rightPanel.btn_ty"] = { -- ← “互动”按钮
varname = "btnTY",
binds = {
event = "touch",
methods = {
ended = bindHelper.self("onClickFereRoom") -- 点它 → onClickFereRoom
}
}
},
["rightPanel.btn_zf"] = { -- ← “祝福”按钮
varname = "btnZF",
binds = {
event = "touch",
methods = {
ended = bindHelper.self("onClickFereCard") -- 点它 → onClickFereCard
}
}
},
...
}
btn_ty = 互动,btn_zf = 祝福。两个按钮,两个回调,目标锁定。
9.3 门卫一号:onClickFereRoom
function HousefereView:onClickFereRoom()
if self.currPoints:read() < FERE_FEEL_DATAS[1] then -- 好感度 < 100 ?
gGameUI:showTip(gLanguageCsv.yijiejiesuo) -- 弹“需一阶满级时解锁”
return -- 直接返回
end
gGameUI:stackUI("city.house.fereRoom", nil, {
full = true
}, self.curSelFere:read(), self.currPoints:read(), self.proData) -- 达标才进房间
end
逐行翻译:
1、self.currPoints:read() —— 读取“当前好感度”
2、< FERE_FEEL_DATAS[1] —— 也就是 < 100
3、成立 → 弹提示 + return
4、不成立 → stackUI 打开 city.house.fereRoom(房间界面)
9.4 门卫二号:onClickFereCard
function HousefereView:onClickFereCard()
if self.currPoints:read() < FERE_FEEL_DATAS[3] then -- 好感度 < 300 ?
gGameUI:showTip(gLanguageCsv.sanjiejiesuo) -- 弹“需三阶满级时解锁”
return
end
gGameUI:stackUI("city.house.fereCard", nil, nil, self.curSelFere:read(), self:createHandler("onSelectCard"))
end
祝福卡牌被 300 点拦着。这跟我们的主目标(看互动视频)是两码事,不过顺手的事。
9.5 门卫三号:列表锁定
继续往下翻,在 onCreate 里还有一层:
for k, v in csvPairs(csv.banlvxiaowu) do
local lockState = false
if not feres[k] then -- 如果你还没拥有这个伴侣
lockState = not dataEasy.isGateFinished(v.gateID) -- 且对应关卡没通关
end
tData[k] = {
id = v.id,
icon = v.icon,
gateId = v.gateID,
isLock = lockState, -- → 列表里显示“锁定”
...
}
end
含义:如果你还没有这个伴侣,而且它对应的关卡(v.gateID,比如伴侣 1 是 10102)没通关,那这个伴侣在列表里就是“锁定”状态,连点都点不了。
注意这里的 v.gateID,它从哪来的?就是我们之前拿到的那张 csv.banlvxiaowu 表。索引表和对照表在这里合流了。
到这里,我们摸清了所有门卫布局:
拦截点 | 位置 | 条件
进房间 | fereView:onClickFereRoom | 好感度 < FERE_FEEL_DATAS[1] = 100
祝福卡 | fereView:onClickFereCard | 好感度 < FERE_FEEL_DATAS[3] = 300
列表锁定 | fereView onCreate 数据构造 | 未拥有 且 关卡未通关
9.6 暗哨?
好,改完这三处,不就完事了?
别急。逆向有个基本原则,改之前,把这条链路上所有的地方都看一遍,别改一半。
Ⅰ、房间页 fereRoom
> python3 find.py fereRoom
src/app.views.city.house.fereRoom
reposize=2231 size=2231
repository/txt_xuanze/icon_bq_hql5b9c.fereRoom
打开它,找到播放按钮的回调(RESOURCE_BINDING 里 btnPlay → onClickPlayVideo):
function FereRoomView:onClickPlayVideo()
-- if self.currPoints:read() < 200 then ← 看这里
-- gGameUI:showTip(gLanguageCsv.erjiejiesuo)
-- return
-- end
if #self.menuDatas > 1 then
gGameUI:createView("city.house.fereMenu", self):init(self.menuDatas)
else
...
gGameUI:stackUI("city.house.fereVideo", nil, {full = true}, self.menuDatas[1].path)
end
end
可以发现,四行关键检查代码已经被官方注释掉了!
(这也解释了为什么语言表里的 erjiejiesuo “二阶解锁” 那句提示,我们在游戏里从来没见过。因为它根本没机会被调用。)
再去看一眼它的 UI 配置文件 fere_room.json(同样按映射反查:res/uijson/fere_room.json → repository/img_zjx4/jiesuan493e.json):
{ "name": "btnPlay", "visible": true, "tag": 727831538 }
点击播放的按钮默认就是可见的。没有藏起来、没有置灰。
看来这一层是安全的。
Ⅱ、多视频菜单 fereMenu
反查 src/app.views.city.house.fereMenu → repository/icon_bb_12/flooor_6b5fe.fereMenu。
function FereMenuView:onClickPlayVideo(index)
if self.listData[index] then
local needDown = gGameApp.subPack:checkNeedDownload(self.listData[index].path)
if needDown then
gGameUI:stackUI("city.house.fereDown", nil, nil, self.listData[index].path)
else
gGameUI:stackUI("city.house.fereVideo", nil, {full = true}, self.listData[index].path)
end
end
ViewBase.onClose(self)
end
代码非常干净,唯一的判断是 checkNeedDownload,也就是本地文件是否存在,跟好感度一毛钱关系都没有。
而且注意,这个 checkNeedDownload 就是之前分包下载器里的方法,逻辑是遍历 fileInfo,如果该文件状态不是“已完成”,就返回 true(需要下载)。
Ⅲ、下载页 fereDown
反查 src/app.views.city.house.fereDown → repository/miaowacao_attack/union_apply7995.fereDown。
function FereDownView:onCreate(path, cb)
...
gGameApp.subPack:downloadOneFile(path, function()
performWithDelay(self, function()
self:onClose()
gGameUI:stackUI("city.house.fereVideo", nil, {full = true}, path)
end, 1)
end)
end
纯粹一个下载+跳转,没有任何校验。
Ⅳ、播放页 fereVideo
反查 src/app.views.city.house.fereVideo → repository/hunting/logo_haipafef2.fereVideo。
function FereVideoView:onCreate(path, cb)
...
local videoFullPath = cc.FileUtils:getInstance():fullPathForFilename(path)
if #videoFullPath == 0 then
local writablePath = cc.FileUtils:getInstance():getWritablePath()
videoFullPath = writablePath .. "package/video/" .. path
end
...
videoPlayer:setFileName(videoFullPath)
videoPlayer:play()
end
就是我们之前看到的那段路径兜底逻辑,没有任何权限判断。
这下我们放心了,门后没有“暗哨”
十、改包、加密、回填
10.1 为什么不能直接改
APK 里存的是 tj! 加密格式,不是明文。我们上期写的是解密工具,现在需要它的逆过程,即加密工具。
回顾一下 tj! 的结构 (上篇的成果):
tj!(3) + keymat(16) + plen(4) + XXTEA密文
XXTEA 明文 = [4B 小端解压长度][LZ4 数据]
XXTEA 密钥 derived = s_xxteaKey XOR keyma
加密器要做的,正好是解密器的逆过程:
1、把明文 Lua 用 LZ4 压缩
2、前面拼上 [4B 小端解压长度]
3、用 derived 密钥做 XXTEA 加密
4、前面拼 tj! + keymat + plen 头
其中最关键的是 XXTEA 加密核心,这里很容易写错。它必须是解密核心的严格逆运算:把减号换成加号、轮序反过来、y 的取法跟着逆推。
static void xxtea_encrypt_core(uint32_t *v, int n, const uint32_t *k) {
int q = 6 + 52 / n;
uint32_t s = 0, e, y, z, ki;
while (q--) {
s = (s + DELTA) & MASK32;
e = (s >> 2) & 3;
z = v[n - 1];
y = v[1];
mx = ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((y^s) + (k[e]^z));
v[0] = (v[0] + mx) & MASK32; // 解密是减,加密是加
for (p = 1; p < n; p++) { // 解密倒着走,加密正着走
z = v[p - 1];
y = (p == n - 1) ? v[0] : v[p + 1];
mx = ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((y^s) + (k[(p&3)^e]^z));
v[p] = (v[p] + mx) & MASK32;
}
}
}
这里有一个铁律:写完加密器,必须先做“往返自检”。
encrypt 加密 → decrypt 解密 → cmp 原文
逐字节一致,那么就证明我们写的解密脚本没有问题。这一步省了,可能会出现很多奇奇怪怪的问题。
10.2 四处修改
我们备份后对原文件进行修改:
mkdir -p Modify/hunting
cp repository/hunting/logo_ytz42a3.fereView Modify/hunting/logo_ytz42a3.fereView
cmp repository/hunting/logo_ytz42a3.fereView Modify/hunting/logo_ytz42a3.fereView # 校验一致
然后对副本修改四处:
Ⅰ、好感度阈值清零
-- 改前
local FERE_FEEL_DATAS = {100, 200, 300}
-- 改后
local FERE_FEEL_DATAS = {0, 0, 0} -- MOD: 好感度阈值清零, 直接解锁全部伴侣玩法
Ⅱ、进房间的 100 点检查注释掉
function HousefereView:onClickFereRoom()
-- MOD: 好感度一阶 gate 已移除, 直接进入小屋
-- if self.currPoints:read() < FERE_FEEL_DATAS[1] then
-- gGameUI:showTip(gLanguageCsv.yijiejiesuo)
-- return
-- end
gGameUI:stackUI("city.house.fereRoom", nil, {full = true},
self.curSelFere:read(), self.currPoints:read(), self.proData)
end
Ⅲ、伴侣列表强制全部解锁
for k, v in csvPairs(csv.banlvxiaowu) do
local lockState = false
-- MOD: 强制全伴侣解锁, 不再依赖 gateID 通关
-- if not feres[k] then
-- lockState = not dataEasy.isGateFinished(v.gateID)
-- end
...
end
Ⅳ、祝福卡的 300 点检查
既然都改了,onClickFereCard 里那个 FERE_FEEL_DATAS[3] 的判断也一并注释掉。反正一行的事。
10.3 重新加密
我们看一眼加密后的文件头,验证格式对不对:
格式完全正确。
10.4 测试
将文件回填 APK ,签名后测试。
好,见证奇迹的时刻到了。
深吸一口气,点开游戏,进主城,点伴侣小屋...
10.5 无解的闪退
屏幕一黑。
然后,游戏闪退了。
TMD... 前面辛辛苦苦搞了这么久,密钥有了、格式懂了、加密也逆了……结果你告诉我黑屏?
更让人抓狂的是,我们改动的逻辑明明是绕过检查,正常来说最坏的结果也就是没生效,怎么会直接崩呢?难道有检验?
肯定是哪里有问题。行,排查开始。
10.6 排查
我的第一反应是:是不是加密加错了?
这非常正常。毕竟 encrypt 是新写的,出错的概率最大。
但我们在之前已经做过往返自检,所以排除这种可能。
接下来我们考虑,是不是签名、安装的问题?
可能有同学会想是签名校验,但是经过我实测,直接重新签名后是能够正常游玩的,这说明不是这方面问题。
那么,是不是我们改的 Lua 语法写崩了?
有道理,我们毕竟把好几行注释掉了。检查一下语法:
> luac -p Modify/hunting/logo_ytz42a3.fereView
> echo >?
0
语法完全正确。
而且,退一万步说,就算 Lua 真有语法错误,Cocos 的表现通常是弹一个红色的错误框、或者把错误打到日志里,而不是直接黑屏闪退。排除。
到这里,嫌疑对象全部洗清。那到底是什么在崩?不会要我查日志吧?
相信很多同学到这里就要放弃了吧?
10.7 破局
当一个地方怎么查都查不出问题时,说明我们的假设本身就是错的。那还盯着文件看啥,回去看游戏是怎么读文件的。
于是我把第四节的流程重新在脑子里过了一遍:
代码里要读 res/video/... 或者要加载 src/app/views/city/house/fereView.lua
↓ 先查 repo.mapping
得到物理路径:repository/hunting/logo_ytz42a3.fereView
↓ 读文件、解密
交给渲染层 / Lua 虚拟机
等等,好像发现华点了,先查 repo.mapping。
赶紧打开 repo.mapping,找到 fereView 那条:
> python3 -c "
import json;m=json.load(open('repository/repo.mapping'))
print(m['src/app.views.city.house.fereView'])"
{'reposize': 7995, 'mapping': 'repository/hunting/logo_ytz42a3.fereView', 'size': 7995}
哇,竟然有大小声明!
再确认一下修改过后补丁文件现在的实际大小:
> ls -l repository/hunting/logo_ytz42a3.fereView
-rw-rw-r-- 1 ... 8435 repository/hunting/logo_ytz42a3.fereView
7995 vs 8435。
真凶找到了。
为什么这会导致黑屏闪退?
因为引擎读文件的流程是这样的(注意,mapping 是 native 层消费的,Lua 层根本 grep 不到 reposize 这个词,这也是为什么它极难被发现,我们不可能靠搜代码搜出来):
问题就出在这里。文件现在有 8435 字节,可引擎只读或只认前 7995 字节。
各位还记得吗,tj! 的最后一步是长度一致性校验。文件头 offset 19 处写着 plen = 19754,XXTEA 解密出来的明文长度也必须等于 19754,两边对不上就返回失败。
现在缓冲区根本读不满,解密直接判失败,返回 NULL。Lua 加载器拿到一个空指针,去 require 这个模块,不崩就怪了。
(顺带一提,这也解释了为什么症状是黑屏而不是错误提示。伴侣小屋这个界面的 Lua 主体压根没加载成功,界面连骨架都没建起来,自然是一片黑。)
复盘一下,这个坑最恶心的地方在于:
1、我们自己写的三个环节(加密、头、语法)全是对的,所以你根本不会往那想;
2、repo.mapping 是 native 层消费的,Lua 层搜不到任何引用,你没法"搜"出来。
10.8 重建索引
真凶抓到了,接下来就简单了。
我们可以发现,所有 src/ 开头的条目,reposize 和 size 都是相等的,而且都等于磁盘上那个加密文件的大小。
所以这两个字段都得改成 8435。后续我给 encrypt 工具里加了一个 mapsize 子命令,专门干这个:
> ./encrypt mapsize ./Modify/repository/repo.mapping.mod ./repo.mapping.new src/app.views.city.house.fereView 8435 8435
已更新
验证一下:
> python3 -c "
import json;m=json.load(open('repo.mapping.new'))
print(m['src/app.views.city.house.fereView'])"
{'reposize': 8435, 'mapping': 'repository/hunting/logo_ytz42a3.fereView', 'size': 8435}
我们刚才是把磁盘上的 repo.mapping 解密成 JSON 改的,改完也必须重新加密回去:
> ./encrypt enc 'tj!' repo.mapping.new -d encrypt_out/
==> 处理: repo.mapping.new (2.8 MB)
[√] 加密成功
好,现在重新回填、重新签名、重新安装。
进游戏,点伴侣小屋:
完全成功!
界面正常加载。所有伴侣全部可点。
点进去,直接进房间,视频秒播。
好感度锁彻底失效
十一、翻车点总结
我总结了一下,修改类似架构的游戏主要坑点无非三个:
Ⅰ、大小没同步
症状:能进游戏,但一打开伴侣小屋就黑屏闪退(注意:是崩,不是“没生效”)。
原因:改了 Lua 却没同步 repo.mapping 里的 reposize/size,引擎按旧长度读文件,解密长度校验失败,Lua 模块加载不出来。
排查:对一下 mapping 里该条目的 reposize,是不是等于加密后的实际字节数。
Ⅱ、加密密钥对不上
症状:一进伴侣小屋就闪退。
原因:encrypt 加密时的 keymat 处理错了。
原理:加密端要用 derived = 内置密钥 XOR keymat,文件头写 keymat;解密端再算一遍 derived = 内置密钥 XOR 文件头keymat,两边必须一致。
我们的工具默认 keymat 全 0,也就是 derived = 内置密钥,批量保持统一即可。
但无论如何,动手前务必先跑一遍往返自检:encrypt → decrypt → cmp 原文。
Ⅲ、游戏热更新覆盖
症状:刚装上有效,玩两天又变回原样了。
原因:很多国产手游启动时会去服务器拉资源 diff,把改动过的文件“打补丁”覆盖掉。
这个坑,其实我们已经看到了蛛丝马迹 —— grep 域名的时候,命中了一个 updater/main(物理文件 box_wddw2/logo_bxys2ae8)。当时只是顺手看了一眼,没细究。
现在回头看它的内容,真相大白:
-- updater入口
local updateUrls = {
...
js={
[1]="http://xn--xxxxxxx.com/r_"..APP_CHANNEL.."/"..APP_CHANNEL.."_version.json",
[2]="http://px5jyv.xxxxx.com/r_"..APP_CHANNEL.."/"..APP_CHANNEL.."_version.json",
[3]="https://xxxxxx.xxx.cn-south-1.myhuaweicloud.com/r_"..APP_CHANNEL.."/"..APP_CHANNEL.."_version.json",
},
...
}
local updateUrl=updateUrls[APP_CHANNEL] or updateUrls["js"]
它是一个热更新入口,启动时会去拉 r_<channel>/<channel>_version.json 这份版本清单,然后按清单里的差异下载资源,覆盖本地文件。
这时候你的改动只在离线或断网时有效,不过我现在还没遇到这样的问题,暂且不表。
十二、防御侧反思与加固建议
前面通篇都在讲“怎么破”,现在来讲讲“怎么防”。
可以说,这套系统是当之无愧的“屎山代码”,把所有权重都放在了客户端。
希望所有的开发者都不要一时偷懒,代码只有跟随时代不断进步,才能够保证你的程序固若金汤
以下是针对类似项目的一些建议:
12.1 加密层加固(上篇)
1、发布前 strip 符号,导出表只留必需项;关键函数静态链接并内联,避免依赖 PLT 桩形成“调用指纹”。
2、字符串与关键路径整体混淆,不要只混一半;关键流程可用 OLLVM 控制流平坦化、字符串加密抬高分析成本。
3、密钥不再静态生成,改由服务端下发,并与账号、设备、会话绑定、短期有效,至少保证“拿到 SO”不等于“拿到密钥”。
4、不把 keymat 当安全设计,它天生明文可见;真正要保护的是内置密钥本身。
5、按模块或敏感级别拆分密钥(分片密钥),放弃固定可识别的魔数。
12.2 授权层加固(下篇)
1、解锁判定上移服务端:由服务端签发一次性凭证(用户 ID + 资源 ID + 时效 + HMAC 签名),客户端只拿凭证去播放,过期即失效。
2、资源 CDN 走 HTTPS 并对 URL 签名(时间戳 + HMAC,短时效),开启防盗链,经鉴权网关按用户授权下发地址。
3、资源映射与清单改由服务端按需下发,不要把 repo.mapping、csv.banlvxiaowu 这类全量对照表随包发布。
4、Lua 脚本上字节码并混淆;资源与脚本一律做完整性签名校验,只加载签名通过的。
5、不依赖文件长度这类脆弱的间接校验,改用密文或明文的签名/哈希校验;篡改时明确拒绝,而不是崩溃。
6、热更新的版本清单与更新包必须签名验签,防止中间人劫持与假服务器投毒。
7、增加行为上报与风控(好感度异常跳变、资源请求频率、客户端完整性),保留事后发现的能力。
十三、结语
从 960MB 的安装包出发,上篇我们做了三件事:定位引擎、还原密钥、写出解密器。
这一篇我们又做了三件事:读懂现象、逆向 Lua、把改动塞回去。
上篇我说,逆向不是魔法,是一套可复盘的工程方法。
这一篇我想补一句:逆向最有意思的地方,不是“解开”,而是在解不开时,回头发现自己忽略的地方。
码字不易,点赞可有?
帖子内所有工具都已放在下面,可供大家练手,评论自取。
https://jiguro.lanzouw.com/ivkJA4853t3i |