-
-
[原创][原创]KCTF2026第四题:未时·车流困城(AI总结)
-
发表于: 22小时前 131
-
拿到这道题的前 10 小时,我一度以为自己已经破解了核心算法 —— 我脱了壳、逆向了 AES 结构、恢复了 S 盒、算出了多项式根,生成的 Serial 在自己补丁后的程序里稳稳输出Successful!。
但一拿到原版程序上跑,直接红底白字跳出Failed!。
反复核对了三遍算法,数学上完全自洽,每一步逆推都能正向复现,但原版就是不认。
后来我才明白:这道题最难的地方从来不是 “算法有多复杂”,而是它布下了三层递进的欺骗陷阱。你以为自己在走正门,其实从一开始就掉进了出题人精心设计的诱饵迷宫里。
这篇手记会按照我实际踩坑、破局的顺序,完整还原从 “拿到程序一头雾水” 到 “跑出正确 Serial” 的全过程,所有结论均经过原版程序实测验证。
一、撕开第一道伪装:32 位 PE 下的 64 位暗门
程序是个再普通不过的 32 位控制台 CrackMe,拖进 IDA 里能看到完整的导入表和导出函数名:DayDayUp、MengXinQiuFangGuo、GoodGoodStudy、Warning,乍一看结构非常清晰。
主函数骨架很短,核心就是 5 次调用同一个跳板函数sub_4010E0,每次传入不同的函数指针和参数。
sub_4010E0这个函数很短,但关键指令一眼就懂:
asm
pusha
pushf
mov eax, [esp+28h] ; 第4个参数:函数指针
mov ebx, [esp+24h+arg_0]
mov ecx, [esp+24h+arg_4]
mov edx, [esp+24h+arg_8]
push 33h ; 64位代码段选择子
call $+5
add [esp+2Ch+var_2C], 5
retf ; 远返回切入x64长模式
这是经典的Heaven's Gate(天堂之门)技术:32 位程序通过远返回切换 CPU 到 64 位长模式,去执行藏在内存里的 64 位代码。
IDA 默认按 32 位解析,所以核心逻辑所在的.data段全被识别成了数据,静态分析看到的 32 位代码,只是个负责调度的 “壳子”。
2. 异常驱动的宏指令 ISA
跟进 64 位代码后,我第一个疑惑是:里面充斥着大量in、out、int3这类特权指令,正常 ring3 程序根本不会这么写。
很快我反应过来:这些指令不是真的要执行 IO 操作,而是故意触发异常,程序提前注册了 VEH(向量异常处理)函数Warning,异常触发后由Warning根据触发指令的 opcode 分发执行对应的业务逻辑。
换句话说,出题人用异常机制自己造了一套指令集:
0xED(in eax, dx):反调试自检
0xEE(out dx, al):格式长度校验
0xEF(out dx, eax):派生加密密钥
0xEC(in al, dx):计算混淆跳转地址
这里第一个大坑就来了:不能直接把异常指令 NOP 掉。很多人(包括我一开始)觉得异常就是反调试,直接 patch 掉就能正常跑。结果跳过异常后,程序确实还能运行,但跑的已经是另一套假算法了 —— 异常本身承担了状态传递、密钥累加、控制流跳转的功能,它是算法的一部分,不是单纯的阻碍。
二、最隐蔽的陷阱:环境决定算法真假
这是我卡最久的一个坑,也是区分 “假解” 和 “真解” 的核心门槛。
def integer_roots(poly):
"""poly: 系数列表,从常数项到最高次项,首一多项式"""
assert poly[-1] == 1
constant = abs(poly[0])
if constant == 0:
return [0] + integer_roots(poly[1:])
# 枚举常数项的所有正因子
factors = get_all_positive_factors(constant)
roots = []
remaining = poly.copy()
for f in factors:
# Horner法则验证
val = 0
for coef in reversed(remaining):
val = val * f + coef
if val == 0:
roots.append(f)
# 多项式降次
remaining = poly_deflate(remaining, f)
if len(remaining) == 1:
break
return sorted(roots)
all_roots = []
for state in derive_states("KCTF"):
poly = decrypt_polynomial(state)
roots = integer_roots(poly)
all_roots.extend(roots)
plain_text = "-".join(str(r) for r in all_roots)
plain_text = "0" * 6 + plain_text
plain_bytes = plain_text.encode("ascii") + b"\x00" * (6912 - len(plain_text))
cipher = custom_aes_encrypt(plain_bytes, round_keys, sbox_inv)
serial = custom_base64_encode(cipher)
final_serial = HEAD + serial + TAIL
- 诡异的 “环境玄学”
我最早用 PowerShell 7 拉起程序调试,无论怎么输入公开的合法样例,永远输出Failed!。我一度以为公开样例是错的,或者我漏了什么校验。
直到偶然双击直接运行程序,手动粘贴公开样例,居然一次就过了。
同一个程序、同一个输入、同一个系统,只是启动方式不同,结果完全不一样。 - 定位父进程白名单
顺着这个线索排查,很快定位到程序启动后会调用NtQuerySystemInformation枚举进程,拿到自己的父进程名,做一次大小写不敏感的 djb2 哈希,和内置的三个哈希值对比。
命中白名单(explorer.exe、cmd.exe、powershell.exe)才会进入真实算法分支;不命中就静默进入诱饵分支。
PowerShell 7 的进程名是pwsh.exe,不在白名单里,所以永远跑的是假算法。这也是为什么很多人用 Python subprocess 拉起程序永远不对的原因。 - PEB 里的隐蔽信道
光过了父进程检测还不够。程序在执行流程中,分两次往PEB->BeingDebugged这个字节里写值:
执行 AES 解密(MengXin)时,加0x1A
执行格式校验(gate2)时,再加0x40
两次加起来正好是0x5A,这个值就是最终多项式校验层 VM 字节码的解密密钥。
如果你跳过了中间任何一步校验,或者调试器修改了 PEB 字段,这个值就不对,最终校验层会直接返回失败,没有任何报错提示。
我把它叫做 “两次盖章机制”:你必须完整走完所有正常流程,才能凑齐打开最后一扇门的钥匙。任何投机取巧的跳过,都会悄无声息地滑进错误路径。
快速判断自己是否在真路径的方法
不用等到最后看结果,做一个简单的差分实验:
修改 Serial 的第 1 个字符,运行后对比解密后的第 1 个 16 字节块。
如果输出和公开样例的对应块完全对不上,不用往下看了,你百分百在诱饵路径里,算法再完美也没用。
三、拆解第一层:Serial 的自定义编码还原
搞定环境问题后,正式开始从外到内逐层逆向。 - Serial 结构初探
合法 Serial 固定 9226 字符,首尾各 5 个字符是固定的标识头和尾,中间 9216 个字符是正文。
统计正文字符集,正好 64 个可打印字符,第一反应就是变种 Base64。 - 还原仿射偏移
如果只是换了字母表,那直接映射就能解码。但我做了个单字符修改实验:只改 Serial 第 i 位的字符,看解码后对应字节的变化。
结果发现:每个位置的字符,对应的 6 位值不是固定的,而是随位置线性变化。
通过多个位置采样计算,最终还原出变换公式:
设字符在字母表中的下标为a_i,对应的真实 6 位值为v_i
解码方向:v_i = (a_i + 51 + 27 * i) mod 64
编码方向:a_i = (v_i - 51 - 27 * i) mod 64
9216 个 6 位值正好打包成 6912 字节,没有填充位。这一步是一一映射,只要拿到 6912 字节的目标密文,就能唯一反推出 Serial 正文。
四、拆解第二层:从诱饵 S 盒到真实 AES 结构
6912 字节正好是 432 个 16 字节块,很自然想到是分组密码。 - 确认 ECB 模式
还是用差分法:修改第 0 块的输入,看其他块的输出有没有变化。
结果是完全没有,说明是 ECB 模式,块与块之间独立,没有链式依赖。这意味着只要恢复出一个 16 字节块的逆变换,就能批量处理全部 432 块。 - 状态转置的坑
一开始我按行优先的常规 AES 布局去套,怎么都对不上行移位和列混合的规律。
直到我把输入输出按列优先重排了一遍,结构瞬间清晰了:输入缓冲区按列主序存储,但内部轮函数按行主序运算。
漏掉这个转置,S 盒看起来可能还是个置换,但行移位、列混合永远对不上,这也是很多人卡在这一层的原因。 - 还原真实 S 盒与轮密钥
前面说过,调试器附加会得到假 S 盒,所以不能用普通断点 dump。
我用的是无侵入的内存轮询法:
异常触发时,Windows 会把完整的 64 位寄存器上下文(包括 XMM 寄存器)存在线程栈上。我找到这份 CONTEXT 结构的固定位置,只读轮询内存,记录每一轮状态的变化。
整个过程不打断程序执行,不修改任何内存,程序全程以为自己在干净环境里运行,输出的自然就是真实算法的结果。
采集到相邻轮的状态后,通过差分计算:
单字节变化只扩散到一列,确认是 AES 的列混合结构,约简多项式是标准的0x11B
反向消去线性层,得到字节代换关系,拼出完整的 256 字节逆 S 盒
多块求交消去噪声,恢复出全部轮密钥和首轮输入异或值
最终确认:这是一个魔改的 AES-128 解密结构,正向执行用的是 AES 的逆列混合 + 右移行,不能直接调用标准 AES 库。
另外有个细节:每轮线性层之后,都会异或一遍全0x40的常量,漏掉这个偏移,单轮看没问题,多轮累计下来结果就会完全偏移。
五、拆解第三层:多项式根校验与长度暗桩
AES 解密出来是一段 ASCII 字符串,用-分隔的十进制数字,一共 1000 个整数,分成 100 组,每组 10 个。 - 多项式系数的解密
用户名KCTF会经过两次哈希映射,生成 100 个 14 位的组号。每个组号对应 11 条系数记录,存在.rdata段的大表里。
这些系数不是明文,是加密过的压缩 BCD 码。解密密钥由组号和系数序号共同派生:
plaintext
base = (组号 * 0x9E3779B9) XOR (系数序号 * 0x517CC1B7)
解密后的格式是:首字节高 1 位是符号,低 7 位是十进制位数,后面的字节按半字节存储十进制数字。
11 个系数正好对应一个 10 次首一整系数多项式(最高次项系数为 1)。 - 整数根求解
校验逻辑很直接:每组的 10 个数,代入对应多项式,结果必须全为 0。
换句话说,这 10 个数就是这个 10 次多项式的全部正整数根。
求解不用浮点运算,利用数论里的基本定理:首一整系数多项式的整数根,一定能整除常数项。
常规组:直接在整数环内做精确因式分解,得到 10 个互异正整数根
异常组(第 84 组):一次项系数被故意篡改,无法直接分解。我采用的方法是枚举常数项的所有正因子(共一万多个,计算量很小),逐一代入验证,筛选出唯一一组 10 个互异根。 - 最后一个暗桩:文本长度
算完 1000 个根的时候我特别兴奋,直接编码成 Serial 拿去跑,结果又失败了。
数学上完全正确,为什么程序不认?
对比公开样例的明文长度,我发现了最后一个坑:
程序对解密后的明文长度有严格要求,必须落在特定区间内。原始根拼接起来的文本长度是 6891 字节,不满足条件。
而十进制解析是允许前导零的 —— 数值不变,但文本长度可以增加。
我在第一个整数前面补了 6 个前导零,总长度变成 6897 字节,尾部补 NUL 对齐到 6912 字节,再重新加密编码,这次一次通过。
这是出题人非常精妙的设计:你数学全对,但只要没注意到格式约束,照样拿不到分。
六、完整逆推:从多项式根到最终 Serial
把所有环节反过来串起来,就是完整的解题流程:
计算组号:输入用户名KCTF,通过哈希 + 雪崩变换,生成 100 个去重的 14 位组号
提取多项式:根据组号索引系数表,解密得到 100 个 10 次首一整系数多项式
求解整数根:对每个多项式因式分解,得到 10 个正整数根,组内升序排列
构造明文:将 1000 个根用-连接,通过补前导零调整文本长度,尾部补零到 6912 字节
AES 加密:用还原出的自定义 AES 算法,正向加密 6912 字节明文
编码 Serial:按自定义 Base64 规则编码密文,加上固定首尾,得到最终 9226 位 Serial
七、最终验证与结果
将生成的 Serial 输入未打任何补丁的原版程序,控制台稳定输出:
plaintext
Successful!
全程无爆破、无遍历、无内存补丁,完全通过代数逆推得到正确结果。
关于 “多解” 的补充
我测试发现,同一组数值可以生成很多个合法 Serial:
前导零可以补在不同位置,只要总长度合规就行
末尾多加一个-分隔符,程序也会接受
甚至因为程序实现有越界 bug,有 51 个位置的数值可以替换成任意大值,依然能通过校验
但从数学设计上来说,干净的正确解是唯一的。那些额外的解,要么是格式冗余,要么是实现缺陷。
八、复盘:这道题的精髓在哪里
做完这道题最大的感受是,它考的不是 “你会不会逆向 AES”、“你会不会解多项式”,而是你能不能跳出思维定式,识别出出题人的欺骗。
第一层欺骗:给你看 32 位代码,真实逻辑藏在 64 位里
第二层欺骗:给你一套完整的假算法,让你在调试器里自以为破解成功
第三层欺骗:数学全对也没用,格式暗桩卡在最后一步
它不是一道 “算法难题”,而是一道 “逆向工程素养题”—— 真正的逆向,从来不是对着代码死磕,而是时刻保持怀疑,验证每一步的前提,确认自己走的每一步都在真实路径上。
附:核心解题脚本(关键片段)
这里给出最核心的多项式求根与 AES 逆推逻辑框架,完整脚本可自行补全环境验证:
python
运行
我最早用 PowerShell 7 拉起程序调试,无论怎么输入公开的合法样例,永远输出Failed!。我一度以为公开样例是错的,或者我漏了什么校验。
直到偶然双击直接运行程序,手动粘贴公开样例,居然一次就过了。
同一个程序、同一个输入、同一个系统,只是启动方式不同,结果完全不一样。
顺着这个线索排查,很快定位到程序启动后会调用NtQuerySystemInformation枚举进程,拿到自己的父进程名,做一次大小写不敏感的 djb2 哈希,和内置的三个哈希值对比。
命中白名单(explorer.exe、cmd.exe、powershell.exe)才会进入真实算法分支;不命中就静默进入诱饵分支。
PowerShell 7 的进程名是pwsh.exe,不在白名单里,所以永远跑的是假算法。这也是为什么很多人用 Python subprocess 拉起程序永远不对的原因。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。