-
-
[原创]腾讯游戏安全初赛 Android 客户端分析
-
发表于: 5小时前 212
-
1. 逆向分析过程
2. 算法逻辑分析
3. 安全机制解析
4. 附:token生成逻辑分析
刚拿到题目时,手上的信息其实已经足够构成三条候选路线。第一,APK 内能看到典型的 Godot 项目结构,这说明脚本、场景与扩展配置都可能藏有关键逻辑。第二,包内同时存在独立的 libsec2026.so,这又说明题目很可能把核心逻辑下沉到了 native 层。第三,题目运行在真机上,屏幕左上角会实时生成 token,场景里还有黄色与绿色两个触发块,因此这不是“离线恢复一个固定 flag”就能结束的题,而是要对任意 token 建立稳定的 token -> flag 和 flag -> token 映射。

分析从 APK 的整体结构开始。包体内同时存在 Godot 项目资源和本地 so:一侧是 assets/project.binary、assets/assets.sparsepck、assets/token.gdc、assets/Trigger/trigger.gdc、assets/label2.gdc,另一侧是 lib/arm64-v8a/libgodot_android.so 与 lib/arm64-v8a/libsec2026.so。这说明题目逻辑既可能藏在 Godot 资源层,也可能藏在 native 扩展里,因此两条线都要保留,但要先判断哪一条阻力更小。
我先沿资源线做了快速验证,从github上了解到GDRE_tools-v2.5.0-beta.5-windows项目可以直接解包,但是控制台输出:
这两行已经足够说明 assets.sparsepck 的目录本身就是加密的,连资源枚举都无法顺利完成。进一步检查 token.gdc、Trigger/trigger.gdc、label2.gdc 与 sec2026.gdextension 的文件头,又能看出它们带有 Godot FileAccessEncrypted 风格包装。换句话说,资源线不是没有价值,而是它一上来就被“pack 目录加密 + 单文件再加密”双重阻断,短时间内不适合作为主线。
这一步最重要的不是“把资源完全解出来”,而是及时得出一个策略结论:资源线暂时只能当备线,用来佐证推测或补充个别细节,不能继续吞掉主要时间。也正因为这个判断,后续主线被坚定地切换到 native。
libsec2026.so 的第一道门槛是自解密壳。静态看 start 时只能看到很少量函数,但入口行为非常明确:它会读取 /proc/self/auxv,调用 sub_69984 做解压或解密,随后执行 memfd_create、mmap(PROT_EXEC),最后通过 BR X14 跳到运行时代码。这类写法的目的很直接,就是让真正的业务逻辑不以普通 ELF .text 段的形式静态落地,而是在进程启动后再被铺进内存。
原始 IDA 视图本身就给出了一个很强的异常信号。下图是最初未回填运行时代码时的 IDA 顶部概览条,可以看到大部分区域并不是正常的“已识别函数区”,而是被 Unexplored、Data 和少量 Library function 占据。
对于一个已经完整装载、主体逻辑都老老实实落在 .text 段中的 so,这样的比例显然很不自然。也正因为这个现象,我没有把它简单理解成“IDA 一时没识别出来”,而是把它视为“静态文件里并没有完整业务代码,真正代码在运行时才出现”的直接信号。
为了把这条链截下来,我写了一个 Frida 脚本,专门跟住 0x6996c 处的跳转、第二层 mmap 包装器和第二层最后一次调用点,自动识别最终可执行映射并导出整段代码。核心逻辑如下:
脚本跑起来后,终端里出现了下面这组关键输出:
这几行把问题说得非常清楚:真正执行段的运行时基址是 0x7087352000,大小约 0x9c000,真正入口是 0x7087353c48,折算回原模块偏移约 0x4cc48。随后把这段代码回填到 IDA 的 0x4b000..0xe6fff 区间后,IDA 自动重新识别出约 3491 个函数,分析才真正进入业务逻辑阶段。
因此,“为什么判断它是运行时自解密”并不是只靠某一个现象,而是由三组证据共同支撑的:原始 IDA 视图中极不自然的函数分布;入口路径上明确出现的 memfd_create、mmap(PROT_EXEC) 与 BR X14;以及顺着这条路径实际 dump 出来的新 r-x 映射和回填后恢复出的函数区。三组证据首尾相扣,所以可以较强地判断这不是普通混淆,而是明确的运行时自解密 / 自装载过程。
拿到真实执行段后,下一步不是立刻找算法,而是先找 Godot 和 native 之间的桥。为此我又写了一个 Frida 跟踪脚本,在 extension_init 周围 hook get_proc_address,把 GDExtension 注册过程中请求到的接口全部打出来,并重点跟住 classdb_register_extension_class5、classdb_register_extension_class_method、classdb_register_extension_class_property、classdb_register_extension_class_signal。
脚本中用于识别候选方法签名的部分如下:
脚本跑起来后,很快就出现了两条决定性的输出:
这说明两件事。第一,GDExtension 确实注册了自定义类 Process 和方法 input。第二,这个方法的类型签名正好是 PackedByteArray -> String,和题目表现出来的“输入某种字节流,输出可显示字符串”高度吻合。到这里为止,native 主入口不再是一个猜测,而是被运行时注册链直接锁定了。
为了继续走深一层,我又补了一个专门追 Process.input 的脚本,在注册阶段直接读取 method_userdata、虚表槽位和 call/ptrcall 包装器,最终得到两条稳定调用链:ptrcall 主链为 0x63e3c -> 0x4b6e0 -> 0x4ce04 -> 0x4c8e4 -> 0x4e198,call 主链为 0x63de4 -> 0x4d7a8 -> 0x4ffd0 -> 0x4bd68 -> 0x4c8e4 -> 0x4e198。这说明 0x63de4/0x63e3c 只是 Godot 包装层,真正要分析的核心逻辑在 0x4e198,而 0x4bd68/0x4c8e4 则负责 Variant、PackedByteArray 与 String 之间的桥接。
当 Process.input 被定位后,native 层的算法轮廓很快浮现出来。对 0x4e198 的行为观察表明,它会先初始化一个工作缓冲区,再读取 PackedByteArray 的数据指针与长度,对输入做字节级 XOR 处理,最后把结果逐字节格式化成两位大写十六进制字符串。格式化这一点有一个非常直接的证据:在 0x4c6d4 处可以确认到格式串 %02X。因此,Process.input 的返回值不是“原始字节直接转字符”,而是“输出字节流先做大写 hex 编码,再返回 Godot String”。
继续往下拆,算法核心被收敛到一组 ChaCha 风格函数。结合静态跟踪与动态调用,可以把相关职责整理为:0x5bb54 是 quarter round,0x5bcec 负责根据 16-word state 生成 64 字节 keystream block,0x5b950 负责把 keystream 和输入按块 XOR,0x5b818 负责 ctx 初始化。最初看到 16 / 12 / 8 / 7 这组旋转常数时,很容易把它当成标准 ChaCha20;但后续动态验证表明,这只能说明“轮函数形似 ChaCha20”,并不能说明它就是标准实现。
为了把这个判断坐实,我又写了一个实时 hook 脚本,直接在活进程中挂住 base + 0x5b818、base + 0x5bf18 与 base + 0x4e548,读取 key、nonce、ctx state 和输入输出缓冲区。脚本中的关键常量如下:
通过这条运行时路径,先拿到了真正参与算法的 key 和 nonce,即 Th1s ls n0t a rea1 key!!@sec2026 与 012345678901。这一步非常重要,因为它把此前看到的那串可疑字符串重新定性了:它确实不是 PCK 解密 key,但它又并非完全无用,而是被 PART1 native 算法拿来当作 32 字节 key。紧接着,通过直接调用 ctx_init/xor_stream,又确认 state 的前四个常量 word 实际为:
这与标准 ChaCha20 的 0x61707865 0x3320646e 0x79622d32 0x6b206574 只差了每个 word 的最后一个字节,但正是这 4 个 byte 的变化足以让标准库推导全部失效。这个结论来自实际运行态 state,而不是人工猜测,因此后续求解器必须自己实现变种 ChaCha,而不能直接调用现成标准实现。
做到这里时,native 层看起来已经几乎闭环,于是我又主动调用内部流加密函数,对屏幕左上角的 token ca567fe5 做了两种候选实验:一是把 ASCII ca567fe5 的 8 字节直接送入算法,得到 197E667F44504649;二是先按十六进制解码成 4 字节 CA 56 7F E5,再送入算法,得到 B0492CAC。这一步不是线下手算,而是借助题目自身的 native 实现直接算出来的。
但也正是在这里,native 主线第一次显露出“还差半步”。候选值虽然都能算出来,却没有一个能和绿色方块的真实结果对上。也就是说,native 本身已经基本看清,但输入流真正进入 Process.input 之前,前面还隔着一层尚未恢复的脚本预处理。
如果绿色方块在正常玩法中可达,那么这个问题只需要撞一次实机就能解决;但题目的绿色方块在房顶,小车无法正常到达,因此我们开始转向场景层。首先反编 Java 壳,可以看到 Godot Android 层保留了对象方法调用桥:
这意味着,只要拿到任意 Godot 对象的 godotObjectId,就能不依赖脚本明文,直接从 Java bridge 调用它的方法。
不过这里也没有一步到位。真正尝试时碰到一个很典型的小坑:通过 Java bridge 直接调 Engine.get_main_loop(),实际返回的是 null。这说明 Java 层桥虽然可用,但还不足以单独打穿整条场景链。
路线再次收束到 native helper,也就是继续借助 classdb_get_method_bind 和 object_method_bind_ptrcall 去走引擎对象链。于是我从github官方拉取了 Godot 4.5.1 ,提取出 Engine.get_main_loop、SceneTree.get_root、SceneTree.get_current_scene 等方法的 hash 后,核心流程如下:
这一轮不是停留在“理论上可行”,而是实际打通了 Engine -> SceneTree -> root/current_scene。继续向下枚举后,很快发现 current_scene 其实只是 UI 容器,它下面只有两个孩子,显示的正是 Token: ca567fe5 和 flag{sec2026_PART0_example};真正的 3D 游戏场景并不在这里,而在 root.child[1] 那棵 Node3D 子树下。这个发现非常关键,因为它解释了为什么一开始盯着 current_scene 总像是在看“对的场景却没有对的对象”。
接下来对 3D 场景做分层枚举,最终识别出两个关键 Area3D,并读取到了它们的全局坐标:
这组坐标与题面现象完全吻合:低处是可正常触碰的黄色示例块,高处是房顶绿色计分块。到这里,场景问题终于从“我知道有一个绿色方块”变成了“我知道它具体是哪个对象、在什么位置、玩家又具体是哪一个物理节点”。再顺着 Marker3D -> Node3D -> VehicleBody3D 这条链继续摸,最终定位到真实玩家物理节点 VehicleBody3D id = 37329307145。
为了让这一步更容易阅读,我把确认下来的场景结构单独整理成一张图。

