首页
社区
课程
招聘
[原创]南极动物游戏安全2026-安卓决赛
发表于: 19小时前 428

[原创]南极动物游戏安全2026-安卓决赛

19小时前
428

魔改Godot引擎,加上虚拟机,真做吐了。混淆我是一个没去、就说这思路能不能跑吧

主要操作:模拟执行,过反调试,Frida Hook(相关脚本不放了、太丑,思路肯定是对的就是了)

FLAG1:

FLAG2:

FLAG3(模拟跑通了、没时间扒下来) 几个不同的触发题触发条件分别是:

frida远程调用直接查出来的属性

先解包并还原 GDScript,确认三个 Trigger 各自负责什么;再定位 GameExtension.Process() / Tick() 的 native 入口;最后分别处理 PART2 的 watchdog / anti-hook,以及 PART3 的 Godot 事件构造链。

改改初赛的解包脚本就能用,并通过 GDC 文件头确定解的是对的,但这次旧脚本直接按上游 Godot token 表解释会全乱。后面确认不是多了一层“token 解混淆函数”,而是决赛包把引擎内部 token 编号表直接改了,所以正确做法是去 libgodot_android.so 的 GDScript 解析链上做动态 dump,把触发器挂载的脚本先还原出来。

我不知道那个字节码混淆是怎么搞出来的,多半是在libgodot里面有什么混淆的操作,对Unity开发经验比较多、肯定有一套解释器运行的是去掉混淆过后的脚本,猜测从那个位置去dump基本就可以无视混淆了,类似于il2cppdump一样。

trigger1:

trigger2:

trigger3:

trigger4:

Trigger4非常神奇,没玩过Godot研究了一整天,我一直以为flag回传是在tick里面做得操作,甚至怀疑dump下来的内容是不是有误,检查了好多遍,最后发现写死在引擎内测(libgodot.so)的0x25f6718函数里,真的吐血。

这一段关键地址大致是:

trigger2.gd 还原出来后,PART1 的脚本逻辑就比较直白了:命中对应 Trigger 的碰撞关系后,脚本去读 /root/TownScene/Label,去掉前缀 "Token: " 拿到 8 位十六进制字符串,再进脚本内 _fe() 做 8 轮 Feistel 风格变换,最后写到 Label2

所以 PART1 的 FLAG 处理算法可以概括成(还原见样例程序PART1_flag):

例如样本 3a5e9ecd 最后可以还原出 flag{sec2026_PART1_EEC57451}

unicorn模拟执行分析vmp、平坦化混淆还是花指令什么的,常规操作了,关键思路是找到入口、出口、拿到中间的执行流,只要有执行流混淆基本就只剩下各种花指令,相对于死磕静态分析还是比较划算的。

binary ninja mcp插件见样例(框架改的面目全非)。prompt:

通过 Hook 游戏侧的 Process 调用,可以把 PART2 的 native 主链基本钉死。这里自然触发同样不是单纯“谁进了 Area3D 都算”,而是要先满足对应 Trigger 的碰撞关系;移车脚本专门有 trigger_probe / trigger_arm 去查 Trigger 的 monitoringmonitorable 和子 CollisionShape3D.disabled,再把 car 下真正参与物理的 body 传送过去。命中后,GameExtension.Process() 的真实绑定落点(怎么拿到拓展函数的地址见初赛操作)是 libsec2026.so+0x97704,往下马上推进到 0xA936C,核心 16 字节块变换在 0xA7194。调用链大致可以记成:

这里几个关键点是:

主算法整体很像 AES,但不是标准 AES。静态参数块可以确定:

轮函数层大概可以这样记:

也就是说,这条链虽然长得像 AES 风格 SPN,但实际是“自定义 S-box + 自定义轮常量 + 自定义 whitening/mask”的一套魔改版本。真实样本里,trigger3 处理 token 48cde866 可以得到 flag{sec2026_PART2_2e15242437e16b2ec0d472561c75018b},可以拿来做结果校对。这里截一眼执行链路:

这条路模拟执行出奇地顺,可能就是预留来这样做的吧,出题人高抬贵手只给20分不是没理由的。

同时调试的时候发现有多个奇怪的线程在 native loader 阶段被拉起,所以顺着 extension_init 和 watchdog 启动块往下看,能把保护面大概分成三条:

其中 inline_hook_guardian 的主链比较关键:

它会枚举模块的可执行 PT_LOAD 段做完整性校验,所以直接在 libsec2026.so 代码页上做 inline hook 很容易触发。再往里还能看到:

ptrace_guardian 可以直接拦掉不管,真正更稳定会炸进程的是 Tick() 里的 watchdog。GameExtension.Tick() 的绑定落点是 0x9AD68,全局时间戳在 qword_1834B8,当 delta_us > 0x989680,也就是超过 10 秒时,会跳到 0x9AE70/0x9AE74 的毒分支。

最后绕过的办法就没必要硬 patch 代码页了,直接在 tools/frida_bypass_antidebug_final2026.js 里定时刷新 qword_1834B8,把 Tick() 一直维持在正常返回路径即可。这种 data-only keepalive 比直接改 libsec2026.so 文本段稳很多。

找了 libsec2026.so 半天没找着,最后发现真正的 PART3 拼 flag 路在 libgodot_android.so 内部。思路是去 Hook Label2 / 信号发射相关的写入链,最后确认真正直接构造 flag{sec2026_PART3_...} 的地方在 sub_25F6718。字符串搜不到,但是可以翻得到提示。

这一段关键地址可以记成:

主要逻辑是:

说人话:整个保护最精髓的地方就在这里,在引擎源码内部自己实现了一套和拓展方法与游戏场景内通讯的办法,并在引擎侧和拓展侧上依赖保护,拓展侧通过回调、共享内存的办法来传递信息到场景内的gameObject中,相当于绕过了gdc、C#这种字节解释的弱保护,同时又保证拓展层不被卸载能够保护游戏运行的反作弊。具体操作见下

(在libsec2026.so内部的虚拟机处理算法,实在没时间写思路了、以下内容直接给ai帮跑日志的,主要是模拟执行。)

这里一开始最容易误判的是 sub_1FD9B60(a1, &out)a1 + 480,看起来像是在喂中间串,但后面证实那只是旁路;真正参与 sec2026_PART3_ 中间段构造的是 qword_40541E0 以及它后面的运行时变换结果。

在libsec2026中0x4A9A7C 是主要处理函数,一个扁平状态机/小型解释器,不是普通的直接字符串算法。也就是说,这里硬啃全静态当然能做,但性价比不高;更稳的做法是直接 Hook 三个点:

如果还想在静态侧再往前推一步,性价比最高的不是直接硬啃 0x4A9A7C 全部状态,而是先把它依赖的 helper 单独摘出来做语义化。实际在给 Binary Ninja 的模拟执行框架补环境时,可以看到这条链会间接打到 libsec2026.so 里一个 helper(按 BN 视图地址记是 0x51B090,相对偏移是 0x11B090,需要加 0x400000 基址)。一开始很容易把它也误判成“又一层加密”,但沿着状态机拆下去之后会发现,这个 helper 本身并不是新的密码算法,而更像一个“按范围查表并搬运数据”的运行时资源分发器。

大致可以把它理解成:

这样处理之后,F(token) 那个扁平状态机里最烦的一层“动态 helper”其实就能被抽象成一个普通的 range-copy stub,模拟执行也就能继续往下走。也就是说,PART3 这里真正难啃的还是上层解释器状态机本身,而不是 helper 里另藏了一套独立加密;helper 更像是在给状态机按 key 提供字节片段、常量块或者运行时表项。

这样 PART3 的关键链路就能比较快闭环,不用一直困在那堆扁平状态机里。

游戏侧的helper去喂了某些东西到拓展里面、拓展依赖helper写入内存去运行,如果卸载了拓展,那么关键的flag3自然就无法生成,而要单独模拟分析拓展就不得不还原引擎侧的helper,从而实现了依赖保护。加上vmp、反调试,不管是静态和动态弄起来都十分难搞,搞机经验多一点的话应该早就想到会有这种操作,不至于全把时间投进libsec2026里面做分析,从而忽略掉引擎的操作,最后痛失50分。能模拟分析这种类vmp的话,其实感觉离扒干净不远了,操作放在附件里、鄙人是没做完这一层、提供个思路权当参考。

只找到三条保护线程,ptrace_guardian占坑退坑检查防止ptracer附加调试、inline_hook_guardian扫内存完整性并和tick函数有依赖关系,tick时间校验函数检测游戏存活不存活就走毒分支到vkthread里面触发崩溃,触发崩溃的方式就是故意往一些内存里写入些东西。

下为AI写,报告真的懒得写。

这一题的反调试一开始最坑的地方,是它不是那种“启动时检查一次,patch 一个点就结束”的简单保护。最开始我们只是对 extension_init 做 Frida inline hook,结果即使把别的 probe 都关掉,进程还是会在启动大约 10 秒后稳定崩掉,PC 分别落在 0x9B1800x9B2B00x9B330 这一组保护 stub 上。把 extension_init hook 去掉后又不再崩,所以能先确认:真正有问题的不是业务逻辑,也不是后续主动调用,而是“改写了 libsec2026.so 代码页字节”这件事本身。

顺着 extension_init 和 watchdog 启动链往下看,最后把保护面大致理成了三条后台线程:

其中最关键的是完整性线程。它的主链可以记成:

这里的核心思路不是盯单个函数地址,而是先枚举 ELF,筛出目标模块的可执行 PT_LOAD 段,再把 basesize 丢给 0x9AF98 做字节级校验,最后拿算出来的值和预置常量比较。后面还能在一条已确认分支里解出目标模块名是 libgodot_android.so,对应的期望校验值是 0xCE8FA9E9。也就是说,至少有一条完整性分支确实会去扫 Godot 的可执行段,所以只要在它覆盖的代码页上做 inline hook,就有很大概率把自己送进 crash stub。

再往里看,inline_hook_guardian 还不只是单纯 hash。它在同一个状态机里还带了两个比较麻烦的能力:

所以后面就不能再把问题简单理解成“只要过掉一个校验函数就行”,而是要把保护面拆开看:一类是 ptrace,一类是 proc/maps 扫描,一类是代码完整性,还有一类是 Tick() 自己带的时间陷阱。

(Tick部分的执行可以通过模拟来验证是否有分歧从而判定时间关系)

Tick() 这一条是后面真正收口的关键。它的绑定落点在 0x9AD68,会把时间戳记到全局 qword_1834B8。用仿真把这条链单独跑通后,可以确认它比较的是微秒级时间差,只要:

也就是严格大于 10 秒,就会跳到 0x9AE70/0x9AE74 的毒分支,通过一个坏掉的间接跳转直接把进程带崩。这个链和实机里反复出现的 VkThread 崩溃路径是能对上的:

这一步很重要,因为它说明之前很多“像是 inline guardian 触发”的崩溃,其实并不一定全是完整性线程直接打死的,GameExtension.Tick() 本身就带一个 10 秒 watchdog。如果 Frida 脚本、调试停顿或者主线程节奏被拖住了,单靠这一条也足够把进程干掉。

所以最后真正稳定的绕过思路,不是去硬 patch libsec2026.so 文本段,而是尽量走低侵入路线:

最后这一点是最稳的。tools/frida_bypass_antidebug_final2026.js 里加的是 data-only keepalive:定时调用 clock_gettime(CLOCK_MONOTONIC, ...),换算成微秒后写回 libsec2026.so+0x1834B8,让 qword_1834B8 始终保持新鲜。这样 Tick() 一直走正常返回路径,不需要修改任何可执行字节,也不用去 hook 0x9AE74 这种明显高风险的位置。实际测下来,这种做法已经足够稳定压住之前反复出现的 timeout-to-trap 崩溃。

如果一定要走静态 patch,当前更靠谱的也不是硬啃 0x9AF98 的混淆状态机,而是直接切线程链,例如:

但从实际工程效果看,这题更推荐的还是前面的低侵入方案:不和完整性线程正面硬碰,先把 ptrace/proc_scan 拦掉,再用 data-only keepalive 稳住 Tick()。这样后面无论是跑 Process()、抓 PART3 事件链,还是做 Godot 侧被动追踪,整体都会稳定很多。

学到不少,起码也是日过ACE的人了。第一次搞安卓游戏安全感觉还挺好玩的,为了弄flag3的分析、PC那边都不打了就日vmp,可惜功亏一篑。

extends Label

var rng := RandomNumberGenerator.new()
var score = 0
var CHARS := "0123456789" + "abcdef"
var TOKEN_LEN := 4 * 2

func generate_token(n: int) -> String:
    var s := ""
    var max_index := CHARS.length() - 1
    var i := 0
    while i < n:
        var idx := rng.randi_range(0, max_index)
        s += CHARS[idx]
        i += 1
    return s

func _ready() -> void:
    rng.randomize()
    var prefix := "Token: "
    var value := generate_token(TOKEN_LEN)
    text = prefix + value
    var out := text
    print(out)
extends Area3D

signal collided_with(name)

var _gx = GameExtension.new()
var _tv: float = 0
var _ix: int = 0

func _h2b(_s: String) -> PackedByteArray:
    var _r: PackedByteArray = PackedByteArray()
    var _n: int = _s.length()
    var _j: int = 0
    while _j < _n:
        _r.append(_s.substr(_j, 2).hex_to_int())
        _j += 2
    return _r

