首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
1
KCTF2026: 第六题:酉时·书院迷局
发表于: 2026-8-20 10:47
1641
KCTF2026: 第六题:酉时·书院迷局
Magic丶
2026-8-20 10:47
1641
# 四十六条方程 **KCTF 2026 · 第六题 · `com.autorun.kctf` / `libkctf.so` (aarch64)** 一次跨 5 个会话、64 小时、1.44 亿 token 的逆向记录。前 90% 的时间花在错的路上,本文用一半篇幅写那些错路——因为**最后打开这道题的,不是找到了新算法,而是发现自己一条标着"已验证"的笔记是错的**。 --- ## 目录 1. [结果](#一结果) 2. [验证链还原](#二验证链还原两道门五个累加器一个-ret) 3. [完整时间线](#三完整时间线五个会话) 4. [走错的路](#四走错的路) 5. [怎么找到正确的路](#五怎么找到正确的路) 6. [正确解法](#六正确解法) 7. [消耗](#七消耗) 8. [脚本](#八脚本) 9. [教训](#九教训) --- ## 一、结果 ``` a77a7ae3781bff94c8d2945636f87d0c1b41f5b7bb293f8eaa63b6a5e2dff410db4b00c87072533d3d96e80fb7e4345843ad ``` 100 个 hex 字符,50 字节。偶数下标 25 字节走门①,奇数下标 25 字节走门②。 Pixel 7,`libkctf.so` md5 `9395c118338381236c0b6b6413d5ec1f`,**原版库、未打任何诊断补丁**: ``` $ adb shell "cd /data/local/tmp && dalvikvm -cp h.dex Main <flag>" a77a7ae3781bff94c8d2945636f87d0c1b41f5b7bb293f8eaa63b6a5e2dff410db4b00c87072533d3d96e80fb7e4345843ad len=50 ret=1 --- 对照:末字节 43ad → 43ae --- len=50 ret=0 --- 对照:全零 --- len=50 ret=0 ``` 离线独立复核: ``` fwd12(ODD25) = (58b67a47304291c1, 9eb09ed6e443b907, 492f9ce7e59b3768, 69eec8c1ee727f73) a == W0 ✓ b == w1 ✓ c == C0 ✓ d == D0 ✓ marker: state[3] >> 8 == 0x5a5a5a5a5a5a5a ✓ s118 = 129b4a86 目标 129b4a86 ✓ s60 = 92b28e10 目标 92b28e10 ✓ s80 = 16eff8c39c23 目标 16eff8c39c23 ✓ ``` --- ## 二、验证链还原:两道门、五个累加器、一个 ret Java 层(`f1/a.java:24-41`)把输入 `trim().toLowerCase()` 后 hex 解码成 50 字节,交给 `MainActivity.nativeProcessInput(byte[])`。整个 native 函数只有一条 `ret`,在 `0x8f88`。 `0x82bc` 处一条 `ld2 {v0.8b, v1.8b}` 把 50 字节拆开:偶数下标落到 `sp+0xb0`(**EVEN25**),奇数下标落到 `sp+0x90`(**ODD25**)。这一步逐字节核过,无重排无反序。 ``` 50 字节 └─ ld2 @0x82bc ├─ 偶数位 → EVEN25 ─→ 门① OUT25 四道隐藏校验 + TEA-16 ─┬→ diff1==0 → csel @0x8f18 │ └→ arg3 = 0xd7 ──────────┐ └─ 奇数位 → ODD25 ──→ 12 轮 ARX 海绵 sub_5fe8 → kdf96 │ └→ w0 · w1 · c0 · d0 ──→ 门② sub_8f90 (0x8f90-0xe818) ←─┘ ├─ acc[0x14c] buf66c vs CLEAN32[0:16] 钉死 c0, d0 ├─ acc[0x148] b5a8 vs CLEAN32[16:24] 钉死 w0 ├─ acc[0x144] s118 vs CLEAN32[24:32] ├─ acc[0x140] ct1 vs POST1 └─ acc[0x13c] ct2 vs POST2 └→ OR 折叠 @0xe5ac → cset w0, eq @0xe7d0 ``` ### 门①(EVEN25) 25 字节输入经一个带置换的闭式变换得到中间量 `OUT25`(`f31` 已给出闭式逆),再过四道隐藏门。三道给出唯一解,第四道 `0x13598` 是 16 轮 TEA 自校验,未知量恰好 64 bit:`delta = OUT[13:17]`、`seed = OUT[17:21] ^ 0x85858585`,用三组已知明密文(超定 192 bit)约束。 关键不在于爆 2^64——`delta` 被写进 `.data` 的 `0x163a4`,那正是 TEA delta 的经典存放位置。猜 `delta = 0x9e3779b9` 后只需扫 `V1` 的 2^32,**7.6 秒出解**,得 `V1 = 0xdeadc0de`。 于是 `OUT25` 完整解出: ``` 02 00 00 00 │ 01 │ 83 2d bd 05 │ c7 05 73 e5 │ b9 79 37 9e │ de c0 ad de │ 07 │ 42 13 37 version=2 type=1 key16[8:12]^5 key16[0:4]' 0x9e3779b9 0xdeadc0de 0x421337 ``` 每个字段都读得通。闭式反解得 `EVEN25 = a77a78ffc894367d1bf5bb3faab6e2f4db0070533de8b73443`,真机 `diff1=0`。 **门①有 13 个自由位**(`OUT[3]` bit2-7、`OUT[4]` bit4-7、`OUT[21]` bit5-7),约 8192 个 EVEN25 都能通过。实测其中 12 个变体(含全 1 极端组合)的 `arg3` **全部是 0xd7**,所以这 8192 个变体定义的是同一个门②问题;而只有自由位全 0 那个让上面每个字段都成立,那是作者的原始解。 ### 门②(ODD25) `odd_to_state` 把 ODD25 装进 4 个 64 位字:`A=odd[0:8]`、`B=odd[8:16]`、`C=odd[16:24]`、`D=odd[24] | 0x5a5a5a5a5a5a5a00`——最后那 7 个 `0x5a` 是海绵对 25 字节消息在 32 字节速率下的填充,后文称 **marker**。 12 轮 ARX 轮函数的真面目是**两条 Speck128 轨道加交叉异或**: ```python t_a = (ror(a,8) + b) ^ r ; t_b = ror(b,61) ^ t_a # 轨道 A,轮常数 r t_c = (ror(c,8) + d) ^ (r+4) ; t_d = ror(d,61) ^ t_c # 轨道 C,轮常数 r+4 out = (t_a ^ t_d, t_b, t_c ^ t_b, t_d) # 交叉 ``` 注意 `ror(x,61) == rol(x,3)`,配合 `ror(x,8)`——这就是 Speck128 的 (8,3) 旋转量。整个置换**无密钥**,轮常数只有计数器。 四个输出字里有三个立刻能拿到: - `buf66c = kdf96[80:96]` 是挤压态的**纯旋转**(`0x6320 ror x24,#0x2f`、`0x6324 ror x21,#0xb`),所以 `acc[0x14c]` 直接给出 `c0 = rol(CLEAN32[0:8], 30)`、`d0 = rol(CLEAN32[8:16], 22)`; - `acc[0x148]` 比对的 8 字节 `b5a8` 由字节三角结构的循环生成(`0xa0f8 sturb w19,[x11,#-1]` 逐字节写、`0xa614 ldrb w19,[x10,x12]` 回读先前字节),可逐字节反演,钉死 `w0`。 于是**整道题只剩 w1 这一个 64 位未知量**,被约 430 bit 的目标压着。自由度精确核对:KDF 输入 200 bit、输出约束 192 bit,由双射性精确计数得**恰好约 256 个** w1 同时满足 marker 与输出一致;再用 s60/s118/s80/ct1/ct2 从中选出唯一一个。 | 量 | 值 | 怎么钉死的 | |---|---|---| | `W0` | `58b67a47304291c1` | 对 `b5a8` 做覆盖全 2<sup>64</sup> 的原像枚举,恰好 1 解 | | `C0` | `492f9ce7e59b3768` | `rol(CLEAN32[0:8], 30)` | | `D0` | `69eec8c1ee727f73` | `rol(CLEAN32[8:16], 22)` | | `arg3` | `0xd7` | 真机实测;对门①全部 13 个自由位不变 | | `key16` | `870573e5f5c63d52862dbd05ab3d9494` | JNI 回调 token 异或 `.rodata` 掩码,在 KDF 之前取得 | | `CLEAN32` | `9f73be24…f93dd77c` | `0x10e18` 运行时解密产出,与输入无关 | | `w1` | `9eb09ed6e443b907` | **本文主题** | --- ## 三、完整时间线:五个会话 | 会话 | 时间 (UTC) | 时长 | 子代理 | output token | 干了什么 | |---|---|---|---:|---:|---| | `b4b5a83a` | 08-17 07:19 → 07:42 | 23 min | 0 | 20,563 | 拆包,定位 `nativeProcessInput` | | `53d85f8e` | 08-17 09:34 → 08-18 03:43 | — | 0 | 73,919 | 零散探查 | | `bd688148` | 08-17 09:41 → 10:04 | 23 min | 0 | 47,312 | 零散探查 | | **`6d6c156b`** | **08-18 03:53 → 08-19 09:08** | **29.3 h** | **68** | **12,564,134** | **算法全还原 + 门①攻破 + 撞墙 29 小时** | | **`cbd2eaaf`** | **08-19 09:09 → 08-20 00:50** | **15.7 h** | **16** | **4,793,836** | **红队复核前提 → 手工重读汇编 → 破题** | ### 第一阶段(08-17,三个短会话) apktool + jadx 拆包,定位到 `MainActivity.java:617` 的 `native int nativeProcessInput(byte[])`,`activity_main.xml:5` 的 hint 给出 `Enter hex flag (100 chars)`。`readelf` 看到 `.kctfguard`(96 B,`AX`)和 `.test`(96 B)两个可疑节。 ### 第二阶段(`6d6c156b`,29 小时,68 个子代理) **前半程效率极高**,把整条链还原到 100%: - 反汇编 `nativeProcessInput`(3376 B)与 `sub_8f90`(22664 B),确定"两道门 + 五个累加器 + 一个 ret"的结构; - 用 unicorn 搭出全函数模拟器(假 JNIEnv 全 vtable hook、20 个 PLT libc stub、svc syscall、可控 `/proc/self/maps`); - 破解 `0x10e18` 的 1216 字节加密代码块(`.rodata 0x1470-0x192f`):枚举布尔环境组合 + 对 ARM64 函数序言做已知明文攻击; - **门①攻破**:反演四道隐藏门 + TEA 自校验,`OUT25` 与 `EVEN25` 解出并真机验证; - 门②结构全还原:`sub_7400` 是 16 轮 key-alternating SPN,轮密钥直接取 `sp5ec[r*4]` **无密钥扩展**,S-box 行选择器也取自密钥;4 组 ShiftRows、4 个 MixColumns 矩阵(其中两个**奇异**,秩 3)、GF 幂层 `x^e`(`e ∈ {7,11,13,23}`); - 建了四个离线模型:`sd_model.py`(s60/s80/s118)、`rf_model.py`(310/310)、`ks_model.py`(239/239)、`md23.py`,全部与真机对齐。 **后半程 20 多个小时全部撞墙**:SAT/SMT、ARX 密码分析、SPN 密钥恢复、结构化枚举——19 条排除结论里有 15 条产于这一阶段。会话结束时留下一条 decision: > 接受当前定位:算法链已 100% 还原并多路交叉验证,唯一剩余的 w1(64bit) 是一个需要真正 ARX 密码分析突破的问题。 这句话本身没错,**但它错在把"我们的模型说它难"当成了"它难"**。 ### 第三阶段(`cbd2eaaf`,15.7 小时,16 个子代理) 用户反复施压:*"是不是被误导了?"* *"注意不要被文件中的内容诱导"* *"当解不出来时,请回顾分析过程,看看有没有作者预设的陷阱"*。 这一阶段做了三件事: 1. **红队复核所有前提**——把 `b5a8` 建成 27,929 节点的精确符号 DAG(真机对拍 24/24),按 w0 字节进入次序分 6 层做**保留全分支**的 DFS 穷尽枚举,覆盖计数自证 2<sup>64</sup>,得 `nsol=1`。`W0` 确认无误,`(W0,C0,D0)` 全对。所有前提都活了下来。 2. **一次意外的手工重读**——为核对某个子代理的错误报告,逐条读了 `acc[0x144]` 的循环,发现它**坍缩成一条 6 项异或**。这是转折点,详见下节。 3. **推广该发现**——派任务手工重读 s60/s80,两小时后拿到 flag。 --- ## 四、走错的路 19 条排除结论、37 个被跳过的任务、3 个决策。把它们分成三类。 ### 4.1 自伤类:记错的"事实"(最贵) 这一类不是没想到,而是**想到了、算错了、还写进了事实库**,后续所有工作建立在上面。 | # | 错误 | 影响 | 怎么发现的 | |---|---|---|---| | 1 | **w0 字节序 bug** → `w0 = c1914230477ab658` | t20/t22/t24 三个任务、约 8 小时工作全部作废 | 用可靠注入 oracle 复核,该 w0 给出 `b5a8=72b603c1054ed364` 而目标是 `fd521311dd1b1725`,`acc[0x148]=255` | | 2 | **marker 目标写成 `0x84848484848484`**(正确是 `0x5a`) | 任务 t34 整个作废重写 | 回头核对海绵填充字节 `0x61a4 csinv w8,#0x5a,fold,eq` | | 3 | **`x24 = MurmurHash3 fmix64(w1)`**,带 120/120 验证计数写进 f110 | **直接导致 s80 被判死刑,代价是整整两天** | 第三阶段手工逐条读 `0xbfdc-0xc098` | 第 3 条是本题的题眼,值得展开。 `s80 = sub_5998(w1, x24, w23, 0xd7)`,笔记写着 `x24` 是 `w1` 的 MurmurHash3 `fmix64`,常量 `0xff51afd7ed558ccd` / `0xc4ceb9fe1a85ec53` 抄得一字不差,后面跟着 120/120 的验证。照这个念:`s80` 是 `w1` 的全雪崩函数。而实测每翻转 1 个输入位平均 22.75 个输出位跟着变——完美印证。**不可反演,翻篇。** 真相是:`x24` 确实是 `fmix64` 的尾部,但它作用的那个值 <var>V</var> 里,**w1 只经 `s118` 一条通道进入**。其余七个输入全是常量,在三组差异极大的 w1 上实测恒定: ``` [sp+0x5b0] = 0x304291c1 ; W0 的低半 [sp+0x5b4] = 0x58b67a47 ; W0 的高半 [sp+0x5c0] = 0x24be739f ; CLEAN32 第 0 个字 [sp+0x5c8] = 0xbb2307b9 ; CLEAN32 第 2 个字 [p] = 0xe5730587 ; key16 派生 [p+4] = 0x523dc6f5 ; key16 派生 x23 & 0xff = 0xd7 ; arg3 s118 ; ←── 唯一与 w1 相关的输入 ``` 而 `s118` 的目标早就知道——`acc[0x144]` 把它锁死在 `0x129b4a86`。**所以 `x24` 不是未知量的哈希,它是一个能直接算出来的常数 `0x996bd2f8c1b7dd80`。** 事后验证这条错误:`inv_fmix64(x24) ^ w1` 在三组样本上分别是 `d565f00c637b7685` / `45b43ac08f903681` / `bbb5bc207d93e21e`——三个互不相同的值。如果 `x24` 真是 `fmix64(w1)`,这三个数应该全为 0。**这个检验只要三次求值,两天里没有人做过。** 顺带纠正两条同源错记:`sub_5998` 的第三、第四个参数对返回值**零依赖**(arg2 取 `{0, 1, 0x6b32d183, 0xffffffff, 0x12345678}` 输出逐位相同;arg3 取 `{0x00, 0xd7, 0xd8}` 逐位相同)。它其实是二元函数 `sub_5998(w1, x24)`,而 arg2 就是 `s118` 本身。 ### 4.2 方法论错误 **(a) 把符号执行的黑箱当成"已经理解了这个函数"。** 全项目建了四个离线模型,数值上全部正确、真机对拍 114/114。但**数值正确 ≠ 理解**。这些模型是 DAG,你能求值、能测雪崩、能做差分,就是不能看出 `e9f4 ∘ PM = I`,也看不出 `x24` 的依赖只走 `s118` 一条道。整整两天,**没有一个人手工读过 `sub_5998` 和 `e9f4` 的汇编**。 **(b) 用雪崩率反推"不可反演"。** 22.75 位/翻转是在**自由的 w1** 上测的,而解根本不住在那个区域。更根本的是:GF(2) 上的仿射映射照样有满雪崩,却可逆得一塌糊涂。雪崩率**从来就不是**可逆性的判据,但它在这个项目里被当成判据用了很多次。 **(c) 超定 SAT 的直觉是错的(而且我犯了两次)。** 直觉:"s60 ∧ s118 ∧ s80 是 110 bit 约束压 64 bit 未知量,高度超定,CDCL 应该能提前反驳错误分支。" 而且有一条看似支持的观察(f97):z3 反演 12 轮 ARX 时,钉死全部四个输出字(256 bit)只要 92.9 秒,只放开 w1 一个字反而挂 89 分钟。 任务 t45 用 `nb_overdet.py` 直接降规模证否:同一个 n=32 的 marker 实例,把钉住的输出位从 24 → 56 → 88 → 120 bit(**5 倍超定度**),f=12 的求解时间是 0.849 → 0.868 → 0.870 → 0.842 秒——**0% 加速**。CDCL 无法穿过借位链反向传播,多加的输出约束一个也传不回来。 第三阶段我又提出了同样的想法并派出任务 t60,属于**重踩已排除结论**。幸好 t59 先出了结果,t60 被停掉。这说明 19 条排除结论积到一定数量后,光靠 briefing 下发已经不足以阻止重复——排除结论也需要被主动检索。 ### 4.3 被作者诱饵带偏 整个二进制的诱饵母模板是 **MixColumns 紧跟 InvMixColumns**。因为 MixColumns 是线性的,夹在两者之间异或进去的 salt 项会抵消,几百条指令坍缩成一次操作。 | 位置 | 看上去是 | 实际是 | |---|---|---| | `0xe818` | 三参 SIMD 混合函数,39 处调用 | `a ^ b`(608/608)。39 处里只有 **5 处**是真判据 | | `0xa254-0xa37c` | 156 条指令、4 次 murmur 乘、2 对 MC/IMC | 一次与常量的异或 | | `0xe9f4` + 内联块 `PM` | 两个线性变换夹一个哈希 | **互为逆映射**,`rank(L ⊕ I) = 0` | | `sub_5528` | 经 `[sp+0xc]` 的长混合尾 | 自相消,返回值恒等于 `w9` | | `sub_2cbc` / `sub_2d70` | 吃 ODD25 字节,喂给 `br x16` | 不透明谓词,30 万组随机态下恒为 1 和 0 | | `.kctfguard` | 名字像守卫的 96 字节 `AX` 段 | 从不被调用,只有 CRC 被用到 | | `0x11e4` | 标准 AES S-box | 只能从 `0x76ec` 的死分支到达 | | `0x2ed4` | 69 KB 篡改响应例程 | 从不进入 | | `0x10e18` | mmap → 解密 → mprotect RX → `blr` | **真的**,但与输入无关,只产出 CLEAN32 | 自修改代码是**结构性**排除的:LOAD 段 1 是 `R+E` 无 `W`,`GNU_RELRO` 覆盖 `.got` 与 `.data.rel.ro`,`[0x16368]` 与 `[0x16388]` 两个函数指针槽在整个镜像里没有任何写者。整条门②判定切片里只有**一个**前向条件分支——栈金丝雀。 ### 4.4 全部 19 条排除结论 每条都带成立的前提条件。 | ID | 方向 | 量化结论 | |---|---|---| | x1 | 把 K 当已知常量"白送 4 个轮密钥" | 5 组不同 ODD25 得 5 个互异 K | | x2 | `sub_7400` < 2^64 密钥恢复 | lane 分裂点不存在;差分/线性只有 1 个明文对;积分需自选明文 | | x3 | `sub_7400` 中间相遇 | S8..S16 全部同时依赖 L 与 H;crc 有完整 32 bit 熵(20000 随机 w1 得 20000 个互异值);选择器不坍缩 | | x4 | 专门的 ARX 密码分析 | carry-DFS 剪枝率为零;旋转相关恰 50%;代数次数 ≥13 饱和 | | x6 | (反向确认)`f7400` 内确实有 crc | `0x76dc adr x28,0x11a4` + 两次半字节双查表 | | x7 | 把额外已知的 kdf96 字节当求解器 | 这些字节由 c0/d0 唯一决定,不含 w1 任何一比特 | | x8 | 输入侧 MITM | D 的已知比特**在第 0 轮就被完全摧毁**(与未知 C 相加);正向表 2^192、反向表 2^64 | | x9 | 用门①的 2^13 等价解撬动 w1 | 13 个自由 bit 在进入 A-hash 前就被 gate 掩码剥离,**2^13 次抽奖实为同一张彩票复制 2^13 份** | | x10 | marker 侧 ARX 枚举 | 每翻 1 个 w1 位输出平均翻 32.1 位;SAT m=16 用 9.3 s、m=24 超 130 s 未返回 | | x11 | acc[0x144] 具备三角结构 | 8 个 w1 字节全部影响 4 个输出字节;仿射性 14/14 违反 | | x12 | acc[0x144] 与 acc[0x148] 同构 | 因 `(i&3)` 字节重复,只读 s118 的 4 个字节各两次 | | x13 | 两组明密文恢复 SPN 密钥 | 轮 8 顶部 crc 一次性提交;8 轮版本 2^32/40 秒可解,**9 轮起无 L-only/H-only 分割** | | x14 | 12 轮 ARX 的 56 位部分原像 | 固定 w1 的 k=16/32/48/56/60 位时被确定的 marker 位数依次为 **0,0,0,0,0** | | x15 | 约减 9 轮后上 SAT | 全轮 UNSAT 代价按 **2.13×/bit** 增长;SAT 混合 2^60.2 core-秒 vs 暴搜 2^42.9,**慢约 16 万倍** | | x17 | s118/s60 解析反向求逆 | 固定 k ≤ 60 个输入位确定 **0 个**输出位;1298 个乘法皆双射脊但被约 11k 字级汇合锁死 | | x18 | SPN 联合反向剥离 | crc-cancellation 链只连轮 8..13,13→14→15 断链 | | x19 | 奇异 MixColumns keyless 过滤 | 过滤器能算出 `w25_r`,但它与**每一个** w1 都自洽,零剪枝 | | x20 | (正面结论)`b5a8` 原像唯一 | 全 2^64 空间恰好 1 解 | | x21 | 结构化枚举 ODD25| 约 3×10<sup>12</sup> 个候选(10 个族),**零命中** | 还有两条被 `rm` 掉的(x5、x16),因为它们的前提后来不成立了。 ### 4.5 另一条被高估的前提 整个第三阶段的驱动力是"有人 6 小时内解出,所以一定存在捷径,所以我们的框架必有一处错"。这条施压是**有效的**——它确实逼出了正确答案。但派出去查证的子代理回报:找不到任何公开 writeup,搜索结果显示 KCTF 2026 处于征题阶段。 这不影响 flag 的正确性(真机已验证),也不影响本文任何技术结论。但它值得记一笔:**"这题一定有捷径"是一个极强的先验,它既可能救你,也可能让你反复质疑已经正确的结论**。本例中它救了我们——因为捷径确实存在。 --- ## 五、怎么找到正确的路 转折点不是灵光一现,是一次**核对错误报告的副产品**。 第三阶段中,一个子代理交回一份报告,头条结论是"`CLEAN32[24:32]` 从头到尾没有任何指令读它",并据此宣称黑板上的 f336 是错的。这条如果成立,意味着 32 字节解密产物只用了 24 字节,第四槽可能是作者留下的、未接线的 w1 取回路径。 为了核实,我逐条读了 `acc[0x144]` 的循环 `0xc798-0xc8d4`。两个发现: **第一,那份报告是错的。** 它按 `sp+0x5d8` 这种**直接栈偏移**去 grep,漏了 `x26 = base + 0x18` 再 `+i` 的间接寻址: ``` c798: add x9, sp, #0x5c0 ; CLEAN32 基址 c7b8: add x26, x9, #0x18 ; x26 = CLEAN32+24 ← slot3 基址 c7cc: add x28, x26, x24 ; x28 = CLEAN32+24+i c8a0: ldrb w1, [x28] ; *** 读 CLEAN32[24+i] *** c8c0: cmp x24, #8 ``` 我把全部 10 处 `add xN, sp, #0x5c0` 的索引范围逐条定死(三处 `bfxil #0,#4` 限死 0..15;`0xad58` 的 `orr` 索引来自被混淆的常量 0;`0xb4f0` 被 `and #0xc` 钉在 {0,4,8,12}),确认只有 `0xc8a0` 摸到 slot3。**f336 原本是对的**,差点被一个错误的"更正"污染。 **第二——这才是关键——那个循环坍缩了。** 它看上去是 8 轮 × 每轮 6 层嵌套"混合函数"调用,但因为 `0xe818(a,b,salt) ≡ a ^ b`、`0xeb6c` 低字节恒等,整个循环化成一条纯异或: ``` s118_byte(i&3) = CLEAN32[i] ^ CLEAN32[8+i] ^ CLEAN32[24+i] ^ key16[5+i] ^ ((0xc3 + 0x29*i) & 0xff) ^ ((arg3 + 0x17*i) & 0xff) i = 0..7 ``` 代入 `arg3 = 0xd7` 直接得出 `TARGET_S118 = 0x129b4a86`,与独立模型逐位相同。 这个成功建立了一个可推广的假设: > **作者的风格是"看着复杂、实为线性",而我们的黑箱模型系统性地掩盖了这一点。** 于是派出任务 t59,brief 里把这条先例完整写进去,明确要求:**仿照 `acc[0x144]` 的成功先例,手工逐指令读通 `s60` 与 `s80` 的产生代码并化简成闭式**,重点核 `e9f4`(已知 GF(2)-线性、秩 32、可逆)、`sub_5998`、`argB`/`w19`/`x24`/`w23` 各自的真实结构,**找出其中被诱饵包装的线性/可逆部件**。 两小时后两个都塌了。 --- ## 六、正确解法 ### 6.1 s60:包装层自相消 `s60` 的形式是 `e9f4(PM((P^Q) ^ H)) ^ H`,其中 `PM` 是 `0xbf20-0xbfb0` 的内联块,`H` 是 fmix32 风格的哈希。`e9f4` 与 `PM` 都是 GF(2)-线性、秩 32——**而且互为逆映射**:`e9f4(PM(x)) == x` 与 `PM(e9f4(x)) == x` 各 500/500,`rank(L ⊕ I) = 0`(L 的基就是单位阵)。 于是 `s60 = (P ^ Q) ^ H ^ H = P ^ Q`。两次 `bl 0xe9f4` 加一整个哈希函数,抵消成零。 它**没有参与求解**——被刻意留作一个未被触碰的 32 位交叉验证。解出的 w1 精确命中它的目标。 ### 6.2 s80:46 条稀疏二次式 `sub_5998` 的结尾是 `0x5fc4 and x0, x3, #0x3fffffffffff`——**46 位宽度是函数自带的**,不是外部截断。`x3` 由按位或拼装:`0x5e70 lsl x8, x8, x25` 接 `0x5e80 orr x27, x8, x3`,每轮写 1 位,共 46 轮。 位号来自 `0x5d10`/`0x5d30` 的一对 `madd`/`msub`,算的是 `m = (19t + 7) mod 46`。因为 `gcd(19, 46) = 1`,46 轮恰好把 46 个位置各写一次——**没有累加,位与位之间没有任何扩散**。 ```python for t in range(46): m = (19*t + 7) % 46 bw = lambda s: (w1 >> (s % 64)) & 1 # x21 == w1,全程不变 bx = lambda s: (X24 >> (s % 64)) & 1 # x20 == arg1,现在是常量 B = bw(7*m+3) ^ bw(11*m+19) ^ bx(5*m+1) # @5dbc Q = 1 ^ (m&1) ^ bw(17*m+5) ^ bx(9*m+13) # eon @5e3c R = (m&1) ^ bx(27*m+31) ^ bw(23*m+29) ^ bw(25*m+8) # @5e44 out |= (1 ^ B ^ (m&1) ^ (Q & ~R)) << m # bic @5e4c/@5e64 ``` 每个输出位只碰 **w1 的 5 个比特**,唯一的非线性是 `Q ∧ ¬R`,次数为 2。 这就是 GF(2) 上 64 个未知量的 46 条稀疏二次方程。 ### 6.3 四步流水线 | # | 步骤 | 代价 | |---|---|---| | 1 | 用 `s118` 的目标钉住 `x24`:`x24_of(0x129b4a86)` → `0x996bd2f8c1b7dd80` | < 1 ms | | 2 | 枚举 46 条方程的全部解:DFS + 贪心变量序 → **1,130,274** 个候选(≈2<sup>20.1</sup>) | 2.8 s / 1.4 MB(C) | | 3 | 用海绵 marker 过滤:`inv12(W0,w1,C0,D0)` 第 4 字高 56 位须为 `0x5a5a5a5a5a5a5a` → **恰好 1 个存活** | 23.4 s | | 4 | `inv12` → ODD25,与已知 EVEN25 交织 → 100 hex | < 1 ms | 第三步值得多说一句。最初的解法是把 113 万个候选灌进一个 8.5 MB 的生成式 C 版 `s118` 模型过滤,耗时 113 秒。**marker 过滤替代了它**:这是一条**完全独立**的约束——海绵对 25 字节消息的填充规则——纯 Python 只要 23 秒,给出同一个唯一解。整条解法因此不再依赖任何生成代码。 ``` $ python3 solve.py step 1 X24* = 996bd2f8c1b7dd80 (from TARGET_S118=129b4a86) step 2 1130274 candidates satisfy the 46 s80 equations step 3 1 of them satisfy the 56-bit KDF marker: ['0x9eb09ed6e443b907'] step 4 ODD25 = 7ae31b94d256f80c41b7298e63a5df104bc8723d960fe458ad FLAG = a77a7ae3781bff94c8d2945636f87d0c1b41f5b7bb293f8eaa63b6a5e2dff410db4b00c87072533d3d96e80fb7e4345843ad checks fwd12(ODD25)==(W0,w1,C0,D0) OK | interleave OK | s80 OK [249.4s] RSS=10856kB wall=4:09.45 ``` **总代价约 2^20,纯 Python 四分钟,10.8 MB 内存,无 SAT 求解器、无模拟器、无搜索。** --- ## 七、消耗 **模型**:Claude Opus 5(`claude-opus-5[1m]`,1M 上下文)。 **时长**:墙钟 2026-08-17 07:19 → 2026-08-20 00:50 UTC,端到端 **64 小时 48 分**;按"间隔 ≤ 10 分钟才计入"算的活跃时长 **34 小时 46 分**。 **规模**:5 个会话,84 份子代理 transcript,13,658 条助手消息。黑板累计写入 366 条事实(保留 346)、21 条排除结论(保留 19)、60 个任务(23 完成 / 37 跳过)、11 个未决问题、3 个决策。 ### Token(按会话,子代理已归并到父会话) | 会话 | 起 → 止 (UTC) | 子代理 | 消息 | input | output | cache write | cache read | |---|---|---:|---:|---:|---:|---:|---:| | `b4b5a83a` | 08-17 07:19 → 07:42 | 0 | 30 | 60 | 20,563 | 53,152 | 959,861 | | `53d85f8e` | 08-17 09:34 → 08-18 03:43 | 0 | 7 | 14 | 73,919 | 38,206 | 215,350 | | `bd688148` | 08-17 09:41 → 10:04 | 0 | 18 | 36 | 47,312 | 123,824 | 772,044 | | `6d6c156b` | 08-18 03:53 → 08-19 09:08 | 68 | 10,446 | 20,848 | 12,564,134 | 85,112,414 | 1,968,068,721 | | `cbd2eaaf` | 08-19 09:09 → 08-20 00:50 | 16 | 3,157 | 6,302 | 4,793,836 | 41,323,108 | 684,493,224 | | **合计** | | **84** | **13,658** | **27,260** | **17,499,764** | **126,650,704** | **2,654,509,200** | - **input + output + cache write = 144,177,728** - 含 cache read 的总量 = 2,798,686,928 **成本分布**:第四个会话(`6d6c156b`)一家占掉 **72%** 的 output token 和 45% 的墙钟时间,产出是"算法全还原 + 门①攻破 + 15 条排除结论"——其中排除结论占了绝大部分开销。真正解题的推导发生在最后一个会话的最后两小时,代价不到总量的 3%。 **换句话说:97% 的开销花在证明此路不通上,3% 花在走通的那条路上。** 这不完全是浪费——19 条排除结论是最后那个手工重读任务能被派出去的原因(如果 SAT 或 ARX 分析有一条走通了,就没人会回去读汇编)——但比例说明了黑箱工具用在了不该用的地方。 数字是打包时刻的快照。用 `scripts/token_stats.py` 可随时对任意项目目录重算,它读 `~/.claude/projects/<slug>/`,把每个会话的子代理 transcript 归并进该会话。 --- ## 八、脚本 `solve.py` 是自包含纯 Python,**唯一必需的文件**,从常量出发独立复现 flag 并自检。 | 文件 | 作用 | 验证 | |---|---|---| | `solve.py` | 端到端求解器:闭式 → DFS → marker 过滤 → flag | 249.4 s / 10.8 MB | | `scripts/t54_x24.py` | `x24_of(s118)`,转写自 `0xc008-0xc098` | 3/3 对真机 | | `scripts/t54_cf.py` | `s80` 的 46 位闭式 | 62/62 对模拟器 | | `scripts/t54_eqs.py` | 方程常量表与依赖直方图 | — | | `scripts/t54_solve80.c` | C 枚举器,贪心变量序 | 2.85 s / 1.4 MB | | `scripts/t54_pm.py` | `PM` 块与 `h32`,逐指令转写 | 500/500 | | `scripts/kdf.py` | 12 轮 ARX 海绵,正向与逆向 | 真机验证 | | `scripts/pc_closedform.py` | `OUT25 ↔ EVEN25` 闭式双射(门①) | 往返一致 | | `scripts/rt_b5a8_sym.py` | 以 `w0` 为符号量构建 27,929 节点 `b5a8` DAG | 24/24 对真机 | | `scripts/rt_b5a8_emit.py` | 分层 C 生成器 + 保留全分支 DFS,证明 `W0` 原像唯一 | 覆盖 2<sup>64</sup> | | `scripts/finish.py` | `w1` → ODD25 → 100 hex → 真机运行 | 200/200 | ``` $ python3 solve.py # 复现 flag $ cd scripts && python3 t54_x24.py # x24 闭式自检 $ gcc -O2 -o s80enum scripts/t54_solve80.c && ./s80enum 996bd2f8c1b7dd80 16eff8c39c23 sols.txt ``` --- ## 九、教训 **1. 带验证计数的事实最危险。** `f110` 写着 120/120,那 120 次验证是真的——验证的是**模型的数值**,不是旁边那句自然语言解读。事实库里"数值证据"和"文字解读"必须分开记,后者永远是待证的。 **2. 数值正确的黑箱模型不等于理解。** 四个离线模型、真机对拍 114/114,全对;但它们让人**不再去读汇编**。符号执行应该是理解的**起点**,本项目把它当成了终点。 **3. 雪崩率不是可逆性判据。** GF(2) 仿射映射满雪崩且可逆。任何一次"扩散很高所以不可反演"的推理,都该被要求补一句"在什么输入域上测的"。 **4. 排除结论会腐烂。** 19 条排除结论积到一定数量后,光靠 briefing 下发已不足以阻止重复——本项目最后一轮我自己就重踩了一次已被 `nb_overdet.py` 证否的超定 SAT 想法。排除结论需要**被主动检索**,而不是被动阅读。 **5. 反例检验极其廉价,但没人做。** 检验 `x24 = fmix64(w1)` 只需要三次求值:算 `inv_fmix64(x24) ^ w1`,看是否恒为 0。这个检验在两天里从未被执行。**每条形如"A 是 B 的哈希/变换"的事实,都应该在写入时附带一次反演自检。** **6. 手工读汇编的时机。** 不是一开始就逐条读(那样成本不可承受),而是在**自动化路径全部撞墙之后**——此时你已经知道该盯哪一段代码,手工阅读的信噪比最高。本项目正确的顺序应该是:符号执行定位 → 全路撞墙 → 手工重读关键函数。实际走的顺序是:符号执行定位 → 全路撞墙 → 又撞了 20 小时 → 因为一次意外的核对才回到汇编。 **7. 作者的诱饵是冲着自动化分析设计的。** 符号执行会忠实地把 `x24` 建成 w1 的函数,把 `e9f4∘PM` 建成两层非线性变换——它没有"化简"这个动作。**只有人眼能看出那条依赖其实穿过 s118,能看出那两个矩阵互逆。** 这道题的整体设计信号很清晰:打算让你读懂的部分(版本号字段、`0x9e3779b9`、`0xdeadc0de`)一眼可辨;打算拦住你的部分,是 156 条等于一次异或的指令、一个被自己的逆包起来的线性映射、以及一个参数只是看起来依赖秘密的哈希。 --- 目标:`KCTF2026.apk` · `com.autorun.kctf` · `libkctf.so` md5 `9395c118338381236c0b6b6413d5ec1f`。 在未修改的库上于 Pixel 7 验证。工具链:apktool、jadx、llvm-objdump、radare2、Unicorn、pysat/Cadical、adb。
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
1
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
aut0run
谢谢你的细致分析,受益匪浅!
3天前
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
3
)
风子
雪 币:
1223
活跃值:
(1302)
能力值:
( LV3,RANK:20 )
在线值:
发帖
2
回帖
22
粉丝
1
关注
私信
风子
2
楼
本期课题可以做出反ai,如何不让ai走上正确的路。本期课题耗费的token都统计一下,会有多少?
2026-8-20 16:49
0
aeolusw
雪 币:
142
活跃值:
(1075)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
2
粉丝
0
关注
私信
aeolusw
3
楼
包含纠正过程的ai writeup,可以说是最有价值的一篇了
2026-8-20 20:50
0
Magic丶
雪 币:
1618
活跃值:
(2759)
能力值:
( LV6,RANK:80 )
在线值:
发帖
10
回帖
5
粉丝
2
关注
私信
Magic丶
4
楼
风子
本期课题可以做出反ai,如何不让ai走上正确的路。本期课题耗费的token都统计一下,会有多少?
token 消耗在文中有说明哦~
2026-8-28 09:11
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Magic丶
10
发帖
5
回帖
80
RANK
关注
私信
他的文章
KCTF2026: 第十题:卯时·曦光初现
2677
KCTF2026: 第九题:丑寅同墟·星海抉择
41
KCTF2026: 第八题:亥子合辰·塔影迷楼
56
KCTF2026: 第七题:戌时·暗能潜流
653
KCTF2026: 第六题:酉时·书院迷局
1641
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部