前言:写的比较流水账。看了其它佬的文章,发现比赛还是漏掉了n多得分点…
.gdc 文件头格式仍是 MD5(16) | plain_len(8) | IV(16) | ciphertext和初赛的方法一样,依旧是hook open_and_parse_password,捕获到密钥仍是 ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061但这回没改加解密,解密模式是标准 AES-CFB
直接gdre解出来的gd长这个样子。像是魔改过了。这个关键字分布感觉是魔改了token序。

打了个标准版的4.5.1-stable包,想直接diff看看差别,但……
中间还崩溃了几次。最后直到比赛结束也没diff完
直接人工diff。
对照着源码分析,看看是改了哪里。
一开始想在scan里去找,但scan里的映射有点怪,有几个token被映射到了同一个id。猜测可能是故意堵了这条路?于是打算从_binary_to_token慢慢扒parse。
根据常量字符串Identifier index out of bounds.定位到_binary_to_token偏移是0x147CF48。扔到在线文本比对工具里。

左边是题目,右边是demo。可以看到明显有不同。题目这版魔改了解析token时的映射顺序。
对照着源码分析,这里能确定的是
7 = NAN = official 94;11/12 是 ANNOTATION/IDENTIFIER 这对;13/50 是LITERAL/ERROR 这对。
顺着找其他parse:
is_identifier在0x1474F58,能确定4,5,6,7,12,60,61这一组对应IDENT / MATCH / WHEN / PI / TAU / INF / NAN
parse_variable在0x144DE30,能确认 12 = IDENT = official 02;95 = : = official 84;38 = = = official 28
parse_signal在0x144AB4C,能确认12 = IDENT = official 02;88 = ( = official 77;89 = ) = official 78;90 = , = official 79
parse_enum在0x144D16C,能确认12 = IDENT = official 02;86 = { = official 75;87 = } = official 76;38 = = = official 28
parse_function_signature在0x1453670,能确认94 = ... = official 83;90 = , = official 79;89 = ) = official 78;97 = -> = official 86;lambda 分支再次给出95 = : = official 84
parse_function在0x144BB10,能确认12 = IDENT = official 02;88 = ( = official 77
parse_assert在0x1458C5C,能确认88 = ( = official 77;90 = , = official 79;89 = ) = official 78
parse_for在0x1456EC0,能确认12 = IDENT = official 02;95 = : = official 84;72 = in = official 61
parse_if在0x14566E0,能确认52 = elif = official 41;53 = else = official 42;95 = : = official 84
parse_match在0x145A044,能确认61 = when = official 50;95 = : = official 84;1 = NEWLINE = official 88
parse_subscript在0x1461064,能确认85 = ] = official 74
parse_call在0x14616E8,能确认92 = . = official 81;88 = ( = official 77;89 = ) = official 78
parse_type_test在0x146459C,能确认22 = not = official 12
...
最后归纳一下规律,魔改token到官方token的变换:
写脚本,初赛解gdc脚本改一下就能用,然后再按这个规律置换一下。然后就能用gdre正常反编译了。`
可以看到trigger2的算法全在gd层。



变量名做了混淆。直接扔给大模型来读。也不需要啥额外提示词。有一个值得注意的点是碰撞放在了native层。


然后就是把方块传送过来。此处方法和初赛一样,依旧是先hook到libsec2026.so的extension_init获取到关键api,然后classdb_get_method_bind +ptrcall动态调用godot原生方法递归遍历整个场景树,定位到小车和trigger2的位置后,set_global_position把方块传送到小车的位置。

用到的脚本:
frida_dump_scene_tree.js、run_dump_scene_tree.py dump场景树,scene_tree_dump.jsondump的结果。
run_trigger2_move.js、run_trigger2_move.py 传送trigger2
token生成flag算法:token2flag2.c
flag生成token算法:flag2token2.c
这里说一下flag2token2.c的思路。
先看正向流程。脚本先把输入的 8 位十六进制字符串转成 4 个字节,然后把这 4 个字节拆成前后两个 2 字节分组,分别记成 lo 和 hi。程序把 "Sec2026_Godot" 作为轮密钥,一共跑 8 轮。每一轮先对右半块 hi 跑一遍轮函数,轮函数里先和密钥按轮次偏移异或,再乘 7 加上轮号,最后做一次左旋 3 位。轮函数输出以后,再和左半块 lo 做异或得到新的右半块 nr。然后执行 Feistel 交换,也就是 lo = hi,hi = nr。8 轮结束以后,把最终的 lo || hi 拼起来作为flag。
flag2token2.c 按完全相反的顺序逐步还原。把这 输入的位十六进制串解析成 4 个字节,再拆成当前的 lo 和 hi。这两个值对应的是加密 8 轮结束以后的左右半块。
接下来程序从第 7 轮倒着还原到第 0 轮。因为正向每一轮最后做的是 lo = hi_old,hi = lo_old ^ F(hi_old, round),所以逆的时候先把上一轮的右半块直接恢复成当前的左半块,也就是 prev_hi = lo。然后重新对 prev_hi 跑同一轮的轮函数,得到当时的 F(prev_hi, round)。有了这个值以后,再用当前右半块 hi 去异或它,就能把上一轮的左半块恢复出来,也就是 prev_lo = hi ^ F(prev_hi, round)。这样上一轮的两个分组就都拿回来了,接着把 lo 和 hi 更新成 prev_lo 和 prev_hi,继续处理前一轮。
8 轮全部还原以后,程序拿到的就是最初输入给脚本 _fe() 的 4 字节 token。最后把这 4 个字节重新转成 8 位十六进制字符串输出,就是原始 token。
先尝试了直接调w7,屏幕正常出现flag。
然后直接把方块挪过来,没有flag。
题目提示要开启碰撞。直接翻文档。

这里hook发现trigger3的monitorable和monitoringtrue,但trigger4的这两个是false。
场景树里可以看到它们还有子节点CollisionShape3D


这个应该是碰撞的实体。hook发现trigger3和trigger4的CollisionShape3D子节点的disabled都是true。

尝试把disable设置成false,然后把trigger3传送到小车上,成功显示flag

用到的脚本:frida_dump_trigger_flags.js、run_dump_trigger_flags.py 打印这几个属性。
改标志位并传送Trigger3到小车:run_trigger3_enable_move.py、frida_trigger3_enable_move.js。
主动调用尝试几组输入输出:
这次的扩散非常强,不能跟初赛那样靠选择明文攻击来猜了。
这里仍然沿用初赛时的思路定位 Process 的入口,从 Godot GDExtension 的标准初始化流程入手。扩展库加载后,Godot 会调用 extension_init(get_proc, library, init_struct),其中get_proc 是扩展用来查询 Godot API 的入口。先 hook extension_init,把第一个参数 get_proc 替换成自己的 wrapper。这个 wrapper 内部仍然调用原始 get_proc,但当查询classdb_register_extension_class_method 时,把这个注册函数 hook 住。
等 classdb_register_extension_class_method 被调用时,读第三个参数,也就是 GDExtensionClassMethodInfo 结构。这个结构里能看到统一的 Godot 调用 wrapper,以及对应的 method_userdata。 dump method_userdata 可以看到它的 vtable,从 vtable 的 +0x18 和 +0x20 两个槽位可以得到 Process 的两条实际调用桥接函数,分别是 libsec+0x9f5d8 和 libsec+0x9c5f8

继续分析这两个桥接函数,可以看到它们还不是最终业务逻辑,而是会继续读取 method_userdata + 0x50 和 method_userdata + 0x58,并把这两个值作为下一层分发参数。运行时 dump 这两个字段可以看到,method_userdata + 0x50 = libsec+0x97704,method_userdata + 0x58 = 0。

可以推断Process的入口就是0x97704。同理可得到Tick的入口0x9ad68
此处用到的脚本:frida_locate_process_entry.js run_locate_process_entry.py
ida打开能看到一些花指令插在代码段里。比如:
这里我是写了个ida python脚本把0xA2F2FFF1全都给nop了,一共82处应该是。用到的脚本:junk2nop.py
定位到process入口0x97704

sub_A936C就是加密的主函数

把初始token复制一份扩展到16字节。然后调用sub_A7900和sub_A7194,然后进入一个比较友好的控制流平坦化。看入参,sub_A7900应该是用于构造208字节的上下文。其中最后16字节是特意复制进去的一块。

这里ida识别不出来switch结构,需要手工添加一下。

sub_A936C本身没什么内容,除了前面的函数调用,后面就是解密字符串,解出来是%02x,然后sub_AA6AC用它调用_vsprintf_chk用来写入flag。所以核心加密就在sub_A7194。

A7194的核心代码也不长。case0是每次对16大小的块做运算,v5是。case1拷贝计算结果,v6就是state+192,也就是每轮计算完把结果拷贝到这里。case2是终止条件。这里可以合理推断state前192字节是不变的S盒,最后16字节是每轮运算的state。
这里每轮就是调用sub_AA9B0和sub_A8D44做计算。只要搞清楚这俩干了啥就ok了。

AA9B0就是做异或。


相当于
接下来接着跟sub_A8D44。两个初始化函数。

sub_A7944是和常量做异或

翻译过来就是
sub_AAB64关键代码其实就这一行

变量太多了不想一个一个跟了。直接复制粘贴扔给大模型分析。


所以这里化简完就是:
往下看主体,sub_AAB64在sub_A8D44的主循环中也被频繁调用

直接整个扔给大模型

所以主循环化简完就是:
如法炮制,先手工恢复一下switch-case,然后一个一个扔给大模型看:


sub_AADE8不用扔,代码十分简洁。


ok到这里已经逆完了。还差hook把S-box和byte_183700的内容抓出来,然后把代码拼凑到一起就是生成算法了。
写个frida脚本抓一下:run_dump_sbox_c_arrays.py、

最终token生成flag生成算法:token2flag3.c
flag生成token算法:flag2token3.c
这里说一下flag2token3.c的思路。
先看正向流程。原程序读入 8 字节输入以后,把这 8 字节复制两遍,拼成一个 16 字节状态,前半块和后半块完全相同。接着程序先对状态做两次固定异或,再做第 0 轮的轮密钥异或,然后进入主轮函数。主轮一共分成两部分,前 10 轮都执行同一套完整变换,最后 1 轮执行尾轮变换。完整轮里依次做 S 盒替换、4x4 状态矩阵顺时针旋转、轮掩码异或、行置换、列混合以及轮密钥异或;尾轮沿用相同框架,只是省掉了列混合。所有轮结束以后,程序再对结果异或一组固定常量,最后把 16 字节状态转成 32 位十六进制字符串输出。
flag2token3.c 按完全相反的顺序逐步还原。程序先把输入的 32 位十六进制串解析成 16 字节密文块,然后先去掉末尾那组固定异或。接着开始逆最后一轮,因为正向最后一轮的最后一步是加轮密钥,所以解密时先异或掉第 11 轮轮密钥,再依次执行逆行置换、逆轮掩码、逆旋转和逆 S 盒。做完这一轮以后,状态就回到了进入尾轮之前的样子。
然后从第 10 轮一路逆到第 1 轮。每一轮都先异或掉对应轮密钥,再执行逆列混合、逆行置换、逆轮掩码、逆旋转和逆 S 盒。
前 11 轮全部还原以后,程序再去掉最开头的第 0轮轮密钥,然后依次去掉最前面的两组固定异或。最后 16 字节状态被还原到token || token。
分析trigger3的时候已经发现trigger4有三个需要改的属性了。全改了然后传送过来,成功得到flag。

此处用到的脚本:run_trigger4_enable_move.py和frida_trigger4_enable_move.js
这里比较奇怪的是,即便改了token,再次碰撞返回的flag也不变。推测可能flag在碰撞前或者初始化阶段就完成计算了。token本身没有往外发的操作,那么它应该是在启动时主动取了token,计算出flag,然后缓存在某处。
gd脚本基本只有两个操作,一个collided_with注册碰撞,一个调用native层的Tick()。
Tick本身就很像反调试。他本身会在每一帧被调用,每超过十秒就更新一次qword_1834B8里的时间戳。

既然能通过信号传flag给label,那接下来的思路就是找哪里调用了emit_signal
libgodot_android里找到collided_with,交叉引用只有一个函数0x25F6718。合理推断sub_3C58F78是emit_signal,那么这个v86就是生成出的flag。

往前追v86的来源

跟进sub_3CACE6C是一个字符串构造函数。构造的结果是第一个参数。
sub_3CAD6D8是字符串加法,相当于v79 = v78 + v29
sub_3CAD53C也是字符串加法,相当于v80 = v79 + v77 = v78 + v29 +v77。区别是它的第二个参数是个内部字符串对象,sub_3CAD6D8第二个参数是c字符串。
向上跟踪,发现v78的来源s就是flag的前半段:

v77的来源v81就是右括号

那么v29就是flag了。

[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 2天前
被Kevin·编辑
,原因: