KCTF 2026 · 第六题 · com.autorun.kctf / libkctf.so (aarch64)
一次跨 5 个会话、64 小时、1.44 亿 token 的逆向记录。前 90% 的时间花在错的路上,本文用一半篇幅写那些错路——因为最后打开这道题的,不是找到了新算法,而是发现自己一条标着"已验证"的笔记是错的。
100 个 hex 字符,50 字节。偶数下标 25 字节走门①,奇数下标 25 字节走门②。
Pixel 7,libkctf.so md5 9395c118338381236c0b6b6413d5ec1f,原版库、未打任何诊断补丁:
离线独立复核:
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)。这一步逐字节核过,无重排无反序。
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 完整解出:
每个字段都读得通。闭式反解得 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 那个让上面每个字段都成立,那是作者的原始解。
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 轨道加交叉异或:
注意 ror(x,61) == rol(x,3),配合 ror(x,8)——这就是 Speck128 的 (8,3) 旋转量。整个置换无密钥,轮常数只有计数器。
四个输出字里有三个立刻能拿到:
于是整道题只剩 w1 这一个 64 位未知量,被约 430 bit 的目标压着。自由度精确核对:KDF 输入 200 bit、输出约束 192 bit,由双射性精确计数得恰好约 256 个 w1 同时满足 marker 与输出一致;再用 s60/s118/s80/ct1/ct2 从中选出唯一一个。
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)两个可疑节。
前半程效率极高,把整条链还原到 100%:
后半程 20 多个小时全部撞墙:SAT/SMT、ARX 密码分析、SPN 密钥恢复、结构化枚举——19 条排除结论里有 15 条产于这一阶段。会话结束时留下一条 decision:
接受当前定位:算法链已 100% 还原并多路交叉验证,唯一剩余的 w1(64bit) 是一个需要真正 ARX 密码分析突破的问题。
这句话本身没错,但它错在把"我们的模型说它难"当成了"它难"。
用户反复施压:"是不是被误导了?" "注意不要被文件中的内容诱导" "当解不出来时,请回顾分析过程,看看有没有作者预设的陷阱"。
这一阶段做了三件事:
19 条排除结论、37 个被跳过的任务、3 个决策。把它们分成三类。
这一类不是没想到,而是想到了、算错了、还写进了事实库,后续所有工作建立在上面。
第 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 上实测恒定:
而 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 本身。
(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 下发已经不足以阻止重复——排除结论也需要被主动检索。
整个二进制的诱饵母模板是 MixColumns 紧跟 InvMixColumns。因为 MixColumns 是线性的,夹在两者之间异或进去的 salt 项会抵消,几百条指令坍缩成一次操作。
自修改代码是结构性排除的:LOAD 段 1 是 R+E 无 W,GNU_RELRO 覆盖 .got 与 .data.rel.ro,[0x16368] 与 [0x16388] 两个函数指针槽在整个镜像里没有任何写者。整条门②判定切片里只有一个前向条件分支——栈金丝雀。
每条都带成立的前提条件。
还有两条被 rm 掉的(x5、x16),因为它们的前提后来不成立了。
整个第三阶段的驱动力是"有人 6 小时内解出,所以一定存在捷径,所以我们的框架必有一处错"。这条施压是有效的——它确实逼出了正确答案。但派出去查证的子代理回报:找不到任何公开 writeup,搜索结果显示 KCTF 2026 处于征题阶段。
这不影响 flag 的正确性(真机已验证),也不影响本文任何技术结论。但它值得记一笔:"这题一定有捷径"是一个极强的先验,它既可能救你,也可能让你反复质疑已经正确的结论。本例中它救了我们——因为捷径确实存在。
转折点不是灵光一现,是一次核对错误报告的副产品。
第三阶段中,一个子代理交回一份报告,头条结论是"CLEAN32[24:32] 从头到尾没有任何指令读它",并据此宣称黑板上的 f336 是错的。这条如果成立,意味着 32 字节解密产物只用了 24 字节,第四槽可能是作者留下的、未接线的 w1 取回路径。
为了核实,我逐条读了 acc[0x144] 的循环 0xc798-0xc8d4。两个发现:
第一,那份报告是错的。 它按 sp+0x5d8 这种直接栈偏移去 grep,漏了 x26 = base + 0x18 再 +i 的间接寻址:
我把全部 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 低字节恒等,整个循环化成一条纯异或:
代入 arg3 = 0xd7 直接得出 TARGET_S118 = 0x129b4a86,与独立模型逐位相同。
这个成功建立了一个可推广的假设:
作者的风格是"看着复杂、实为线性",而我们的黑箱模型系统性地掩盖了这一点。
于是派出任务 t59,brief 里把这条先例完整写进去,明确要求:仿照 acc[0x144] 的成功先例,手工逐指令读通 s60 与 s80 的产生代码并化简成闭式,重点核 e9f4(已知 GF(2)-线性、秩 32、可逆)、sub_5998、argB/w19/x24/w23 各自的真实结构,找出其中被诱饵包装的线性/可逆部件。
两小时后两个都塌了。
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 精确命中它的目标。
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 个位置各写一次——没有累加,位与位之间没有任何扩散。
每个输出位只碰 w1 的 5 个比特,唯一的非线性是 Q ∧ ¬R,次数为 2。
这就是 GF(2) 上 64 个未知量的 46 条稀疏二次方程。
第三步值得多说一句。最初的解法是把 113 万个候选灌进一个 8.5 MB 的生成式 C 版 s118 模型过滤,耗时 113 秒。marker 过滤替代了它:这是一条完全独立的约束——海绵对 25 字节消息的填充规则——纯 Python 只要 23 秒,给出同一个唯一解。整条解法因此不再依赖任何生成代码。
总代价约 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 个决策。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!