首页
社区
课程
招聘
[原创 ]2026 腾讯游戏安全竞赛决赛 - Android WriteUp
发表于: 4天前 896

[原创 ]2026 腾讯游戏安全竞赛决赛 - Android WriteUp

4天前
896

本题需要围绕游戏中的 PART1 / PART2 / PART3 完成三件事:确认每一部分 flag 的真实生成路径,恢复正向算法,给出逆向恢复方法,并最终用 C / C++ 写成可复现工具。

这题的难点不在于单独逆一段 libsec2026.so,而在于要把 Godot 脚本、场景资源、资源加密、引擎 native 和题目扩展这几层关系先串起来。只有先把入口、触发条件和调用关系理顺,后面的算法还原才不容易偏离方向。因此本文不按零散线索来展开,而是按照实际解题顺序来组织内容:先确认程序结构与扩展注册,再恢复脚本和场景,接着把 PART1 / PART2 分析清楚,然后处理最难的 PART3,最后补上为运行时验证服务的反调试与反 Frida 绕过部分,把整条分析链收完整。

截至当前,已经完成:

本文重点放在“算法、资源恢复、行为验证”这三部分。目标不是把每一层运行时都铺开,而是把 Godot 脚本、场景入口、引擎 native 和 libsec2026.so 串成一条连续证据链,最后统一到一份 C++ 实现中。

如果只看交付结果,本报告已经覆盖题目要求的四类内容:入口定位、算法恢复、逆向恢复、运行验证。后文再按“先把路径讲清楚,再把算法写清楚”的顺序展开。

拿到 APK 之后,首先确认整体结构。样本为 arm64-v8a,核心文件实际上只有两层:libgodot_android.so 是 Godot 4.5.1 引擎本体,libsec2026.so 则是题目自定义的 native 扩展。也就是说,后续所有分析都要回答一个基础问题:libsec2026.so 在 Godot 工程里到底暴露了什么能力,它是不是全部逻辑的唯一入口。

APK 结构与关键 so

图 1 APK 结构与关键 so。样本真正需要重点分析的部分主要集中在 libgodot_android.solibsec2026.so

本节先不给算法结论,只做一件事:确认 native 扩展在 Godot 里以什么形式暴露给脚本。后续哪些逻辑回脚本层看,哪些逻辑继续追 native,都建立在这个前提上。

顺着这条注册链,可以确认 libsec2026.so 在 Godot 中注册的是:

证据 1 是类注册主链 sub_A07F4。这里可以直接看到“类名对象”和“父类名对象”同时被装入注册描述体。整理成伪代码后大致如下:

因此 GameExtension : Node 不是字符串偶然出现,而是沿着注册链可以明确确认的结果。

同时它对脚本层暴露了两个关键方法:

证据 2 是 bind_methods 主链 sub_97B6C。虽然外层是 OLLVM 状态机,但关键绑定动作仍然能整理成下面这段伪代码:

其中:

Tick 更像逐帧入口,Process(input) -> String 更符合 flag 计算接口形态,因此后续优先沿 ProcessPART2

本节结论可以概括成一句话:libsec2026.so 不是脱离引擎独立运行的算法黑盒,而是以 GameExtension : Node 的形式接入 Godot,并向脚本暴露 Tick / Process 两个关键入口。

上一节只把扩展入口确定下来,但还没有回答真正和出题目标直接相关的问题:哪个 Trigger 对应 PART1,哪个 Trigger 对应 PART2,左上角 token 从哪里来,结果最终写到哪里,以及为什么有些 Trigger 在场景里明明存在却根本碰不出来。要回答这些问题,分析必须从 native 注册链回到 Godot 脚本和资源层。

这里采用了两条互相印证的恢复路线。第一条路线是在 libgodot_android.so.gdc 加载链上做运行时 dump,再离线解析;第二条路线则是在 libgodot_android.so 中定位 Godot 加密资源 key,用 GDRETools 恢复完整资源。两条路线分工并不相同:前者解决“资源加载后真实长什么样”,后者解决“如何离线拿到完整脚本与场景结构”。两条结果互相校验后,后面的脚本结论和 Trigger 场景结论就不再依赖单点猜测。

恢复出的关键资源包括:

token.gdc 直接说明左上角 token 是脚本生成的,而不是 native 层生成的;它的长度固定为 8,字符集固定为 0123456789abcdef。因此后续三部分算法的输入可以统一定义为:

对应脚本片段如下:

label2.gdc 负责把 Trigger 的输出结果真正显示到界面上。也就是说,只要脚本或 native 最终把 flag 写进 Label2.text,就会在游戏里显示出来。这个结论后面会和 Trigger 脚本直接对上。

对应脚本片段如下:

资源 key 的定位依赖两条硬证据:其一,FileAccess.open_encrypted() 明确要求 key 长度为 32;其二,上层初始化逻辑会把同一处 32 字节常量复制进 key 缓冲区,再传给 open_encrypted()。因此可以把 libgodot_android.so 中的这组常量认定为实际资源 key,而不是“像 key 的数据”。

如果把这条链整理成伪代码,核心关系可以写成:

关键判断依据是调用关系本身:0x400EF18 处的 32 字节常量被复制进 key 缓冲区,并作为参数传给 open_encrypted()

最终恢复出的 key 为:

使用该 key 跑 GDRETools 后,可以成功恢复 Trigger 脚本和场景资源,这说明前面的判断不是“像 key”,而是“确实能把资源外层解开”。这里也要说明边界:key 的作用是解开加密资源包,把其中的 .gdc / .scn 文件取出来;它并不能一步把 .gdc 直接变成源码 .gd

恢复结果的关键目录结构如下:

GDRETools 恢复结果

图 2 使用资源 key 配合 GDRETools 恢复出的关键资源目录。后续脚本与场景分析都可以直接落在这些文件上。

脚本语义恢复最终依赖两条分工明确的链路:

到这里,本节已经完成四件事:拿到 Trigger 脚本,拿到场景资源,确认 token 来源,确认 flag 的界面落点。后续 PART1 / PART2 / PART3 的对应关系分析都建立在这组恢复结果上。

脚本和场景拿到手之后,下一步不是平铺直叙地介绍四个 Trigger,而是先把 PART1PART2 的对应关系彻底确定下来。只有把哪一块对应哪一部分 flag 说清楚,后面算法分析和场景验证才有明确对象。

先看 Trigger2 对应脚本。这里关键不只是前缀里写了 sec2026_PART1_,而是核心函数 _fe() 完全停留在脚本层内部实现,轮函数 key 也直接写成了 Sec2026_Godot,整个流程里没有调用 _gx.Process。这说明 PART1 的本体就在脚本中,而不是 native 层的 Process。因此可以明确写成:

这里真正完成的是“解耦”:通过 Trigger 脚本本身,就可以把 PART1 和 native 算法明确区分开。

再看 Trigger3 对应脚本。这里真正的关键点不是 _PART2_ 前缀本身,而是输入来源、参数转换和 _gx.Process(_buf) 三件事被直接连在了一起:

这意味着:

再结合上一节对 GameExtension 的确认,就可以把调用链稳定整理为:

因此 PART2 可以明确写成:

这一步的关键在于,PART2 的分析入口不是靠猜,而是由 Trigger 脚本直接给出来的。

把对应关系确定下来之后,下面进入算法本身。这里需要解决的不只是“正向怎么算”,还要说明“为什么逆向恢复 token 是可行的”,因为最终交付要求本来就同时包含正向和逆向两部分。

PART1 的算法恢复并不是先凭经验判断“这像 Feistel”,而是直接从 Trigger2 脚本里读更新关系。恢复出来的 _rf()_fe() 关键片段如下:

决定结构类型的是 _fe() 的三句赋值:_fv = _rf(_hi, ...)_nr = _xb(_lo, _fv)_lo = _hi; _hi = _nr。把变量名还原后,更新关系就是:

这组“右支路进轮函数、左支路异或、整体交换”的模式足以认定它是标准 Feistel。轮函数细节也直接写在脚本里:固定 key 为 Sec2026_Godot,每字节先按轮异或,再做 * 7 + round,最后做 rol8(3)。因此 PART1 的逆算法不需要反求 _rf(),只要把 8 轮按逆序回放即可。

PART2 的还原过程完全不同。它不是从脚本里直接看到算法,而是从 GameExtension.Process 的 native 代码里把每一层字节变换拆出来。分析顺序就是 xref -> 表 -> 读表/换位/乘法 这条证据链。

