-
-
[原创]第八题「亥子合辰·塔影迷楼」解析
-
发表于: 2026-8-21 14:48 34
-
程序要求输入 16 个字符,正确时输出 correct,否则输出 fault。
整个校验的数学本质是:
Feistel 置换是双射(可逆),所以可以从目标常量倒推回 r;再在有限域 GF(p) 上给五次方程求根,就能拿到 acc;最后按 94 进制展开,就是 flag。
(字符和恰好 1280,正好满足程序里的另一个校验条件。)
用到的工具:
拖进 IDA,先别急着看代码,先看几个全局特征。
最后那个 .tgt 不是编译器生成的标准节区名,是作者自己加的。经验上,这种自定义节区里往往放着参数和目标密文。后面会看到,确实如此。
整个程序只导入了 8 个 API:
信息量很大:
配合上面"没有文件 API"这一点,答案出来了:程序自己解析 ntdll 里这些函数的系统调用号,然后直接 syscall,绕过用户态 API。这既能反 Hook,也能让新手在下 API 断点时扑空。
IDA 只识别出 12 个函数,其中有一个 sub_140004411 大小 8801 字节却被标为"叶子函数(不调用任何东西)"——这不合常理。
这是加密代码的典型征兆。 一大片被加密的字节,IDA 会把它当成随机数据强行反汇编,识别出的"函数"边界和内容都是假的。看到这种情况,不要去读它,先去找解密它的代码。
这一节把后面反复出现的概念先讲清楚,读代码时就不会卡住。
程序运行时把自己的一段代码从密文改成明文,再跳进去执行。标准三步:
第 3 步是必须的:CPU 有独立的指令缓存,改了内存不刷新,CPU 可能还在执行旧指令。
对逆向的意义:静态看到的字节是密文,必须先在 IDA 里手工解密(patch),才能反编译。
一个极简的字符串哈希,两行就能写完:
本题里 0x100000001B3(FNV 质数)和 0xCBF29CE484222325(FNV 偏移量)出现了很多次,看到这两个常数基本就能认定是 FNV-1a。
一个很快的伪随机数生成器,核心是"雪崩函数":
特征常数:0x9E3779B97F4A7C15(黄金分割率,常做步长)、0xBF58476D1CE4E5B9、0x94D049BB133111EB。
特征常数 0xFF51AFD7ED558CCD。本题第二层的密钥流用到它。
本题的模数是 p = 2¹²⁷ − 39(一个 127 位的素数)。
两个 128 位数相乘会得到 256 位结果。要对 p 取模,程序用了一个巧妙的技巧:
因为 2¹²⁸ ≡ 78 (mod p)(78 = 2 × 39),所以
这样一次乘法就把 256 位压回 128 位左右,再做几次条件减法就规范化了。
看代码时的识别特征:反编译结果里反复出现 78(0x4E)、0xFFFFFFFFFFFFFFD9(即 −39)、0x7FFFFFFFFFFFFFFF,就是在做模 2¹²⁷−39 的运算。
在 Python 里我们不用管这些技巧,直接 % P 即可,结果完全一样。
密码学里构造可逆变换的经典方法。状态分成左右两半 (A, B):
关键点:不管 F 多复杂多不可逆,整个结构永远可逆——因为解密时 F 的输入是已知的。
这正是本题能被解开的突破口。
入口函数 start 在 0x1400012c0。开头几行:
读一下这两个全局变量(它们就在 .tgt 节区里):
再看解密循环,SSE 版本是 xorps xmm1, xmm2,其中 xmm2 加载自 xmmword_1400070D0:
尾部处理用的是标量版 *v7++ ^= 0x5A。
结论:第一层 = 地址 0x140001800,长度 0x510,逐字节 XOR 0x5A。
打开 IDA 的 Python 控制台(Shift+F2 或 File → Script command),执行:
这里有个新手常踩的坑:如果不先 del_func / del_items,IDA 会保留之前对密文的错误分析结果(错误的指令边界、错误的函数范围),patch 之后反编译出来还是乱码。先清理,再写入,再建函数。
解密后开头字节是 48 89 5C 24 ...(mov [rsp+xx], rbx),标准的 x64 函数序言——说明解对了。
解密完之后,start 的流程很清晰:
注意每个分支最后都是 sub_140001000(...) 然后 while(1);——sub_140001000 就是通过 syscall 调 NtTerminateProcess 结束进程,后面的死循环只是编译器认为函数不返回而生成的填充。
实测提示:如果你在调试器或脚本里运行它,进程结束前可能会短暂占满一个 CPU 核。用命令行测试时建议加超时,例如printf 'xxxxxxxxxxxxxxxx\n' | timeout 8 ./kctf2026_CrackMe08.exe
sub_140001220(name) 负责取系统调用号:
sub_1400017B0 则是通用 syscall 存根:
对逆向的影响:在 NtReadFile / NtWriteFile 上下 API 断点是无效的,程序根本不经过 ntdll 的那段代码。想断,就断 0x1400017F2(syscall 指令本身)。
这个函数长得吓人(十几层嵌套的 __ROR8__ 和乘法),但仔细看:
那个全局变量 qword_140008000 在后面的校验里从未被使用。哈希撞上 0x1111111111111111 的概率是 2⁻⁶⁴,也永远不会发生。
这是纯粹的时间浪费陷阱——故意做得很显眼、很像"核心算法",引诱你去分析它。
经验:判断一段代码是不是干扰项,最快的办法是看它的输出去了哪里。如果计算结果没有流向最终的返回值/比较,那它就是噪音。用 IDA 的交叉引用(X 键)查一下写入的全局变量有没有被读取即可。
loc_1400018D0(解密后是 sub_1400018D0)就是第二层解密器。反编译后核心是:
密钥不是硬编码的常数,而是"第一层代码本身的哈希"。
这意味着:
这叫代码自校验(self-checksumming),是很常见的反调试/反篡改组合技。
应对办法:用硬件断点(Dr 寄存器)代替软件断点——但本题的第三层又专门检查 Dr 寄存器(见第 9 节)。所以本题的正确姿势是纯静态分析 + 离线复现算法,这也是本文的做法。
解密后 0x140001d10 处是 40 53 48 83 EC 20(push rbx; sub rsp, 20h),又是标准函数序言,说明解对了。
sub_140001D10 很短,但埋了两个坑:
8 个表项完全一样。这个"根据输入选择处理函数"的复杂计算纯属演戏,无论 idx 算出什么,都是调 sub_140004380。
dword_14000A010 = 0x500,所以比较的是 0x500 ^ 1 = 0x501。
而 sub_140004380 内部要求 sum == 0x500。两个条件放在一起:
所以这个"额外条件"实际上什么都没约束,只是让你在看反编译结果时以为逻辑写反了,浪费时间去怀疑自己。
记住一条:16 个字符的 ASCII 码之和必须是 1280。 这在最后验证答案时是个很好的自检。
sub_140004380,大小 8946 字节,Hex-Rays 反编译出来 56,000 多个字符、400 多个局部变量。
不要试图从头到尾逐行读它。 正确的做法是先找骨架,再填细节。
按执行顺序,它分成 7 段:
段 ③ 的结果会喂进段 ④ 的随机数种子——被调试 → 种子变 → 所有常量变 → 必然判错。这是把反调试和算法绑死的经典做法,光把检测代码 nop 掉是没用的(种子还是错的),必须让检测真的返回"没被调试"。
好消息是:我们只做静态分析,直接令 v11 = 0 即可。
这是全题最关键的一环——搞清楚这一段,所有常量就都能离线算出来。
其中 qword_14000A018 = 0(.tgt 里读出来的),v11 = 0(未被调试),所以:
实算结果:
43 轮 × 2 个 = 86 个 64 位数 = 688 字节,正好覆盖 CONTEXT 结构的 0x000–0x2B0。
为什么用 CONTEXT 结构当缓冲区?
一是省栈空间(GetThreadContext 本来就需要这块内存);二是迷惑分析者——反编译器会把偏移显示成 Context.Dr3、Context.Xmm5.High 这种名字,让人误以为在读真实的寄存器值,实际上早就被伪随机数覆盖了。
遇到这种情况,把成员名换算成数字偏移来看,思路会清晰得多。
x64 的 CONTEXT 结构布局(本题相关部分):
程序从填充好的这块内存里取走:
几点细节:
(a) 128 位数的取法与"归约"
(b) S 盒的交错重排
程序不是直接复制 256 字节,而是每 16 字节一组做了交错:
对应汇编里那一堆 BYTE1(v52) / BYTE1(v53) 交替赋值。
(c) 目标值 = .tgt 里的常量 ⊕ 掩码
这样设计的目的:静态看 .tgt 里只是一堆随机字节,必须先完整复现 splitmix64 填充过程才能还原真正的目标值。这挡住了"直接从二进制里抠出目标密文"的偷懒路线。
反编译结果里,轮常量表显示成两个数组 v421[9](_OWORD)和 v423[10],而循环却按 qword 读了 20 次——看起来"越界"了。
去查函数的栈帧布局(IDA 里按 Ctrl+K,或用脚本读):
对照赋值语句 v421[0] = Context.Legacy[4](Legacy[4] 在 CONTEXT 偏移 0x160):
差值恒为 0x100——这是一整块连续的 320 字节拷贝,只是 Hex-Rays 把它拆成了好几个变量。所以:
正好首尾相接,没有任何越界。
教训:Hex-Rays 对大块栈数组的变量划分经常是错的。凡是涉及数组边界的判断,一定要回去看栈帧布局或汇编,不能只信反编译出来的变量名。
程序检测 5 类特征,结果按位存进 v11:
关于 bit 1:and eax, 70h / cmp al, 70h 是检测 NtGlobalFlag 的经典模式(被调试时会置上堆栈校验相关的三个标志位 0x10|0x20|0x40)。但 0x68 是 32 位 PEB 里 NtGlobalFlag 的偏移;在 x64 的 PEB 中,0x68 是 ApiSetMap 指针,NtGlobalFlag 在 0xBC。由于 ApiSetMap 指向的地址是页对齐的,低位为 0,这条检测在 64 位下实际上永远不会触发。
无论如何,这些标志的用途是一样的:
只要有一位被置上,后面所有常量就全变了,结果必错。
正常运行(不调试)时 v11 = 0,我们在脚本里直接写死。
这就是标准的进制转换。翻译成数学式:
为什么是 94 和 33? ASCII 可打印字符是 0x21(!,十进制 33)到 0x7E(~,126),共 94 个。所以这一步等价于:把 flag 看成 94 进制数,'!' 是数字 0,'~' 是数字 93。
这给了我们两个重要信息:
接下来是标准的**霍纳法则(Horner's method)**求多项式值:
展开就是:
脚本算出的系数:
在反编译结果里,这五步会被展开成几百行的 128 位乘法、进位处理和条件减法,看起来极其吓人。识别技巧:找 + v411、+ v412、+ v413、+ v414、+ v415 这几个加法点——它们之间夹着的一大坨就是一次"乘 acc 再取模"。看到这个"加常量 → 一大坨 → 加常量"的节奏,就知道是霍纳法则。
r 算完后拆成高低两半:
然后同一个初值分别送进两条 20 轮循环。
设 K(i) 是本轮密钥(下面说),tbl[i] 是本轮常量:
两个辅助函数:
轮密钥的选择(两条链故意相反,这样即使初值相同,输出也完全不同):
反编译结果里,把噪音去掉后剩下的骨架是:
关键特征:新状态 = 另一半旧状态 ⊕ 某个复杂函数。 只要看到这个形式,不管那个"复杂函数"里面是乘法、S 盒还是别的什么,整个结构就是可逆的。
再确认 v407 恒等于 A:循环开始前 v407 = A₀,循环末尾 v407 = v403 = A',下一轮开头 A = v248 = v403。两者始终相等,所以 B' = A ⊕ g(x₂) 成立。
已知本轮结束后的 (A', B'),倒推轮开始前的 (A, B):
顺序很重要:必须先算 A(因为 x₁ 需要 A),再算 B。
从第 19 轮倒着做到第 0 轮,就还原出了 (A₀, B₀),也就是 r。
条件 3、4 只是额外的完整性校验,条件 1、2 才是我们要逆的。
两条链给了我们两个独立的方程,而它们应该反推出同一个 r——这是绝佳的自检机制:如果实现有任何一点错,两边算出来的 r 必然不同。
先说说不能怎么做:
正确的路线是沿着数据流反着走:
按 12.3 节的逆运算,从两个目标值各倒推 20 轮:
两条完全独立的链(不同的常量表、相反的轮密钥)反推出了完全相同的值——实现无误。
再正向跑一遍验证能得回目标值,双向都对上,这一步就可以放心了。
现在要解:
一元五次方程没有求根公式,但在有限域 GF(p) 上求根是有标准算法的——Cantor–Zassenhaus 算法。原理只需要知道两点:
(1) 找出所有一次因子
费马小定理的多项式版本:x^p − x 恰好等于 GF(p) 中所有元素对应的一次式 (x − a) 的乘积。所以
就是 f 的所有一次因子的乘积,也就是所有根的信息。
计算 x^p mod f(x) 用多项式快速幂:p 是 127 位,只需约 127 次"多项式平方 + 取模",而多项式次数始终小于 5,所以很快(毫秒级)。
(2) 把因子劈开
如果 g 的次数大于 1(有多个根),随机取 a,计算 gcd((x+a)^((p−1)/2) − 1, g)。因为半数元素是二次剩余、半数不是,这个 gcd 有约 1/2 的概率把 g 劈成两块,递归下去即可。
本题运气好,g 的次数直接就是 1——五次方程在 GF(p) 上只有一个根:
0x174a5e1d4cf4f146e72f70092ac ≈ 2¹⁰⁴,确实小于 94¹⁶,符合预期。
字符和这个独立的约束能对上,是答案正确的强力旁证。
两个脚本都只用 Python 标准库。
做四件事:
关键片段:
运行输出:
手写 GF(p)[x] 的多项式乘法、取模、gcd、快速幂,再跑 Cantor–Zassenhaus。核心不到 20 行:
运行输出:
正确输入输出 correct,错误输入输出 fault。
(加 timeout 是因为程序在打印结果后有个死循环,见 4.2 节。)
但设计上留了一个致命弱点:核心变换用了 Feistel 结构,而 Feistel 是可逆的。 一旦看穿这一点,整道题就从"不可能爆破"变成了"倒着算一遍"。
如果作者把 Feistel 换成一个真正的单向哈希(比如 SHA-256),这题就只能靠碰撞了——当然那样也就不成其为一道有解的 CTF 题。
1. 先看轮廓,再看细节。
面对 5 万字符的反编译结果,逐行读是绝路。先用"这段的输出流向哪里"来筛选,把干扰代码整块跳过。本题真正有用的逻辑不超过 100 行。
2. 认常数。0x100000001B3 → FNV;0x9E3779B9… → 黄金分割率/splitmix;0xFF51AFD7ED558CCD → MurmurHash3;0xBF58476D1CE4E5B9 → splitmix64。把常数丢进搜索引擎,经常能一秒钟定位算法。本题的 78 和 −39 则直接暴露了模数 2¹²⁷−39。
3. IDA patch 之前先清理。del_func + del_items 再 patch_bytes,否则 IDA 会拿旧的错误分析结果反编译。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。