首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
3
2
[原创 ]2026 腾讯游戏安全竞赛决赛 - Android WriteUp
发表于: 2026-4-24 14:50
7482
[原创 ]2026 腾讯游戏安全竞赛决赛 - Android WriteUp
libc
1
2026-4-24 14:50
7482
# 2026 腾讯游戏安全竞赛决赛 - Android 客户端安全完整解题报告 ## 1. 题目目标与当前范围 本题需要围绕游戏中的 `PART1 / PART2 / PART3` 完成三件事:确认每一部分 flag 的真实生成路径,恢复正向算法,给出逆向恢复方法,并最终用 `C / C++` 写成可复现工具。 这题的难点不在于单独逆一段 `libsec2026.so`,而在于要把 Godot 脚本、场景资源、资源加密、引擎 native 和题目扩展这几层关系先串起来。只有先把入口、触发条件和调用关系理顺,后面的算法还原才不容易偏离方向。因此本文不按零散线索来展开,而是按照实际解题顺序来组织内容:先确认程序结构与扩展注册,再恢复脚本和场景,接着把 `PART1 / PART2` 分析清楚,然后处理最难的 `PART3`,最后补上为运行时验证服务的反调试与反 Frida 绕过部分,把整条分析链收完整。 截至当前,已经完成: - `PART1 / PART2` 的定位 - token 来源与两部分 flag 格式确认 - `PART1 / PART2` 的正向 / 逆向 `C++` 实现 - `Trigger2 / Trigger3 / Trigger4` 的关键场景验证 - `PART3` 的真实触发路径定位 - `PART3` 的正向 / 逆向 `C++` 实现 - `libsec2026.so` 对抗链路梳理与动态验证环境搭建 本文重点放在“算法、资源恢复、行为验证”这三部分。目标不是把每一层运行时都铺开,而是把 Godot 脚本、场景入口、引擎 native 和 `libsec2026.so` 串成一条连续证据链,最后统一到一份 `C++` 实现中。 ### 1.1 评分项对照表 | 模块 | 已完成交付 | |---|---| | `PART1` | 定位完成;正向算法恢复完成;逆算法恢复完成;游戏内触发验证完成 | | `PART2` | 定位完成;`GameExtension.Process` 路径确认完成;正向算法恢复完成;逆算法恢复完成;游戏内触发验证完成 | | `PART3` | `Trigger4` 真实路径确认完成;`Tick` 路线证伪完成;正向算法恢复完成;逆算法恢复完成;游戏内触发验证完成;样本对拍与随机对拍完成 | | 检测对抗 | 主要检测面梳理完成;Frida 动态验证环境搭建完成;关键 hook 点稳定验证完成 | 如果只看交付结果,本报告已经覆盖题目要求的四类内容:入口定位、算法恢复、逆向恢复、运行验证。后文再按“先把路径讲清楚,再把算法写清楚”的顺序展开。 --- ## 2. 程序结构与关键入口定位 ### 2.1 APK 结构与核心分析对象 拿到 APK 之后,首先确认整体结构。样本为 `arm64-v8a`,核心文件实际上只有两层:`libgodot_android.so` 是 Godot 4.5.1 引擎本体,`libsec2026.so` 则是题目自定义的 native 扩展。也就是说,后续所有分析都要回答一个基础问题:`libsec2026.so` 在 Godot 工程里到底暴露了什么能力,它是不是全部逻辑的唯一入口。  *图 1 APK 结构与关键 so。样本真正需要重点分析的部分主要集中在 `libgodot_android.so` 与 `libsec2026.so`。* 本节先不给算法结论,只做一件事:确认 native 扩展在 Godot 里以什么形式暴露给脚本。后续哪些逻辑回脚本层看,哪些逻辑继续追 native,都建立在这个前提上。 ### 2.2 扩展类注册结论 顺着这条注册链,可以确认 `libsec2026.so` 在 Godot 中注册的是: ```text GameExtension : Node ``` 证据 1 是类注册主链 `sub_A07F4`。这里可以直接看到“类名对象”和“父类名对象”同时被装入注册描述体。整理成伪代码后大致如下: ```c // 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); ``` 因此 `GameExtension : Node` 不是字符串偶然出现,而是沿着注册链可以明确确认的结果。 ### 2.3 对脚本暴露的方法 同时它对脚本层暴露了两个关键方法: - `Tick` - `Process(input: PackedByteArray) -> String` 证据 2 是 `bind_methods` 主链 `sub_97B6C`。虽然外层是 OLLVM 状态机,但关键绑定动作仍然能整理成下面这段伪代码: ```c // sub_97B6C bind_method("Tick", sub_9610C(..., sub_9AD68, 0)); bind_method("Process", sub_9B5E8(..., sub_97704, 0)); bind_param_name("Process", 0, "input"); ``` 其中: - `Tick` 最终绑定到 wrapper `sub_9610C`,真实实现入口是 `sub_9AD68` - `Process` 最终绑定到 wrapper `sub_9B5E8`,真实实现入口是 `sub_97704` - 参数名 `input` 也在同一条绑定链里被恢复出来 `Tick` 更像逐帧入口,`Process(input) -> String` 更符合 flag 计算接口形态,因此后续优先沿 `Process` 追 `PART2`。 本节结论可以概括成一句话:`libsec2026.so` 不是脱离引擎独立运行的算法黑盒,而是以 `GameExtension : Node` 的形式接入 Godot,并向脚本暴露 `Tick / Process` 两个关键入口。 --- ## 3. 脚本与资源恢复 ### 3.1 为什么必须回到脚本层 上一节只把扩展入口确定下来,但还没有回答真正和出题目标直接相关的问题:哪个 Trigger 对应 `PART1`,哪个 Trigger 对应 `PART2`,左上角 token 从哪里来,结果最终写到哪里,以及为什么有些 Trigger 在场景里明明存在却根本碰不出来。要回答这些问题,分析必须从 native 注册链回到 Godot 脚本和资源层。 ### 3.2 两条恢复路线 这里采用了两条互相印证的恢复路线。第一条路线是在 `libgodot_android.so` 的 `.gdc` 加载链上做运行时 dump,再离线解析;第二条路线则是在 `libgodot_android.so` 中定位 Godot 加密资源 key,用 `GDRETools` 恢复完整资源。两条路线分工并不相同:前者解决“资源加载后真实长什么样”,后者解决“如何离线拿到完整脚本与场景结构”。两条结果互相校验后,后面的脚本结论和 Trigger 场景结论就不再依赖单点猜测。 恢复出的关键资源包括: - `token.gdc` - `label2.gdc` - `Trigger/trigger1.gdc` - `Trigger/trigger2.gdc` - `Trigger/trigger3.gdc` - `Trigger/trigger4.gdc` - `town_scene` - `car_base` ### 3.3 token 来源 `token.gdc` 直接说明左上角 token 是脚本生成的,而不是 native 层生成的;它的长度固定为 8,字符集固定为 `0123456789abcdef`。因此后续三部分算法的输入可以统一定义为: ```text token = 8 位十六进制字符串 ``` 对应脚本片段如下: ```gdscript 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 ``` ### 3.4 结果显示位置 `label2.gdc` 负责把 Trigger 的输出结果真正显示到界面上。也就是说,只要脚本或 native 最终把 flag 写进 `Label2.text`,就会在游戏里显示出来。这个结论后面会和 Trigger 脚本直接对上。 对应脚本片段如下: ```gdscript 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) ``` ### 3.5 资源 key 的定位与验证 资源 key 的定位依赖两条硬证据:其一,`FileAccess.open_encrypted()` 明确要求 key 长度为 `32`;其二,上层初始化逻辑会把同一处 32 字节常量复制进 key 缓冲区,再传给 `open_encrypted()`。因此可以把 `libgodot_android.so` 中的这组常量认定为实际资源 key,而不是“像 key 的数据”。 如果把这条链整理成伪代码,核心关系可以写成: ```c // 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, ...); } ``` 关键判断依据是调用关系本身:`0x400EF18` 处的 32 字节常量被复制进 `key` 缓冲区,并作为参数传给 `open_encrypted()`。 最终恢复出的 key 为: ```text ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061 ``` 使用该 key 跑 `GDRETools` 后,可以成功恢复 Trigger 脚本和场景资源,这说明前面的判断不是“像 key”,而是“确实能把资源外层解开”。这里也要说明边界:`key` 的作用是解开加密资源包,把其中的 `.gdc / .scn` 文件取出来;它并不能一步把 `.gdc` 直接变成源码 `.gd`。 恢复结果的关键目录结构如下: ```text 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 ```  *图 2 使用资源 key 配合 `GDRETools` 恢复出的关键资源目录。后续脚本与场景分析都可以直接落在这些文件上。* ### 3.6 本节小结 脚本语义恢复最终依赖两条分工明确的链路: - `GDRETools + key` 负责把加密资源中的 `.gdc / .scn` 文件本体恢复出来 - 自实现解析脚本负责把 `.gdc` 或运行时 dump 出来的 tokenizer buffer 继续拆成 `identifiers / constants / tokens / line` 级语义,再整理成可读脚本 到这里,本节已经完成四件事:拿到 Trigger 脚本,拿到场景资源,确认 token 来源,确认 flag 的界面落点。后续 `PART1 / PART2 / PART3` 的对应关系分析都建立在这组恢复结果上。 --- ## 4. PART1 与 PART2 的定位结果 脚本和场景拿到手之后,下一步不是平铺直叙地介绍四个 Trigger,而是先把 `PART1` 和 `PART2` 的对应关系彻底确定下来。只有把哪一块对应哪一部分 flag 说清楚,后面算法分析和场景验证才有明确对象。 先看 `Trigger2` 对应脚本。这里关键不只是前缀里写了 `sec2026_PART1_`,而是核心函数 `_fe()` 完全停留在脚本层内部实现,轮函数 key 也直接写成了 `Sec2026_Godot`,整个流程里没有调用 `_gx.Process`。这说明 `PART1` 的本体就在脚本中,而不是 native 层的 `Process`。因此可以明确写成: ```gdscript 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) ``` ```text PART1 对应绿色方块 Trigger2 PART1 是纯 GDScript 算法,不走 native Process flag 格式为 flag{sec2026_PART1_<8hex>} ``` 这里真正完成的是“解耦”:通过 Trigger 脚本本身,就可以把 `PART1` 和 native 算法明确区分开。 再看 `Trigger3` 对应脚本。这里真正的关键点不是 `_PART2_` 前缀本身,而是输入来源、参数转换和 `_gx.Process(_buf)` 三件事被直接连在了一起: ```gdscript var _raw := str(_lt.text).substr(7) var _buf := _raw.to_utf8_buffer() var _rv := _gx.Process(_buf) _lb.text = "flag{" + "sec2026" + "_PART2_" + _rv + "}" + " " ``` 这意味着: 1. 输入来自左上角 `Label.text.substr(7)`,也就是 token 本身 2. token 被转成 `PackedByteArray` 3. 脚本直接调用 `GameExtension.Process(_buf)` 4. 返回值被拼接成 `PART2` flag 再结合上一节对 `GameExtension` 的确认,就可以把调用链稳定整理为: ```text Trigger3 -> GDScript -> GameExtension.Process -> native core ``` 因此 `PART2` 可以明确写成: ```text PART2 对应红色方块 Trigger3 PART2 的 native 核心入口是 GameExtension.Process flag 格式为 flag{sec2026_PART2_<32hex>} ``` 这一步的关键在于,`PART2` 的分析入口不是靠猜,而是由 Trigger 脚本直接给出来的。 --- ## 5. PART1 / PART2 算法与逆算法思路 把对应关系确定下来之后,下面进入算法本身。这里需要解决的不只是“正向怎么算”,还要说明“为什么逆向恢复 token 是可行的”,因为最终交付要求本来就同时包含正向和逆向两部分。 ### 5.1 PART1:脚本层 Feistel `PART1` 的算法恢复并不是先凭经验判断“这像 Feistel”,而是直接从 `Trigger2` 脚本里读更新关系。恢复出来的 `_rf()` 和 `_fe()` 关键片段如下: ```gdscript 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 ``` 决定结构类型的是 `_fe()` 的三句赋值:`_fv = _rf(_hi, ...)`、`_nr = _xb(_lo, _fv)`、`_lo = _hi; _hi = _nr`。把变量名还原后,更新关系就是: ```text fv = F(hi, round) nr = lo XOR fv lo = hi hi = nr ``` 这组“右支路进轮函数、左支路异或、整体交换”的模式足以认定它是标准 Feistel。轮函数细节也直接写在脚本里:固定 key 为 `Sec2026_Godot`,每字节先按轮异或,再做 `* 7 + round`,最后做 `rol8(3)`。因此 `PART1` 的逆算法不需要反求 `_rf()`,只要把 8 轮按逆序回放即可。 ### 5.2 PART2:native 层自定义 SPN `PART2` 的还原过程完全不同。它不是从脚本里直接看到算法,而是从 `GameExtension.Process` 的 native 代码里把每一层字节变换拆出来。分析顺序就是 `xref -> 表 -> 读表/换位/乘法` 这条证据链。 先看入口 `sub_A936C`。在 `0xA93E4` 和 `0xA93F8`,它连续两次把输入缓冲区前 8 字节复制到局部块里,因此主流程吃到的不是 4 字节二进制,而是 8 字节 ASCII token 被扩成 `token || token` 这个 16 字节块。 第一层关键证据来自 `unk_183700`。对这张表做 xref 后,只会落到两处:`sub_A9884` 负责构造,`sub_A82C8` 负责读取。`sub_A82C8` 的核心代码形态如下: ```asm 0xA841C ldrb w21, [x20, x19] 0xA8420 ldrb w21, [x5, x21] 0xA8424 strb w21, [x20, x19] ``` 其中 `x5` 在 `0xA833C` 由 `ADR X5, unk_183700` 得到,`x20/x19` 则是当前状态块中的字节位置。这三条指令的语义就是“取状态字节 -> 以该字节为索引查 256-byte 表 -> 写回状态”,而外层循环覆盖的是全部 16 个字节,所以这里可以稳定认成 `SubBytes`。 第二层是固定置换。这里不需要把所有 helper 都摊开,只保留最能说明问题的两处。`sub_AADE8` 的反编译直接把 16 字节数组按固定槽位原地交换: ```c 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; ``` 这已经足以说明其中存在一层固定 16-byte 重排,也就是 `ShiftRows` 一类的行移位。除此之外,`sub_A8F00` 里还能看到另一种“固定取位后再写回”的轮内置换,关键字节操作是: ```asm 0xA9024 add x10, x13, x10, lsl #2 0xA902C ldrb w10, [x10, x12] 0xA9040 strb w10, [x12, x13] ... 0xA92D4 ldrb w13, [x13, x12] 0xA92D8 eor w13, w13, w0 0xA92DC strb w13, [x10, x12] ``` 因此这一步也不是普通 xor,而是“固定字节重排 + 按轮 stream xor”的组合。 第三层是列混合。`sub_A6F20` 先把状态按连续 4 字节一组切成列,然后反复调用 `sub_A96F0`。在 `0xA6FF4..0xA7158` 这一段里,可以直接看到每个输出字节对应的乘数模式: ```asm ; out[0] mov w1, #6 mov w1, #3 mov w1, #5 mov w1, #2 ; out[1] mov w1, #2 mov w1, #6 mov w1, #3 mov w1, #5 ``` 后两行输出同理,整体就是一张固定 `4x4` 矩阵去混合单列 4 字节。再往里看 `sub_A96F0`,`0xA9758` 直接把约化常量装成 `0x71`,而 `0xA9818..0xA9828` 则是典型的有限域 `xtime` 形态: ```asm 0xA9758 mov w6, #0x71 0xA9818 sxtb w0, w19 0xA9820 and w0, w6, w0, lsr #7 0xA9828 eor w0, w0, w19, lsl #1 ``` 也就是说,这里做的不是普通整数乘法,而是 `GF(2^8)` 上带 `0x71` 约化的字节乘法。把这两层合起来,`sub_A6F20 + sub_A96F0` 就足以判断为自定义 `MixColumns`。 最后一层是常量 material 混入。这个结论同样可以直接靠 xref 固定下来:`unk_58510` 只被 `sub_A7944` 引用,`unk_58590` 只被 `sub_A84A4` 引用,而这两个函数体都出现了同样的 `ldrb -> eor -> strb` 形态: ```asm 0xA7AE4 ldrb w22, [x6, w22, sxtw] ; unk_58510 0xA7AEC eor w22, w25, w22 0xA7AF0 strb w22, [x24, x23] 0xA8640 ldrb w23, [x7, w23, sxtw] ; unk_58590 0xA8648 eor w23, w26, w23 0xA864C strb w23, [x25, x24] ``` 再加上 `sub_AA9B0` 在 `0xAAAC8..0xAAAD8` 做的“状态字节 xor 另一组 16-byte material”,以及 `sub_A8D44` 在进入主循环前先调用 `sub_A7944`、`sub_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 集合。 ### 5.3 本节小结 到这里,`PART1 / PART2` 已经分别完成三件事: - `PART1`:确定脚本层 Feistel 结构,恢复正向算法,并说明逆向为什么成立 - `PART2`:确定输入是 `token || token`,恢复正向算法,并明确逆向回退顺序 - 两部分都已经整理到可以写进 `flag_codec.cpp` 的程度 --- ## 6. Trigger 与场景验证 到这里,`PART1 / PART2` 的代码对应关系已经清楚,但题目仍然存在一个很实际的问题:理论上应该能触发的东西,为什么在原始游戏里并不一定能碰到。这个问题单靠脚本回答不了,必须把场景配置也纳入证据链。 从 `town_scene` 可以看到,`Trigger2` 默认在空中,不在正常行驶路径上,这解释了为什么绿色方块在原场景中不容易直接触发。`Trigger3` 的脚本入口虽然存在,但其 `CollisionShape3D` 默认被禁用,因此红色方块默认不会产生有效碰撞事件,这也正好解释了“碰红块没反应”的现象。`Trigger4` 的情况更极端,它默认同时存在 `monitoring = false`、`monitorable = false`、碰撞体禁用和不可见等问题,因此 `PART3` 默认入口比 `PART2` 更难触发。 到这一步,如果只停留在静态分析层面,结论仍然只是“看起来如此”。因此继续做最小化场景补丁验证: - 打开 `Trigger3` 的碰撞后,可验证 `PART2` 的脚本与 native 调用链结论 - 将 `Trigger2` 下压并移动到可触达位置后,可验证 `PART1` 的脚本算法结论 - 将 `Trigger1` 移走后,可避免示例 flag 干扰 `Trigger2` 验证 这一步的价值在于,它把“为什么触发不了”从脚本层猜测,变成了可以通过场景补丁直接验证的结论。因此这一阶段可以明确写成: ```text PART1 / PART2 触发困难并不只是脚本逻辑问题,场景资源中的碰撞与监控配置同样是关键条件。 ```  *图 3 场景补丁后的验证视角。当前阶段通过调整 `Trigger2 / Trigger3` 的位置或碰撞配置,把静态推断变成了可以在游戏里重复验证的结果。* --- ## 7. C++ 实现与本地验证 完成 `PART1 / PART2` 的对应关系、算法和场景验证以后,就可以先把已经分析清楚的部分写成工具。题目要求的不只是分析过程,还要求给出可复现实现,因此这里没有继续拆成多个独立程序,而是从一开始就把实现统一到同一个 `C++` 工具里,便于后续继续并入 `PART3`,最终作为随报告一并提交的唯一代码附件: - `flag_codec.cpp` 最终统一接口为: ```text flag_codec.exe --part 1|2|3 [--inverse] [--flag] [--selftest] values... ``` 它最终支持: - `--part 1`:计算或逆推 `PART1` - `--part 2`:计算或逆推 `PART2` - `--part 3`:计算或逆推 `PART3` - `--flag`:直接输出完整 `flag{...}` - `--inverse`:从 flag 后缀或完整 flag 反推 token - `--selftest`:跑三部分内置样例 当前样例结果如下。 `PART1`: ```text 00000000 -> 03c0ccc9 -> 00000000 12345678 -> 1d77d8ea -> 12345678 deadbeef -> 06ab5d2a -> deadbeef a528056c -> 349e600a -> a528056c ffffffff -> b38dc8f2 -> ffffffff ``` `PART2`: ```text 12345678 -> ba28d4c19bd4753a1d52afc722001ca5 -> 12345678 abcdef12 -> 9904e593e12b3fdaded3c05767fcd919 -> abcdef12 00000000 -> be75f176a587d29af20b445dc2c0f3a8 -> 00000000 ffffffff -> 46dc49a6b1751d9e95b6ce3e0d54d68d -> ffffffff a528056c -> 192babc4939733427a235e955af1ead1 -> a528056c ``` 直接计算示例: ```text flag_codec.exe --part 1 --flag a528056c => flag{sec2026_PART1_349e600a} flag_codec.exe --part 2 --flag a528056c => flag{sec2026_PART2_192babc4939733427a235e955af1ead1} ``` 逆向时如果直接传完整 `flag{...}`,在 PowerShell 下需要给参数加引号。除此之外,还保留了 `process_emu.py` 作为 `PART2` 的额外交叉验证手段,用于确认纯算法实现与模拟执行结果一致。 完成这一步之后,`PART1 / PART2` 就不再只是“分析上说得通”,而是已经变成了可以离线对任意 token 直接计算和逆推的工具链。也正因为前两部分已经闭环,`PART3` 的问题才被完整暴露出来:它缺的不是单个公式,而是整条路径都还没有理清,包括真实入口、真正发信号的位置,以及中间值到底由谁计算。  *图 4 统一 `C++` 工具对 `PART1 / PART2` 的正向与逆向自测结果。*  *图 5 调整 `Trigger2` 场景后,游戏内成功输出 `PART1` flag。*  *图 6 打开 `Trigger3` 碰撞后,游戏内成功输出 `PART2` flag。* --- ## 8. PART3:隐形方块的真实路径与算法还原 前面的 `PART1 / PART2` 基本都是先从脚本确认触发对象和调用入口,再分别回到脚本层或 native 层把算法写清楚。`PART3` 难就难在,这条路走到 `Trigger4` 这里先断了。脚本层几乎没有有效算法信息,默认场景入口还是假的,最显眼的 `Tick()` 还会把分析带偏。所以这里不能一上来只写最后公式,而是得先把“真正的触发链到底在哪”讲明白。 这一部分实际是按下面这个顺序推进的: ```text 先把 Trigger4 打开 -> 拿到真实 flag 输出 -> 解脚本,怀疑 Tick -> 用 Tick -> ret 证伪 -> 转向 Godot native 的 collided_with 发信号链 -> 继续追查 sec2026 的真实中间值生成入口 -> 只围绕最终输出做切片分析 -> 整理成纯 `C++` 的正向 / 逆向实现 ``` ### 8.1 手动打开 Trigger4,并确认 PART3 可以被触发 `PART3` 的起点不是一段可读算法,而是一个默认根本触发不到的对象。`Trigger4` 同时存在: - `monitoring = false` - `monitorable = false` - `CollisionShape3D.disabled = true` - `MeshInstance3D.visible = false` 所以第一步不是去逆 native,而是先把这个入口打开,确认 `PART3` 在游戏里到底能不能稳定触发。具体做法很直接: 1. 打开 `Trigger4` 的监控、碰撞和可见性 2. 把 `Trigger4` 移到出生点附近 关键修改本质上就是把场景属性改为启用状态: ```python set_prop_value_index("Trigger4", None, "monitoring", true_variant_index) set_prop_value_index("Trigger4", None, "monitorable", true_variant_index) set_prop_value_index("CollisionShape3D", "Trigger4", "disabled", false_variant_index) set_prop_value_index("MeshInstance3D", "Trigger4", "visible", true_variant_index) ``` 这一步不是为了直接还原算法,而是先把最基本的事实确认下来。它至少说明三件事: - `PART3` 不是不存在 - 入口可以被人工打开 - 一旦入口打开,游戏会真实输出 `flag{sec2026_PART3_...}` 后面的分析就是建立在这个结果上的:`PART3` 确实存在,而且只要把 `Trigger4` 打开,就能稳定复现。这样后面排除某条路线时,才能确认自己排除的是错误路径,而不是触发条件没满足。  *图 7 打开 `Trigger4` 的监控、碰撞和可见性并将其移到出生点附近后,游戏内可以直接触发 `PART3` flag。* ### 8.2 Trigger4 脚本到底做了什么 把入口打开以后,再回头看 `Trigger4` 脚本本身。恢复出来的核心逻辑很短: ```gdscript extends Area3D signal collided_with(name) func _ready(): _gx = GameExtension.new() func _process(_d): _gx.Tick() _rv = _d _tv += _d * 2.0 _m3() ``` 关键不在于这段代码短,而在于脚本层根本没有 flag 生成该有的痕迹: - 只有 `signal collided_with(name)`,没有 `emit_signal("collided_with", ...)` - `_process(_d)` 里唯一可疑的 native 调用就是 `_gx.Tick()` - 没有 `"flag{"`、`"sec2026_PART3_"`、字符串格式化或 hex 拼接逻辑 也就是说,脚本层只是在维护一个会动的触发物体和一个 native 扩展对象,并没有像 `PART1` 那样自己实现算法,也没有像 `PART2` 那样直接把输入包一层再调 native。因此最开始最自然的判断就是: ```text PART3 的 flag 算法可能藏在 GameExtension::Tick() ``` ### 8.3 证明 Tick 不是 PART3 的算法入口 既然脚本层唯一显眼的 native 调用就是 `Tick`,顺着它继续往下追是当时最自然的选择。`bind_methods` 能确认: - `Tick` 绑定到 `sub_9AD68` - `Process` 绑定到 `sub_97704` 其中 `Process` 后来对应 `PART2`,而 `Tick` 在当时是 `PART3` 最大的嫌疑点。实际往 `sub_9AD68` 里追了一段之后,看到的大多不是 flag 生成逻辑,而是环境检测、状态检查和一些更像拖时间的外围函数。到这里才开始怀疑:`Tick` 很可能根本不是 `PART3` 的算法入口,而只是作者故意摆在脚本层最显眼位置的一个烟雾弹。 既然已经怀疑它是烟雾弹,后面就没有继续在 `Tick` 里硬抠细节,而是直接做最短验证:把 `Tick` patch 成 `ret`,再运行已经打开的 `Trigger4`。关键补丁只有一条: ```python PATCHES = { 0x9AD68: bytes.fromhex("c0035fd6"), # Tick -> ret } ``` 实验结果非常明确: ```text Tick 已经被 patch 成 ret 但 Trigger4 仍然输出 PART3 flag => PART3 算法不在 Tick 主体里 ``` 所以这里不是一上来就拍脑袋说 `Tick` 是诱饵,而是先顺着它追进去,发现里面长期都在做环境检测和外围检查,才开始怀疑这条线本身就是在浪费分析时间;随后再用 `Tick -> ret` 的实验把这件事彻底证明。问题也就随之变成了:既然脚本不发信号、`Tick` 也不是算法入口,那游戏里最后显示出来的 `PART3` flag 到底是谁发出来的。 ### 8.4 PART3 的 flag 到底是谁发出来的 `Tick` 被排除之后,视角就必须从 `libsec2026.so` 的表面入口转回引擎侧。既然 `trigger4.gdc` 自己不发信号,`Tick()` 又已经被证明不是算法入口,那么剩下唯一合理的解释就是: ```text Trigger4 只是入口 -> Godot native 负责真正发出 collided_with -> flag 参数里的 middle 来自别处 native 计算 ``` 顺着这个问题继续往回找,分析重心自然就从 `libsec2026.so` 的 `Tick` 外壳回到了 `libgodot_android.so`。在 Godot 引擎侧,目前可以稳定确认下面几段函数: - `sub_25F94A4` - `sub_25F6718` - `sub_25F5CE0` - `sub_25F634C` 其中 `sub_25F94A4` 的关键点是它明确把 `sub_25F6718` 安装成回调: ```c v7[4] = v2; v7[5] = *(_QWORD *)(v2 + 88); v7[6] = sub_25F6718; sub_3C36F04(v7, v7 + 4, 32); (*(void (__fastcall **)(__int64, __int64, __int64 *))(*(_QWORD *)v6 + 584LL))(v6, v5, &v15); ``` 这说明 `sub_25F6718` 不是随便 trace 到的一条热路径,而是 Godot 明确挂上的回调。 `sub_25F6718` 里又能直接看到 `PART3` 外层字符串的静态证据: - `0x510FC0`:`"flag{sec2026_PAR"` 前缀块 - `0x3AC580`:`"T3_"` - `0x3ABF48`:`"} "` - `0x42BBD1`:`"collided_with"` 对应反编译代码: ```c *(_OWORD *)s = xmmword_510FC0; v85 = (unsigned int)"T3_"; strcpy(v81, "} "); ... sub_3C91744(&v82, "collided_with", 0); result = sub_3C58F78(a1, &v82, &v86, 1); ``` 到这里,这条链已经可以直接写成: ```text Trigger4 触碰 -> libgodot_android.so:sub_25F94A4 安装回调 sub_25F6718 -> sub_25F6718 构造 "flag{sec2026_PART3_" + middle + "} " -> sub_25F6718 发出 collided_with(flag_string) ``` 到这里可以确定:`PART3` 的外层 flag 字符串是在 Godot native 里拼出来的。也就是说,前面那条“算法可能全藏在 sec2026 的 Tick 里”的猜测已经不能成立了;真正可信的链条是 Godot native 负责发信号,而 `libsec2026.so` 只在更深的位置提供中间结果。 ### 8.5 middle 这 16 个十六进制字符是谁算的 到这里,外层 flag 字符串是谁拼出来的已经清楚了,但 `PART3` 还没有真正讲完,因为最核心的 16 个十六进制字符还不知道是谁算的。接下来只剩一个问题: ```text middle 这 16 个 hex 是谁算出来的? ``` 继续和 `libsec2026.so` 的静态证据对应起来后,可以把这 16 个十六进制字符定位回 `libsec2026.so:+0xA9A7C`。同时还能确认最终格式化缓冲区: - 输出缓冲区:`0x1836E0` - 格式串缓冲区:`0x183920` - 最终格式:`"%08x%08x"` 因此真实调用关系不是“Trigger4 脚本里算 flag”,而是: ```text Trigger4 场景入口 -> Godot native 回调 sub_25F6718 -> sec2026:sub_A9A7C 生成 middle -> Godot native 拼成 flag{sec2026_PART3_<middle>} -> emit collided_with(flag) ``` 到这一步,`PART3` 的触发路径已经说清楚了,剩下的难点只剩最后一个:`sub_A9A7C` 本身不是普通函数,而是一层 VM 风格的扁平化计算壳。也就是说: - `Tick` 不是算法本体 - `collided_with` 的发射在 Godot native - `middle` 的生成仍然回到 `libsec2026.so:+0xA9A7C` ### 8.6 怎么处理 `sub_A9A7C` 这层 VM 壳 把 `middle` 追到 `sub_A9A7C` 之后,问题并没有结束,而是真正进入了最难的部分。这个函数不是普通顺序可读的 C 代码,而是: - OLLVM 风格扁平化 - VM 式 dispatch / trampoline - 最后才通过 `%08x%08x` 格式化出最终输出 如果硬逆整台 VM,会遇到两个问题: 1. 工作量极大 2. 大量 handler 和 runtime 与最终输出无关 所以后面的做法不是“把整台 VM 全部还原出来”,而是: ```text 只保留影响 out_hi / out_lo 的逆向切片 把它们整理成纯 32-bit 表达式 ``` 具体做法是: - 只围绕 `out_hi` / `out_lo` 做逆向切片 - 每个节点只记 `value / op / parents / def_site` - 不关心它来自哪个 handler 名字 - 只关心它最后能不能闭合成 `A / B / const` 的 32-bit 表达式 目标不是证明自己会跑虚拟机,而是把虚拟机最后算出来的结果还原成连续的 `uint32_t` 运算。 ### 8.7 从输出结果反推轮函数结构 顺着最终输出往回切,节点一个个补全以后,局部子图开始重复出现。最后可以归并成两类核心形状: 第一类: ```cpp u32(u32(v << 4) + 0xf95d664a) ^ u32(v + k) ^ u32((v >> 7) + 0x12aa364c) ``` 第二类: ```cpp u32(u32(v << 6) + 0x33ad3cee) ^ u32(v + k) ^ u32((v >> 5) + 0xaabbccdd) ``` 继续按时间顺序对应起来后,可以整理成 28 轮双分支更新: ```cpp x = u32(x + mix4(y, k)); y = u32(y + mix6(x, k)); k = u32(k + 0x29e59c9f); ``` 输入定义为: ```cpp A = pack4_le(token[0..3]); B = pack4_le(token[4..7]); x = A; y = B; ``` 最终输出为: ```cpp out_hi = y; out_lo = x; middle = hex8(out_hi) + hex8(out_lo); ``` 这一步不是凭“看起来像某种算法”猜出来的,而是因为大量局部切片节点在反复化简之后,都会落到同样的固定 `uint32_t` 结构上。 ### 8.8 本节结论 到这里,`PART3` 的路径和算法主体都已经说清楚了: - 入口层:`Trigger4` 默认不可触发,但可以通过场景补丁稳定打开 - 发信号层:真正构造并发出 `collided_with(flag)` 的是 `libgodot_android.so` 的 native 回调 - 算法层:`middle` 回到 `libsec2026.so:+0xA9A7C`,并被整理成纯 `uint32_t` 的轮函数表达式 --- ## 9. PART3 正向算法与逆算法 当 `sub_A9A7C` 的最终输出切片分析完成以后,`PART3` 就从“难以直接阅读的 VM 外壳”整理成了一段普通的 `C++` 算法。到这一步,前面追入口、证伪 `Tick`、继续追查 `middle` 的工作才真正和题目要求的“算法实现”接上。此时它的最终 flag 格式可以写成: ```text flag{sec2026_PART3_<16hex>} ``` 其中 16 位 hex 的生成算法是: ```cpp static inline uint32_t u32(uint64_t x) { return static_cast<uint32_t>(x); } static inline uint32_t pack4_le(const char* s) { return static_cast<uint32_t>(static_cast<uint8_t>(s[0])) | (static_cast<uint32_t>(static_cast<uint8_t>(s[1])) << 8) | (static_cast<uint32_t>(static_cast<uint8_t>(s[2])) << 16) | (static_cast<uint32_t>(static_cast<uint8_t>(s[3])) << 24); } static inline uint32_t mix4(uint32_t v, uint32_t k) { return u32(u32(uint64_t(v) << 4) + 0xf95d664aU) ^ u32(v + k) ^ u32((v >> 7) + 0x12aa364cU); } static inline uint32_t mix6(uint32_t v, uint32_t k) { return u32(u32(uint64_t(v) << 6) + 0x33ad3ceeU) ^ u32(v + k) ^ u32((v >> 5) + 0xaabbccddU); } std::string part3_forward(std::string_view token) { uint32_t x = pack4_le(token.data() + 0); uint32_t y = pack4_le(token.data() + 4); uint32_t k = 0x29e59c9fU; for (int i = 0; i < 28; ++i) { x = u32(x + mix4(y, k)); y = u32(y + mix6(x, k)); k = u32(k + 0x29e59c9fU); } std::ostringstream oss; oss << std::hex << std::nouppercase << std::setfill('0') << std::setw(8) << y << std::setw(8) << x; return oss.str(); } ``` 由于这套结构本身不是 hash,而是逐轮可回退的双分支迭代,所以逆算法也可以直接按轮逆序回退: ```cpp static inline std::string unpack4_le(uint32_t x) { std::string out(4, '\0'); for (int i = 0; i < 4; ++i) { out[i] = static_cast<char>((x >> (8 * i)) & 0xffU); } return out; } std::string part3_inverse(std::string_view middle_hex) { uint32_t y = parse_hex_u32(middle_hex.substr(0, 8)); uint32_t x = parse_hex_u32(middle_hex.substr(8, 8)); uint32_t keys[28]; uint32_t k = 0x29e59c9fU; for (int i = 0; i < 28; ++i) { keys[i] = k; k = u32(k + 0x29e59c9fU); } for (int i = 27; i >= 0; --i) { const uint32_t prev_y = u32(y - mix6(x, keys[i])); const uint32_t prev_x = u32(x - mix4(prev_y, keys[i])); y = prev_y; x = prev_x; } return unpack4_le(x) + unpack4_le(y); } ``` 这里之所以天然可逆,是因为每一轮更新关系是: ```cpp x' = u32(x + mix4(y, k)); y' = u32(y + mix6(x', k)); ``` 回退时 `x'`、`y'` 和 `k` 都已知,所以可以直接做相反方向的减法: ```cpp y = u32(y' - mix6(x', k)); x = u32(x' - mix4(y, k)); ``` --- ## 10. PART3 验证结果 有了公式和逆过程之后,还不能直接把它们当作结论提交,最后还需要把整条链完整对上。这里既要用真实样本做对拍,也要用离线路径做交叉验证,确保恢复出来的不是“只对几组样本成立”的表达式,而是和原始执行路径一致的算法。 已知样本全部命中: ```text f2811bcc -> 95c52737dcf2879a 756bd185 -> 3924de9c19fc912a dedff088 -> dfe2f2edf9304510 ``` 逆向也全部正确: ```text 95c52737dcf2879a -> f2811bcc 3924de9c19fc912a -> 756bd185 dfe2f2edf9304510 -> dedff088 ``` 统一工具中的直接示例: ```text flag_codec.exe --part 3 --flag f2811bcc => flag{sec2026_PART3_95c52737dcf2879a} flag_codec.exe --part 3 --inverse "flag{sec2026_PART3_95c52737dcf2879a}" => f2811bcc ``` 此外,纯 `C++` 算法还与离线模拟路径做过随机样本交叉对拍: - 10 个随机 token:`10/10` - 100 个随机 token:`100/100` 说明当前结果不是“样本拟合器”,而是与原始执行路径一致的真实算法。也正因为正向和逆向都已经在同一套工具链里跑通,`PART3` 到这里才算真正分析完成。  *图 8 统一工具对 `PART1 / PART2 / PART3` 的正向与逆向验证结果。其中 `PART3` 的正向与逆向都已经纳入同一套 `C++` 工具链。* --- ## 11. 反调试链路与动态验证环境 前面几节里,`PART2` 和 `PART3` 的一些结论并不只是静态看出来的,还依赖运行时观察、定点 hook 和行为验证。问题在于,`libsec2026.so` 本身并不欢迎这样做:Frida 直接附加后,一旦对关键路径下 hook,进程很快就会崩。因此这部分不能只当作旁支材料丢开,必须说明清楚它是怎么影响分析、又是怎么被处理掉的。 ### 11.1 检测面 把 `libsec2026.so` 的线程创建总入口顺下来后,可以先把主要检测面分成四组: | 检测面 | 关键位置 | 作用 | |---|---|---| | 双进程反调试 | `0x99094 -> 0x9C654` | `fork + ptrace` 占用 tracer,阻止外部附加 | | `/proc` 扫描 | `0x99094 -> 0x9CDC4` | 枚举线程和 FD,识别 Frida 痕迹 | | 心跳与 CRC | `0x99094 -> 0x9B7D8` | 周期更新 heartbeat,并触发 `.text` 完整性校验 | | 独立完整性检查 | `0x15CEF0` | 通过 `process_vm_readv` 风格读代码区,补一条额外校验链 | 继续把字符串解密以后,和 Frida 直接相关的两个关键字就很明确了: - 线程名扫描命中的是 `gum-js-loop` - 文件描述符扫描命中的是 `linjector` 真正导致“能附加但一 hook 就崩”的主因不是 `fork + ptrace`,而是 `.text` 完整性校验。它不是简单读文件做哈希,而是沿原始 syscall 路径去读进程内存;只要 Frida 在代码段上落了 trampoline,CRC 就会看到差异,进程随后退出。 ### 11.2 绕过方案 这一部分最终没有走“到处 patch 检测函数 return”的路线,而是做成了一套能继续支撑动态分析的稳定方案。实际落地方式是重打包 APK,并在 `GodotApp` 初始化阶段提前加载自定义 SO,让补丁逻辑先于 `libsec2026.so` 生效。核心实现集中在: - `hook.c` 方案可以概括成五件事: 1. 对 `fork` 和 `ptrace` 做 inline hook,直接把双进程反调试压成单进程运行。 2. 在 `libsec2026.so` 加载后先备份干净的 `.text` 段。 3. 用 `seccomp user_notif` 拦截校验路径上的 `process_vm_readv`,把对代码段的读取重定向到 clean backup。 4. 伪造 `/proc/self/maps`,让映射视图和备份后的代码区保持一致。 5. 通过 `opendir/readdir/closedir` 过滤 `/proc/self/task` 和 `/proc/self/fd` 的枚举结果,把 `gum-js-loop` 和可疑 FD 隐掉。 ### 11.3 动态验证 验证时主要盯了四个结果: 1. `fork + ptrace` 是否还会把目标拉成双进程。 2. heartbeat 在线程 hook 之后是否仍持续推进。 3. 高风险地址上的 `Interceptor.attach` 是否还能稳定存活。 4. `gum-js-loop` 这类真实痕迹是否确实存在,但又不会被目标扫描到。 先看单进程验证。补丁生效后,设备侧 `pidof` 和 `ps -A` 只看到一个同包名进程,说明 `fork + ptrace` 这条链已经被截断,没有继续分裂出第二个守护进程。  *图 9 设备侧只看到一个目标进程;同时对代码段做最小改写后,进程没有立即崩,说明后续完整性绕过已经开始接管。* 再看高风险 hook 验证。这里直接选了最容易出事的两个点: - `0x96A00`:CRC 调用点 - `0x9EFB4`:`dl_iterate_phdr` 回调 在附加 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,因此这里只能记为“静态确认 + 方案覆盖”,不能写成动态命中验证。 --- ## 12. 综合结论 1. 样本基于 Godot 4.5.1,`libsec2026.so` 以 `GameExtension : Node` 的形式接入。 2. token 由脚本生成,为固定 8 位十六进制字符串。 3. `PART1` 对应绿色方块 `Trigger2`,其 flag 逻辑完全在脚本层实现,算法为 Feistel 结构。 4. `PART2` 对应红色方块 `Trigger3`,其脚本直接调用 `GameExtension.Process`,native 核心入口已稳定定位并完成正反向还原。 5. `PART3` 对应隐形方块 `Trigger4`;默认入口是假的,但可以通过场景补丁稳定打开。 6. `Trigger4.gdc` 脚本层不是 `PART3` 算法本体;`Tick` 也已经通过 `ret` 实验证伪。 7. `PART3` 的真实输出路径由 `libgodot_android.so` native 回调 `sub_25F6718` 负责拼接并发出 `collided_with`,其中 `middle` 回到 `libsec2026.so:+0xA9A7C` 生成。 8. `PART1 / PART2 / PART3` 都已经具备“给定 token 正向求 flag”和“给定 flag 逆向恢复 token”的本地 `C++` 实现。 9. 为了支撑运行时验证,本次还把 `libsec2026.so` 的反调试、完整性校验和 `/proc` 扫描链压到了可稳定做 Frida hook 的状态。 ### 12.1 提交附件 | 文件 | 作用 | |---|---| | `flag_codec.cpp` | 统一实现 `PART1 / PART2 / PART3` 的正向计算、逆向恢复与基础自测 | | `hook.c` | 动态验证环境使用的绕过实现,覆盖反调试、完整性校验与 `/proc` 枚举过滤 |
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
上传的附件:
hook.c
(19.40kb,7次下载)
flag_codec.cpp
(24.11kb,7次下载)
收藏
・
3
点赞
・
2
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
心在o
为你点赞!
2026-5-15 01:06
我的小拇指啊
感谢你的积极参与,期待更多精彩内容!
2026-4-24 15:00
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
libc
1
1
发帖
6
回帖
40
RANK
关注
私信
他的文章
[原创 ]2026 腾讯游戏安全竞赛决赛 - Android WriteUp
7480
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
谁下载
×
Itachi_
aeolusw
npc0vo
mb_tvuiswau
mb_kujsrqqv
mb_lthgjpwj
谁下载
×
Itachi_
aeolusw
npc0vo
mb_tvuiswau
mb_kujsrqqv
mb_lthgjpwj
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部