这张图对应的解题意义非常直接。root.child[0] 是 UI 容器,也就是一开始能直接看到 Token 和 Part0 示例 flag 的那棵树;root.child[1] 才是真正的 3D 游戏主场景。继续往下看,绿色计分块是高处的 Area3D,而真正需要传送的对象不是相机也不是普通显示节点,而是 Marker3D -> Node3D -> VehicleBody3D 这条链末端的车体物理节点。也正因为这个层级关系被理清,后续的稳定传送、绿块触发和 xor_enc 恢复才真正形成了闭环。
拿到玩家物理节点后,Frida 改坐标传送这条路线才真正从“建议”变成“已执行”。我写的传送脚本直接取 VehicleBody3D 的实例指针,并连续 60 个 tick 调用 Node3D.set_global_position,确保玩家不是一闪而过,而是在目标区附近稳定停留多个 physics tick。核心代码如下:
同步读回的位置表明写位是稳定生效的,位置持续命中高处 Area3D 附近。随后在真机上肉眼观察到首次真实高处分支结果,截图显示左上角 token 为 ca567fe5,右上角结果为 flag{sec2026_PART1_784B50482235734B}。

但矛盾仍然存在:此前直接把 ASCII ca567fe5 喂给 native 算出来的是 197E667F44504649,与真实截图 784B50482235734B 并不相同。这说明高处逻辑不是“token 原样进 native”,中间还隔着一层脚本预处理。于是我继续转向高处 Area3D 自身的方法列表,枚举其方法后找到了明显不是引擎内建的 xor_enc。进一步通过 Java bridge 直接调用该方法:
对 xor_enc("ca567fe5") 的真实返回结果是一个 byte[],内容为 0254030151035037。把这 8 个字节重新送入前面已经恢复好的 native 流加密核心,得到的正好就是 784B50482235734B,与实机截图完全一致。
这一刻才是真正意义上的闭环点。此前我们已经有 native 算法,也已经有实机结果,但两者中间始终隔着一个无法解释的差值;直到 xor_enc 被抓出来,这个缝才被完全补上。这样一来,完整正向链条第一次闭环:
为了把 xor_enc 从“能调通”提升到“能重写”,我又对多组可控输入批量调用 xor_enc,观察输出规律。最终恢复出的精确公式是:若 token 的 8 个 ASCII 字节为 b0..b7,则预处理结果 p0..p7 满足 p0=b0^b1,p1=b1^b2,一直到 p6=b6^b7,最后 p7=b7^p0。将 ca567fe5 代入,得到的正是 02 54 03 01 51 03 50 37。这一步意味着算法不再依赖脚本在线调用,可以完整重写。
最后,为了确认这不是只对一组 token 成立的巧合,再次重启 App,新的屏幕 token 为 f45e8514。本地根据已恢复的 xor_enc + 变种 ChaCha20 链条预测其后缀为 281E03147E32261A,随后用相同传送路径在真机上观察到右上角结果与该值完全一致。至此,算法恢复从“单样本闭环”提升为“至少双样本实机复核通过”。


经过前述动态验证,PART1 的 flag 生成逻辑已经可以用一条稳定链条描述:先读取屏幕左上角的 8 字符 token;在 GDScript 里调用 xor_enc,把它变成 8 字节的预处理结果;再把这 8 字节送入 native 层的 Process.input 核心;native 使用一套“ChaCha20 轮函数不变、常量被魔改”的流密码生成 keystream,并与输入逐字节 XOR;最后把得到的 8 个输出字节用 %02X 转成 16 个大写十六进制字符,再拼接进 flag{sec2026_PART1_<HEX16>}。
正向流程图如下:

设 token 的 8 个 ASCII 字节为 b0 b1 b2 b3 b4 b5 b6 b7,则 xor_enc 输出的 8 字节 p0..p7 满足下式:
这个结构表面上只是“相邻字节链式异或”,但最后一项不是简单的 b7 ^ b0,而是 b7 ^ p0,也就是 b7 ^ (b0 ^ b1)。这一点如果写错,逆向恢复时会整体偏掉。用真实样本 ca567fe5 代入,可以得到:
这与通过 Java bridge 直接调用 xor_enc("ca567fe5") 得到的 0254030151035037 完全相同,因此可以确认这套公式不是“拟合样本的猜测”,而是脚本层真实执行的逻辑。
xor_enc 算法流程图如下:

native 层流密码的轮函数仍然是标准 ChaCha quarter round 结构,也就是 add / xor / rol 的经典组合,旋转常数仍为 16 / 12 / 8 / 7。真正被修改的不是轮函数本身,而是 state 的前 4 个常量 word。
实际使用的 state 头四项为:
其余参数如下表:
虽然本题只处理 8 字节输入,实际只会用到首个 64 字节 keystream block 的前 8 个字节,但从实现角度仍然应该完整生成整个 block,再截取前 8 个字节参与 XOR。
从实现角度看,正向过程可以概括为:
与真机对照:
native 部分本身也可以再抽象成一张独立数据流图:

逆算法成立的原因在于 native 部分本质上还是流密码,解密和加密是同一件事:只要再次用相同 keystream 做一次 XOR,就能把密文还原回 xor_enc 的输出 p0..p7。因此逆向过程的第一步非常直接:先把 flag 后缀的 16 个 hex 字符解码为 8 字节,再与同一套变种 ChaCha20 keystream 做 XOR,恢复出 p0..p7。
真正需要推导的是如何从 p0..p7 还原 b0..b7。因为 xor_enc 的结构是链式关系,所以可以先解出 b0,再顺推整个 token。推导后的恢复公式如下:
将真实样本 784B50482235734B 代入,先经 native 逆向 XOR 得到 0254030151035037,再按上式恢复,最终得到的正是 ca567fe5。这说明脚本预处理虽然不是简单恒等映射,但它本身是完全可逆的,没有额外丢失信息。这也是本题可以要求“根据 flag 反推 token”的根本原因。
逆向流程图如下:

对应关系表如下:
就本题而言,可复现性的核心不在于“能把一个样例算出来”,而在于同一实现同时满足三件事:其一,能把 ca567fe5 算成 784B50482235734B;其二,能把 784B50482235734B 逆回 ca567fe5;其三,能把第二个独立样本 f45e8514 算成 281E03147E32261A。当前实现和实机截图已经同时满足这三个条件,因此可以认为算法恢复已经达到可验证标准。
防护链总图:

