2026腾讯游戏安全技术竞赛-安卓客户端安全-初赛 Writeup
前言
拿到题目也是懵逼了,腾子你怎么换引擎了今年,那我复现的往年那么多 UE4 算什么(T_T)
只能硬着头皮做了。
担惊受怕两天也是如愿所偿,进决赛了/(ㄒoㄒ)/~~,希望决赛能好好发挥,佬们轻点打!!

*.gdc 文件解密分析
寻找核心加密代码
上来直接解压看到 lib/ 中有libgodot_android.so ,确定是 Godot 引擎。
同时在 assets/ 中看到有 .gdc 的 GDScript 字节码,查看他的文件头发现不是标准的 GDSC 开头

既然如此,那就直接开始在 libgodot_android.so 里找怎么解密这些 .gdc 文件
网上找了很多,也试了很多常见的字符串搜索,如 open_encrypted 是加密文件打开的方法名、open_encrypted_with_pass 是带密码的加密打开的方法名,但向上对他们进行交叉引用只招到了一些 Godot 方法注册

最后搜索的是 PackedSourcePCK ,这个是 PCK 包读取器的 RTTI 名,定位到

前面的15是指字符串 PackedSourcePCK 的长度,往上找交叉引用,

在这里我们找到了 PackedSourcePCK 类的虚函数表,当进入函数 sub_3804C2C 时,完全可以确定就是这里了

这里明显都是在做 PCK 文件头校验,这里代码太多太乱了,交给 ai,以下两个函数分析来自 GPT5.4 结合 idapro mcp进行的分析
sub_3804C2C 函数分析
在 sub_3804C2C 中,0x3804F08 处有一个关键条件判断
1 | v36 = (*(__int64 (__fastcall **)(_QWORD *))(*a17 + 360LL))(a17); |
当 v31 & 1 为 1 时,代码不会跳转,而是继续往下执行加密处理路径。这里的 v31 来自 PCK 文件头中的 flags 字段,bit 0 标志”是否加密”。
加密分支的关键代码段 (0x3804F10 ~ 0x3804F78)
当进入加密分支后,汇编如下:
1 | ; --- 0x3804F10: 保存上下文,准备分配密钥缓冲 --- |
关键发现:密钥地址 0x400EDF0
上面的汇编可以清晰看到:
ADRP X26, #0x4004000+LDR X26, [X26, #0x488]:通过 GOT 间接加载,实际读取的是地址0x4004488处存储的指针。GOT 表
0x4004488处存储的值:1
0x4004488: F0 ED 00 04 00 00 00 00
即
0x000000000400EDF0,确认 X26 最终指向0x400EDF0。LDRB W28, [X26, X27]:从0x400EDF0 + i读一个字节。STRB W28, [X8, X27]:写入密钥缓冲区key_buf[i]。循环 32 次(因为前面分配了 32 字节)。
在 IDA 中直接提取密钥,导出十六进制:
1 | CE 4D F8 75 3B 59 A5 A3 9A DE 58 AC 07 EF 94 7A |
即密钥十六进制表示:
1 | ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061 |
为什么可以确认这是 AES 密钥而不是其他数据
证据链:
(1) 上下文:这段代码在 sub_3804C2C(PCK 包打开函数)中,且只在”加密标志为 1”时才执行。
(2) 长度:恰好 32 字节 = 256 bit,这是 AES-256 的标准密钥长度。
(3) 去向:复制完成后,密钥缓冲传给了 sub_3801410(见下一节),而 sub_3801410 会验证密钥长度 必须是 32:
1 | // sub_3801410 @ 0x3801450: |
(4) 复用:同一个 byte_400EDF0 在另一条路径 sub_3805EDC(0x38061B0)中也被使用,用途相同——传给 sub_3801410 做加密文件初始化。
(5) 最终:sub_3801410 内部会将这 32 字节传给 AES key schedule 函数 sub_197C210,而 sub_197C210 在 a3 == 256 时设置 *a1 = 14(AES-256 的 14 轮),进一步确认这是 AES-256 密钥。
0x3801410 函数分析
在 sub_3804C2C 的 0x3804FB0 处:
1 | v42 = sub_3801410(v41, &a15, &a18, 0LL, 0LL, &a13); |
其中:
v41= FileAccessEncrypted 对象&a15= 底层文件访问对象指针(用于读取原始加密文件)&a18= 密钥缓冲区
sub_3801410 就是 FileAccessEncrypted 的初始化/打开函数。
反编译 sub_3801410 的关键段
在 sub_3801410 中,当 a4 == 0(打开模式 = 读取)时,执行以下步骤:
步骤 1:验证密钥长度
1 | // @ 0x3801450 |
步骤 2:(可选)检查 GDEC magic
1 | // @ 0x380170C |
1128612935 = 0x43454447,字节序转换为 ASCII = "GDEC"。
注意:这个
GDECmagic 检查只在某些模式下触发。在本题样本中,加密文件直接以 MD5 开头,没有GDEC前缀——也就是说我们分析的文件走的是不带GDECmagic 的那条路径。
步骤 3:读取文件头字段
1 | // @ 0x3801724 |
步骤 4:读取密文
1 | // @ 0x38017C0 ~ 0x38017D4 |
步骤 5:设置 AES 上下文 & 解密
1 | sub_376EDA0(&a12); // AES 上下文结构体初始化(分配 0x120 字节) |
其中 sub_376EDFC 内部调用了 sub_197C210(key expansion),sub_376EF68 内部调用了 sub_197DE18(核心解密循环),参数 W1 = 0 表示解密模式。
步骤 6:MD5 校验
1 | // @ 0x3801A2C ~ 0x3801A54 |
还原出的文件头结构
综合以上分析,加密资源文件的格式为:
1 | 偏移 大小 字段 说明 |
用 token.gdc 验证格式
1 | 文件: preliminary/assets/token.gdc |
这两个函数分析完,我获取了密文,密钥,iv,以及原文件md5值,这些所有内容,但是当我尝试通过 gdre_tools 对 .gdc 文件进行解密和反编译时,发现始终无法成功。
这里了卡了我很久,我一开始以为是文件格式还有我不会使用工具的原因,后来我在网络上找到一些帖子,才考虑到是不是加密算法被魔改了。
以下对算法的深入分析同样来自 GPT5.4 结合 idapro mcp 给出
为什么”标准 AES-CFB”解不出正确结果
最直觉的假设
到这一步,我们已经知道:
- 密钥:32 字节(AES-256)
- IV:16 字节
- 文件头有 MD5 校验
这样做出来的 MD5 不匹配。
深入 0x197DE18:找到真正的逐字节解密逻辑
调用链追踪
从 sub_3801410 中的解密调用出发:
1 | sub_376EF68(ctx, length, iv_buf, ciphertext, output) |
对比另一个封装函数 sub_376EE9C(加密方向):
1 | sub_376EE9C: |
所以 sub_197DE18 是 统一的 CFB 处理函数,参数 a2 决定方向:
a2 == 0:解密a2 == 1:加密
反编译 sub_197DE18
完整伪代码(IDA F5)如下:
1 | __int64 sub_197DE18( |
函数首先检查 mode > 1 则返回错误,然后检查 *offset_ptr > 15 也返回错误。
解密分支的汇编(mode == 0,即 a2 == 0)
从 0x197DF04 开始的解密循环:
1 | ; 循环入口:检查是否需要刷新 keystream |
逐指令翻译为伪代码
1 | state = bytearray(iv) # 16 字节状态块,初始值为 IV |
与标准 AES-CFB 的对比
| 步骤 | 标准 AES-CFB128 | Godot 变种 |
|---|---|---|
读入密文字节 c |
c |
c |
| 混合操作 | 无 | mixed = c ^ offset |
| 生成明文 | p = keystream[offset] ^ c |
p = keystream[offset] ^ mixed |
| 反馈到状态块 | state[offset] = c |
state[offset] = mixed |
差异只有一个地方:Godot 在每个字节处理时额外混入了 offset(块内位置 0~15)。
加密分支的反向验证
对比加密分支(mode == 1,在 0x197DEAC 开始的循环):
1 | 197DEAC LDRB W8, [X21], #1 ; W8 = *input++ (明文字节) |
加密伪代码:
1 | mixed = state[offset] ^ plain_byte # 先与 keystream 异或 |
可以验证:解密是加密的精确逆过程 ✓
确认 sub_197CDB8 是 AES-ECB
sub_197CDB8 的反编译结果中可以看到:
- 它读取
a1指向的结构体来获取轮数和 round keys。 - 使用了
dword_4046118、dword_4046918等查找表——这些是标准 AES 实现中的 T-table(Te0~Te3 的变体)。 - 使用了
byte_4044F18——这是 AES 的 S-Box。 - 函数接受 16 字节输入(
a2),输出 16 字节(a3),是典型的 AES 单块加密。 - key schedule 函数
sub_197C210中,当传入a3 = 256时设置*a1 = 14(14 轮 = AES-256)。
因此 sub_197CDB8 就是 AES-256-ECB 单块加密函数。
既然如此,在ai的配合下就很容易的写出解密代码如下
1 | from Crypto.Cipher import AES |
由于没有多少个文件,我就直接解密了,不写批量解密代码了
此时查看解密后的 .gdc 文件,可以看到已经将文件头恢复成功了

此时使用 gdre_tools 就可以正常反编译了

flag游戏内验证
*.gd 文件分析
首先是 token.gd

这里是 token 的生成逻辑,可以看到是随机生成的
最关键的代码在 trigger.gd
1 | extends Area3D |
结合 ai 给出分析如下
这个脚本继承自 Area3D,也就是一个 3D 区域检测节点,常用于碰撞检测、触发器。
如果碰到 Trigger1,直接显示测试 flag
如果碰到 Trigger2 则绑定了 flag 生成逻辑:
- 获取 Label 文本,截取
substr(7)去掉"Token: "前缀,得到 8 位 hex token。 - 对 token 调用
xor_enc()做一轮链式异或变换(7 次相邻异或 + 尾首异或),得到 8 字节PackedByteArray。 - 将结果传给
GameExtension.Process()——这是 native 扩展libsec2026.so提供的方法。 - native 层返回的字符串拼接为最终 flag:
flag{sec2026_PART1_<native返回值>}。
说明接下来我们该去分析 libsec2026.so 了
获得flag
因为 Godot 4 引擎的 String 内部以 char32_t (UTF-32LE) 存储, 分配在堆上。而 GDScript 编译后的常量池中, 字符串字面量作为 String 对象存在。
因此可以直接修改堆上的 char32_t 数据:
将 elif 条件中的 “/Trigger2” → “/Trigger1” (使其匹配 Trigger1 节点),同时将 if 条件中的 “/Trigger1” → “/TriggerX” (使其不再匹配任何节点),这样当我们在游戏内碰触 Trigger1 时, 代码走 elif 分支, 执行真实 flag 计算。
如下图,我碰到了下面的黄色方块,但得到了另一个 flag

Frida 代码如下
1 | function patchTriggersUTF32() { |
运行效果如下图所示

flag生成逻辑分析
libsec2026.so 分析
首先打开看到 start 函数

函数通过 openat + read 读取 /proc/self/auxv,获取 AT_PAGESZ(页大小)
sub_69984 是一个解压函数,将代码段的压缩数据解压,之后通过 memfd_create + write + mmap 将解压后的代码映射到内存,通过 BR X14 跳转到映射的代码中执行。
现在需要想办法得到已经被解压之后的代码,这里通过frida动态dump的方式获取,这里我直接hook dlopen 函数,只要 dlopen 返回,说明进程中已经存在了完整脱壳的 so 文件。
同时已知的一些字符串特征可以用来辅助dump
1 | var TARGET_LIB = "libsec2026.so"; |
dump.so 分析
由于代码太过于混乱,实在不好分析,只好交给ai
障碍:跳板混淆
所有函数调用都通过间接跳板 (trampoline) 实现。每个跳板是一段固定模式的代码:
1 | ADR X8, loc_XXXX ; 加载一个地址 |
规律:无论中间计算如何,最终跳转目标始终为 ADR 操作数 + 0x20。
例如:
ADR X8, 0x5B7F8→ 实际跳转到0x5B818(ChaCha20 init)ADR X8, 0x5BB34→ 实际跳转到0x5BB54(quarter_round)ADR X8, 0x5B930→ 实际跳转到0x5B950(encrypt)
应对方法:在逆向时,每次遇到 ADR X8, addr; ... BR 模式,直接去看 addr + 0x20 处的代码。
字符串搜索 — 找到突破口
在 IDA 中 Shift+F12 打开 Strings 窗口搜索:
| 地址 | 字符串 | 意义 |
|---|---|---|
| 0x19ED8 | fxpaod 31-byse k |
★ 魔改的 ChaCha20 常量 |
| 0x19EC8 | GameExtension |
RTTI 类名 |
| 0xD48 | extension_init |
GDExtension 初始化函数 |
疑点 ④:"fxpaod 31-byse k" 与标准 ChaCha20 常量 "expand 32-byte k" 高度相似,仅若干字符不同。这是识别算法的关键线索。
常量对比 — 确认 ChaCha20
将两个 16 字节常量按小端序 u32 解析:
1 | 标准: "expand 32-byte k" |
差异模式:末尾字节分别为 5→6, e→f, 2→1, 4→3。这不影响算法结构,只影响初始状态。
从常量出发交叉引用
对 byte_19ED8 按 X 查找交叉引用 → 被 sub_5B818 引用。
1 | ; sub_5B818 — ChaCha20 初始化函数 |
然后连续 8 次调用 off_EBEF8(实际为 LDR W0, [X0]——即小端读 u32)将密钥写入 state[4..11]:
1 | BLR X23 ; read_le_u32(key+0) |
接着 3 次读 Nonce 写入 state[13..15]:
1 | STR W0, [X19, #0x34] ; state[13] = nonce_word[0] |
最后设置 [X19, #0x80] = 0x40(内部缓冲区用完标记,强制首次调用时生成新 keystream block)。
标准 ChaCha20 状态矩阵得到确认:
1 | state[ 0.. 3] = 常量 ("fxpaod 31-byse k") |
quarter_round (sub_5BB54)
1 | ; 参数: X0=&state[a], X1=&state[b], X2=&state[c], X3=&state[d] |
旋转量为 16, 12, 8, 7 — 完全标准。
rotl32 (sub_5B648 → 0x5B764)
1 | ; W0 = value, W1 = shift_amount |
这是一个带混淆的 rotl32 实现,数学上等价于 (x << n) | (x >> (32-n))。
column_round / diagonal_round
column_round (sub_5BF6C):
| 调用 | 参数偏移 (×4=索引) | 实际索引 |
|---|---|---|
| 第 1 次 | +0x00, +0x10, +0x20, +0x30 | QR(0, 4, 8, 12) |
| 第 2 次 | +0x04, +0x14, +0x24, +0x34 | QR(1, 5, 9, 13) |
| 第 3 次 | +0x08, +0x18, +0x28, +0x38 | QR(2, 6, 10, 14) |
| 第 4 次 | +0x0C, +0x1C, +0x2C, +0x3C | QR(3, 7, 11, 15) |
diagonal_round (sub_5BC48):
| 调用 | 参数偏移 | 实际索引 |
|---|---|---|
| 第 1 次 | +0x00, +0x14, +0x28, +0x3C | QR(0, 5, 10, 15) |
| 第 2 次 | +0x04, +0x18, +0x2C, +0x30 | QR(1, 6, 11, 12) |
| 第 3 次 | +0x08, +0x1C, +0x20, +0x34 | QR(2, 7, 8, 13) |
| 第 4 次 | +0x0C, +0x10, +0x24, +0x38 | QR(3, 4, 9, 14) |
完全标准的 ChaCha20 column + diagonal round 索引。
block 函数 (sub_5BCF0)
1 | ; 1. 复制 state → working (栈上 64 字节) |
标准 ChaCha20 block function:copy → 20 rounds → add → write → counter++
encrypt 函数 (sub_5B950)
1 | ; 参数: X0=ctx, X1=input, X2=output, X3=length |
对于 ≥32 字节的数据,使用 NEON 向量化 XOR 加速:
1 | LDP Q0, Q1, [X13, #-0x10] ; 读 32 字节 input |
Process 函数 (sub_4E548)
这是 GameExtension.Process() 的 native 实现:
1 | ; 1. 获取互斥锁 |
关键确认:
- 密钥地址:
0xED5D2 - Nonce 地址:
0xED5F3 - 格式化:
sprintf(buf, "%02X", byte)— 大写十六进制
密钥为Th1s ls n0t a rea1 key!!@sec2026
Nonce为 012345678901
疑惑的点
到这里其实已经分析完了,但这里有一个疑惑的点,我dump出的so文件在地址 0xED5D2 可以看到这里的值是正常的

而在ida中看到的值却是不正常的

这里卡了我很久,直到我在010中查看的时候才发现密钥是不一样的
同时在 libsec2026.so 中,可以看到这里的值是乱码,猜测是在解压的代码中有覆盖操作

我抱着求证的心态尝试去hook chacha20_init
1 |
|
注意这个脚本需要在先跑起上获得flag的js代码后再 attach 上去,然后操控小车撞上黄色方块即可

果然传入的是这个 key 和 nonce
算法实现
flag生成算法与生成逆算法python实现如下
1 | import struct |
C语言实现如下
1 |
|
使用如图所示

总结
学到很多新知识,感谢腾讯给的学习机会,不过考了太多加解密知识,让我有点措手不及,幸好今年初赛题目似乎比往年简单,或许是换了引擎的原因吧。希望能进决赛,继续加油吧!