func _b2h(_ba: PackedByteArray) -> String:
    var _r := ""
    for _v in _ba:
        _r += "%02x" % _v
    return _r

func _xb(_a: PackedByteArray, _b: PackedByteArray) -> PackedByteArray:
    var _r: PackedByteArray = PackedByteArray()
    var _n: int = _a.size()
    var _j: int = 0
    while _j < _n:
        _r.append(_a[_j] ^ _b[_j])
        _j += 1
    return _r

func _rf(_bl: PackedByteArray, _ky: PackedByteArray, _rn: int) -> PackedByteArray:
    var _r: PackedByteArray = PackedByteArray()
    var _ks: int = _ky.size()
    for _j in _bl.size():
        var _v = _bl[_j] ^ _ky[(_j + _rn) % _ks]
        _v = (_v * 7 + _rn) & 255
        _v = ((_v << 3) | (_v >> 5)) & 255
        _r.append(_v)
    return _r

func _fe(_th: String) -> String:
    var _da = _h2b(_th)
    # High-confidence reconstruction: script expects 8 hex chars -> 4 bytes.
    assert(_da.size() == (2 << 1))

    var _hl = 2
    var _lo = _da.slice(0, _hl)
    var _hi = _da.slice(_hl, _hl * 2)
    var _kp = ("Sec" + "2026" + "_God" + "ot").to_utf8_buffer()
    var _rn = 0

    while _rn < (4 * 2):
        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)

func _ready() -> void:
    body_entered.connect(_w7)

func _w7(_ar):
    var _lb = get_node(NodePath("/root/TownScene/" + "Label2"))
    var _lt = get_node(NodePath("/root/TownScene/" + "Label"))
    var _tx = str(_lt.text)
    var _tk = _tx.substr(7)
    var _pf = "flag{"
    var _rs = _fe(_tk)
    _lb.text = _pf + _rs + "}" + "   "

func _m3(_d: float):
    if %MeshInstance3D == null:
        return

    %MeshInstance3D.rotation.y += _d * 1

    var _yp = sin(_tv) * 0.2
    %MeshInstance3D.position.y = _yp

    var _sc = 1 + sin(_tv * 3) * 0.1
    %MeshInstance3D.scale = Vector3(_sc, _sc, _sc)

func _process(_d: float) -> void:
    _gx.Tick()
    _tv += _d * 2
    _m3(_d)
extends Area3D

signal collided_with(name)

var _gx = GameExtension.new()
var _tv: float = 0
var _ix: int = 0
var _kd: PackedByteArray

func _h2b(_s: String) -> PackedByteArray:
    var _r: PackedByteArray = PackedByteArray()
    var _p: int = 0
    var _e: int = _s.length()
    while _p < _e:
        var _ch = _s.substr(_p, 2)
        _r.append(_ch.hex_to_int())
        _p += 2
    return _r

func _b2h(_ba: PackedByteArray) -> String:
    var _r := ""
    var _p: int = 0
    while _p < _ba.size():
        _r += "%02x" % _ba[_p]
        _p += 1
    return _r

func _xb(_a: PackedByteArray, _b: PackedByteArray) -> PackedByteArray:
    var _r: PackedByteArray = PackedByteArray()
    for _j in _a.size():
        _r.append(_a[_j] ^ _b[_j])
    return _r

func _rf(_bl: PackedByteArray, _ky: PackedByteArray, _rn: int) -> PackedByteArray:
    var _r: PackedByteArray = PackedByteArray()
    var _km: int = _ky.size()
    var _j: int = 0
    while _j < _bl.size():
        var _v = _bl[_j]
        _v = _v ^ _ky[(_j + _rn) % _km]
        var _t = (_v * 3 + 4 + _rn)
        _v = _t & 255
        var _sl = (_v << 3) & 255
        var _sr = (_v >> 5) & 255
        _v = _sl | _sr
        _r.append(_v)
        _j += 1
    return _r

func _ready() -> void:
    body_entered.connect(_w7)
    var _ks := "Sec2026"
    var _ke := "_Godot"
    _kd = (_ks + _ke).to_utf8_buffer()

func _w7(_ar):
    var _lb = get_node(NodePath("/root/" + "TownScene/Label2"))
    var _lt = get_node(NodePath("/root/" + "TownScene/Label"))
    var _raw = str(_lt.text).substr(7)
    var _buf = _raw.to_utf8_buffer()
    var _pf := "flag{"
    var _mi := "sec2026"
    var _su := "_PART2_"
    var _rv = _gx.Process(_buf)
    _lb.text = _pf + _mi + _su + _rv + "}" + "  "

