初赛对godot的理解不够,以前学习的主要是UE4相关,godot还是第一次搞,godot也有类似ue4那种dump,决赛才发现.
个人博客
2026腾讯游戏安全竞赛Android初赛 | Matriy's blog
到达绿色方块也可获得flag
更简单的方法是把属性dump出来直接去触发碰撞,我这样调得出来的效率太低了
补充patch掉校验 主动触发 然后重新编译打包 失败会闪退 估计是得用原来的引擎或版本不对?
这个其实应该放在后面的,我是解完包,逆完算法在开始的,还好搞了,不对不知道,自己逆向的算法搞错了
要想获得flag 无非几种常见思路
由于初次解出godot对感觉没有ue4那么有规律,也不知道怎么看字符串,对照方法,只能硬调
当前方案是用 Frida 强制 Trigger2 碰撞,让真 flag 显示在 Label2 UI 上,decrypted/decompiled/trigger.gd 是挂在 Trigger1 和 Trigger2 两个 Area3D 节点上的共用脚本。简化后的_process逻辑:
Trigger2 是屋顶的绿色方块,是一个可穿过的 Area3D 触发器(应该不是实体),只是 Y 高度超出车能到达的范围,所以 get_overlapping_bodies() 永远返回空数组,Process永远不被调用,flag 永远不显示。
Part1 的 20 分要求让 Trigger2 触发一次,使真 flag 出现在右上角 Label2 UI 上
不改 APK (之前试过改 trigger.gdc 再打包,改出来游戏直接崩,不是签名问题就是字节码格式问题),改用 Frida 在运行期 hook Area3D::get_overlapping_bodies,在 onLeave 里调用 Array::resize(out_array, 1) 强制把返回的空数组 resize 成size=1,trigger.gd 的 body.size() > 0 判断就过了,Trigger2 的分支自然执行,flag 直接写到 Label2,不需要替换字符串也不需要走 UI hack。
get_overlapping_bodies:获取当前这个 Area3D 正在重叠,碰撞到的物体列表
两个难点:
由于不太懂godot,一开始找错了函数 (Area2D 不是 Area3D)
第一次用 find_overlapping_bodies.py扫 libgodot_android.so,找 "get_overlapping_bodies"字符串,再从 _bind_methods 的 ADRP+ADD pair 找到目标函数指针,结果定位到:

反编译看起来非常像 get_overlapping_bodies:sub_3957654 构造 Array 检查monitoring flag 遍历链表 sub_395126Cresize 返回。但里面Array::set_typed 传的类名字符串是Node2D:
把 0x27af370 塞进 Frida hook 后,调用次数一直是 0,但 Array::resize 探针每 2 秒能记到 20+ 次调用,证明 Frida 能 hook libgodot_android.so,就是这个函数从来没被执行过 , 它是 Area2D::get_overlapping_bodies,游戏里没 Area2D 节点,所以根本不跑。find_overlapping_bodies.py 脚本的启发式把 Area2D 和 Area3D 弄反了。