先看入口 sub_A936C。在 0xA93E40xA93F8,它连续两次把输入缓冲区前 8 字节复制到局部块里,因此主流程吃到的不是 4 字节二进制,而是 8 字节 ASCII token 被扩成 token || token 这个 16 字节块。

第一层关键证据来自 unk_183700。对这张表做 xref 后,只会落到两处:sub_A9884 负责构造,sub_A82C8 负责读取。sub_A82C8 的核心代码形态如下:

其中 x50xA833CADR X5, unk_183700 得到,x20/x19 则是当前状态块中的字节位置。这三条指令的语义就是“取状态字节 -> 以该字节为索引查 256-byte 表 -> 写回状态”,而外层循环覆盖的是全部 16 个字节,所以这里可以稳定认成 SubBytes

第二层是固定置换。这里不需要把所有 helper 都摊开,只保留最能说明问题的两处。sub_AADE8 的反编译直接把 16 字节数组按固定槽位原地交换:

这已经足以说明其中存在一层固定 16-byte 重排,也就是 ShiftRows 一类的行移位。除此之外,sub_A8F00 里还能看到另一种“固定取位后再写回”的轮内置换,关键字节操作是:

因此这一步也不是普通 xor,而是“固定字节重排 + 按轮 stream xor”的组合。

第三层是列混合。sub_A6F20 先把状态按连续 4 字节一组切成列,然后反复调用 sub_A96F0。在 0xA6FF4..0xA7158 这一段里,可以直接看到每个输出字节对应的乘数模式:

后两行输出同理,整体就是一张固定 4x4 矩阵去混合单列 4 字节。再往里看 sub_A96F00xA9758 直接把约化常量装成 0x71,而 0xA9818..0xA9828 则是典型的有限域 xtime 形态:

也就是说,这里做的不是普通整数乘法,而是 GF(2^8) 上带 0x71 约化的字节乘法。把这两层合起来,sub_A6F20 + sub_A96F0 就足以判断为自定义 MixColumns

最后一层是常量 material 混入。这个结论同样可以直接靠 xref 固定下来:unk_58510 只被 sub_A7944 引用,unk_58590 只被 sub_A84A4 引用,而这两个函数体都出现了同样的 ldrb -> eor -> strb 形态:

再加上 sub_AA9B00xAAAC8..0xAAAD8 做的“状态字节 xor 另一组 16-byte material”,以及 sub_A8D44 在进入主循环前先调用 sub_A7944sub_AAB64(0, ...),就可以把整体链路稳定整理为:预白化、固定 xor、轮 material、S-box、固定置换、GF 列混合、后 xor。

因此 PART2 不是先主观地称作“SPN / AES-family”,而是先从代码里把“查表替换、原地换位、固定置换、GF 乘法列混合、轮 material xor”这些组成部分一层层拆开,最后再把整体抽象成一套自定义的 SPN / AES-family 变换链。最终输出编码为 sec2026_PART2_<32hex>

这些层都恢复到可以单独写出逆过程的程度以后,PART2 的逆算法也就成立了:逆向时按相反顺序回退后异或、轮 material、InvMixColumns、逆置换、逆 S-box,最终恢复出 token || token,再校验前后 8 字节一致并且字符属于 ASCII hex 集合。

到这里,PART1 / PART2 已经分别完成三件事:

到这里,PART1 / PART2 的代码对应关系已经清楚,但题目仍然存在一个很实际的问题:理论上应该能触发的东西,为什么在原始游戏里并不一定能碰到。这个问题单靠脚本回答不了,必须把场景配置也纳入证据链。

town_scene 可以看到,Trigger2 默认在空中,不在正常行驶路径上,这解释了为什么绿色方块在原场景中不容易直接触发。Trigger3 的脚本入口虽然存在,但其 CollisionShape3D 默认被禁用,因此红色方块默认不会产生有效碰撞事件,这也正好解释了“碰红块没反应”的现象。Trigger4 的情况更极端,它默认同时存在 monitoring = falsemonitorable = false、碰撞体禁用和不可见等问题,因此 PART3 默认入口比 PART2 更难触发。

到这一步,如果只停留在静态分析层面,结论仍然只是“看起来如此”。因此继续做最小化场景补丁验证:

这一步的价值在于,它把“为什么触发不了”从脚本层猜测,变成了可以通过场景补丁直接验证的结论。因此这一阶段可以明确写成:

场景补丁与触发验证

