首页
社区
课程
招聘
[原创]第八题「亥子合辰·塔影迷楼」解析
发表于: 2026-8-21 14:48 34

[原创]第八题「亥子合辰·塔影迷楼」解析

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(黄金分割率,常做步长)、0xBF58476D1CE4E5B90x94D049BB133111EB

特征常数 0xFF51AFD7ED558CCD。本题第二层的密钥流用到它。

本题的模数是 p = 2¹²⁷ − 39(一个 127 位的素数)。

两个 128 位数相乘会得到 256 位结果。要对 p 取模,程序用了一个巧妙的技巧:

因为 2¹²⁸ ≡ 78 (mod p)(78 = 2 × 39),所以

这样一次乘法就把 256 位压回 128 位左右,再做几次条件减法就规范化了。

看代码时的识别特征:反编译结果里反复出现 780x4E)、0xFFFFFFFFFFFFFFD9(即 −39)、0x7FFFFFFFFFFFFFFF,就是在做模 2¹²⁷−39 的运算。

在 Python 里我们不用管这些技巧,直接 % P 即可,结果完全一样。

密码学里构造可逆变换的经典方法。状态分成左右两半 (A, B)

关键点:不管 F 多复杂多不可逆,整个结构永远可逆——因为解密时 F 的输入是已知的。

这正是本题能被解开的突破口。

入口函数 start0x1400012c0。开头几行:

读一下这两个全局变量(它们就在 .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 的那段代码。想断,就断 0x1400017F2syscall 指令本身)。

这个函数长得吓人(十几层嵌套的 __ROR8__ 和乘法),但仔细看:

那个全局变量 qword_140008000 在后面的校验里从未被使用。哈希撞上 0x1111111111111111 的概率是 2⁻⁶⁴,也永远不会发生。

这是纯粹的时间浪费陷阱——故意做得很显眼、很像"核心算法",引诱你去分析它。

经验:判断一段代码是不是干扰项,最快的办法是看它的输出去了哪里。如果计算结果没有流向最终的返回值/比较,那它就是噪音。用 IDA 的交叉引用(X 键)查一下写入的全局变量有没有被读取即可。

loc_1400018D0(解密后是 sub_1400018D0)就是第二层解密器。反编译后核心是:

密钥不是硬编码的常数,而是"第一层代码本身的哈希"。

这意味着:

这叫代码自校验(self-checksumming),是很常见的反调试/反篡改组合技。

应对办法:用硬件断点(Dr 寄存器)代替软件断点——但本题的第三层又专门检查 Dr 寄存器(见第 9 节)。所以本题的正确姿势是纯静态分析 + 离线复现算法,这也是本文的做法。

解密后 0x140001d10 处是 40 53 48 83 EC 20push 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 结构的 0x0000x2B0

为什么用 CONTEXT 结构当缓冲区?
一是省栈空间(GetThreadContext 本来就需要这块内存);二是迷惑分析者——反编译器会把偏移显示成 Context.Dr3Context.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 中,0x68ApiSetMap 指针,NtGlobalFlag0xBC。由于 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_itemspatch_bytes,否则 IDA 会拿旧的错误分析结果反编译。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 2026-8-21 14:52 被教教我吧~编辑 ,原因: 修改
上传的附件:
收藏
免费 2
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回