脚本就不贴了,没啥用,思想就是
就是扫 libgodot_android.so 找 godot::Area3D::get_overlapping_bodies的 vaddr 偏移。
在 Godot 的 Area3D::_bind_methods() 里, 会有一段ClassDB::bind_method(D_METHOD("get_overlapping_bodies"), &Area3D::get_overlapping_bodies);编译后的 ARM64 汇编是:
这两对 ADRP+ADD 之间通常只差几条指令.我们扫 libgodot_android.so 的 .rodata 找 "get_overlapping_bodies\0" 字符串,
再扫 .text 里引用这个地址的 ADRP+ADD, 然后在引用点附近找第二对 ADRP+ADD,
目标就是 Area3D::get_overlapping_bodies.
注: Area2D 也有同名方法, 所以会找到 2 个候选, 选 arm64 的第二个大概率是 Area3D.
最后是用 LR反向追踪找到真函数
既然 Array::resize (sub_395126C,这里是被去掉符号了) 的 hook 能生效,就在它的 onEnter里读 this.context.lr(= caller's next PC = BL resize 指令的下 4 字节)反向定位所有调 resize 的 call site,采样 10 秒:
为什么要 hook 它,Area3D::get_overlapping_bodies 内部会两次调用 Array::resize:
所以 trigger.gd 每帧每个 trigger 跑一次 get_overlapping_bodies,就会触发 2 次 Array::resize 调用。hook 这个函数等于间接监视到了 get_overlapping_bodies的内部活动,尽管我们还不知道 get_overlapping_bodies 的地址。
什么意思嘞,可以看效果解释
开车 10 秒后,top hits:

this.context.lr LR 寄存器BL 指令的下 4 字节,LR 是 Link Register,ARM64 里叫 X30,专门存函数返回地址,ARM64 的函数调用指令是 BL target,执行时 CPU 做两件事:
举个例子,假设 get_overlapping_bodies 里有一条指令:
当 CPU 执行到 0x25fa790 这条 BL 时:
此时 Frida 的 Interceptor.attach(sub_395126C) 的 onEnter 会被触发。在 onEnter 里 this.context.lr 就是当时的 LR 值 = 0x25fa794。 想知道谁调了我? 做一个减法:
call_site = lr - 4 = 0x25fa794 - 4 = 0x25fa790
0x25fa790 就是那条 BL sub_395126C 的地址,也就是call site(调用点)。它所在的函数就是调用者。
1392≈ 60 FPS × 2 triggers × 10 秒 ,完全对得上两个 Trigger 每帧各调一次。两个 call site 都在同一函数 sub_25FA6F4 0x25fa6f4 内 (一次用于初始 resize,一次用于最终 resize)。反编译这个函数:
识别 Trigger2 的 this 指针,知道了函数地址,还得区分 Trigger1 和 Trigger2。
get_overlapping_bodies 这个函数是所有 Area3D 都用的。Trigger1 和 Trigger2 都是 Area3D如果不区分很可能会看不出效果
解决办法是看 trigger.gd 的代码,obj.Process() 这行只在 Trigger2 的分支里出现。Trigger1 分支压根不调 Process
办法借助 GameExtension::Process 的调用时机。trigger.gd 里 obj.Process() 只在 Trigger2 分支里被调:
所以:
(trigger2Ptr == null)对所有 Area3D force,确保 Trigger2 那帧能进Process 分支,把自己的 this 指针"暴露"出来。一旦暴露,立刻切换到只 force Trigger2模式 ,Trigger1 及其他 Area3D 的 get_overlapping_bodies 不再被干扰。
流程时序 (两种可能的场景树顺序都 OK):
反过来 (Trigger2 先跑、Trigger1 后跑) 也可以,因为一旦 Process 被调、trigger2Ptr被设,后续 Trigger1 不再被 force,Trigger1 分支不执行,真 flag 不被覆盖。


这里的token 和flag可以拿到后面验证
建议先看后面章节再回来
上面的 Frida 方案虽然能出 flag,但技术味道偏运行时取巧, hook Area3D::get_overlapping_bodies 强制 resize,本质是让游戏自己去执行那段 GDScript 把 flag 写到 Label2。这种做法放到 UE4 里对标,大概等于用 UEDumper 的live 模式打断点抓内存,而不是经典的SDK dumper,照着源码的数据结构,静态枚举对象和读属性
Godot 4 里有没有 UE4 FUObjectArray+ SDK dumper 的对应物?有:
但有个更好的做法:Godot 的场景是完整序列化在磁盘上的 .scn(二进制 PackedScene)或 .tscn(文本)。节点类、名字、父子关系、所有属性的初始值 全部写死在文件里。APK 解包后,这些文件就在 export-.scn*根本不需要游戏运行就能完整还原 SceneTree。
decrypt_godot45.py放在了解包章节
调用流程:
scn 文件是加密的
APK 解包拿到的 .scn首 4 字节不是 RSRC,而是随机字节:
用 decrypt_godot45.py(key 是从 libgodot_android.so .data 段扫 Shannon 熵找到的 32 字节值)解出来
脚本也在解包那里
参考源码,RSRC 格式 (core/io/resource_format_binary.cpp)
Godot 4 的二进制资源文件布局固定:
ustring的编码是:u32 length_including_NUL 后跟 length`个 UTF-8 字节(没有 4 字节对齐填充)。
然后Variant 序列化
这里是坑最多的地方。每个 Variant 是 32 type_tag 后跟类型相关字节。Godot 4 的类型枚举:
写 parser 时踩的第一个坑:原本照Godot 4的通用写 PACKED_VECTOR2_ARRAY = 3,解 town_scene 时在某个 CSGPolygon3D 的 polygon 数组处就跑偏了 。实际那个 tag 35 在 4.5 里已经是 PACKED_VECTOR3_ARRAY。
Python 实现:
Object 引用
场景里大量字段是引用另一个资源"脚本、mesh、纹理、subscene)。VARIANT_OBJECT的编码:
在 4.5 文件里几乎只见 2 和 3,都只是一个 u32 索引,很紧凑。
PackedScene 的扁平化节点数组
最核心的一步。解出来的 PackedScene资源只有一个属性 _bundled(Dictionary),里面是完整的场景定义。字段:
nodes数组是整棵树的扁平编码。scene/resources/packed_scene.cpp:SceneState::pack() 的写入顺序:
还原算法就一个循环:从头走 nodes[],按这个 schema 解析每个节点。parent_idx 自然形成树。
完整代码

没接触过godot,但是应该需要解包,查阅相关资料
DownUnderCTF 2025 - Un1corn - 博客园
查到工具Releases · GDRETools/gdsdecomp
观察结构发现是散包模式资源直接散在assets下,不是打成单个.pck,还有一些so,Godot 导出模板在编译时内置一个 script_encryption_key[32] 全局变量,导出 APK 时编辑器将真实 key 写入该变量位置。key 必然存在于 libgodot_android.so 的 .data段(因为是有显式初始值的可写全局变量)。
根据博客的两种方式定位key寻找失败,似乎是被strip了,但是肯定的是key肯定在libgodot_android里
可以写一个算法专门扫描key,大概思路是
AES字节的混乱程度为熵
来加快扫描
输出
在IDA看

找到key但是用工具解不开,报错Wrong key,猜想可能被魔改AES加密算法?
问了GPT每个加密的 .gdc/ .gdextension 文件格式,这是Godot常见加密文件头和导出脚本格式(带完整性校验的 AES 加密文件头)
.gdextension是Godot 4 的原生扩展描述文件。用来告诉 Godot 应该如何加载一个 GDExtension,包括要加载哪些平台下的动态库、入口符号、兼容性信息等;真正执行的是它指向的本地库文件,比如 .dll / .so / .dylib。
.gdc是 GDScript 的编译/加密后的脚本产物。Godot 3.x 里,官方有script encryption key机制,导出时可以把脚本加密,避免以明文形式直接被看到。对应资料里明确提到导出时可对脚本使用 256-bit AES 密钥保护。
追踪调用链:

关键是识别sub_197C210大量AES的特征

sub_197C210通过 AES key schedule 展开逻辑确认是setkey_enc(正向密钥扩展)
sub_197DE18是自定义的AES 魔改,函数签名如下,与标准 mbedtls CFB-128 的关键差异在于解密分支(mode = 0):
标准 mbedtls CFB-128 解密:
魔改 CFB-128 解密:
解压
.gdextension解密后是完整的 INI 文本:

Godot 4.5 gdc 文件格式

使用GDRE Tools反编译,解密后的 .gdc 是标准格式,GDRE Tools 可以直接处理:
5 个文件全部成功反编译为 .gd`源码。

可以看到part1
flag生成算法为根据屏幕左上角的随机token生成的右上角的flag算法
token.gd
Token 是 8 个随机 hex 字符
trigger.gd
输入[a, b, c, d, e, f, g, h]:
变换后:
xor_enc 逆运算
给定输出 [x0, x1, x2, x3, x4, x5, x6, x7],还原原始 [a, b, c, d, e, f, g, h]:
flag1还要加上Process的处理
发现libsec2026.so没几个函数,字符串没有交叉引用
nm libsec2026.so ,T extension_init 0x56d50
readelf 0x56d50 在 LOAD 段(perm=R+X,应该是代码)
但 get_bytes(0x56d50) 读出来完全不像 ARM64 指令
应该是被壳或者其它东西保护了


start 是合法的 ARM64 代码,是加壳器的解密 stub,运行时把真正的代码解密到内存中。

Header 在 0x69A60:
这样就有两种方法了,静态逆向,动态dump
分析剩下的代码发现实现了以下功能
sub_69984(64 字节)+ sub_699C4(24 字节位读取器)+ sub_699DC(gamma 码读取器)
是aPlib变种,其中特征
让AI搓了一个脚本
> 坑点:gamma 码的 control bit 语义,b.cc loop意味着 control=0 继续循环,control=1停止,第一次写反了导致完全解错
把 6048 字节的 stage 2 加载到新 IDA segment 分析
执行效果

在sub_201150发现

0x2158055=UPX!,是个UPX壳
12 字节 chunk header 格式:
LZMA 魔改
sub_200394 是 LZMA range decoder,也是魔改:
而且属性字节 (lc, lp, pb) 编码方式也不同(3 个分开的字节而不是打包的 (pb*5+lp)*9+lc):
Python标准lzma模块无法解码这个变种
BL 重定位过滤器(filter=82 'R'),LZMA 解压后还有一层 AArch64 BL/B 指令修正:
把绝对偏移形式的 BL 转回相对偏移形式
因为 LZMA 变种太复杂,手写 Python 移植太耗时,用 Unicorn 直接模拟执行 sub_20024C:
> py_eval 环境下 hook 函数的闭包不能访问外部变量,要用 builtins._emu_state 之类的 hack 传状态
需要在IDApython的环境先安装unicorn
输出

可以当AArch64 指令解出来解释处理,说明 LZMA 解压成功。但这时还没做 BL 重定位过滤器 (filter=82='R'),所以里面的 B/BL 指令的偏移还是存储优化后的值,需要再跑一遍 filter 修正
加密区布局与 chunks 位置
加密区在 0x4b070 - 0x69860(125424 字节)
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 2天前
被Matriy编辑
,原因: