-
-
[原创] KCTF2605 第五题 忆海倒带 Writeup
-
发表于: 2026-8-17 15:28 89
-
题目:看雪 KCTF2026 第五题(规则 5.1.1 Windows 方案一)
样本:cm.exe(PE32 / i386 / SHA1 d1c8124c5964af1531c6e311328854a05ca40bca)
判胜:输入序列号回车后输出 verify success.
分析方式:全程由 AI Agent(ZCode + GLM-5.3)自动完成,人工零干预
最终 Key:
这道题的体验非常奇妙:你以为自己在读代码,其实代码在读你。
二进制里埋了三条"开发者注释",一会儿说密码是 admin123,一会儿说"真正的校验在异常处理器里"。而当你真的跑去分析 SEH,就正中下怀——那是个空壳。
真正的核心藏在三件东西里:
而题目名"忆海倒带"也暗合解法:数据在内存里被"倒带"(limb 逆序 + 字节序翻转),想要读出真相,你得把它再倒回去。
下面按真实破解时间线展开。
拿到样本先做三件事:校验哈希、看节表、扫字符串。
节表非常干净,.text 只有 0x4a80(约 19KB),意味着核心逻辑完全在明面上,不需要脱壳:
字符串扫描直接命中判胜点:
交叉引用定位到主逻辑 main @ 0x403d90。反汇编里随处可见这种"垃圾缝合":
每个基本块之间都插了 mov eax,eax / mov ecx,ecx / nop / nop 和 ff 33(坏字节)填充。这是典型的轻量混淆——不碍事,但会把 IDA 的线性分析搅乱,也劝退肉眼阅读。AI 直接忽略跳转之间的死代码继续读。
.rdata 里躺着三句人畜无害的注释,以及一个反 AI 彩蛋:
这是本题最漂亮的反直觉设计:main 启动时把这三条字符串轮流喂给 0x402100——一个大数十六进制解析函数。解析器只认 0-9 / A-F / a-f,其余字符一律按 0 处理。也就是说,这三句"注释"里所有恰好是 hex 字符的字母(F、E、a、d、1、2、3…)被悄悄拼成了三个大数常量。
你以为是注释,其实是参数。
配套的还有一组陷阱输入:admin123、r3v3rs3!、password(0x406208/214/220)。输入三者之一会触发 int3,走进一个标准的 MSVC __try/__except——处理完啥也不干,继续走。真正的 Key 是 88 个 hex 字符,根本不可能等于这三个串,所以整个 SEH 剧场对解题者是纯烟雾弹。
教训:字符串的"语义"由引用它的代码决定,不由内容决定。看到字符串先查 xref,再下结论。
把 0x402bd0(构造)、0x402a70(operator[])、0x402d70(置换读取)、0x402da0(洗牌)、0x401330/0x401510/0x401a90/0x4012d0(置数/加/乘/赋值)串起来,可以还原出一个自制大数库的对象布局:
关键在 operator[]:
构造函数用种子 0xcafebabe 的 Fisher-Yates 洗牌初始化 perm。这意味着:任何大数的 limb 在堆上都是乱序存放的,你 dump 内存永远看不到一个"顺序"的数——这就是"忆海倒带"的第一层含义(也是动态扒内存路线的劝退点)。
幸运的是,所有算术(加、乘、赋值、进位)都统一走 operator[],置换对运算是透明的——它只混淆观测,不混淆数学。这一点决定了后面可以纯靠静态数据 + 标准数论解题。
main 在解析输入后有一段让静态分析一度自我怀疑的检查:
矛盾点:若按"11 个字符"理解(11×8=88),一个 11 字符的 hex 串最多 44 bit,只可能产生 2 个 limb,flag*8 永远到不了 88——这个检查看起来永远失败。
用 gdb 动态验证(样本开了 ASLR,复制一份清掉 DllCharacteristics 的 0x0040 DYNAMIC_BASE 位再调试):
翻转视角:flag == 11 需要数值 ≥ 2^320,即 88 个十六进制字符(352 bit = 11×32bit limb)。此时 flag*8 = 88 = 0x58 恰好成立——0x58 这个常数身兼两职(bit 数 / 字符数),出题人在这里放了一个小小的文字游戏。
结论:Key 是 88 个 [0-9A-F] 大写字符,解析成 44 字节大端序列 buf[0..43](limb 逆序 + 每 limb 字节序翻转,又一处"倒带")。
通过长度检查后是两道校验和(都作用在 44 字节上):
第二处极易读错——and ecx,0x7f 的对象是累加和 edx 而非索引。动态断点实测校准模型(构造 XOR 合法 key,断在 0x40425f 读 [ebp-0x930],实测 0x9bdff 与模型逐位一致)。
然后出现了本题最戏剧性的一幕:无论怎么构造,校验②都过不去。
先怀疑模型错了——动态实测排除;再怀疑解空间不够——给尾部 3 个自由字节、按同余方程精确定位最后一个字节,255 个前缀全部无解;最后直接放飞做200 万次随机搜索(固定 43 字节随机 + 1 字节 XOR 修正),零命中。
零命中不是坏运气,是数学结构。对 20 万组随机样本统计 (s & 0xFFFF) 的分布:
不变量证明(一行版):设 r = s mod 128。当 r = 127 时乘数 (s&0x7F)+1 = 128,于是 s' = s + 128b ≡ s (mod 128)——r = 127 是吸收态;而 44 个"随机"字节几乎必然在前几步就落进去。但目标 0xBEEF mod 128 = 111 ≠ 127,落在吸收态之外的轨道上。
换句话说:这两个校验和不是"要你去满足的约束",而是"唯一真 Key 的指纹"。
出题人先定了 Key,再从 Key 算出 0x8F / 0xBEEF 刻进代码。任何随机/拼凑的输入都会被不变量挡在门外,唯有沿"唯一解"逆推出来的 Key 会恰好同时命中两个常数——这反而是最好的正确性证明。
这个认知把解题策略从"满足约束"切换到"完全逆推"。
校验和之后调用核心函数 0x403500,它干了四件事:
这里又是一个反汇编陷阱:count 字段的值 0x800 是靠往 0x43801d 写一个字节 0x08 凑出来的(小端下 dword 0x43801c 恰好变成 0x00000800),漏看这一字节会以为 count=0 而全盘崩溃。
R1/R2 的 limb 数据同样躺在被置换过的 .data 缓冲里,按 limb[i] = mem[data + perm[i]*4] 还原:
后 16 字节的宿命:M = Key[28..43] 被送进 RSA,输出 R = M^E mod N 又回填覆盖 buf[28..43]。也就是说,key 的尾 16 字节是"密文入口",真正参与最终判定的是它的 RSA 轮回——第三个"倒带"。
最终变换是一个查表映射(表全部是静态 .rdata 数据,无需执行即可提取):
44 个字节逐个过表,输出与 0x416380 处的明文做 strcmp:
恰好 44 个字符,与 44 字节 key 一一对应。穷举 v ∈ [1,255] 建逆映射后发现:每个明文字符的前像唯一——映射是单射。于是最终 44 字节被完全锁定:
需要 M = R^d mod N,就得分解 N。128-bit 半素数,FactorDB 早有记录:
拼装、模型全链路自检:
那两个"200 万次都过不去"的校验和,在唯一解上分毫不差地同时命中——比任何调试器都更有说服力的正确性证明。
原始样本实测:
整条解题链没有一处靠"爆破",全部是结构化逆推——这正是出题人想筛选的能力。
本题从落盘样本到输出正确 Key 全程由 AI Agent 自动完成(静态反汇编 → gdb 动态验证 → Python 建模求解),人工零干预、零提示补丁。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。