图 3 场景补丁后的验证视角。当前阶段通过调整 Trigger2 / Trigger3 的位置或碰撞配置,把静态推断变成了可以在游戏里重复验证的结果。

完成 PART1 / PART2 的对应关系、算法和场景验证以后,就可以先把已经分析清楚的部分写成工具。题目要求的不只是分析过程,还要求给出可复现实现,因此这里没有继续拆成多个独立程序,而是从一开始就把实现统一到同一个 C++ 工具里,便于后续继续并入 PART3,最终作为随报告一并提交的唯一代码附件:

最终统一接口为:

它最终支持:

当前样例结果如下。

PART1

PART2

直接计算示例:

逆向时如果直接传完整 flag{...},在 PowerShell 下需要给参数加引号。除此之外,还保留了 process_emu.py 作为 PART2 的额外交叉验证手段,用于确认纯算法实现与模拟执行结果一致。

完成这一步之后,PART1 / PART2 就不再只是“分析上说得通”,而是已经变成了可以离线对任意 token 直接计算和逆推的工具链。也正因为前两部分已经闭环,PART3 的问题才被完整暴露出来:它缺的不是单个公式,而是整条路径都还没有理清,包括真实入口、真正发信号的位置,以及中间值到底由谁计算。

统一工具验证结果

图 4 统一 C++ 工具对 PART1 / PART2 的正向与逆向自测结果。

游戏内触发 PART1

图 5 调整 Trigger2 场景后,游戏内成功输出 PART1 flag。

游戏内触发 PART2

图 6 打开 Trigger3 碰撞后,游戏内成功输出 PART2 flag。

前面的 PART1 / PART2 基本都是先从脚本确认触发对象和调用入口,再分别回到脚本层或 native 层把算法写清楚。PART3 难就难在,这条路走到 Trigger4 这里先断了。脚本层几乎没有有效算法信息,默认场景入口还是假的,最显眼的 Tick() 还会把分析带偏。所以这里不能一上来只写最后公式,而是得先把“真正的触发链到底在哪”讲明白。

这一部分实际是按下面这个顺序推进的:

PART3 的起点不是一段可读算法,而是一个默认根本触发不到的对象。Trigger4 同时存在:

所以第一步不是去逆 native,而是先把这个入口打开,确认 PART3 在游戏里到底能不能稳定触发。具体做法很直接:

关键修改本质上就是把场景属性改为启用状态:

这一步不是为了直接还原算法,而是先把最基本的事实确认下来。它至少说明三件事:

后面的分析就是建立在这个结果上的:PART3 确实存在,而且只要把 Trigger4 打开,就能稳定复现。这样后面排除某条路线时,才能确认自己排除的是错误路径,而不是触发条件没满足。

游戏内触发 PART3

图 7 打开 Trigger4 的监控、碰撞和可见性并将其移到出生点附近后,游戏内可以直接触发 PART3 flag。

把入口打开以后,再回头看 Trigger4 脚本本身。恢复出来的核心逻辑很短:

关键不在于这段代码短,而在于脚本层根本没有 flag 生成该有的痕迹:

也就是说,脚本层只是在维护一个会动的触发物体和一个 native 扩展对象,并没有像 PART1 那样自己实现算法,也没有像 PART2 那样直接把输入包一层再调 native。因此最开始最自然的判断就是:

既然脚本层唯一显眼的 native 调用就是 Tick,顺着它继续往下追是当时最自然的选择。bind_methods 能确认:

其中 Process 后来对应 PART2,而 Tick 在当时是 PART3 最大的嫌疑点。实际往 sub_9AD68 里追了一段之后,看到的大多不是 flag 生成逻辑,而是环境检测、状态检查和一些更像拖时间的外围函数。到这里才开始怀疑:Tick 很可能根本不是 PART3 的算法入口,而只是作者故意摆在脚本层最显眼位置的一个烟雾弹。

既然已经怀疑它是烟雾弹,后面就没有继续在 Tick 里硬抠细节,而是直接做最短验证:把 Tick patch 成 ret,再运行已经打开的 Trigger4。关键补丁只有一条:

实验结果非常明确:

所以这里不是一上来就拍脑袋说 Tick 是诱饵,而是先顺着它追进去,发现里面长期都在做环境检测和外围检查,才开始怀疑这条线本身就是在浪费分析时间;随后再用 Tick -> ret 的实验把这件事彻底证明。问题也就随之变成了:既然脚本不发信号、Tick 也不是算法入口,那游戏里最后显示出来的 PART3 flag 到底是谁发出来的。

Tick 被排除之后,视角就必须从 libsec2026.so 的表面入口转回引擎侧。既然 trigger4.gdc 自己不发信号,Tick() 又已经被证明不是算法入口,那么剩下唯一合理的解释就是:

顺着这个问题继续往回找,分析重心自然就从 libsec2026.soTick 外壳回到了 libgodot_android.so。在 Godot 引擎侧,目前可以稳定确认下面几段函数:

其中 sub_25F94A4 的关键点是它明确把 sub_25F6718 安装成回调:

这说明 sub_25F6718 不是随便 trace 到的一条热路径,而是 Godot 明确挂上的回调。

sub_25F6718 里又能直接看到 PART3 外层字符串的静态证据:

对应反编译代码:

到这里,这条链已经可以直接写成:

到这里可以确定:PART3 的外层 flag 字符串是在 Godot native 里拼出来的。也就是说,前面那条“算法可能全藏在 sec2026 的 Tick 里”的猜测已经不能成立了;真正可信的链条是 Godot native 负责发信号,而 libsec2026.so 只在更深的位置提供中间结果。

到这里,外层 flag 字符串是谁拼出来的已经清楚了,但 PART3 还没有真正讲完,因为最核心的 16 个十六进制字符还不知道是谁算的。接下来只剩一个问题:

继续和 libsec2026.so 的静态证据对应起来后,可以把这 16 个十六进制字符定位回 libsec2026.so:+0xA9A7C。同时还能确认最终格式化缓冲区:

因此真实调用关系不是“Trigger4 脚本里算 flag”,而是:

到这一步,PART3 的触发路径已经说清楚了,剩下的难点只剩最后一个:sub_A9A7C 本身不是普通函数,而是一层 VM 风格的扁平化计算壳。也就是说:

middle 追到 sub_A9A7C 之后,问题并没有结束,而是真正进入了最难的部分。这个函数不是普通顺序可读的 C 代码,而是:

如果硬逆整台 VM,会遇到两个问题:

所以后面的做法不是“把整台 VM 全部还原出来”,而是:

具体做法是:

目标不是证明自己会跑虚拟机,而是把虚拟机最后算出来的结果还原成连续的 uint32_t 运算。

顺着最终输出往回切,节点一个个补全以后,局部子图开始重复出现。最后可以归并成两类核心形状:

第一类:

第二类:

继续按时间顺序对应起来后,可以整理成 28 轮双分支更新:

输入定义为:

最终输出为:

这一步不是凭“看起来像某种算法”猜出来的,而是因为大量局部切片节点在反复化简之后,都会落到同样的固定 uint32_t 结构上。

到这里,PART3 的路径和算法主体都已经说清楚了:

sub_A9A7C 的最终输出切片分析完成以后,PART3 就从“难以直接阅读的 VM 外壳”整理成了一段普通的 C++ 算法。到这一步,前面追入口、证伪 Tick、继续追查 middle 的工作才真正和题目要求的“算法实现”接上。此时它的最终 flag 格式可以写成:

其中 16 位 hex 的生成算法是:

由于这套结构本身不是 hash,而是逐轮可回退的双分支迭代,所以逆算法也可以直接按轮逆序回退:

这里之所以天然可逆,是因为每一轮更新关系是:

回退时 x'y'k 都已知,所以可以直接做相反方向的减法:

有了公式和逆过程之后,还不能直接把它们当作结论提交,最后还需要把整条链完整对上。这里既要用真实样本做对拍,也要用离线路径做交叉验证,确保恢复出来的不是“只对几组样本成立”的表达式,而是和原始执行路径一致的算法。

已知样本全部命中:

逆向也全部正确:

统一工具中的直接示例:

此外,纯 C++ 算法还与离线模拟路径做过随机样本交叉对拍:

说明当前结果不是“样本拟合器”,而是与原始执行路径一致的真实算法。也正因为正向和逆向都已经在同一套工具链里跑通,PART3 到这里才算真正分析完成。

三部分统一正逆验证结果

图 8 统一工具对 PART1 / PART2 / PART3 的正向与逆向验证结果。其中 PART3 的正向与逆向都已经纳入同一套 C++ 工具链。

前面几节里,PART2PART3 的一些结论并不只是静态看出来的,还依赖运行时观察、定点 hook 和行为验证。问题在于,libsec2026.so 本身并不欢迎这样做:Frida 直接附加后,一旦对关键路径下 hook,进程很快就会崩。因此这部分不能只当作旁支材料丢开,必须说明清楚它是怎么影响分析、又是怎么被处理掉的。

libsec2026.so 的线程创建总入口顺下来后,可以先把主要检测面分成四组:

继续把字符串解密以后,和 Frida 直接相关的两个关键字就很明确了:

真正导致“能附加但一 hook 就崩”的主因不是 fork + ptrace,而是 .text 完整性校验。它不是简单读文件做哈希,而是沿原始 syscall 路径去读进程内存;只要 Frida 在代码段上落了 trampoline,CRC 就会看到差异,进程随后退出。

这一部分最终没有走“到处 patch 检测函数 return”的路线,而是做成了一套能继续支撑动态分析的稳定方案。实际落地方式是重打包 APK,并在 GodotApp 初始化阶段提前加载自定义 SO,让补丁逻辑先于 libsec2026.so 生效。核心实现集中在:

方案可以概括成五件事:

验证时主要盯了四个结果:

先看单进程验证。补丁生效后,设备侧 pidofps -A 只看到一个同包名进程,说明 fork + ptrace 这条链已经被截断,没有继续分裂出第二个守护进程。

单进程与代码段读写验证

图 9 设备侧只看到一个目标进程;同时对代码段做最小改写后,进程没有立即崩,说明后续完整性绕过已经开始接管。

再看高风险 hook 验证。这里直接选了最容易出事的两个点:

在附加 Frida 并对这两个位置执行 Interceptor.attach 后,heartbeat 仍持续增长,sub_96A00 也能在测试窗口内多次进入,进程没有退出。与此同时,诊断线程能看到 suspicious_tids=1,说明真实 /proc/self/task/*/status 中确实存在 gum-js-loop 对象;但目标本身没有因为它崩溃,说明目录枚举过滤是生效的。

图 10 上半部分显示关键地址已经被稳定 hook,heartbeat 仍在推进;下半部分显示代码段读取被重定向到 clean backup,且真实环境里确实出现了 gum-js-loop 线程。

/proc/self/fd 里的 linjector 关键字已经通过静态分析确认,过滤逻辑也已覆盖;但在当前这套 Frida 运行路径下没有实际命中对应 FD,因此这里只能记为“静态确认 + 方案覆盖”,不能写成动态命中验证。

模块 已完成交付
PART1 定位完成;正向算法恢复完成;逆算法恢复完成;游戏内触发验证完成
PART2 定位完成;GameExtension.Process 路径确认完成;正向算法恢复完成;逆算法恢复完成;游戏内触发验证完成
PART3 Trigger4 真实路径确认完成;Tick 路线证伪完成;正向算法恢复完成;逆算法恢复完成;游戏内触发验证完成;样本对拍与随机对拍完成
检测对抗 主要检测面梳理完成;Frida 动态验证环境搭建完成;关键 hook 点稳定验证完成
检测面 关键位置 作用
双进程反调试 0x99094 -> 0x9C654 fork + ptrace 占用 tracer,阻止外部附加
/proc 扫描 0x99094 -> 0x9CDC4 枚举线程和 FD,识别 Frida 痕迹
心跳与 CRC 0x99094 -> 0x9B7D8 周期更新 heartbeat,并触发 .text 完整性校验
独立完整性检查 0x15CEF0 通过 process_vm_readv 风格读代码区,补一条额外校验链
文件 作用
flag_codec.cpp 统一实现 PART1 / PART2 / PART3 的正向计算、逆向恢复与基础自测
hook.c 动态验证环境使用的绕过实现,覆盖反调试、完整性校验与 /proc 枚举过滤
GameExtension : Node
// sub_A07F4
class_name  = sub_9631C();   // 懒初始化类名
parent_name = sub_970CC();   // 懒初始化父类名
register_extension_class(class_name, parent_name, callbacks);

// sub_9631C
init_utf32_string(&name_obj, 0x63B90);   // "GameExtension"

// sub_970CC
sub_9EA1C(&tmp, &unk_63B42, 0xCD1F64F4AB960940, 5);  // XOR 解出 "Node"
init_string(&parent_obj, &tmp);
// sub_97B6C
bind_method("Tick",    sub_9610C(..., sub_9AD68, 0));
bind_method("Process", sub_9B5E8(..., sub_97704, 0));
bind_param_name("Process", 0, "input");
token = 8 位十六进制字符串
extends Label
var _r8 := RandomNumberGenerator.new()
var _hx := "0123456789" + "abcdef"
var _tl := 4 * 2

func _mk(n: int) -> String:
    var _buf := ""
    var _mx := _hx.length() - 1
    var _j := 0
    while _j < n:
        var _p := _r8.randi_range(0, _mx)
        _buf += _hx[_p]
        _j += 1
    return _buf

func _ready() -> void:
    _r8.randomize()
    var _pfx := "Token: "
    var _val := _mk(_tl)
    text = _pfx + _val
extends Label
var _q2m := ""

func _k7w(p_arg):
    _q2m = p_arg
    if _q2m.length() > 0:
        text = _q2m

func _ready() -> void:
    var _base := "/root/TownScene/Trigger"
    var _arr := []
    var _i := 1
    while _i < 5:
        _arr.append(get_node(NodePath(_base + str(_i))))
        _i += 1
    for _nd in _arr:
        _nd.collided_with.connect(_k7w)
// libgodot_android.so
FileAccess *open_encrypted(path, key, iv, ...) {
    if (key.length != 32) {
        return fail;
    }
    ...
}

void init_pck_reader(obj, ...) {
    memcpy(obj->key, (void *)0x400EF18, 32);   // 固定 32-byte key 常量
    ...
    open_encrypted(path, obj->key, obj->iv, ...);
}
ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061
gdre_recover_with_key/
├─ token.gdc
├─ label2.gdc
├─ Trigger/
│  ├─ trigger1.gdc
│  ├─ trigger2.gdc
│  ├─ trigger3.gdc
│  └─ trigger4.gdc
├─ town/
│  └─ town_scene.tscn.remap
└─ vehicles/
   └─ car_base.tscn.remap
func _fe(_th: String) -> String:
    var _da := _h2b(_th)
    var _lo := _da.slice(0, 2)
    var _hi := _da.slice(2, 4)
    var _kp := ("Sec" + "2026" + "_God" + "ot").to_utf8_buffer()
    var _rn := 0
    while _rn < 8:
        var _fv := _rf(_hi, _kp, _rn)
        var _nr := _xb(_lo, _fv)
        _lo = _hi
        _hi = _nr
        _rn += 1
    var _ot := PackedByteArray()
    _ot.append_array(_lo)
    _ot.append_array(_hi)
    return "sec" + "2026" + "_PART" + "1_" + _b2h(_ot)
PART1 对应绿色方块 Trigger2
PART1 是纯 GDScript 算法,不走 native Process
flag 格式为 flag{sec2026_PART1_<8hex>}
var _raw := str(_lt.text).substr(7)
var _buf := _raw.to_utf8_buffer()
var _rv := _gx.Process(_buf)
_lb.text = "flag{" + "sec2026" + "_PART2_" + _rv + "}" + "  "
Trigger3 -> GDScript -> GameExtension.Process -> native core
PART2 对应红色方块 Trigger3
PART2 的 native 核心入口是 GameExtension.Process
flag 格式为 flag{sec2026_PART2_<32hex>}
func _rf(_bl, _ky, _rn):
    var _v := _bl[_j] ^ _ky[(_j + _rn) % _ks]
    _v = (_v * 7 + _rn) & 255
    _v = ((_v << 3) | (_v >> 5)) & 255

func _fe(_th):
    var _lo := _da.slice(0, 2)
    var _hi := _da.slice(2, 4)
    while _rn < 8:
        var _fv := _rf(_hi, _kp, _rn)
        var _nr := _xb(_lo, _fv)
        _lo = _hi
        _hi = _nr
fv = F(hi, round)
nr = lo XOR fv
lo = hi
hi = nr
0xA841C  ldrb w21, [x20, x19]
0xA8420  ldrb w21, [x5, x21]
0xA8424  strb w21, [x20, x19]
result[13] = result[9];
result[9]  = result[5];
result[5]  = result[1];
result[1]  = v3;
...
result[2]  = result[6];
result[6]  = result[10];
result[10] = result[14];
result[14] = v7;

[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

上传的附件:
收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回