首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
4
4
[原创]南极动物游戏安全2026-安卓决赛
发表于: 2026-4-20 00:54
10370
[原创]南极动物游戏安全2026-安卓决赛
心若空境
1
2026-4-20 00:54
10370
魔改Godot引擎,加上虚拟机,真做吐了。混淆我是一个没去、就说这思路能不能跑吧 主要操作:模拟执行,过反调试,Frida Hook(相关脚本不放了、太丑,思路肯定是对的就是了) ## 展示: FLAG1:   FLAG2:   FLAG3(模拟跑通了、没时间扒下来)   几个不同的触发题触发条件分别是: - PART1 对应 `trigger2.gd`,脚本层确实是 `body_entered.connect(_w7)`,直接撞上去就能触发。 - PART2 对应 `trigger3.gd`,脚本层入口也是 `body_entered -> _w7`,但同样要结合 Trigger 节点的碰撞属性要打开`monitoring` / `monitorable` - PART3 对应的 `trigger4.gd` 要打开`monitoring` / `monitorable` / `CollisionShape3D.disabled` frida远程调用直接查出来的属性 ## 整体思路: 先解包并还原 GDScript,确认三个 Trigger 各自负责什么;再定位 `GameExtension.Process()` / `Tick()` 的 native 入口;最后分别处理 PART2 的 watchdog / anti-hook,以及 PART3 的 Godot 事件构造链。 ## PART1: 改改初赛的解包脚本就能用,并通过 GDC 文件头确定解的是对的,但这次旧脚本直接按上游 Godot token 表解释会全乱。后面确认不是多了一层“token 解混淆函数”,而是决赛包把引擎内部 token 编号表直接改了,所以正确做法是去 `libgodot_android.so` 的 GDScript 解析链上做动态 dump,把触发器挂载的脚本先还原出来。 我不知道那个字节码混淆是怎么搞出来的,多半是在libgodot里面有什么混淆的操作,对Unity开发经验比较多、肯定有一套解释器运行的是去掉混淆过后的脚本,猜测从那个位置去dump基本就可以无视混淆了,类似于il2cppdump一样。 trigger1: ``` 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) ``` trigger2: ``` 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) ``` trigger3: ``` 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) ``` trigger4: ``` 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() ``` Trigger4非常神奇,没玩过Godot研究了一整天,我一直以为flag回传是在tick里面做得操作,甚至怀疑dump下来的内容是不是有误,检查了好多遍,最后发现写死在引擎内测(libgodot.so)的0x25f6718函数里,真的吐血。 这一段关键地址大致是: - 外层解包: - `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` 把 `trigger2.gd` 还原出来后,PART1 的脚本逻辑就比较直白了:命中对应 Trigger 的碰撞关系后,脚本去读 `/root/TownScene/Label`,去掉前缀 `"Token: "` 拿到 8 位十六进制字符串,再进脚本内 `_fe()` 做 8 轮 Feistel 风格变换,最后写到 `Label2`。 所以 PART1 的 FLAG 处理算法可以概括成(还原见样例程序PART1_flag): - `_rf()`:每字节先异或循环 key,再乘 `7`、加轮号、左旋 `3` - `_fe()`:4 字节输入拆成左右 2 字节,做 8 轮 Feistel - 最终直接拼成 `flag{sec2026_PART1_<8位hex>}` 例如样本 `3a5e9ecd` 最后可以还原出 `flag{sec2026_PART1_EEC57451}`。 ## PART2: unicorn模拟执行分析vmp、平坦化混淆还是花指令什么的,常规操作了,关键思路是找到入口、出口、拿到中间的执行流,只要有执行流混淆基本就只剩下各种花指令,相对于死磕静态分析还是比较划算的。 binary ninja mcp插件见样例(框架改的面目全非)。prompt: ``` 参考这份模拟执行框架代码,帮我补充一下处理算法从输入到输出,应该是个AES算法,我来运行提供报错,麻烦你修正了 --codex ``` 通过 Hook 游戏侧的 `Process` 调用,可以把 PART2 的 native 主链基本钉死。这里自然触发同样不是单纯“谁进了 Area3D 都算”,而是要先满足对应 Trigger 的碰撞关系;移车脚本专门有 `trigger_probe` / `trigger_arm` 去查 Trigger 的 `monitoring`、`monitorable` 和子 `CollisionShape3D.disabled`,再把 `car` 下真正参与物理的 body 传送过去。命中后,`GameExtension.Process()` 的真实绑定落点(怎么拿到拓展函数的地址见初赛操作)是 `libsec2026.so+0x97704`,往下马上推进到 `0xA936C`,核心 16 字节块变换在 `0xA7194`。调用链大致可以记成: ``` 0xB2AB4 -> 0x9F5D8 -> 0x9AA7C -> 0x973DC -> 0x97704 -> 0xB50F8 -> 0xA936C -> 0xA7194 ``` 这里几个关键点是: - `0xB50F8` 先做输入整理,真实输入视图不是简单“随便传个字符串”。 - `0xA936C` 会把 8 字节输入复制成 `input8 || input8` 的 16 字节块,然后把最终结果格式化成 32 位小写 hex 字符串。 - `0xA7194` 进去先走 `0xAA9B0`,再走 `0xA8D44` 主轮函数。 主算法整体很像 AES,但不是标准 AES。静态参数块可以确定: - `0x58510`:预白化表 - `0x58550`:key schedule 种子 - `0x58600`:`ctx + 0xC0` 的 tail mask - `0x652C9`:Rcon-like 常量表 - `0x183700`:运行时生成的自定义 S-box 轮函数层大概可以这样记: - `0xA82C8`:SubBytes - `0xA8F00`:自定义重排 + mask - `0xAADE8`:ShiftRows - `0xA6F20`:MixColumns,系数是 `[6,3,5,2]`,有限域约化常量是 `0x71` - `0xAAB64`:自定义 AddRoundKey,额外混入 `(round * 0x5B + row) & 0xFF` 也就是说,这条链虽然长得像 AES 风格 SPN,但实际是“自定义 S-box + 自定义轮常量 + 自定义 whitening/mask”的一套魔改版本。真实样本里,`trigger3` 处理 token `48cde866` 可以得到 `flag{sec2026_PART2_2e15242437e16b2ec0d472561c75018b}`,可以拿来做结果校对。这里截一眼执行链路:  这条路模拟执行出奇地顺,可能就是预留来这样做的吧,出题人高抬贵手只给20分不是没理由的。 同时调试的时候发现有多个奇怪的线程在 native loader 阶段被拉起,所以顺着 `extension_init` 和 watchdog 启动块往下看,能把保护面大概分成三条: - `0x9C654`:`ptrace_guardian` - `0x9CDC4`:`proc_scan_guardian` - `0x9B7D8`:`inline_hook_guardian` 其中 `inline_hook_guardian` 的主链比较关键: ``` 0x9B7D8 -> 0x9BACC -> 0x96A00 -> dl_iterate_phdr(0x9EFB4) -> 0x9AF98(base, size) ``` 它会枚举模块的可执行 `PT_LOAD` 段做完整性校验,所以直接在 `libsec2026.so` 代码页上做 inline hook 很容易触发。再往里还能看到: - `0x9BCB0`:`mprotect(..., PROT_READ | PROT_EXEC)` - `0x9BDE4 -> 0x98564`:扫 `maps`,关键特征串有 `"frida"`、`"/memfd:"` `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` 文本段稳很多。 ## PART3: 找了 `libsec2026.so` 半天没找着,最后发现真正的 PART3 拼 flag 路在 `libgodot_android.so` 内部。思路是去 Hook `Label2` / 信号发射相关的写入链,最后确认真正直接构造 `flag{sec2026_PART3_...}` 的地方在 `sub_25F6718`。字符串搜不到,但是可以翻得到提示。  这一段关键地址可以记成: - `0x25F6718`:PART3 直接字符串构造路径 - `0x25F69C8`:运行时变换函数 `F(token)` 的间接调用点 - `0x25F6B74`:最终通过 `"collided_with"` 事件发出去 - `0x3BC1E90`:把 `"Token: "` 后面的内容提取出来并缓存 - `0x4A9A7C`:当前静态上能钉住的 `F` 候选入口 主要逻辑是: 1. `sub_3BC1E90` 先把输入文本里 `"Token: "` 后面的部分取出来,写到全局缓存 `qword_40541E0` 2. `sub_25F6718` 在满足 `a2 == 0 && *(a1 + 0x564) == 6` 时进入 PART3 分支 3. 这里会直接写前缀 `flag{sec2026_PART3_` 和后缀 `}` 4. 中间那一段不是直接取 `a1 + 480`,而是把 `qword_40541E0` 转成 UTF-8 后,送进 `0x25F69C8` 处解析出来的运行时函数指针 5. 变换结果再通过 `"collided_with"` 这条 Godot 事件链发出去 说人话:整个保护最精髓的地方就在这里,在引擎源码内部自己实现了一套和拓展方法与游戏场景内通讯的办法,并在引擎侧和拓展侧上依赖保护,拓展侧通过回调、共享内存的办法来传递信息到场景内的gameObject中,相当于绕过了gdc、C#这种字节解释的弱保护,同时又保证拓展层不被卸载能够保护游戏运行的反作弊。具体操作见下 (在libsec2026.so内部的虚拟机处理算法,实在没时间写思路了、以下内容直接给ai帮跑日志的,主要是模拟执行。) 这里一开始最容易误判的是 `sub_1FD9B60(a1, &out)` 或 `a1 + 480`,看起来像是在喂中间串,但后面证实那只是旁路;真正参与 `sec2026_PART3_` 中间段构造的是 `qword_40541E0` 以及它后面的运行时变换结果。 在libsec2026中`0x4A9A7C` 是主要处理函数,一个扁平状态机/小型解释器,不是普通的直接字符串算法。也就是说,这里硬啃全静态当然能做,但性价比不高;更稳的做法是直接 Hook 三个点: - `0x3BC1E90`:看原始 token - `0x25F69C8` 前后:看 `F(token)` 的输入输出 - `"collided_with"` 发射点:直接拿最终 emit 的 flag 如果还想在静态侧再往前推一步,性价比最高的不是直接硬啃 `0x4A9A7C` 全部状态,而是先把它依赖的 helper 单独摘出来做语义化。实际在给 Binary Ninja 的模拟执行框架补环境时,可以看到这条链会间接打到 `libsec2026.so` 里一个 helper(按 BN 视图地址记是 `0x51B090`,相对偏移是 `0x11B090`,需要加 `0x400000` 基址)。一开始很容易把它也误判成“又一层加密”,但沿着状态机拆下去之后会发现,这个 helper 本身并不是新的密码算法,而更像一个“按范围查表并搬运数据”的运行时资源分发器。 大致可以把它理解成: - 先检查参数,`table_ptr` 或输出缓冲为空就直接返回错误 - 读 `table + 0x820` 的 entry 数量,从 `table + 0x20` 开始按 `0x20` 一项遍历 - 每项大致是 `start / span / flags / data` - 找到满足 `start <= key && key + len <= start + span` 的区间后,如果 `flags & 1` 为真,就从 `data + (key - start)` 拷一段到输出缓冲并返回 `0` - 没找到或者标志位不对,就返回 `0x603` 这样处理之后,`F(token)` 那个扁平状态机里最烦的一层“动态 helper”其实就能被抽象成一个普通的 range-copy stub,模拟执行也就能继续往下走。也就是说,PART3 这里真正难啃的还是上层解释器状态机本身,而不是 helper 里另藏了一套独立加密;helper 更像是在给状态机按 key 提供字节片段、常量块或者运行时表项。 这样 PART3 的关键链路就能比较快闭环,不用一直困在那堆扁平状态机里。 ``` 当前能比较确定地说,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 4:5 字节记录,读一个小端 u32 例子里第一条就解出 0x00010051,它后面正好变成下一次 helper key kind 3:3 字节记录,读一个小端 u16 kind 6:9 字节记录,读一个小端 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字符串 ``` 游戏侧的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 分别落在 `0x9B180`、`0x9B2B0`、`0x9B330` 这一组保护 stub 上。把 `extension_init` hook 去掉后又不再崩,所以能先确认:真正有问题的不是业务逻辑,也不是后续主动调用,而是“改写了 `libsec2026.so` 代码页字节”这件事本身。 顺着 `extension_init` 和 watchdog 启动链往下看,最后把保护面大致理成了三条后台线程: - `0x9C654`:`ptrace_guardian` - `0x9CDC4`:`proc_scan_guardian` - `0x9B7D8`:`inline_hook_guardian` 其中最关键的是完整性线程。它的主链可以记成: ```text 0x99094 -> 0x9B7D8 -> 0x9BACC -> 0x96A00 -> dl_iterate_phdr(0x9EFB4) -> 0x9AF98(base, size) ``` 这里的核心思路不是盯单个函数地址,而是先枚举 ELF,筛出目标模块的可执行 `PT_LOAD` 段,再把 `base` 和 `size` 丢给 `0x9AF98` 做字节级校验,最后拿算出来的值和预置常量比较。后面还能在一条已确认分支里解出目标模块名是 `libgodot_android.so`,对应的期望校验值是 `0xCE8FA9E9`。也就是说,至少有一条完整性分支确实会去扫 Godot 的可执行段,所以只要在它覆盖的代码页上做 inline hook,就有很大概率把自己送进 crash stub。 再往里看,`inline_hook_guardian` 还不只是单纯 hash。它在同一个状态机里还带了两个比较麻烦的能力: - `0x9BCB0`:会对监控区间做 `mprotect(..., PROT_READ | PROT_EXEC)` - `0x9BDE4 -> 0x98564`:会扫 `maps`,里面能直接解出 `"frida"`、`"/memfd:"`、`"%lx-%lx %s"` 这些特征串 所以后面就不能再把问题简单理解成“只要过掉一个校验函数就行”,而是要把保护面拆开看:一类是 `ptrace`,一类是 `proc/maps` 扫描,一类是代码完整性,还有一类是 `Tick()` 自己带的时间陷阱。 (Tick部分的执行可以通过模拟来验证是否有分歧从而判定时间关系) `Tick()` 这一条是后面真正收口的关键。它的绑定落点在 `0x9AD68`,会把时间戳记到全局 `qword_1834B8`。用仿真把这条链单独跑通后,可以确认它比较的是微秒级时间差,只要: ```text delta_us > 0x989680 ``` 也就是严格大于 10 秒,就会跳到 `0x9AE70/0x9AE74` 的毒分支,通过一个坏掉的间接跳转直接把进程带崩。这个链和实机里反复出现的 `VkThread` 崩溃路径是能对上的: ```text 0xB2AD8 -> 0x9753C -> 0x9846C -> 0x9AE74 ``` 这一步很重要,因为它说明之前很多“像是 inline guardian 触发”的崩溃,其实并不一定全是完整性线程直接打死的,`GameExtension.Tick()` 本身就带一个 10 秒 watchdog。如果 Frida 脚本、调试停顿或者主线程节奏被拖住了,单靠这一条也足够把进程干掉。 所以最后真正稳定的绕过思路,不是去硬 patch `libsec2026.so` 文本段,而是尽量走低侵入路线: 1. `ptrace_guardian` 直接拦掉 2. `proc_scan_guardian` 直接拦掉 3. 不主动去碰 `libsec2026.so` 已知会被校验的代码页 4. 对 `Tick()` 不 patch 分支本身,而是只维护 `.bss` 里的时间戳 最后这一点是最稳的。`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` 的混淆状态机,而是直接切线程链,例如: - `0x9AD68` 线程入口直接 `mov x0, xzr; ret` - 或者在 `0xA62F0` 直接跳到 `0xA6318`,保留“已经启动过”的标志位 但从实际工程效果看,这题更推荐的还是前面的低侵入方案:不和完整性线程正面硬碰,先把 `ptrace/proc_scan` 拦掉,再用 data-only keepalive 稳住 `Tick()`。这样后面无论是跑 `Process()`、抓 PART3 事件链,还是做 Godot 侧被动追踪,整体都会稳定很多。 ## 总而言之: 学到不少,起码也是日过ACE的人了。第一次搞安卓游戏安全感觉还挺好玩的,为了弄flag3的分析、PC那边都不打了就日vmp,可惜功亏一篑。
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
#Reverse
上传的附件:
程序附件.zip
(231.71kb,38次下载)
收藏
・
4
点赞
・
4
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
你的分享对大家帮助很大,非常感谢!
2026-7-16 08:37
iCookiie
为你点赞!
2026-4-22 09:13
juice4fun
为你点赞!
2026-4-20 10:28
xxrinkoss
谢谢你的细致分析,受益匪浅!
2026-4-20 10:17
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
2
)
Imxz
雪 币:
112
活跃值:
(9420)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
752
粉丝
9
关注
私信
Imxz
2
楼
tql
2026-4-20 17:37
0
wx_Huber Barrientos
雪 币:
0
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
207
粉丝
1
关注
私信
wx_Huber Barrientos
3
楼
666
2026-4-25 10:38
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
心若空境
1
2
发帖
0
回帖
50
RANK
关注
私信
他的文章
[原创]南极动物游戏安全2026-安卓决赛
10370
[原创][讨论] 南极企鹅游戏安全2026-Android-初赛
6313
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
谁下载
×
huangyalei
asd
lixianhuei
顽劣
pzhxbz
wx_Dispa1r
pan_634488
寻影星尘
阿腊婆
aeolusw
B.M.K
mb_ldbucrik
mb_kgfqphsx
npc0vo
mb_bfghjvta
xxrinkoss
mb_kujsrqqv
心若空境
mb_cebxltxx
mb_ejzhvykd
mb_lthgjpwj
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部