首页
社区
课程
招聘
[原创]第六题:酉时·书院迷局 从双分支耦合到代数逆推
发表于: 2天前 270

[原创]第六题:酉时·书院迷局 从双分支耦合到代数逆推

2天前
270

题目给出一个 Android APK。界面上只有一个输入框和一个 VERIFY 按钮,目标是找到
一组合法输入,使未修改的原版程序提示:

这题真正麻烦的地方不是某一个算法特别复杂,而是 native 层同时布置了多套状态机、
opaque helper、完整性检查和真假分支。输入又被拆成奇偶两组:odd 分支决定 even 的
目标,even 分支反过来决定 odd 的 salt。只看任意一边,都会得到一个无法独立验证的
“半成品答案”。

最终没有修改 APK,也没有绕过返回值。整个解法是先确定程序的真实正向数据流,再从
odd 的终端约束逆出 odd25,随后求出 even 的状态参数,最后把两组字节重新交织回原始
50 字节输入。

求解顺序可以概括为:

入口在 com.autorun.kctf.MainActivity。Java 层先规范化输入,然后调用 JNI:

因此输入有三个直接约束:

JNI 校验对象是十六进制字符串解码得到的 50 个字节,不要求这些字节本身可打印。

native 会通过 Java 方法读取 APK/ELF 中的 .kctfguard 元数据。Java 先用
nativeShareMask 遮罩前 16 字节,native 再用相同掩码恢复。

对未修改 APK,真实运行时材料为:

后面的 guard 校验值、地址和长度只是完整性材料,不是隐藏 flag。

50 字节输入通过三次 ARM64 VLD2 按下标奇偶拆成两个 25 字节数组:

两支不是独立的:odd 先决定 even 的摘要目标,even 再决定传给 odd 最终校验器的
salt。

下面这张图只描述 MainActivity.nativeProcessInput(byte[50]) 内部的主校验数据流。
even25 完成可逆预处理后会写入一个 25 字节中间缓冲区,后续四个 stage 都从这里
读取状态。

真实验证链流程图

看起来像环形依赖,但方向是确定的:

native 中有大量 opaque helper 和伪分支。通过静态化简、Unicorn/angr 与真机动态
回归,可归纳为:

这一步很重要:如果保留所有伪分支直接符号执行,状态空间会迅速爆炸;化简后真正的
根返回门只有 even_okodd_ok

odd[0:25] 后补 7 个 0x5a,组成 32 字节并解释成四个小端 64 位字。
sub_5FE8 的单轮可写成:

跑满 12 轮后得到 96 字节派生状态 O。用于 even 目标变换的 selector 是:

odd 最终校验器的调用形式为:

尾部虽然很长,但返回 1 实际要求以下检查同时成立:

其中一组 sub_7400 tag 的目标变换对 salt 可逆,已经足以唯一确定:

另一组给出同一结果,是冗余校验。固定 salt=0xd7 后,tag 与 mask 约束可用
Z3/PySAT 反解 A,B,再逆 12 轮 ARX 得到 odd25。

最终 odd 候选为:

具体回归值:

该 odd 候选在真机上强制 salt=d7 时,sub_8F90 返回 1。

对该 odd25:

native 先生成基础目标:

再执行 sub_EDCC

得到最终 even 目标:

even25 先经过一个严格可逆的逐字节置换,生成 25 字节中间缓冲区。下面简记为
buf[25]

这层只是双射,不参与成功判定。因此可以先求出中间缓冲区,再按相反顺序逐字节逆回
even25。

Stage 1~4 的局部检查 不是最终返回门

所以不能从控制流上断言“四个 stage 必须全过”。正确做法是把全通过路径作为一个候选
构造,求解后再检查最终摘要和 salt。事实证明本题的预期解确实位于这条自然路径上。

Stage 1 的自然通过前缀为:

Stage 2 以 0xe57305c7 为 xorshift32 seed,生成并散布 256 字节流。自然路径的
S-box 前 16 字节为:

因此 sbox[0] = 0x85

Stage 3 从中间缓冲区读取两个 32 位参数:

LCG 为:

连续生成 32 个 key,再对三组明文做 16 轮 TEA-like ARX:

自然路径取标准 TEA delta:

此时只剩 32 位 seed0。通用 64 位 QF_BV 求解很慢,因此使用 CUDA 完整枚举
2^32 个 seed,只用第一组向量筛选,再用另外两组独立回验:

枚举命中结果为:

于是:

0xdeadc0de 也印证这是预期构造值,而不是偶然碰撞。

Stage 4 的 8 个等式可直接用 Z3 求出:

对应中间缓冲区尾部:

核心初始四个 32 位字固定为:

共执行 12 轮,两条 lane 分别使用偶数和奇数 key。单条 lane 的轮函数为:

完成前 10 轮后、进入第 11 轮前,四个 word 各执行:

代入求出的参数:

得到:

与 odd selector 产生的 expectedDigest 完全一致。

even 还必须自然产生 salt=0xd7,否则已解出的 odd25 仍不能通过。

代入:

得到:

至此 even 摘要和 odd salt 两条耦合约束同时闭合。

完整的 25 字节中间缓冲区为:

按第 6 节的字节关系逆变换,恢复出 even25:

even[0], odd[0], even[1], odd[1], ... 交织:

最终得到:

在未修改 APK 中被动观测摘要、salt 与 odd 返回值,同时直接调用
MainActivity.nativeProcessInput(byte[50])

成功结果:

停止 Frida,彻底冷启动应用,输入 100 字符并点击真实 VERIFY 按钮,Toast 显示:

这排除了“模型正确但真机路径不一致”、hook 修改寄存器、APK 被修改等情况。

原始 APK 冷启动验收

题目 APK 与设备安装包的 SHA-256 均为:

这题的难点不在某一个轮函数,而在于 odd 和 even 两条分支之间存在双向依赖,同时
四个 stage 的局部检查又只负责选择后续状态,并不直接决定 JNI 返回值。把 opaque
helper 和伪分支化简后,真正的成功条件可以收束为两项:even 生成的 16 字节摘要必须
等于 odd 决定的目标摘要,odd25 还必须在 even 自然生成的 salt 下通过 sub_8F90


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

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回