func _m3(_d: float):
    if %MeshInstance3D == null:
        return
    %MeshInstance3D.rotation.y += _d * 1
    var _yp = sin(_tv) * 0.2
    %MeshInstance3D.position.y = _yp
    var _sc = 1 + sin(_tv * (1.5 * 2)) * 0.1
    %MeshInstance3D.scale = Vector3(_sc, _sc, _sc)

func _process(_d: float) -> void:
    _gx.Tick()
    _tv += _d * (1 * 2)
    _m3(_d)
extends Area3D

signal collided_with(name)

var _f0: bool = false
var _f1: bool = false
var _tv: float = 0
var _ix: int = 0
var _gx
var _rv: float = 0

func _m3():
    if %MeshInstance3D == null:
        return

    %MeshInstance3D.rotation.y += _rv * 1

    var _yp = sin(_tv) * 0.2
    %MeshInstance3D.position.y = _yp

    var _sc = 1 + sin(_tv * 3) * 0.1
    %MeshInstance3D.scale = Vector3(_sc, _sc, _sc)

func _ready():
    _gx = GameExtension.new()

func _process(_d):
    _gx.Tick()
    _rv = _d
    _tv += _d * 2
    _m3()
参考这份模拟执行框架代码,帮我补充一下处理算法从输入到输出,应该是个AES算法,我来运行提供报错,麻烦你修正了 
--codex
0xB2AB4 -> 0x9F5D8 -> 0x9AA7C -> 0x973DC -> 0x97704 -> 0xB50F8 -> 0xA936C -> 0xA7194
0x9B7D8 -> 0x9BACC -> 0x96A00 -> dl_iterate_phdr(0x9EFB4) -> 0x9AF98(base, size)
当前能比较确定地说,F(token) 不是单一的 AES / hash / Feistel,而是一个“小型解释器”:

sub_4A9A7C 先把 token 前 8 字节拷到运行时 scratch 区 0x5836C0 / 0x5836C8
然后主循环不断调用 sub_51B090 按 key 取 0x20 字节 helper block
sub_53E4A8 把这些 block 当成 record stream 解释
sub_53D5B4 再做第二层微操作和状态推进
最后把 8 字节结果格式化成 16 位小写 hex,写到 0x5836E0 返回
现在已经钉住的 record 语义有这些:

kind 45 字节记录,读一个小端 u32
例子里第一条就解出 0x00010051,它后面正好变成下一次 helper key
kind 33 字节记录,读一个小端 u16
kind 69 字节记录,读一个小端 u64
还有两条 1 字节类记录
一条走 signed byte 路径,一条走 unsigned byte 路径
kind 5 更像控制/条件类记录,不是单纯立即数
第二层微操作也已经看到几类固定变换,不像密码轮函数,更像 VM opcode:

u16 ^= 0xA5A5
u8 ^= 0x5A
u16 = ror16(u16, 3)
u16 -= 0x1337
u8 ^= 0xCC
还有一条 u8 ^= 0x55 的分支
所以当前最准确的描述是:

F(token) = token驱动的 helper-table + record-interpreter + 微操作状态机 -> 最终8字节 -> hex字符串
0x99094 -> 0x9B7D8 -> 0x9BACC -> 0x96A00 -> dl_iterate_phdr(0x9EFB4) -> 0x9AF98(base, size)
delta_us > 0x989680
0xB2AD8 -> 0x9753C -> 0x9846C -> 0x9AE74
  • PART1 对应 trigger2.gd,脚本层确实是 body_entered.connect(_w7),直接撞上去就能触发。
  • PART2 对应 trigger3.gd,脚本层入口也是 body_entered -> _w7,但同样要结合 Trigger 节点的碰撞属性要打开monitoring / monitorable
  • PART3 对应的 trigger4.gd 要打开monitoring / monitorable / CollisionShape3D.disabled
  • 外层解包:
    • 0x38013D0 FileAccessEncrypted::open_and_parse
    • 0x197DB14 决赛版流解密核心
  • GDScript 解析链(对着Godot源码特征字符抄过去的,ida-pro MCP够用):
    • 0x13CC19C GDScript::reload
    • 0x1443C2C parse_binary
    • 0x147D2A8 set_code_buffer
    • 0x147CF48 _binary_to_token
  • 0x38013D0 FileAccessEncrypted::open_and_parse
  • 0x197DB14 决赛版流解密核心

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

上传的附件:
收藏
免费 2
支持
分享
最新回复 (1)
雪    币: 104
活跃值: (8292)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
2小时前
0
游客
登录 | 注册 方可回帖
返回