-
-
[原创] 看雪·2026 KCTF 第四题:未时·车流困城 WP
-
发表于: 2026-8-17 15:49 86
-
本文做出来的时候, 使用的是网页端的 GPT-5.6-Sol xhigh, 之前使用 Opus 4.8 + GPT 5.5/5.6 Terra 的时候一直卡在动态调试导致的数据流错误上, 然后就一直卡在模拟和插桩上了(至于为什么用这两个老模型是因为这玩意不爆 Cyber,我又不想装破限提示词怕把我号爆破了), 这几天拿这两个叼毛的完整上下文对账去,顺便可以水水文章()
后面用 Codex 里面的 Sol 指定函数纯静态逆向的时候又不爆 Cyber 了,怪
下面的 WP 为网页端 GPT-5.6-Sol xhigh 生成, 整体解题过程用时约 3 小时(不算我被那两个老模型耗的时间)
样本:CrackMe.exe
目标:给定用户名,构造能够通过校验的超长 password。
最终结论:整个校验可以拆成三层:DayDayUp -> MengXinQiuFangGuo -> GoodGoodStudy。最麻烦的不是数学,而是 MengXin 中的控制流混淆、反调试状态字节,以及一处非常容易误判的 key schedule DWORD 符号扩展。
程序对 password 的实际处理链可以抽象成:
反向 keygen 则反过来:
公开成功向量:
可以完整重建出公开 serial。
MengXinQiuFangGuo 外面套了大量保护:
最初使用的 deobf3.py 还有一个非常重要的限制:它对所有条件跳转只走一条统一路径,默认 fall-through。因此 path2.txt 能帮助看结构,但不能被当成“真实动态执行轨迹”。
这也是前期出现“结构看起来像 AES,但怎么算都不匹配”的主要原因之一。
主程序读入 username 和 password 后,大体依次执行三层逻辑:
因此构造 password 时也只需要把这三层逐层求逆。
先对 username 做一个简单的 31-base hash:
然后使用一个 Murmur 风格的 fmix 更新 state:
每次取:
即从 0..16383 中选 row,直到得到 100 个不重复 row。
恢复出的逻辑:
每个 row 保存 11 个系数:
对应:
[
P(x)=C_0+C_1x+\cdots+C_{10}x^{10}
]
数据本身没有明文保存,而是通过 descriptor 指向编码 blob,再经一个 row / coefficient 相关的 keystream 解出十进制系数字符串。
恢复后发现选中的多项式都能分解为 10 个一次因子,因此每行可以得到 10 个整数根。
100 行 × 10 个根 = 1000 个整数。
最终 GoodGoodStudy 明文形态为:
当前恢复的验证模型对每组 10 个输入主要检查“值是否属于这一行多项式的可接受根集合”。因此至少从数学约束本身来看,根的排列并不天然唯一。
当前 keygen 为了得到稳定输出,主动执行了:
所以它只选了众多候选中的一个 canonical solution。
已经很可信:同一行 10 个不同根的排列存在多解。
至于“同一个根重复 10 次是否也能通过真实二进制”,目前还应保守处理:我们的高层验证器允许 membership-only,但是否存在隐藏的 distinctness 条件,最好再做一次真实程序实验后下最终结论。
这是后期才暴露出来的一个通用性坑。
得到十进制文本后,程序要求在尾部补 NUL,而且长度必须到“严格下一个 16-byte 边界”:
也就是说,即使原长度已经是 16 的倍数,也仍然补一个完整 16-byte block。
例如公开向量:
而 KCTF:
之前错误地 padding 到 lcm(3,16)=48 的倍数,公开向量碰巧没有暴露问题,但 KCTF 立即失败。
这是整题最核心的一层。
它的干净数学结构可以看成:
AES-128 Equivalent Inverse Cipher 骨架 + 两张自定义 S-box + 特殊 key schedule + 环境状态字节。
块大小和 key 大小都是 16 bytes。
恢复出的 master key 为:
入口处直接从 R8 读取 4 个 DWORD,bswap 后写入 w[0..3],因此它就是 K0,而不是 Meng 内部再加工出来的某个中间 key。
这份 key 在样本中来自程序的完整性/self-check 逻辑,可理解为由多个区域的 CRC32 结果拼接而成。对写 solver 来说直接使用这 16 bytes 即可。
程序有两张独立 256-byte permutation:
原代码中表项不是直接存 byte,而是类似:
之后再混入保护状态字节。
一开始我们写成了:
这是错的。
原程序把 S-box 结果保留为 DWORD;当结果最高位为 1 时,值表现为 sign-extended DWORD:
随后直接移位、OR,没有重新 & 0xff。
因此:
不能简单拼成:
而可能因为 ffffffb1 / ffffffda 的高位参与 OR 变成完全不同的 DWORD。
这正是最初大量 AES variant 穷举全部失败的关键原因。
原代码在 substitution 路径中出现:
最初把它理解为普通 BeingDebugged,只考虑 0/1,也是不对的。
保护代码会把这个位置作为完整 8-bit 状态使用。
通过公开成功 pair 和独立 Meng block 测量,目前成功路径等价于:
旧的坏路径 dump 则等价于:
这里要强调:0x40 的“成功路径有效值”已经被向量验证;但它在完整 VEH / protector 状态机中究竟由哪一组异常处理精确演化出来,目前没有必要为了 keygen 强行解释。
总体仍是 AES-128 的 44-word 结构:
当 i % 4 == 0:
Rcon 仍然在 Rijndael GF(2^8) 中通过 0x1b reduction 生成。
round substitution 为:
接下来完全是标准 AES InvShiftRows。
机器代码中的:
正好对应三行右移 1、2、3。
这一层最终确认没有魔改,就是标准 AES InvMixColumns:
有限域仍是:
所以核心 xtime:
早期动态追踪中,InvMixColumns 后面还出现了一组 16-byte XOR,看起来像额外 mask。
最后发现:
它不是随机 mask,而是 AES Equivalent Inverse Cipher 的标准变形。
利用线性性质:
[
IMC(S\oplus K)=IMC(S)\oplus IMC(K)
]
所以:
和:
是等价的。
成功路径可以干净地写成:
或者按原始 Equivalent-Inverse 的写法:
两者等价。
成功路径 peb_byte=0x40 可以重新命中最早独立测得的两个 block vector:
这也是判断 good-path 模型是否正确的非常强的 regression test。
DayDayUp 最终比 Meng 简单很多。