-
-
[原创]第六题:酉时·书院迷局 WP
-
发表于: 1天前 389
-
证据标记:
APK:
Java 输入要求:
JNI 将 50 字节按下标奇偶拆成两条 25-byte 通道:
题解中使用的 canonical 接受输入为:
对应:
原始 ARM64 JNI 在 clean 环境自然执行:
原 APK 真机也接受该输入。
canonical local94 中有 14 个后来被截掉或等价化的自由 bit,因此 canonical effective-state 分支精确对应:
个不同 100-hex 输入。
例如下面这个和 canonical 肉眼差别很大的输入,也已经在原 ARM64 JNI 上自然 return=1:
所以本文会把上面的题解字符串称为 canonical accepted input,而不是数学意义上的唯一 flag。
需要说明边界:目前已经严格证明 canonical effective-state family 恰有 16384 个输入,但尚未穷尽全部 514 张 possible T-table × 64-bit effective state,因此不能声称“全程序总接受数恰好就是 16384”。
Java Activity 只承担三类工作:
Native JNI 入口:
最终 odd verifier 被 .eh_frame 覆盖为完整范围:
Ghidra/JADX 会因为计算跳转、tail block、假 CFG 把它切成多个函数,因此本题必须优先信任:
而不是 decompiler 自动函数边界。
ELF note / comment 还能确认工具链:
没有 .note.gnu.property 声明整库 BTI/PAC。.text 开头少量 BTI c 主要落在 CRT glue,0x13cbc 的 BTI 则属于 clear-cache helper;这些应与作者手写 anti-analysis 分开看。
ELF 中:
.kctfguard 的 96 字节看起来像合法 AArch64 小函数,还放了 SHA-256 IV 一类熟悉常量,但它从不被调用执行。[STATIC]
唯一相关 relocation 是把:
作为内存区间给 Native CRC helper。
因此它本质上是一个伪装成代码的 integrity anchor。
Java 对 section header 篡改还有 fallback:如果找不到 .kctfguard section,会在 .text 中逐字节寻找完整 96-byte guard signature;若 signature 也不存在,则退化为对整个 .text 做 CRC32。当前原始 APK 正常直接走 .kctfguard 路径。[STATIC]
Java 会解出:
但字符串解码函数的第二参数只改变控制状态,不影响真正的输出 byte;所以 classes.dex / META-INF / resources.arsc 只参与控制流烟雾,不进入 material hash。[STATIC]
真正的数据锚仍然是选中的 libkctf.so 及 .kctfguard/.text。
Java 使用 4 个 64-bit salt:
以及经典 PCG LCG:
每个 dword:
代入:
得到:
即:
这 16 字节只有 32 bit 真正自由度。因为:
任取 context 的一个 4-byte word,都能独立反解回同一个:
所以这不是 128-bit KDF,而是 CRC32 的四路 redundant commitment。[ALG]
Java 在跨 JNI 前再 XOR:
因此 Java share 前 16 字节为:
完整 32-byte share:
布局:
JADX 会把一个关键 goto 结构化错,导致看起来 'I' 分支永远不会真正保留。
按 smali 恢复后,状态机可写成:
canonical 结果:
Native 会逐语义重跑同一个状态机;canonical + 20 组随机 share 做过对拍,21/21 完全一致。[DYNAMIC]
Native 内存 guard helper 接收 Java share 的:
但实际只检查:
并对编译时固定的 [guard_start,guard_end) 做 CRC;offset 没有拿去比较真实内存地址。
因此只改 offset、同步重算 d(),原 JNI 仍可 return=1。[DYNAMIC]
任意非零 guard mismatch 经 e960 压成全 1 mask,再把:
XOR 到 context。
b979379e 正是 0x9e3779b9 的 little-endian 字节。
这不是不可逆 reject,而是可预测 fault transform。
如果能直接控制 Java share,可以先把 context 预补偿成:
让 Native poison 后重新恢复 canonical context,原 flag 仍可通过。[DYNAMIC]
但该 128-bit 预补偿 context 不属于正常 Java CRC32→context 的 32-bit image,所以只改 guard bytes、不 patch Java share 逻辑并不能得到它。[ALG]
我们实际构造了另一份 96-byte guard:内容不同,但通过 32×32 GF(2) CRC 补偿保持:
原 Java share、原 flag 不改:
所以 .kctfguard 是 CRC tripwire,不是密码学内容认证。[ALG+DYNAMIC]
Native 将 Java checksum mismatch 写入全局 0x16370;业务层只有 FUN_5fe8 读取它。
clean:
非零时:
因此除了 residual=0 外,只要四字节 XOR=0xa5,也会产生同样的 0x5a padding。
这样的非零 residual 有 2^24 个。
实际把 residual 改成:
原 ARM64 仍自然 JNI=1。[DYNAMIC]
精确验证后的基础 helper:
早期为了保守起见,2cbc/2d70/2e2c/10800 的全局写入被保留;深入分析后可以更强地证明:
只被这组 opaque helper 自己读写,对外返回仍恒 1/0/0/0,没有其它业务观察者。
所以整个 opaque state subsystem 可以整体删除。[STATIC+DYNAMIC]
按原 FDE 统计,仅这些已完全塌缩 helper 就超过:
约占 .text 的 6.5%;还没算 w2/f32c/G3 中后来消掉的大量 Mix→InvMix 与 carry 电路。
曾把 PLT 0x13f00 标成 sysconf,这是错误的。
ELF .rela.plt 明确证明:
两个调用点都:
也就是:
checker只关心:
AArch64 clean 设备低两位对应 FP / ASIMD,正常应满足。
旧 harness 默认 getauxval=0,所以稳定进入诱饵环境,loader key 变成:
修正:
后,loader 自然得到:
此外 dlsym("mprotect") 要返回一个成功的权限切换 stub。只补这两类真实环境语义,不注入任何算法中间值、不手改 PC,原 ARM64 JNI 即可完整 return=1。[DYNAMIC]
FUN_298c(0..9):
Outer loader 扫描:
前 6 条 AArch64 指令。
百余条 cmp/csel/ccmp 最终只是在生成 32-bit marker:
对 5 类语义代表的全部 5^6=15625 序列,以及 10000 组任意 32-bit 指令字,纯公式与原 ARM64 classifier 0 mismatch。[ALG]
总 marker 空间只有:
种。
环境位:
异常 XOR 常量:
64 个组合得到 64 把互不相同 runtime key。
context 先过一个 rank=128 的线性 byte-rotate 编码:
canonical:
再对 FUN_5fe8 开头四个 32-byte block 各做标准 CRC32:
得到:
clean 6-bit env expansion:
最终:
64 种环境全部和原 ARM64 对拍一致。
对完整 0x4c0 payload blob 逐字节验证:
那把曾经看起来很重要的 global static key:
在 clean/bad env 下都对 runtime key 和最终 payload 不产生可观察影响;改成全零、全 ff、随机值,最终代码页逐字节不变。[DYNAMIC]
它只是两遍复杂变换中最终抵消的 chaff material。
真实流程:
0x13cbc 的:
是标准/编译器 runtime 风格的 __clear_cache 实现;作者设计的是动态代码生命周期,不应把这段 cache-maintenance 本身误认成作者手写 anti-debug。
Outer key 的 self-code CRC 只覆盖:
FUN_5fe8 总大小约 908 bytes,而真正 SPECK-like 轮从:
才开始,完全不在 fingerprint 内。
因果实验:
反过来:
更进一步,CRC32 fingerprint 本身可碰撞:
再利用旁边不可达 4-byte slot 做 CRC 补偿,四个 32-byte CRC 全部保持原值;原 ARM64:
所以这层是 anti-analysis tripwire,不是安全意义上的 code integrity。
Inner payload 不再信任 libc:
四类异常分别 XOR:
随后只保留:
四个 basis:
它们在 GF(2) 下独立,因此 16 个 anomaly subset 对应 16 个不同 fp8。
payload return 值始终为:
异常并不靠 return 报错,而是静默污染 payload32。
payload 尾部 48-byte 表:
输出:
clean fp8=0:
而第 0 字节的 anomaly delta:
本身就是可逆的;也就是说 payload 32 字节只是在冗余编码 1-byte anti-debug fingerprint。
早期 Frida 输出无法通过 C7。后续知道 maps anomaly 的 fp8 basis 正好是:
因此那次 trace 基本可以判定触发了 payload 内部的 frid/xpos maps 检测。
把全部 256 个理论 fp8 都代入 C7:
所以 payload return 虽永远为 0,但 C7 最终强制:
Inner anti-debug 不存在另一套 hidden flag world。
clean 下可以画成:
真正最终 hard gates:
五个 stack residual 都会 OR 进 master failure;C7 不是“完全无关的 smoke”。
输入被扩为:
clean Java checksum 下:
按 LE64 解释为 (A,B,C,D)。
第 r 轮:
它本质是两条 SPECK128 round shape,再做可逆 cross-coupling。
逆轮:
完整 round 正逆与 XOF expansion 正逆均已做 20 万组随机双向 round-trip,0 mismatch。[ALG]
每 32-byte block 后:
同样是双射。
clean G1 直接固定的是:
不是“三段都直接比较”。
但因为 C/D expansion 可逆,可以从 O[80:96] 向前逆两次:
从而固定 post-12:
原 ARM64 直接得到:
canonical:
这三个字节全部属于由 G1/XOF 逆已经固定的 C/D 窗口,所以在开始解 even 之前:
已经是常量。
甚至可以直接从 payload 前16字节写出 R 的闭式,不需要 A/B。
所以 odd/even 表面上的双向耦合实际上可拓扑排序。
clean C7 可完全化为 8 个 byte residual:
success 要求:
扫描 256 个 seed:
只有两支。[ALG]
存在严格对称:
会保持所有 C7 residual 不变。
所以 C7 天生无法区分 seed bit7;后面的 generated8 / G3 / G4 才会继续破对称。
目标:
首先,入口状态严格化简为:
canonical:
256/256 seed exhaustive 验证。
对每轮 i:
关键性质:[ALG]
这种 XOR–ADD–XOR permutation 家族。
因此 target y_i 固定后,给定 state 可以唯一反出 O[i]。
进一步看状态依赖:
最终:
两支各唯一。
它只访问 payload[0:15],实际索引集合为 12 个字节;完全不读取 payload[16:31]。
因此 payload32 的构造没有 fixed-point 自引用问题。
把 G2 扩到全部 256 个 seed-low 后,generated8 的解数分布为:
总计 248 个 (seed,A) preimage,均值接近 1;分布很接近 256 点伪随机 fixed-point map 的 Poisson(1) 形状。说明 57/d7 各恰好 1 个 A 并不是简单硬编码,而是该 byte-cycle 构造的自然结果。[ALG]
精确依赖分析得到:
w2_model 中 10 套:
全部严格恒等。
全部替换后 10 万组随机 exact 对拍 0 mismatch;同编译条件下主体从约:
再把 eb6c.low8、两段 ripple-carry 改成普通 + 后:
说明函数大半代码量只是视觉噪声。
canonical d7:
G4 使用的 X 由 sp280 经:
组成。
这些奇数乘法和 xor-shift 都可逆,因此固定 (seed,A,context,payload) 时:
是单射,甚至可从 X 直接反回 sp280。
canonical:
100000 组随机 full-width round-trip 零误差。[ALG]
FUN_5998 的后两个参数最终不影响返回,只依赖:
第 j=0..45 位:
所有下标 mod64。
目标:
每条方程只涉及 2~5 个 B 变量,并且是二次 ANF;46 条覆盖全部 64 bit,primal graph 是单一连通分量,greedy min-fill 宽度约 31。
因此最合适的方法不是把整函数 bit-blast,而是每位枚举局部真值表,生成 blocking clauses。
完整模型数:
为了避免单 solver 累积百万 blocking clause,按少量 B bit 切成 16 个互斥 cube 后可快速并行完整计数。
局部 CNF 的核心并不需要 bit-vector solver:
这样每个 clause 都有直接的局部语义,不会把 46 个小约束 bit-blast 成一个巨大黑盒。
G3 实际接口:
seed high24 不参与;A/B/sp280 全 bit活着。
8 个 InvMix 中:
5 个主体 round,每轮调用 3 次 f32c,domain tags:
f32c 自身也可缩成:
第 5 参数 low8 是真正 domain separator,高24死掉;9 套 AES 恒等和多段 carry 可以删除。
canonical target:
在 d7 的 1,130,274 个 G4 模型里:
就是 canonical:
seed57 的 156,466 个 G4 模型里:
更强的是,即便把 G3 里的 w2 强行固定成 C7 target,不让它读取每个 B 的真实 sp280,G3 仍只有 canonical B 命中,说明其唯一性来自直接 B-path,而不是借 C7 的力。[ALG]
C7 已给两支 (seed,sp280),G2 给每支唯一 A,G4 给 B 候选。
对 d7 的 1,130,274 个 G4 模型,我们完整枚举了三种独立检查:
三者唯一点完全相同:
seed57 三种 hit 都是 0。
所以 canonical odd 有至少三条彼此独立的收束路径。
这也解释了:
FUN_7400 两个 KAT、G5 都是真正 hard gate,但对“求出 odd 解”并不提供必需信息;便宜约束已经唯一化 A/B/C/D。它们更像冗余认证与 anti-analysis 负担。
拿到:
逆 12 轮唯一得到:
因此:
完整 O96:
clean work148 结构可以因式分解为:
四个 selector 恰好就是每个 O[64+r] 的四个 2-bit slice;所以 FUN_7400 看起来有 148-byte “key schedule”,实际大部分只是 O96 的重编码。
master guard 可完全化简为:
而 0x4c249726 恰好也是一个固定 work word q1.high32。
看起来更“干净”的:
会强制:
但我们已经完整扫描其全部 2^32 low half,两个 seed 分支都无法恢复合法 5a*7 padding。
真正 canonical:
所以 deadbeef 不是错误码,而是 intended work key的一部分。
它有:
看起来像 AES/SPN,但 4 个矩阵中:
四个 circulant-style 首行分别为:
canonical 16 轮里,奇异矩阵出现在:
M1/M2 更精确地说是 near-MDS singular:
canonical 16 轮中有 8 轮选到 M1/M2。
因此它不是 permutation。
round1 就选 singular matrix。
在 round1 Mix 前,对四列分别 XOR:
四个 t_i 独立,随后逆回前面的 S-box / ShiftRows / round0,即得到:
个不同初始 128-bit input,完整 16 轮输出全部相同。
例如:
都得到:
随机 5000 个 collision-family 成员全部验证成功。[ALG]
所以两个 KAT 更像 lossy keyed verifier,不是 block-cipher KAT。
canonical 两个固定 plaintext:
目标:
它再次扫描同一 5 个 API,并复用 Outer classifier 的同一语义。
对五个 iteration j=0..4,随机 3000 个 prologue 分类:
只是在 FUN6630 中再加 domain salt:
然后把 marker rotate/XOR 到:
线程名还检测:
异常时不会直接 return,而是改 work148。
单 API hook在完整 JNI上同时使:
失败,而 C7/G2/G3/G5 保持不变。
对常见 5 个 API hook + 4 个 thread marker,我们按新的 G1 target反推 C/D,再用固定 canonical A/B 逆12轮,9/9 都无法得到 uniform 7-byte pad。
更强:已完整枚举:
uniform-tail hit:
4/5 个 API 同时异常的全部组合没有穷尽,因此本文不把“所有可能异常世界无解”夸成全局证明。
旧分析把退出条件 17 误叫成 17 轮。
实际:
因此真正是:
外部可变依赖进一步追踪后只有:
所谓 tail_seed16 不是独立输入,而是由 FUN12650 固定块、seed 与 sp280在前置16次 byte generator里派生。
等 G5 执行时:
所以 G5 读到的全部变量已经唯一。
canonical:
对 320 个外部输入 bit做单 bit flip,318/320 会改变最终 low8;它扩散很广,但最后只保留 8 bit,本质是 checksum。
FUN12650 第 0 字节:
加上:
即:
而前面一大段“读取 FUN10598 机器码 + multiply/rotate”的结果随后被 x ^ x 无条件清零,是严格不可观察 chaff。
实际修改那些被读的 FUN10598 字节,G5/JNI完全不变;改真正保留下来的 0x140c:85→84,expected 立即变 d7,JNI失败。[DYNAMIC]
这个 helper 在全 SO 有 8 个 call site,是贯穿多个 gate 的固定常量骨架。
seed 由:
其中:
所以:
连续跑 4 次 Numerical Recipes LCG:
得到:
即:
在 JNI 主 verifier 的 0xb224 这一次调用里,第四个 word 0x480f6151 完全没有后续业务读者;单独改它仍 JNI=1。但 FUN12650 在其它 call site会整16字节消费,因此只能说“该调用点 P3 是死数据”,不能删除 helper 的第4 word。
它被用于:
于是:
拼起来:
f020 本身约 780 bytes,但可继续因式分解:
所以它主要是 target-obfuscation,不是额外独立密码 gate。
置换:
令:
第 i 步:
逆:
所以它没有增加数学难度,只是把连续的 state/config 字段打散到用户输入里。
全局:
四个 helper 都不是“比较失败立刻 reject”,而是:
这是分析 even 的核心。
四条件:
all-pass:
原代码并没有直接写“小常量2/5/0x40”,而是用一组看似代码地址的 rodata差值构造:
是很典型的“伪地址常量”混淆。
canonical 代表:
以:
生成 256-byte xorshift32 stream:
然后:
并检查:
85 26 27 也不是直接硬编码,而是:
all-pass canonical:
C4 + T-prefix 两组线性条件联合 rank=32,因此 canonical all-pass 中 L[9:13] 没有隐藏自由度。[ALG]
如果把所有 13008/1325c branch 都纳入,最终 retained T-table 也不是任意 256-byte 表,而只可能落在 514 张有限集合中:[ALG]
除 identity 外,513 张 retained non-identity table 的前三字节都满足 85 26 27,T0=85。这把全局 even 问题从“任意表”缩成有限 table family;真正未完全穷尽的仍是每张表下 joint (D,LCG-seed) 64-bit ARX state。
先无条件:
生成:
32 个 word。
再跑三组 16-round XTEA-like KAT:
三组 exact known-pair target:
若三组不通过:
而不是 reject。
ELF .data 中 D 的初值就是:
并且结构明显是 TEA/XTEA。
但 13598 又会从 local94[13:17] 覆盖它,所以不能说“前序 hard gate 数学强制 delta=9e3779b9”。
正确表述:
8 lane AVX2 完整扫描:
唯一:
因为 T0=85:
而 0x135f4 的不可达 magic instruction 恰好就是:
这是非常明显的 breadcrumb。[INFER]
固定 seed 扫全部 D:
固定 D 扫全部 seed:
最终 FUN126b8 16-byte gate 对 canonical D 的 2^32 seed 全扫也只有同一个 seed命中。
所以三组 13598 KAT 对 intended 分支求解并非唯一信息源;最终 early gate本身也独立指向同一 64-bit state。
它和 FUN5 用同一个 1000次短循环 + 200ms 阈值模板。
但 slow 时只是:
后续无观察者。
因此:
它扫描 FUN126b8 前:
只判断:
等价于识别:
原始代码 count=0。
canonical raw:
八个 config equation:
所以后六条只是重复确认/代码膨胀。
42 13 37 本身也明显是 breadcrumb。
把扫描区内真实执行:
换成语义相同:
结果:
而把一个永远不执行的 slot 改成 BRK #0:
所以它检测的是“有没有软件断点类痕迹”,不是代码完整性。
caller固定输入:
内部先 XOR:
12轮两条 64-bit lane,使用同一 D 与 LCG key schedule;canonical:
这里要特别区分:
两者不是同一个东西。
每轮主要结构:
前 10 轮完成后、进入第 11 轮更新前:
这是唯一有意信息损失层。
canonical T 下 byte map:
256个输入只映到:
fiber:
canonical target 四个 low-byte fiber大小:
因为其它 round 都可逆,因此完整 FUN126b8 对固定 state、固定 target 精确有 8 个 128-bit plaintext preimage。
其中 canonical 是 0^128;另一个例子:
也得到:
但 caller 不允许改变 p,所以这些 collision 不产生额外 flag;它们只证明该“大密码核心”本身是 lossy verifier。
Even target先用固定材料经 ec68 得 base,再 edcc(base,R,0xa1)。
canonical:
edcc 内部:
0x3d 是奇数,在 mod256 可逆;x->x^(x>>3) 也可逆。
因此 任意一个 target byte 都单独一一编码 R。
例如只用首字节:
16-byte target 的信息熵只有 8 bit,但要求 even 输出同时命中 16 个冗余一致字节,作为 equality gate 仍然很强。
KAT target 的 seed_low 也是完全相同的冗余编码:KAT1/KAT2 任意一个 target byte都能独立反出 d7。
canonical representative:
即:
FUN126b8:
通过 early gate。
seed fold:
得到:
selector=0x1058c 本身是 rodata 硬加载的 intended 常量,并不是 seed fold 方程的数学唯一解。固定 low8=0x8c 后枚举另外16 bit,共有 276 个 selector 也能 fold 到 d7;0x1058c 只是作者选择的一个代表,同时 low8=140 又承担 T-table shift 的职责。
逆 even map:
canonical local94 里,以下位不改变最终 effective state:
原因:
可把整族直接参数化:
因此:
个 local94 全部坍缩到同一个:
同一个 FUN126b8 output、同一个 seed d7。
local94 <-> even25 又是双射,因此正好对应 16384 个不同用户输入。[ALG]
这些输入在 even25 用户视图中被链式映射充分扩散:25个 even byte只有3个始终固定;绝大多数等价输入相对 canonical 会改变 22/25 个 even byte。
存在另一组代表:
它使 C4 fail,selector=0。
但 xorshift table seed:
恰好满足:
于是:
第一处分歧恰好:
而 canonical FUN126b8 round10 实际查的四个索引:
最大:
正好全部落在共同前缀里。
因此两张表虽然后半完全不同,early gate看见的4个值完全相同。
这一 decoy family 同样有:
个 raw local94,全部:
但:
而 clean C7 256-seed全扫描只允许 57/d7,因此 e0 必失败。
原 ARM64端到端:
这个结构高度说明作者有意构造了一个“early gate假成功族”。[INFER]
本题 anti-analysis 不是统一 return false,而是不断把执行切到仍然能跑、但输入域无解的世界。
前序便宜 hard gates仍唯一钉死同一个 post-state (A,B,C,D)。
逆8轮得到尾7字节:
不全相等。
而 Java residual无论什么值,FUN5输入尾部只能是:
所以 8-round world 对任何 padding byte都无解。[ALG]
256 个理论 fp8 中,C7 只有:
存在任何 (seed,sp280) 解;其它255全部无解。[ALG]
Native故意调用:
"%" 不是合法 JNI field descriptor。
真实 ART:返回 null + pending exception,代码清异常后仍走 clean path。
粗糙 emulator 若错误返回非空 fieldID,会固定把 checksum mirror初态:
最终 residual:
四字节 XOR=5a,因此非零 residual规则产生:
而 canonical post-state逆12轮要求 5a*7,所以该 world 也无解。
假 field 实际“读到什么值”无关;canary只检测非法 descriptor 是否被错误当成有效 field。[DYNAMIC]
后续深入分析可以从 intended odd/even state 不读取现成 target 反生成题目材料。
从 canonical odd:
然后:
因为 generated8只读 payload前16字节:
最后由 C7 residual=0 逐字节反解:
得到完整:
与真实 payload clean output 32/32 字节一致。[ALG]
因此没有 self-reference。
公式中:
是 0..31 的排列;每个 clean output byte 含一个独占 first32 table byte。
所以可以:
实际后16字节:
反解出的前32字节与 payload 完全一致。
ec68 对固定 seed:
(3+7i) mod16 是排列,因此给定 desired target:
再接可逆 edcc。
我们从 canonical even/KAT output 反解出的三组 src 与 SO 中 rodata逐字节一致:
所以这些很像作者围绕 intended state 反向生成的 challenge vector,而不是独立秘密。[INFER]