sec2026

1 题目概述

本题基于 Godot 4.5 Android 客户端构建,核心逻辑由 libsec2026.solibgodot_android.so 共同完成。静态 APK 资源并不足以直接完成分析,原因在于:一方面,关键 SO 在运行态才完成解密与重定位;另一方面,保护模块同时部署了反调试、反注入、代码完整性校验与多线程检测机制。因此,本题的正确解法并不是单点逆向,而是先完成运行时环境接管、再恢复脚本与原生逻辑、最后分别求解 Part1 / Part2 / Part3。

从完整分析过程看,题目可拆为四个阶段:

  1. 运行时 Dump 与 SO 修复。
  1. 反调试与检测机制识别及绕过。
  1. GDScript / GDExtension / Godot 场景逻辑还原。
  1. 三个 Part 的触发条件恢复与 flag 生成算法分析。

2 整体解题路线

2.1 总体策略

思路清晰,分析无忧,首先尝试直接静态分析 APK,会同时遇到三类障碍:

  • so 运行时加载、修正、保护,磁盘文件不可直接作为最终分析对象。
  • 保护模块存在多线程反调试与自校验,直接在目标 so 上做 inline patch 会被立即发现。
  • 部分高层逻辑位于 Godot 的.gdc字节码与场景节点交互中,必须结合 Godot API 与运行时对象进行还原。

因此整体策略为:

  1. 先以 Frida spawn 模式接管进程启动早期阶段(bypass_sec2026.js铺路)。
  1. 拦截退出路径并在目标 so 完成加载后进行内存 dump(dump-so.ts奠基)。
  1. 对 dump 的 so 进行结构修复,恢复到可被 IDA / Ghidra 正常分析的状态。
  1. 结合 Godot 引擎接口、GDScript token 表与运行时脚本对象,尽可能恢复各Part逻辑。
  1. 在不修改 so 代码段的前提下,仅通过 RW 段重定向、线程冻结、运行时对象调用等方式完成反检测绕过与题目求解。

2.2 关键结论先行

最终解题过程中最重要的结论有三点:

关键问题结论
为什么常规 Frida inline hook 不稳定子进程通过 fork + ptrace 对主进程 .text 做完整性校验,任何代码段修改都会暴露
为什么仅 hook libc 不够关键退出、计时、文件读取走 SVC #0,直接绕过 libc hook
稳定绕过点在哪里RW 调度表、spawn 早期接管、冻结校验子进程、以及只做运行时对象层调用

3 运行时 Dump 与 so 修复

3.1 为什么必须从内存 Dump

针对决赛,必须从运行时内存中 dump 两个关键 SO:libsec2026.solibgodot_android.so。原因在于,SO 可能经历了解壳、重定位修正以及保护逻辑注入;磁盘态文件既不完整,也无法反映运行时真实控制流。

3.2 进程保活与退出路径拦截

应用在检测到 Frida 注入后会主动退出,因此第一步不是求解算法,而是保活进程。实验中拦截了以下退出路径:

拦截目标方法处理策略
_exit / exitInterceptor.replace阻止退出并记录调用来源
killInterceptor.replace阻止信号杀进程
abortInterceptor.replace改为挂起,保留现场
raiseInterceptor.replace阻止主动发送终止信号
System.exitJava implementation 替换阻止 Java 层退出
Process.killProcessJava implementation 替换阻止 Java 层 kill
ptrace运行时替换降低反调试效果
fgets运行时替换防止通过 /proc/self/status 检测 TracerPid

这些拦截更多用于早期接管与 dump 阶段。进入 libsec2026.so 的核心保护区后,单纯依赖这些 libc/Java 层 hook 并不足以稳定绕过全部检测,原因会在后文详细解释。

3.3 动态加载捕获

目标 SO 并非启动即加载,因此采用以下方式捕获加载时机:

  • Hook android_dlopen_ext 作为主入口。
  • Hook dlopen 作为兜底。
  • 检测到目标库加载完成后延时约 200ms,再执行 dump,确保 linker 已完成重定位。

3.4 安全 Dump 策略

由于目标 so 内存可能不连续、页权限不统一,最终使用“快速尝试 + 安全逐页 dump”的两级策略。

3.4.1 快速 Dump

直接读取模块地址范围并写出文件,适合小型且连续映射的 so。

3.4.2 逐页安全 Dump

核心步骤如下:

  1. 枚举模块全部可读内存段。
  1. 按地址排序,保证输出布局与虚拟地址一致。
  1. 以 4KB 为单位逐页读取。
  1. 对可读页写真实内容;读取失败页用零页填充。
  1. 对未映射空洞同样做零填充。
  1. 保证最终文件大小与模块声明大小一致。
  1. 使用原生fopen/fwrite/fclose避免 JS 层 I/O 不稳定。

最终生成的 dump 文件可用于后续结构修复。

3.5 Dump 后 so 修复

内存 dump 得到的 so 无法直接被 IDA/Ghidra 正常加载,主要问题包括:

  • LOAD 段 p_offset 与运行时地址混淆。
  • section header 缺失。
  • GOT 已被重定位覆盖。
  • init_array 被改写。

由于通用 SoFixer 无法很好处理这种分段 dump 结果,因此另外编写修复脚本恢复 ELF 结构,使 libsec2026.solibgodot_android.so 可以进入正常逆向流程。

4 反调试与检测机制分析

4.1 初始化后的三线程检测架构

通过函数_dl_unw_init_local进入,启动保护模块会采用 pthread_create 启动三个检测线程:

线程入口作用
Thread 1sub_9C654fork + ptrace 反调试
Thread 2loc_9CDC4/proc 文件系统扫描
Thread 3sub_9B7D8反外挂 / 反调试检测

4.2 反调试检测

sub_9C654 是一个典型的反调试(Anti-Debugging)检测器,主要使用 fork 和 ptrace 进行自我附加和系统调用跟踪。它的整体控制流被高度混淆(控制流平坦化 / 状态机),即通过更新一个状态变量,不断从查表分发入口跳转到对应的功能块,借助AI进行完全分析,它分为5个阶段:

  • 进程分裂:主进程fork后子进程独立于主进程扮演一个看门狗的角色!
  • 双重 ptrace 跟踪:子进程会对父进程发起 ptrace(PTRACE_ATTACH, pid, ...) 或类似的追踪请求。
原理:Linux 中一个进程同一时刻只能被一个调试器 ptrace。如果此时已经有一个真实调试器(如 IDA 或 LLDB)挂载在游戏主进程上,子进程的 ptrace 请求就会失败报错。反过来,如果子进程成功挂载到了父进程,那外面的黑客就再也无法用调试器附加进来了(会报 Operation not permitted)。
  • 系统调用拦截(PTRACE_SYSCALL):子进程并没有只是简单休眠,它向父进程发送了 ptrace(PTRACE_SYSCALL, pid, 0, 0)(对应参数常量 0x10)。
原理:这使得父进程在每次执行任何 Syscall(如 read, write, open)前后,都会被挂起(STOP),并将控制权交给守护进程。
  • 事件捕获与响应循环:子进程进入了一个死循环,其核心逻辑是调用 waitpid(父进程PID, &status, 0) 阻塞等待父进程的状态改变。当父进程因为系统调用或受到信号被挂起时,waitpid 会返回,守护进程通过检查 status(汇编层可看到通过 TST W27, #0x7F 判断信号特征),来判断父进程是正常系统调用,还是遇到了异常(如调试器插入断点引发的 SIGTRAP)。如果认为可疑,它会调用 sub_95CC0 函数。这个函数底层调用了 ptrace(PTRACE_GETREGSET, pid, 1026, &regs)
原理:获取父进程的完整寄存器上下文。原理猜测如果此时检测到 PC 寄存器附近被修改为 0x00 0x00 0x20 0xD4(BRK 硬件指令),或是检测到了硬件调试寄存器被设置,就能确认游戏被动态修改或下了断点。
  • 惩罚执行:ptrace附加失败又或者监控到了调试器的存在,状态机就会把流导向 0x9cae4 分支,最终调用 exit(0) 或 exit(1)(调用了 _exit 函数地址 off_1669B0),强行将子进程和父进程一并结束,导致游戏闪退,从而达到反调试的目的。它还会调用 __stack_chk_fail 来主动触发栈崩溃,增加定位崩溃点的难度。

4.3 文件系统的调试状态检测

0x9cdc4 及以后是检测器的第二阶段:基于 /proc 文件系统的线程与调试状态检测。第一阶段利用了 fork 和 ptrace 进行自我附加和系统调用拦截,而第二阶段则作为第一阶段的补充,通过周期性的主动扫描来捕捉动态调试。

进入休眠(定时扫描准备)

在 0x9cdec 处,它调用了 sleep(1)。这意味着该检测器在一个单独的线程 / 循环中周期性地执行,通过定时休眠(1 秒)来控制扫描频率,以防止过度占用 CPU 被发现,并维持一个持续的守护状态。

枚举线程目录(/proc/self/task 扫描)

唤醒后,状态机导向了 0x9d050 的 opendir() 调用。它通过在运行时解密出一个路径字符串(基于混淆算法,实际指向 /proc/self/task 或 /proc/pid/task)打开当前进程的线程目录。

接着在 0x9da0c 处调用 readdir()。通过循环,它会逐一遍历本进程的所有子线程。

读取并解析 stat 或 status(异常与调试状态检测)

每遍历到一个线程的 TID,它就会组合出类似 /proc/self/task/[tid]/stat(或 status / wchan)的文件路径

在 0x9daf4 等位置,调用 sub_9C4FC / sub_9AD3C 等辅助解析函数。

检测原理:

  • TracerPid 检测:在 Linux 系统中,如果一个进程 / 线程正在被调试,它的 /proc/pid/status 文件中的 TracerPid 字段会变成对应调试器的 PID(而不是 0)。
  • State 检测:当进程处于断点或因为 ptrace 而暂停时,它的状态会变成 T (tracing stop) 或者 t (tracing stop)。检测器通过读取 stat 文件的第 3 个字段来识别异常停止的状态。
  • 另外,它可能还会检查特定线程的名字、挂起地址(wchan 中是否有 ptrace_stop)来确认自己是否处于调试环境。

4.4 反外挂 / 反调试检测

sub_9B7D8 同样是一个高度隐蔽且执行流被严重混淆(控制流平坦化 / 状态机)的检测器线程(Detector Thread)。它不依赖于 libc 层的标准文件操作库函数,而是通过底层的内联系统调用(SVC 0)直接与操作系统内核通信,以绕过常规的 Inline Hook 或 PLT/GOT Hook。该模块的主要目标是读取和检测环境中的高危特征(如 /proc 下的文件或进程状态)。

隐蔽与初始化:

进入该函数后,检测器会进行自我隐藏和降级,避免对游戏的主逻辑造成性能影响或引起分析人员注意:

  • 延迟执行:通过调用 sleep(3),推迟自己的首次检测动作。这种 “延时启动” 策略常被用来避开调试器刚附加时的密集扫描期。
  • 降低调度优先级:调用 setpriority(PRIO_PROCESS, 0, 19)。将当前检测线程的优先级拉低至最低级(19),确保它在后台静默运行,不会抢占 CPU 时间片。

动态字符串解密:

为了对抗静态分析和静态字符串检索,检测器所需的文件路径并没有硬编码在 .rodata 段中。它首先使用 __memcpy_chk 将一段加密数据(位于 unk_1682D0)复制到栈上([X19, #0xE0] 附近)。随后,程序利用 ARM64 的 NEON 向量寄存器(V 系列寄存器)执行了双重 XOR 解密算法

LDR             Q2, [X19,#0x10]
EOR             V0.16B, V0.16B, V2.16B   ; 第一层异或,基于运行时动态 Key
EOR             V0.16B, V1.16B, V0.16B   ; 第二层异或,基于数据段的 Key
STR             Q0, [X19,#0xE0]

自我保护及检测

分析发现,他调用了 mprotect() 给自身的关键内存页重新设置权限(如 RX),猜测具体作用是防止被动态插入断点,由于动态解密无法静态还原文件路径,但在 Android 游戏安全的语境下,结合 openat + read 的连招模式,再次猜测可能是maps或者smaps之类的扫描,因为我的frida已经可以正常注入了,这里不再深入研究。

5 资源脚本与 Godot 层分析

5.1 GDScript 字节码恢复

与初赛一致,PCK 加密方式没有本质变化,但决赛版本的.gdc字节码直接用现有工具反编译会出现错误。问题关键在于 token type 到操作符的映射并不是工具默认假设的值。

后续通过在 libgodot_android.so 中定位GDScript Token::Type枚举表,恢复了正确的 token 语义,并据此修复了自研反编译脚本,得到正确的 Part1 脚本算法。

其中一个关键修正为:

  • 错误反编译:_v = ((_v - 3) % (_v * 5)) & 255
  • 正确逻辑:_v = ((_v << 3) | (_v >> 5)) & 255

也即,原逻辑是 8-bit 左旋 3 位,而不是算术表达式。

5.2 GDExtension API 表定位

通过分析 extension_init 到 API 加载器的调用链,定位到 GDExtension 的函数指针装载过程。其特点是:

  • 通过 get_proc_address("api_name") 动态获取 Godot 接口(熟读godot引擎这块)。
  • API 指针连续存入 .bss/.data。
  • 错误字符串中直接泄露 API 名称。
  • 该加载器早于反调试线程启动,且未被 CFF 保护。

提取关键特征编写一个脚本前向搜索所有的函数:

import idautils, idc, re

func_start = 0xAAE98
func_end = func_start + 10144

# 收集所有 ADRL/ADR + API 名称字符串
adrl_list = []
for head in idautils.Heads(func_start, func_end):
    mnem = idc.print_insn_mnem(head)
    if mnem in ("ADRL", "ADR"):
        op1 = idc.get_operand_value(head, 1)
        s = idc.get_strlit_contents(op1)
        if s:
            s = s.decode('utf-8', errors='replace')
            # 过滤掉错误消息和非 API 字符串
            if "Unable to load" in s or "Cannot load" in s or "src\\" in s:
                continue
            if "ERROR:" in s or s == "init":
                continue
            adrl_list.append((head, s))

# 前向搜索: 从 ADRL 往后找第一个带 off_/qword_ 的 STR
for addr, name in adrl_list:
    nxt = addr
    for _ in range(12):
        nxt = idc.next_head(nxt)
        if nxt > func_end:
            break
        if idc.print_insn_mnem(nxt) == "STR":
            op1 = idc.print_operand(nxt, 1)
            m = re.search(r'(off_|qword_)([0-9A-Fa-f]+)', op1)
            if m:
                offset = int(m.group(2), 16)
                print(f"0x{offset:06X} | {name}")
                break

5.3 为什么选择 variant_call

分析发现,该版本的 classdb_get_method_bind 必须传入真实哈希值,hash=0 不能绕过校验,因此不适合作为通用frida去做主动调用实现动态调试的手段。

// classdb_get_method_bind(StringName class, StringName method, uint32_t hash)
__int64 sub_3C14680(__int64 a1, __int64 a2, __int64 a3 /*hash*/)
{
    // 首次查找: 用提供的 hash 严格匹配
    v5 = sub_3C3B654(class_sn, method_sn, a3 /*hash*/, &compat_flag, 0);

    // 如果失败且 compat_flag 被设置, 尝试兼容哈希表
    if (!v5 && (compat_flag
        && sub_3C1EEB8(class_sn, method_sn, a3, &new_hash)
        && (v5 = sub_3C3B654(class_sn, method_sn, new_hash, ...)) != 0))
    {
        // 找到了兼容哈希
    }
    else if (!v5)
    {
        // 全部失败 → 返回 NULL
        return 0;
    }
    return v5;  // 返回 MethodBind*
}

相比之下,variant_call 通过 StringName 查找方法,可直接按方法名调用对象接口,完全绕过 ClassDB 哈希限制,因此成为最灵活稳定的 Godot 调用方案。

6 variant_call 验证

写一段简单的脚本,让扫描到的方块围着我们转,效果验证成功!variant_call成为最灵活稳定的 Godot 调用方案

引力中心

飞起来!

7 Part1 分析

7.1 场景逻辑与触发条件

绿色方块 Trigger2 位于房顶(),正常驾驶路径无法直接到达,因此需要脱离正常物理路径求解。

var gB=null,sB=null;
function setup(){
    var g=Process.findModuleByName("libgodot_android.so"),s=Process.findModuleByName("libsec2026.so");
    if(!g||!s){setTimeout(setup,500);return;}
    gB=g.base;sB=s.base;
    var frame=0;
    Interceptor.attach(gB.add(0x106dcdc),{onEnter:function(){
        frame++;
        if(frame===600) findVehicle();  
    }});
    console.log("[*] Will search at frame 600");
}

function rA(o){return sB.add(o).readPointer();}

function findVehicle(){
try{
    var _get_singleton=new NativeFunction(gB.add(0x3C1371C),'pointer',['pointer']);
    var _sn_new=new NativeFunction(gB.add(0x3C12E5C),'void',['pointer','pointer','uint8']);
    var _engine_get_ml=new NativeFunction(gB.add(0x36C8768),'pointer',['pointer']);
    var _tree_get_root=new NativeFunction(gB.add(0x200E700),'pointer',['pointer']);
    var _var_call=new NativeFunction(rA(0x1839F0),'void',['pointer','pointer','pointer','int64','pointer','pointer']);
    var _var_nil=new NativeFunction(rA(0x1839E0),'void',['pointer']);
    var _var_from_type=new NativeFunction(rA(0x183AD0),'pointer',['int']);
    var _str_to_utf8=new NativeFunction(rA(0x183BC0),'int64',['pointer','pointer','int64']);
    var _var_stringify=new NativeFunction(rA(0x183A88),'void',['pointer','pointer']);

    function makeSN(s){var b=Memory.alloc(8);b.writeU64(0);_sn_new(b,Memory.allocUtf8String(s),0);return b;}
    function objV(p){var v=Memory.alloc(24);for(var i=0;i<24;i++)v.add(i).writeU8(0);var c=_var_from_type(24);var h=Memory.alloc(8);h.writePointer(p);new NativeFunction(c,'void',['pointer','pointer'])(v,h);return v;}
    function intV(n){var v=Memory.alloc(24);for(var i=0;i<24;i++)v.add(i).writeU8(0);var c=_var_from_type(2);var h=Memory.alloc(8);h.writeS64(n);new NativeFunction(c,'void',['pointer','pointer'])(v,h);return v;}
    function vc(sv,m,ar){var sn=makeSN(m);var r=Memory.alloc(24);for(var i=0;i<24;i++)r.add(i).writeU8(0);_var_nil(r);var e=Memory.alloc(12);var ap=Memory.alloc(8*Math.max(ar.length,1));for(var i=0;i<ar.length;i++)ap.add(i*8).writePointer(ar[i]);_var_call(sv,sn,ap,ar.length,r,e);return r;}
    function vs(v){var g=Memory.alloc(8);g.writeU64(0);_var_stringify(v,g);var l=_str_to_utf8(g,ptr(0),0);if(l<=0)return"?";var b=Memory.alloc(l+1);_str_to_utf8(g,b,l+1);return b.readUtf8String();}

    var engine=_get_singleton(makeSN("Engine"));
    var tree=_engine_get_ml(engine);
    var root=_tree_get_root(tree);
    var rv=objV(root);

    // 用 variant_call("print_tree_pretty") 打印整棵树!
    console.log("[*] Calling print_tree_pretty on root...");
    vc(rv,"print_tree_pretty",[]);

    // 这会输出到 Godot 的 stdout, 我们用 Frida 看不到
    // 改用 get_tree_string_pretty() 返回 String
    console.log("[*] Calling get_tree_string_pretty...");
    var treeStr = vc(rv,"get_tree_string_pretty",[]);
    var s = vs(treeStr);
    console.log("=== SCENE TREE ===");
    console.log(s);
    console.log("=== END ===");

}catch(e){console.log("[ERR] "+e.stack);}
}

通过以上的代码走 variant_call 访问场景树,恢复出的关键流程如下:

  1. Engine singleton → SceneTree → Root。
  1. 获取 TownScene/InstancePos/car/Body 节点,得到车辆对象。
  1. 获取 TownScene/Trigger2 节点。
  1. 读取 Trigger2 的世界坐标。
  1. 将车辆速度清零。
  1. 将车辆直接传送至 Trigger2 附近。
  1. 直接调用 Trigger2 的 _w7() 方法触发 flag。
  1. 读取 UI 文本得到结果。

7.2 实际求解方式

Part1 的核心并不是破解复杂原生逻辑,而是利用 Godot 运行时对象能力完成“场景导航 + 条件直达”。

最终稳定方案有两种:

  • 坐标传送到 Trigger2 附近,再让逻辑自然触发。
  • 直接调用 Trigger2 脚本方法 _w7()。
1. Engine singleton → SceneTree → Root Window
2. variant_call(root, "get_node", "TownScene/InstancePos/car/Body")  → Vehicle*
3. variant_call(root, "get_node", "TownScene/Trigger2")              → Trigger2*
4. variant_call(trigger2, "get_global_position")  → (-14.96, 11.67, -3.08)
5. variant_call(vehicle, "set_linear_velocity", Vector3(0,0,0))      → 清速度
6. variant_call(vehicle, "set_global_position", trigger2_pos + Y=1)  → 传送!
7. object_call_script_method(trigger2, "_w7", [nil])            → 触发 flag (可选)
8. variant_call(label2, "get_text")  → "flag{sec2026_PART1_d9620163}"

运行中成功读取到:

  • Label2 text = flag{sec2026_PART1_8244f7ba}
  • 同时界面还会显示一个中间 token。

7.3 Part1 算法恢复

在修复 .gdc 反编译后,可以完整还原 Part1 对 token/字节序列的处理逻辑,并据此编写逆算法。换言之,Part1 同时具备“动态直取 flag”与“静态算法恢复”两种完成路径。

8 Part2 分析

8.1 题目机制理解

根据脚本逻辑,Part2 需要在满足碰撞相关前置条件后,再进入指定方块区域,才会输出 flag。

重点在于 Godot Area3D 的两个控制项:

  • monitoring:Area 是否主动检测物体进入/离开。
  • 与碰撞相关的状态变量:决定 body_entered 信号是否会真正触发。
function doIt(){
try{
    var _get_singleton=new NativeFunction(gB.add(0x3C1371C),'pointer',['pointer']);
    var _sn_new=new NativeFunction(gB.add(0x3C12E5C),'void',['pointer','pointer','uint8']);
    var _engine_get_ml=new NativeFunction(gB.add(0x36C8768),'pointer',['pointer']);
    var _tree_get_root=new NativeFunction(gB.add(0x200E700),'pointer',['pointer']);
    var _call_script=new NativeFunction(gB.add(0x3C13A68),'void',['pointer','pointer','pointer','int64','pointer','pointer']);
    var _var_call=new NativeFunction(rA(0x1839F0),'void',['pointer','pointer','pointer','int64','pointer','pointer']);
    var _var_nil=new NativeFunction(rA(0x1839E0),'void',['pointer']);
    var _var_from_type=new NativeFunction(rA(0x183AD0),'pointer',['int']);
    var _var_to_type=new NativeFunction(rA(0x183AD8),'pointer',['int']);
    var _str_new_utf8=new NativeFunction(rA(0x183B60),'void',['pointer','pointer']);
    var _var_construct=new NativeFunction(rA(0x183B08),'void',['int','pointer','pointer','int','pointer']);
    var _var_destroy=new NativeFunction(rA(0x1839E8),'void',['pointer']);
    var _var_stringify=new NativeFunction(rA(0x183A88),'void',['pointer','pointer']);
    var _str_to_utf8=new NativeFunction(rA(0x183BC0),'int64',['pointer','pointer','int64']);

    function makeSN(s){var b=Memory.alloc(8);b.writeU64(0);_sn_new(b,Memory.allocUtf8String(s),0);return b;}
    function objV(p){var v=Memory.alloc(24);for(var i=0;i<24;i++)v.add(i).writeU8(0);var c=_var_from_type(24);var h=Memory.alloc(8);h.writePointer(p);new NativeFunction(c,'void',['pointer','pointer'])(v,h);return v;}
    function npV(ps){var gs=Memory.alloc(8);gs.writeU64(0);_str_new_utf8(gs,Memory.allocUtf8String(ps));var sc=_var_from_type(4);var sv=Memory.alloc(24);for(var i=0;i<24;i++)sv.add(i).writeU8(0);new NativeFunction(sc,'void',['pointer','pointer'])(sv,gs);var np=Memory.alloc(24);for(var i=0;i<24;i++)np.add(i).writeU8(0);var a=Memory.alloc(8);a.writePointer(sv);var e=Memory.alloc(12);_var_construct(22,np,a,1,e);_var_destroy(sv);return np;}
    function makeNilV(){var v=Memory.alloc(24);for(var i=0;i<24;i++)v.add(i).writeU8(0);_var_nil(v);return v;}
    function makeStringVar(s){var gs=Memory.alloc(8);gs.writeU64(0);_str_new_utf8(gs,Memory.allocUtf8String(s));var sc=_var_from_type(4);var sv=Memory.alloc(24);for(var i=0;i<24;i++)sv.add(i).writeU8(0);new NativeFunction(sc,'void',['pointer','pointer'])(sv,gs);return sv;}
    function makeIntVar(n){var v=Memory.alloc(24);for(var i=0;i<24;i++)v.add(i).writeU8(0);v.writeU32(2);v.add(8).writeS64(n);return v;}
    function makeBoolVar(b){var v=Memory.alloc(24);for(var i=0;i<24;i++)v.add(i).writeU8(0);v.writeU32(1);v.add(8).writeU8(b?1:0);return v;}
    function vc(sv,m,ar){var sn=makeSN(m);var r=Memory.alloc(24);for(var i=0;i<24;i++)r.add(i).writeU8(0);_var_nil(r);var e=Memory.alloc(12);var ap=Memory.alloc(8*Math.max(ar.length,1));for(var i=0;i<ar.length;i++)ap.add(i*8).writePointer(ar[i]);_var_call(sv,sn,ap,ar.length,r,e);return{ret:r,err:e.readU32()};}
    function vs(v){var g=Memory.alloc(8);g.writeU64(0);_var_stringify(v,g);var l=_str_to_utf8(g,ptr(0),0);if(l<=0)return"?";var b=Memory.alloc(l+1);_str_to_utf8(g,b,l+1);return b.readUtf8String();}
    function varToObj(v){try{if(!v||v.isNull()||v.readU32()!==24)return ptr(0);var d=_var_to_type(24);var o=Memory.alloc(8);o.writePointer(ptr(0));new NativeFunction(d,'void',['pointer','pointer'])(o,v);return o.readPointer();}catch(e){return ptr(0);}}

    var engine=_get_singleton(makeSN("Engine"));
    var tree=_engine_get_ml(engine);
    var root=_tree_get_root(tree);
    var rootVar=objV(root);

    // 取节点
    function getNode(path){return varToObj(vc(rootVar,"get_node_or_null",[npV(path)]).ret);}
    var t2Ptr=getNode("TownScene/Trigger2");
    var t3Ptr=getNode("TownScene/Trigger3");
    var t4Ptr=getNode("TownScene/Trigger4");
    var label2Ptr=getNode("TownScene/Label2");

    // 检查碰撞状态
    function checkColl(label){
        console.log("\n--- "+label+" ---");
        for(var ti=3;ti<=4;ti++){
            var tP=getNode("TownScene/Trigger"+ti);
            if(tP.isNull()) continue;
            var tV=objV(tP);
            var m=vs(vc(tV,"is_monitoring",[]).ret);
            // 检查 CollisionShape3D
            var cc=parseInt(vs(vc(tV,"get_child_count",[]).ret));
            var csDisabled="?";
            for(var ci=0;ci<cc;ci++){
                var cP=varToObj(vc(tV,"get_child",[makeIntVar(ci)]).ret);
                if(cP.isNull()) continue;
                var cV=objV(cP);
                if(vs(vc(cV,"get_class",[]).ret)==="CollisionShape3D"){
                    csDisabled=vs(vc(cV,"is_disabled",[]).ret);
                }
            }
            console.log("  Trigger"+ti+": monitoring="+m+" collShape.disabled="+csDisabled);
        }
    }

    function callW7(tPtr){
        var sn=makeSN("_w7");var nv=makeNilV();
        var a=Memory.alloc(8);a.writePointer(nv);
        var r=makeNilV();var e=Memory.alloc(12);
        _call_script(tPtr,sn,a,1,r,e);
        return e.readU32();
    }

    function tickAll(n){
        for(var ti=1;ti<=4;ti++){
            var tP=getNode("TownScene/Trigger"+ti);
            if(tP.isNull()) continue;
            var gx=varToObj(vc(objV(tP),"get",[makeStringVar("_gx")]).ret);
            if(!gx.isNull()){
                var gxV=objV(gx);
                for(var i=0;i<n;i++) vc(gxV,"Tick",[]);
            }
        }
    }

    // 找车
    var ipPtr=getNode("TownScene/InstancePos");
    var vehiclePtr=ptr(0);
    if(!ipPtr.isNull()){
        function findVB(nV,d){
            if(d>5)return null;
            var cc=parseInt(vs(vc(nV,"get_child_count",[]).ret));
            for(var i=0;i<cc;i++){
                var cP=varToObj(vc(nV,"get_child",[makeIntVar(i)]).ret);
                if(cP.isNull())continue;
                var cV=objV(cP);
                if(vs(vc(cV,"get_class",[]).ret)==="VehicleBody3D")return cP;
                var r=findVB(cV,d+1);if(r)return r;
            }
            return null;
        }
        vehiclePtr=findVB(objV(ipPtr),0);
    }
    console.log("[*] Vehicle: "+vehiclePtr);
    console.log("[*] Token: "+vs(vc(objV(getNode("TownScene/Label")),"get_text",[]).ret));

    // === 初始状态 ===
    checkColl("INITIAL STATE (no trigger fired)");

    // === Tick 100次 (无触发) ===
    console.log("\n[*] Ticking all 100 times...");
    tickAll(100);
    checkColl("AFTER 100 Ticks (no trigger)");

    // === 触发 Part1 ===
    console.log("\n[*] Triggering Part1 (Trigger2._w7)...");
    callW7(t2Ptr);
    var fl1=vs(vc(objV(label2Ptr),"get_text",[]).ret);
    console.log("[*] Part1: "+fl1);

    // tick 让 native 处理状态变化
    tickAll(200);
    checkColl("AFTER Part1 + 200 Ticks");

    // === 传送车到 Trigger2 (模拟到达) ===
    if(vehiclePtr&&!vehiclePtr.isNull()){
        var t2Pos=vc(objV(t2Ptr),"get_global_position",[]);
        vc(objV(vehiclePtr),"set_global_position",[t2Pos.ret]);
        console.log("\n[*] Teleported vehicle to Trigger2");
        tickAll(200);
        checkColl("AFTER teleport to Trigger2 + 200 Ticks");
    }

    // === 传送车到 Trigger3 (不触发 _w7) ===
    if(vehiclePtr&&!vehiclePtr.isNull()){
        var t3Pos=vc(objV(t3Ptr),"get_global_position",[]);
        vc(objV(vehiclePtr),"set_global_position",[t3Pos.ret]);
        console.log("\n[*] Teleported vehicle to Trigger3");
        tickAll(200);
        checkColl("AFTER teleport to Trigger3 + 200 Ticks");
    }

    // === 触发 Part2 ===
    console.log("\n[*] Triggering Part2 (Trigger3._w7)...");
    callW7(t3Ptr);
    var fl2=vs(vc(objV(label2Ptr),"get_text",[]).ret);
    console.log("[*] Part2: "+fl2);

    tickAll(200);
    checkColl("AFTER Part1+Part2 + 200 Ticks");

    // === 传送车到 Trigger4 ===
    if(vehiclePtr&&!vehiclePtr.isNull()){
        var t4Pos=vc(objV(t4Ptr),"get_global_position",[]);
        vc(objV(vehiclePtr),"set_global_position",[t4Pos.ret]);
        console.log("\n[*] Teleported vehicle to Trigger4");
        tickAll(200);
        checkColl("AFTER teleport to Trigger4 + Part1+Part2 + 200 Ticks");
    }

    // === 大量 Tick ===
    console.log("\n[*] Mass tick 2000...");
    tickAll(2000);
    checkColl("AFTER 2000 more Ticks");

    // === 检查最终 Label2 ===
    var finalL2=vs(vc(objV(label2Ptr),"get_text",[]).ret);
    console.log("\n[*] Label2 final: "+finalL2);

    console.log("\n=== EXPERIMENT DONE ===");
}catch(e){console.log("[!] "+e.stack);}
}

setTimeout(setup,1000);

编写脚本扫描PART2PART3的碰撞状态,分析得到:

monitoringCollisionShape需要开启的因素
Part2truedisabled1 个:启用 CollisionShape
Part3falsedisabled2 个:启用 monitoring + 启用 CollisionShape

8.2 分析方法

Part2 主要通过脚本层还原条件,再结合运行时对象调用控制节点状态,最终使区域进入事件成立,并读取界面文本中的 flag。

从原稿恢复出的 flag 拼接格式为:flag{sec2026_PART2_<rv>}

这表明其最终生成仍在脚本层完成,原生层更多承担保护与部分辅助逻辑。

8.3 与反调试绕过的关系

Part2 的关键经验是:在完成总体保活后,后续题目求解尽量走“引擎对象层”而非“安全模块 patch 层”。这也是为什么在前面先要把反调试绕过做稳,否则后面即便逻辑看懂,也无法稳定复现去做算法的验证

8.4 算法分析

结论先行,Part2 是一个基于 自定义 SPN (Substitution-Permutation Network) 分组密码 的加密挑战。玩家触发红色方块后,游戏生成一个 8 字符 hex token,通过 native 层的 GameExtension.Process()方法加密,产生 32 字符 hex 的 flag 后缀。

GDScript 层:

# trigger3.gd
func _w7(_ar):
    var _raw = str(_lt.text).substr(7)       # 去掉 "Token: " → 8 hex chars
    var _buf = _raw.to_utf8_buffer()          # 8 bytes ASCII
    var _rv = _gx.Process(_buf)               # 调用 native Process()
    _lb.text = "flag{sec2026_PART2_" + _rv + "}"

libsec2026.so:

sub_97704  (Process 回调入口)
  ├── variant_to_native(input)
  ├── sub_A936C  (加密入口)
  │   ├── memcpy(buf, input, 8)      # 复制 token 前 8 字节
  │   ├── memcpy(buf+8, input, 8)    # 复制一次,形成 16 字节明文
  │   ├── 加载密钥材料 @0x58550 (16字节) 和 IV @0x58600 (16字节)
  │   ├── sub_A7900(ctx, key, iv)    # 密钥调度初始化
  │   │   ├── sub_A7DE8(ctx, key)    # 密钥扩展 (CFF 混淆)
  │   │   └── memcpy(ctx+192, iv)    # 复制 IV
  │   ├── sub_A7194(ctx, pt, 16)     # 加密 (CFF 混淆)
  │   └── hex_encode(output)         # 输出 32 字符 hex
  └── set_return_value(result)

关键发现:

  • 输入: 8 字节 ASCII token 被复制两次,形成 16 字节明文 token || token
  • 核心函数: sub_A7194 (加密) 和 sub_A7DE8 (密钥扩展) 均被 CFF 混淆
  • CFF 特征: BR Xn 间接跳转 + 状态变量驱动的 switch 分发,IDA 无法有效反编译

8.4.1 S-box 提取

S-box 位于 BSS 段偏移 0x183700 (256 字节)。静态二进制中全为 0,运行时由初始化线程填充。

var sbox = sB.add(0x183700);
var SBOX = [];
for (var i = 0; i < 256; i++) SBOX[i] = sbox.add(i).readU8();

提取结果:

S-box (hex, 16×16):
f1 12 5d c6 a7 8a 6a 48  da 0f 11 3b 3c b3 2d 27
64 2a d3 13 dc 68 c9 cf  17 2e d0 cd c2 b7 c7 f5
66 9b 1c 73 e0 56 dd a5  3a c8 3d d2 b0 e5 ee ec
82 df 43 b8 61 50 ef b1  35 72 8f c3 ea 84 ae 86
67 23 c4 a6 07 4d ed b4  24 7b 22 e6 ba 76 db 9a
49 77 30 eb 97 a0 60 1b  51 58 fb 52 7e 69 7f f8
15 bf bb 28 a8 70 55 31  b9 ab 7c 80 fe 53 d1 05
93 fa 6d f7 ce 45 b5 02  21 bc 4b 19 5e 79 4a 90
e7 47 98 16 6b 20 5a 89  d7 42 f2 ca ff a9 0e a2
46 c5 e9 9e 18 0c 7a 59  54 5f 32 c1 e4 65 44 9d
ad 63 b2 cb 4c 2f a1 1f  9f 4e 04 e3 39 0d d9 71
fc 34 78 8d f4 33 7d f9  36 25 bd 38 b6 de 75 5c
83 9c 8b 40 d4 aa 1d 06  00 a4 6c 88 a3 3f cc f3
d5 be 81 0b 37 94 14 8c  2b 92 fd 1e e1 03 d6 4f
c0 09 74 5b e2 62 af 10  6e 85 f6 96 8e 6f 08 f0
99 95 0a 1a ac 91 d8 29  26 3e e8 01 2c 87 41 57

8.4.2 unicorn辅助分析

由于 CFF 混淆使得静态逆向极其困难,采用 Unicorn CPU 模拟器 作为分析工具,直接加载二进制并执行原始机器码。

from unicorn import *
from unicorn.arm64_const import *

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

# 加载 SO 到 0x0
mu.mem_map(0, 8*1024*1024, UC_PROT_ALL)
mu.mem_write(0, binary_data)

# 栈空间
mu.mem_map(0x200000000, 16*1024*1024, UC_PROT_ALL)
mu.reg_write(UC_ARM64_REG_SP, 0x200000000 + 16*1024*1024 - 0x1000)

# TLS (线程局部存储)
mu.mem_map(0x300000000, 0x10000, UC_PROT_ALL)
mu.reg_write(UC_ARM64_REG_TPIDR_EL0, 0x300000000)

# Mock 函数 (RET 指令)
mu.mem_map(0x400000000, 0x1000, UC_PROT_ALL)
mu.mem_write(0x400000000, b'\xc0\x03\x5f\xd6')  # RET

# 手动写入 S-box 到 BSS
mu.mem_write(0x183700, SBOX)

# GOT 表: __memcpy_chk → mock memcpy
mu.mem_write(0x166920, struct.pack('<Q', MEMCPY_HOOK_ADDR))

密钥调度初始化:

# 调用 sub_A7900(ctx, key_ptr, iv_ptr)
mu.reg_write(UC_ARM64_REG_X0, CTX_ADDR)
mu.reg_write(UC_ARM64_REG_X1, 0x58550)  # 密钥材料
mu.reg_write(UC_ARM64_REG_X2, 0x58600)  # IV
mu.reg_write(UC_ARM64_REG_LR, MOCK_RET)
mu.emu_start(0xA7900, MOCK_RET)

# ctx[0..191] = 密钥调度 (12×16字节)
# ctx[192..207] = IV
saved_ctx = bytes(mu.mem_read(CTX_ADDR, 256))

加密执行:

# 调用 sub_A7194(ctx, plaintext, 16)
mu.mem_write(CTX_ADDR, saved_ctx)  # 恢复上下文
mu.mem_write(PT_ADDR, plaintext_16)
mu.reg_write(UC_ARM64_REG_X0, CTX_ADDR)
mu.reg_write(UC_ARM64_REG_X1, PT_ADDR)
mu.reg_write(UC_ARM64_REG_X2, 16)
mu.emu_start(0xA7194, MOCK_RET)

ciphertext = bytes(mu.mem_read(PT_ADDR, 16))

关键注意: sub_A7194 会修改 ctx 缓冲区,每次加密前必须恢复 saved_ctx

状态缓冲区写入追踪:

def hook_mem_write(uc, access, addr, size, value, data):
    if PT <= addr < PT + 16:
        pc = uc.reg_read(UC_ARM64_REG_PC)
        offset = addr - PT
        write_log.append((classify_pc(pc), offset, value & 0xFF))

通过将写入按 PC 地址范围分类,识别出以下操作:

PC 范围操作名说明
0xaaad0-0xaaae0INIT1初始 XOR (第一组)
0xa7ae0-0xa7b00INIT2初始 XOR (第二组)
0xaad10-0xaad30ARKAddRoundKey
0xa8420-0xa8430SBSubBytes (列优先写入顺序)
0xa92d0-0xa92e0PERM字节置换 + 密钥XOR
0xaadf0-0xaae50SRShiftRows (部分)
0xa7050-0xa7160MCMixColumns (含 gf_mul 调用)
0xa8640-0xa8660FINAL最终轮 XOR

进一步分析内存里面的操作序列,对 PT=0x30*16 加密产生 58 次写入组,对应以下结构:

INIT1 → INIT2 → ARK                          # 初始化 (3步)
[SB → PERM → SR → MC → ARK] × 10           # 中间轮 (50步)
SB → PERM → SR → ARK → FINAL               # 最终轮 (5步)

加密总计 11 轮: 10 个完整轮 + 1 个无 MixColumns 的最终轮。悲催的是!CFF 混淆将标准的 ShiftRows 拆分成了两个独立的操作:

  1. PERM (PC ≈ 0xa92dc): 固定字节置换 + 密钥异或
  1. SR (PC ≈ 0xaadf8): 另一组字节置换

通过恒等 S-box 差分分析确认: PERM 置换 + SR 置换 = 已知的 ShiftRows 置换:

PERM: [3,7,11,15,2,6,10,14,1,5,9,13,0,4,8,12]
SR:   [0,5,14,11,4,9,2,15,8,13,6,3,12,1,10,7]
组合: output[i] = input[[12,11,5,2,13,8,6,3,14,9,7,0,15,10,4,1][i]]

PERM 操作的隐含密钥: PERM 不仅做置换,还对每个字节异或一个按轮变化的常量。这个密钥被 CFF 隐藏在分发逻辑中,无法通过纯静态分析提取。

2.4.5.3 轮函数逐步还原

先进行SubBytes 验证,比较 ARK 输出(SB 输入)与 SB 输出,逐字节检查 SBOX[input[i]] == output[i]

# 对多组测试向量验证
for i in range(16):
    assert SBOX[state_before_sb[i]] == state_after_sb[i]
# 结果: 全部 PASS

轻易分析得出 SubBytes 就是标准的 S-box 字节替换,无额外操作。

ShiftRows 置换的提取,选择用恒等 S-box 做差分分析。将 S-box 替换为 sbox[i]=i,使 SubBytes 变为恒等操作。然后设置一个字节为 1、其余为 0,追踪差异位置如何在 PERM→SR 组合操作中移动。

输入差异在 byte 0 → 组合后差异在 byte 11
输入差异在 byte 1 → 组合后差异在 byte 15
输入差异在 byte 2 → 组合后差异在 byte 3
...

最终得到置换表 (output[i] = input[perm[i]]):

SR_PERM[16] = {12, 11, 5, 2, 13, 8, 6, 3, 14, 9, 7, 0, 15, 10, 4, 1};

还需要确认MixColumns 系数,先去ida静态分析尝试,在 PC=0xa7058 附近的代码中,可以看到 BLR X19 调用 gf_mul (sub_A96F0),并且参数中出现常量 6, 3, 5, 2:

a7064  MOV  W0, W22
a7068  MOV  W1, #6        ; coefficient = 6
a706c  BLR  X19            ; gf_mul(state_byte, 6)
...
a7078  MOV  W1, #3        ; coefficient = 3
...
a7088  MOV  W1, #5        ; coefficient = 5
...

接下来组差分验证对多个测试向量的 SR→MC 转换做差分分析,确认每个输出字节依赖恰好 4 个输入字节,且依赖系数构成 [6,3,5,2] 的循环矩阵:

MixColumns 矩阵 (GF(2^8)/0x171):
┌           ┐
│ 6  3  5  2 │
│ 2  6  3  5 │
│ 5  2  6  3 │
│ 3  5  2  6 │
└           ┘

对4组连续字节 [0-3], [4-7], [8-11], [12-15] 分别应用

GF(2^8) 有限域分析略过,不可约多项式实际为: 0x171 = x^8 + x^6 + x^5 + x^4 + 1 (不同于标准 AES 使用的 0x11b = x^8 + x^4 + x^3 + x + 1)

uint8_t gf_mul(uint8_t a_in, uint8_t b) {
    uint16_t p = 0, a = a_in;  // 注意: a 必须用 uint16_t
    for (int i = 0; i < 8; i++) {
        if (b & 1) p ^= a;
        b >>= 1;
        a <<= 1;
        if (a & 0x100) a ^= 0x171;  // 模约化
    }
    return (uint8_t)p;
}

AddRoundKey 验证一下,主要是对两个不同明文,计算 MC_output XOR ARK_output,检查是否得到相同的密钥。

k0 = MC_output_test0 XOR ARK_output_test0  # 测试 0
k1 = MC_output_test1 XOR ARK_output_test1  # 测试 1
assert k0 == k1  # 校验对比: 纯 XOR 操作 (fuqiuluo)

有效轮密钥提取:

CFF 混淆将 ShiftRows 拆分为 PERM + SR 两步,且 PERM 步骤在置换的同时异或了一个按轮变化的密钥。这个密钥无法通过纯静态分析提取。如果忽略 PERM 密钥,直接使用 SB → 组合SR → MC → ARK 结构和 ctx 中的标准轮密钥,加密结果完全错误。

由Claude和Gemini 3.1 Pro交流我们得到:PERM 密钥 K_perm 在 MC 之前,而 MC 是线性的!材料全丢给AI,分析过程如下:

MC(SR(permute(SB(state)) ⊕ K_perm))
= MC(SR(permute(SB(state)))) ⊕ MC(SR(K_perm))
= MC(combined_SR(SB(state))) ⊕ MC(SR(K_perm))

因此可以定义 有效轮密钥:

eff_key[r] = MC(SR(K_perm_r)) ⊕ ARK_r

这样轮函数简化为标准形式:

state = MC(combined_SR(SB(state))) ⊕ eff_key[r]

利用 Unicorn 追踪得到每轮 ARK 前后的状态:

for r in range(1, 11):
    prev_state = ark_states[r-1]  # 上一轮 ARK 输出 = 本轮 SB 输入
    after_ark  = ark_states[r]    # 本轮 ARK 输出

    # 用纯 Python 计算 SB → SR → MC
    sb_out = SubBytes(prev_state)
    sr_out = ShiftRows(sb_out)      # 使用组合置换
    mc_out = MixColumns(sr_out)

    # 有效密钥 = MC 输出 ⊕ ARK 输出
    eff_key[r] = mc_out ⊕ after_ark

验证: 对两组不同明文分别提取,10 个有效轮密钥完全一致,确认提取正确。

最终轮无 MixColumns:

last_ark = ark_states[10]     # 倒数第二个 ARK (= 最终轮 SB 输入)
ciphertext = final_output

final_key = ShiftRows(SubBytes(last_ark)) ⊕ ciphertext

注意: 必须使用倒数第二个 ARK 状态 (ark_states[-2]),不是最后一个。因为最后一个 ARK 状态 (ark_states[-1]) 是最终轮内部的中间状态,不是 SB 的输入。

恒等 S-box 分析的陷阱:

将 S-box 替换为恒等映射 sbox[i]=i 后,密钥调度 sub_A7DE8 也使用了该 S-box,导致轮密钥完全不同。因此恒等 S-box 下的分析不能直接与真实 S-box 下的结果混用——只能用于提取线性结构(置换、扩散矩阵),不能用于提取密钥。

gf_inv 的循环次数分析错误:

// 错误: 循环 253 次 → 计算 a^253
for (int i = 0; i < 253; i++) r = gf_mul(r, a);

// 正确: 循环 254 次 → 计算 a^254 = a^(-1) (阶为 255)
for (int i = 0; i < 254; i++) r = gf_mul(r, a);

逆 MixColumns 矩阵的时候全部算错,解密完全失败。正向加密不受影响。

8.5 工具清单

文件功能
part2/part2_final.c最终纯C实现 (加密+解密, 已验证)
part2/part2_solve.pyUnicorn 模拟器求解器 (验证用)
part2/trace_correct.py按操作分组的状态追踪
part2/extract_effective_keys.py有效轮密钥提取与验证
part2/trace_ops.py恒等S-box差分分析
part2/trace_ops2.py全尺寸写入追踪
part2/debug_algo.py单步对比调试
part2/emu_analyze.pyUnicorn 分析框架
tool/bypass_libsec2026.js反调试绕过 (仅改RW段)
part2/trigger_part2.js触发 Part2 碰撞
part2/dump_keyschedule_direct.js直接调用 sub_A7900 提取密钥

9 Part3 分析

9.1 题目特点

Part3 相比前两部分明显更偏原生层与混淆执行。根据调用栈与原生分析结果,可以定位到 flag{sec2026_PART3_<L><R>} 的格式化输出点,并确认最终 flag 来自一个多层混淆保护下的自定义计算过程。

分析根据之前对碰撞的分析结果,方块不仅隐形还没有碰撞,我们先恢复碰撞,传送到方块旁边,无方块显形截图

  • MeshInstance3D: BoxMesh,visible=true, scale=(1,1,1)
  • 材质: transparency 设为 0(不透明)
  • 碰撞: CollisionShape3D 启用
  • 位置: (3.75, 4.59, -16.56)

9.2 Flag 生成定位

🤔GodotScript层没有发现任何PART3到痕迹,有两个思路可以选,选择全部移交VM实现(例如纯vm到libc)或者提供一个vm到handler直接使用libc的vsprintf,通过对 memcpyvsprintf 一类函数下 hook,这里hit到了所以说很显然是后者,结合调用栈回溯:

=== CALL STACK ===
	#0 libsec2026.so+0xaa734 (0x79d62f7734)
  #1 libsec2026.so+0xaa734 (0x79d62f7734)
  #2 libsec2026.so+0xaa88c (0x79d62f788c)
  #3 libsec2026.so+0x1383b8 (0x79d63853b8)
  #4 libsec2026.so+0x1383b8 (0x79d63853b8)
=== END STACK ===

经过AI静态分析做的函数的重命名我们可以了解到aa88cvm4_entry_handler ,也就是vspintf到地方,结合其他地方的调用栈分析可以得到以下信息,定位到:

  • 外层状态机入口。
  • VM4 / VM2 / VM3 多层解释执行结构。
  • 最终格式串 "%08x%08x" 的拼接点。

最后可将输出过程概括为:

  • 对输入 token 做两次 VM 计算,分别得到 L 与 R。
  • 使用格式串输出为 8 位十六进制拼接。
  • 组合成 flag{sec2026_PART3_<L><R>}

9.3 为什么引入 Unicorn

Part3 的难点不在于定位输入输出点,而在于中间计算过程被四层结构同时保护:

  1. SBC0 字节码层(VMP)
  1. 外层 CFF 状态机。
  1. 内层 CFF 状态机。
  1. ARM64 原生指令层。

静态分析会陷入海量间接跳转与状态跳转,效率极低。为此构建了 Unicorn 模拟环境,将其作为分析 oracle,而不是最终求解载体。进行一次简单的trace分析我们可以得到:

偏移名称说明
0xA9A7Csub_A9A7C外层状态机入口
0xAA758vm4_entryVM4 入口(调 vm2 两次)
0xAA6ACvsprintf_wrapper最终格式化输出点
0xAA0CCmemcpy_token1复制 token 到 0x1836C0
0xAA0F0memcpy_token2复制 token 到 0x1836C8
0x1385BCvm2_entryVM2 入口(12 opcodes)
0x13879Cvm3_entryVM3 入口(12 opcodes)
0x63D88SBC0_headerSBC0 字节码数据(header + 8192 bytes)
0x1836C0input_buffer输入缓冲区(token×2)
0x1836E0output_buffer输出缓冲区(flag hex)
0x183920fmt_string格式串 "%08x%08x"(运行时 XOR 解密)

9.4 混淆结构识别

使用 Unicorn 的范围化 Hook 替代全局 per-instruction Hook:

# 慢(34M 指令全 hook): ~30-60s
mu.hook_add(UC_HOOK_CODE, handler)

# 快(仅 hook PLT + 停止点): ~0.7s
mu.hook_add(UC_HOOK_CODE, plt_handler, begin=0x300000000, end=0x300010000)
mu.hook_add(UC_HOOK_CODE, stop_handler, begin=0xAA6AC, end=0xAA6B0)

0.7s/次的 oracle 速度使大规模差分分析成为可能。

Layer 1: SBC0 字节码 (0x63D88)
  ├── 8192 字节虚拟指令, 9 种操作码 (0x00-0x09)
  └── 由 Layer 2 解释执行

Layer 2: 外层 CFF 状态机 (0x13D5B4)
  ├── 调度表: 0x180E80 (state → index)
  ├── 跳转表: 0x180658 (index → handler)
  ├── 11 个活跃状态, 87 个 handler 函数
  └── 循环: 73→6→0→50→29→10→68→23→31→79→53

Layer 3: 内层 CFF 状态机 (0x13DE90)
  ├── 调度表: 0x1813A0
  ├── 跳转表: 0x180FE0
  ├── 34 个活跃 handler
  └── 每条字节码指令触发 45-66 次内层转换

Layer 4: ARM64 原生指令
  └── 44 种指令编码类, 3400 万条指令总执行量

在模拟与跟踪后,识别出:

  • AI辅助分析得到 SBC0 共使用多类字节码操作。
操作码处理器状态Handler 地址功能出现次数
068 (默认)0x13D810加载/取字段值8180
1120x13DD30XOR 解码 (0xA5A5/0x5A)2348
2170x13E600比较/条件分支1282
3810x13E094SUB 0x1337/XOR 0xCC 解码6800
468 (默认)0x13D810算术运算6948
5850x13E254MADD 变换 (0x53/0x2B/0x6D/0x72)112
668 (默认)0x13D810移位操作4982
7--跳转/分支116
968 (默认)0x13D810复杂调用1068
  • 每条 SBC0 字节码触发 45-66 次内层 CFF 状态转换,总计 84.8 万次。
  • 存在明显的多轮迭代结构。
  • 轮间状态通过某些固定寄存/缓冲位置传递。

9.5 黑盒与差分分析结果

利用 0.7s/次的快速 Unicorn Oracle,系统性收集了以下数据:

  1. 19 组设备 oracle 对 — 从 Frida 在真机上触发收集
  1. 256 组 byte[0] 扫描 — byte[0]=0x00..0xFF,其余=0x00
  1. 16 组 ASCII 位置扫描 — 每个位置 '0'-'f' 遍历

通过大量 oracle 数据采集与差分比较,可以确认:

  • 算法不是简单线性 XOR/ADD 型变换。
  • 存在明显雪崩效应。
  • 输入字节之间并非独立。
  • 并不匹配常见标准算法,如 TEA/XTEA/SipHash/FNV 等。

9.5.1 堆内存写入追踪

在 Unicorn 中 hook 所有 memcpy 调用,追踪对堆结果区域 0x40032E700 的写入:

# 当 memcpy 目标落在结果区域时记录
if d2 == RESULT_BASE + 0x98 and n == 8:
    counter = struct.unpack('<I', dd[:4])[0]
    vals = [read_uint32(RESULT_BASE + off) for off in range(0x10, 0x90, 8)]
    round_data.append((counter, vals))

追踪发现结果区域有 29 轮写入(计数器 1→29),每轮写入 16 个 uint32 值(v[0]→v[15])加上保存值和计数器:

Round 0 (init):  v[0]=L_in, v[1..15]=0
Round 1 (cnt=2): v[0]=0x03030300, v[1]=0xFC60694A, ..., v[15]=0x522BDB51
Round 2 (cnt=3): v[0]=0x22BDB510, v[1]=0x1C1B1B5A, ..., v[15]=0x5531A5D7
...
Round 28(cnt=29): v[0]=0x50403970, ..., v[7]=0x66D6A4FA(=R), v[15]=0xB45B42CB(=L)

9.5.2 轮间转换: v[15] << 4:

Round N 的 v[15] << 4 = Round N+1 的 v[0]

验证:
  Round 1 v[15] = 0x522BDB51, <<4 = 0x22BDB510 = Round 2 v[0] ✓
  Round 2 v[15] = 0x5531A5D7, <<4 = 0x531A5D70 = Round 3 v[0] ✓
  Round 27 v[15] = 0xF5040397, <<4 = 0x50403970 = Round 28 v[0] ✓

9.5.3 多输入差分比较

对 9 组不同输入(000000001000000001000000, ..., 00000001)分别运行 oracle,提取每轮 16 个值,逐位对比。

9.5.3.1 固定关系发现

通过差分确认 4 个固定加法常量:

v[1]  = v[0]  + 0xF95D664A  (C0)  — 全部 29 轮, 全部输入一致 ✓
v[5]  = v[4]  + 0x12AA364C  (C1)  ✓
v[9]  = v[8]  + 0x33AD3CEE  (C2)  ✓
v[13] = v[12] + 0xAABBCCDD  (C3)  v[3]  = v[1]  ^ v[2]   ✓
v[11] = v[9]  ^ v[10]  ✓

XOR 关系:

v[3]  = v[1]  ^ v[2]   ✓
v[11] = v[9]  ^ v[10]  ✓

9.5.3.2 递推关系发现

v[7]_round_n  - v[6]_round_n  = v[7]_round_{n-1}   ✓ (全部 28 轮)
v[15]_round_n - v[14]_round_n = v[15]_round_{n-1}  ✓

即:
v[7]  = v[6]  + v7_accumulator   (累加器)
v[15] = v[14] + v15_accumulator  (累加器)

9.5.3.3 VM 上下文槽位分析

追踪 7 个 VM 上下文槽位(堆地址 0x4003376D8-0x400337708),发现:

slot[0] = K × 0x29E59C9F  ← 等差数列! 轮常量 RC, 不依赖输入!
slot[1] = R_in            ← token 后 4 字节
slot[2] = (K-1) × 0x29E59C9F  ← 前一轮的 RC
slot[3..6] = 常量 (0, 1, 0x17008, 0)

验证

Round 1: slot[0] = 0x29E59C9F = 1 × 0x29E59C9F ✓
Round 2: slot[0] = 0x53CB393E = 2 × 0x29E59C9F ✓
Round 3: slot[0] = 0x7DB0D5DD = 3 × 0x29E59C9F ✓
差值恒为 0x29E59C9F ✓

9.5.3.4 v[2] 和 v[4] 公式推导

有了轮常量 RC = K × 0x29E59C9F,验证:

# v[2] = RC + state (state = prev_v15 = 累加器)
v2_predicted = (RC + prev_v15) & 0xFFFFFFFF
# 全部 28 轮 × 4 种输入 = 112 次验证, 全部通过 ✓

# v[4] = state >> 7
v4_predicted = prev_v15 >> 7
# 全部通过 ✓

9.5.3.5 第二阶段公式推导

发现 v[8], v[10], v[12] 均由同一轮的 v[7] 派生:

v[8]  = v[7] << 6    # 左移 6 位 ✓
v[10] = RC + v[7]     # 与 v[2] 相同结构! ✓
v[12] = v[7] >> 5     # 右移 5 位 ✓

验证(Round 1, token "00000000"):

v[7]  = 0xE5AF6359
v[8]  = 0xE5AF6359 << 6 = 0x6BD8D640 ✓ (截断 32 位)
v[10] = 0x29E59C9F + 0xE5AF6359 = 0x0F94FFF8 ✓
v[12] = 0xE5AF6359 >> 5 = 0x072D7B1A ✓

9.5.3.6 v[6] 和 v[14] 推导

由 XOR 和递推关系:

v[6]  = v[3] ^ v[5]     ✓ (已知 v[3]=v[1]^v[2], v[5]=v[4]+C1)
v[14] = v[11] ^ v[13]   ✓ (已知 v[11]=v[9]^v[10], v[13]=v[12]+C3)

至此,16 个值全部由 v[0](即 state<<4)、RC、v7_acc、v15_acc 确定。算法完全闭合。

9.6 算法还原

经过密码学的攻击比较,我们可以得到完整的流程为:

输入: 8 字节 ASCII hex token (如 "fd092380")
拆分: L_in = token[0:4] 作为 uint32 LE = 0x39306466
      R_in = token[4:8] 作为 uint32 LE = 0x30383332

state   = R_in
v7_acc  = L_in
v15_acc = R_in

for K = 1 to 28:
    RC = K × 0x29E59C9F        // 轮常量 (mod 2^32)
    v0  = state << 4            // 左移 4 位
    v1  = v0 + 0xF95D664A       // 加常量 C0
    v2  = RC + state             // 轮常量 + 状态
    v3  = v1 ⊕ v2               // XOR
    v4  = state >> 7             // 右移 7 位
    v5  = v4 + 0x12AA364C        // 加常量 C1
    v6  = v3 ⊕ v5               // XOR
    v7  = v6 + v7_acc            // 累加
    v8  = v7 << 6                // 左移 6 位
    v9  = v8 + 0x33AD3CEE        // 加常量 C2
    v10 = RC + v7                // 轮常量 + v7
    v11 = v9 ⊕ v10              // XOR
    v12 = v7 >> 5                // 右移 5 位
    v13 = v12 + 0xAABBCCDD       // 加常量 C3
    v14 = v11 ⊕ v13             // XOR
    v15 = v14 + v15_acc          // 累加
    // 更新累加器
    v7_acc  = v7
    v15_acc = v15
    state   = v1
    
L = v15_acc   // vsprintf 的 X3 参数
R = v7_acc    // vsprintf 的 X4 参数
flag = "flag{sec2026_PART3_" + hex(L, 8) + hex(R, 8) + "}"

这是一个双阶段 Feistel-like (XTEA) 累加密码

  • 第一阶段:v7 = F1(state, RC) + v7_acc,其中 F1 使用移位(<<4, >>7)、加法(C0, C1)、XOR
  • 第二阶段:v15 = F2(v7, RC) + v15_acc,结构完全对称,使用移位(<<6, >>5)、加法(C2, C3)、XOR
  • 轮常量 RC = K × 0x29E59C9F(线性递增)
  • 28 轮后 v7_acc 和 v15_acc 分别输出为 R 和 L

9.7 工具清单

文件功能
part3/part3_final.c最终纯C实现 (28 轮加密, 19 组验证通过)
part3/emu_part3.pyUnicorn 模拟器 oracle (基础版)
part3/part3_fast_oracle.py快速 oracle (0.7s/次, 范围化 Hook)
part3/part3_trace_memcpy.pymemcpy 追踪 (数据流分析)
part3/part3_round_func.py29 轮数据完整捕获
part3/part3_structure.py多输入差分分析 (中间值比较)
part3/part3_extract_algo.py中间值提取与关系验证
part3/part3_trace_cff.pyCFF 状态机追踪 (11 态循环)
part3/part3_vm_decode.pySBC0 字节码执行追踪
part3/part3_analyze.py黑盒算法分析
part3/part3_match_algo.py已知算法匹配测试
part3/part3_result_trace.py堆结果区域监控
part3/part3_field_trace.pyVM 字段值追踪
part3/part3_decode_sbc0.pySBC0 指令流解码
part3/part3_trace_part3.py算术指令追踪
part3/part3_trace_part3_discover.py代码路径发现
part3/oracle_data.py35 组 oracle 数据集
part3/oracle_part3.jsFrida 真机 oracle 收集
part3/stalker_part3.jsStalker 执行路径追踪
part3/probe_part3.jsFrida 场景探测
part3/trigger_part3.js碰撞触发脚本