本题最明显的安全机制,是 libsec2026.so 自身的多层运行时装载。入口不是直接进入业务代码,而是先读取 auxv、解密中间载荷、调用 memfd_create 和 mmap(PROT_EXEC),再通过寄存器跳转进入新的可执行映射。这样做的意义很直接:让真正的代码直到运行时才出现于内存中,静态查看 ELF 时只能看到极少数 stub。
这一层之所以能被明确识别,不是依赖某一个孤立现象,而是有一整条连贯证据链。首先,原始 IDA 视图里 Unexplored 和 Data 占比异常高,几乎看不到正常的函数区;其次,入口路径上确实出现了 memfd_create、mmap(PROT_EXEC) 与 BR X14 这种典型的运行时装载动作;最后,顺着这条路径实际 dump 出了新的 r-x 映射,并在回填后恢复出了数千个函数。三步连起来,才把“它像壳”推进成“它就是运行时自解密 / 自装载”。
从机制层面看,这套壳不一定是为了绝对隐藏代码,而是为了迫使分析者放弃“只扫静态字符串”的打法,转而进入运行时。它提高的不是某个算法本身的复杂度,而是把分析门槛前移到了“先理解装载链”。

第二层安全机制在资源侧。assets.sparsepck 的目录本身经过加密,导致常规 Godot 恢复工具在最开始就无法正常枚举资源树。更进一步,token.gdc、Trigger/trigger.gdc、label2.gdc、sec2026.gdextension 又不是普通明文文件,而是带有 Godot FileAccessEncrypted 风格包装。这样一来,资源线不是单点被卡,而是同时被“目录不可枚举”和“单文件不可直接反编”双重阻断。
这层防护最有效的地方,在于它不是为了彻底阻止后续恢复,而是为了打乱分析顺序。理论上讲,只要拿到正确 key,后续还是能回到脚本层;但在比赛语境下,只要它能让分析者在前期无法把脚本线直接打穿,native 线就会被迫上升为主线。
相关阻断点可以整理成表:
第三层安全机制来自 Godot 框架本身。题目没有把核心逻辑做成显眼的导出函数,而是注册成一个 Process 类的 input 方法,参数类型是 PackedByteArray,返回类型是 String。这让关键代码看上去更像普通 Godot 扩展,而不是传统意义上的“算法库”。如果不顺着 GDExtension 注册链去追,分析者很容易在 libsec2026.so 里找不到一个像样的“主入口”。
更关键的是,题目没有把整条链都放在 native。绿色方块对应的脚本预处理 xor_enc 被藏在高处 Area3D 的对象方法里,而 native 则只负责后半段的变种 ChaCha20 与大写 hex 输出。这样做会制造一个典型错觉:native 分析看起来已经几乎闭环,但真实触发结果却始终对不上,迫使分析者回头补脚本层对象调用。
这一层混淆可以拆成下表:
这层拆分的总体效果,可以用下面这张图概括:

值得特别强调的是那串字符串 Th1s ls n0t a rea1 key!!@sec2026。它既是一个很强的诱饵,又不是纯粹的假线索。对于 PCK/.gdc 资源线来说,它不是解包 key;但对 PART1 native 算法来说,它又确实是 32 字节流密码 key。这种“半真半假”的线索设计非常容易让分析者在不同阶段把两条线混为一谈。
最后一层安全机制是交互设计。黄色方块可以正常触发,但它只显示 flag{sec2026_PART0_example},并且不会调用 Process.input。而真正计分的绿色方块被放在房顶高处,正常玩法下难以到达,这使得“直接触发一次看结果”并不容易。
这层设计的关键,不在于绝对阻止触发,而在于提高“拿到第一组真实样本”的成本。分析者必须先理解场景树,识别哪个 Area3D 是黄色、哪个是绿色,再找到真正参与物理的 VehicleBody3D,最后稳定写位到高处坐标附近,才能拿到第一组真实 Part1 结果。也就是说,题目把“算法恢复”和“运行时对象操控”故意耦合到了一起。
在解完题目之后,我还想复盘一下当时的备选线路——解资源包,在前期,它一上来就被两层保护挡住了。第一层是 assets.sparsepck 的目录加密,第二层是 token.gdc、trigger.gdc、label2.gdc 以及 sec2026.gdextension 这些单文件本身又带了一层 Godot 的 FileAccessEncrypted 包装。