赛事概况
8月10日12:00,2026 KCTF 攻防对抗赛正式开赛。本届赛事沿用多维度积分体系,难度、火力、精致度三重积分同步核算,攻防双向计分机制兼顾趣味性与竞技性。
签到题《辰时·钟鸣破晓》赛事周期内持续开放,比赛全程都可以提交 flag 获取对应攻击积分。
截止今天中午12点,第四题《未时·车流困城》答题通道已关闭。本题有14支战队攻破,『F1ux』战队用时6小时51分7秒拿下本题“一血”,『您的名称过长』战队用时9小时位列第二,『0xCCCCCC』战队用时11小时17分7秒稳居第三。


官方点评
本题将 Heaven's Gate、VEH 异常驱动、反调试、完整性校验、自定义编码与分组变换、多项式约束等机制串成了一条完整验证链。真正的难点并不在某个孤立算法,而在于识别“看似自洽、实际错误”的诱饵路径,并通过原版程序建立可靠的证据闭环。
首解约6小时51分,最终14支队伍完成攻破,既体现了赛题的分析深度,也说明自动化与 AI 工具正在显著提升复杂逆向问题的求解效率。整体是一道层次丰富、对逆向基本功和实验验证能力要求都很高的高质量赛题。

设计思路
出题战队:Archaia
战队成员ID:RascallyDog、TeddyBe4r

本题的整体设计思路可以概括为一句话:
把“算法求解”和“逆向对抗”深度绑定,让选手不仅要看懂程序在算什么,还要先判断自己看到的究竟是不是真实算法。
1. x86 / x64 混合架构:让静态分析先失去方向
题目采用 x86 / x64 混合架构。程序从 32 位入口出发,通过 Heaven's Gate 切换到 x64 执行空间,真正的核心算法以经过混淆的 Shellcode 形式运行。
同时,大量控制流并不是通过普通的 call / jmp 完成,而是主动执行特权指令制造异常,再交给 VEH(Vectored Exception Handler) 修改执行上下文并恢复真正的控制流。
因此,选手直接把程序扔进 IDA 或调试器时,很难获得一条完整、可信的执行路径。
2. 核心谜题:从用户名映射到 1000 个数学约束
题目的最终验证并不是简单比较一个固定 Serial。程序根据输入的用户名生成一组伪随机索引,从 10,000+ 个预置方程中选取目标方程,最终形成:
100 组 × 每组 10 个结果 = 1000 个整数
选手需要恢复这些数学约束,求出对应的精确整数解,并保证每组结果按照从小到大的顺序排列。
因此,真正的求解链可以抽象为:
Name
↓
用户名 Hash / 状态映射
↓
方程索引
↓
恢复多项式系数
↓
求出整数根
↓
100 组 × 10 个结果
↓
构造最终输入
也就是说,即使完全看懂了外围的反调试和加密算法,最终仍然需要解决隐藏在程序内部的数学约束。
3. 输入再经过两层编码保护
数学结果并不能直接作为密码提交。程序还在输入层加入了两层转换:
1000 个整数
↓
特定格式拼接
↓
AES / 自定义分组变换
↓
魔改 Base64
↓
最终 Serial
因此整个题目的求解方向实际上是从验证链的最深处开始,逐层向输入端逆推。这使得选手无法只通过 Patch 成功分支、修改比较结果等传统 CrackMe 手段得到一个真正合法的 Serial。
4. 反调试不是“发现就退出”,而是让算法悄悄算错
本题比较核心的设计思想,是让反调试结果直接参与算法运算。
例如程序会读取 PEB 中的环境状态,并将相关值参与后续解密或密钥生成。
正常环境下:
环境状态正确
↓
密钥正确
↓
算法结果正确
而在调试、异常宿主等环境中:
环境状态发生变化
↓
密钥 / 中间状态发生变化
↓
算法仍然正常运行
↓
最终得到一个“看起来合理但实际上错误”的结果
相比传统的:
检测到调试器 → 直接退出
这种方式更加隐蔽。选手甚至可能得到一套能够完整正向、逆向运行的算法,却始终无法生成被原版程序接受的答案。
5. 多层环境与完整性检测
为了增加动态分析成本,题目还叠加了多种检测机制,包括:
- ntdll 重载与内存保护:降低 Hook 和软件断点分析的可行性;
- 父进程溯源检测:检查 explorer、cmd、powershell 等宿主环境;
- PEB 环境指纹:让环境状态参与算法计算;
- 调试对象检测:检查系统调试状态;
- TF 标志检测:识别单步执行;
- DR0~DR7 检测:发现硬件断点;
- CRC 完整性校验:发现核心代码 Patch;
- 内核调试模块探测:识别内核调试环境;
- 时间流速检测:识别长时间断点、单步或异常执行;
- VMware 环境探测:识别部分虚拟机分析环境;
- 关键常量动态解密:减少静态分析可以直接获得的信息。
这些机制并不是完全独立存在,而是被穿插进正常算法执行过程中。
6. 重编译式代码混淆
为了进一步破坏静态分析,作者还设计了一套以重新编译机器码为核心的混淆体系,主要包括:
垃圾指令
插入大量最终能够相互抵消的指令,并故意拉长前后依赖距离,使局部反汇编很难判断哪些代码真正有意义。
语义等价替换
把简单指令拆解成多个等价操作,破坏编译器生成代码原本具有的特征。
基本块随机乱序
将原本连续的基本块随机打散,再通过大量直接和间接跳转重新连接控制流。
异常驱动跳转
部分目标地址并不直接存在于代码中,而是经过运算或异或处理后,通过异常进入 VEH,再由 VEH 恢复真正 RIP。
高频 Heaven's Gate
在能够切换执行模式的位置主动插入 x86 / x64 切换,使反汇编器和调试器频繁面对架构变化。
指令虚拟化
部分核心验证逻辑进一步转换成自定义字节码,由私有 VM 解释执行。选手需要先理解 VM,再理解 VM 中执行的数学算法,相当于增加了一层新的逆向对象。
7. 数学验证再套一层 VM
最终的数学验证并不是直接出现类似:
P(x) == 0
这样的代码,程序内部设计了一套简单的虚拟机指令,例如:
LOAD_COEFF
PUSH_X
MUL
ADD
END
配合大整数 BCD 数据,对多项式进行计算。因此选手首先看到的是 VM 调度和大整数运算,只有继续还原字节码语义之后,才会发现其本质是在进行多项式求值。
这一层的目的,就是把:
“求多项式的根”
隐藏在:
“逆向 VM + 恢复大整数格式 + 理解运算语义”
之后。
8. 反 AI 也被设计成题目的一部分
题目中还特意植入了一段面向 AI 分析工具的提示文本。它并不是正常程序逻辑的一部分,而是一种针对 AI 辅助逆向场景设计的干扰信息,希望诱导自动化分析工具偏离真正的解题目标。
这也使本题除了传统的:
Anti-Debug
Anti-Disassembly
Anti-Patch
Anti-VM
之外,又增加了一层:
Anti-AI / Prompt Injection
体现了 AI 辅助逆向逐渐普及之后,CTF 赛题设计正在出现的新变化。
设计总结
从作者设计角度看,这道题并不是依靠某一种“特别难”的算法提高难度,而是通过多层机制互相配合:
x86 / x64 混合执行
↓
Heaven's Gate
↓
VEH 异常驱动控制流
↓
重编译代码混淆
↓
反调试 / 环境检测
↓
环境状态参与真实算法
↓
自定义编码与分组变换
↓
VM + 大整数
↓
100 组多项式约束
↓
1000 个整数解
↓
最终 Serial
其中最关键的设计并不是“阻止选手调试”,而是:
让错误的分析路径同样能够得到看似完整、自洽的结果。
只有不断通过原始程序进行验证,区分真实路径与诱饵路径,并最终恢复完整的数据流,才能真正得到合法答案。这使本题考查的不只是反汇编和调试技巧,还包括对实验结果的判断、验证、建模以及自动化求解能力。
赛题解析
该解题思路由论坛会员 Kazusa 提供。

题目概况
题目给出一个 Windows CrackMe 和一组公开的合法 Name/Serial:
Name = 338F493766CFC94B
Serial = 一条长度为 9226 的字符串
目标是求出:
Name = KCTF
对应的合法 Serial,使未修改的原版程序输出:
Successful!
这题最唬人的地方不是计算量,而是它同时布置了多套代码和多层真假校验:
- 直接从原始
CrackMe.exe 文件中反汇编出的 32 位代码,并不是完整真实逻辑;
- 程序启动后会在进程内存中生成并执行另一套 64 位异常驱动代码;
- 调试器或补丁会让核心加密函数切换到诱饵 S 盒;
- 即使最后的数学条件算对了,前面的文本长度检查仍会让程序失败;
- 100 个多项式中还单独污染了一个系数,防止直接批量因式分解。
最终没有爆破 Serial,也没有遍历六位数空间。整个解法是先确定程序的正向数据流,再从最后的数学约束逐层逆回输入。
1. 总体思路
去掉异常跳转、壳代码和诱饵后,真正与 Serial 有关的数据流可以抽象为:
Serial
│ 9226 bytes
▼
DayDayUp
│ 自定义 Base64
▼
DayDayUp 输出缓冲区
│ 6912 bytes = 432 × 16
▼
MengXin
│ 自定义 128-bit 替换-置换网络
▼
decimal record
│ 1000 个十进制数
▼
check2
│ 格式、长度、NUL 位置
▼
GoodGoodStudy(Name)
│ 100 个十次多项式
▼
Successful!
求解时反过来走:
KCTF
▼
提取 100 个 14-bit 状态
▼
恢复 100 个十次多项式
▼
求出 1000 个整数根
▼
构造满足 check2 的 6912 字节记录
▼
MengXin^-1
▼
DayDayUp^-1
▼
最终 Serial
这样做的好处是,每一层都有明确的输入和输出,可以单独验证,不需要在 9226 个字符上做任何猜测。
2. 题目用了哪些防护
2.1 32 位文件态代码与 64 位内存态代码
程序表面上是一个运行在 WoW64 下的 32 位 PE。这里先解释两个后文会反复使用的词:
- 文件态代码:直接从原始
CrackMe.exe 文件中读取和反汇编的内容;
- 内存态代码:程序启动后,从目标进程的虚拟内存中读取到的实际可执行内容。
PE 文件的 .text 一般用于保存程序机器码。把原始 CrackMe.exe 作为普通 PE 载入 IDA 时,首先看到的是文件中 .text 节保存的 32 位静态代码。它包含一套看似合理的函数、调用约定和异常处理逻辑。
但把原始文件字节与程序启动后的相应内存区域逐字节比较,会发现两者并不相同:
原始 PE 文件中保存的代码
!=
进程内存中实际执行的代码
关键逻辑是在运行过程中生成或恢复出来的,而且实际执行的是 64 位代码。程序借助 WoW64 环境和异常分发在 32 位与 64 位上下文之间切换。原始文件中的 32 位代码主要承担入口、调度和迷惑分析的作用,DayDayUp、MengXin、check2、GoodGoodStudy 的算法判断必须以内存态代码和真实运行数据为准。
这类设计会制造两个常见误区:
- 把 32 位反编译结果当成真实算法;
- 用 32 位寄存器和调用约定解释实际的 64 位现场。
后续分析中,我把证据按来源分成四类:
文件态 直接从原始 CrackMe.exe 读取的内容
内存态 程序启动后在目标进程内读取的内容
受干扰路径 调试或补丁触发的错误算法路径
干净路径 未附加调试器、未修改原版的正常路径
只有干净路径产生的状态和常量进入最终模型。
2.2 异常驱动控制流
真实代码大量依赖故意异常、int3、非法内存访问和异常返回。普通的线性反汇编会看到:
- 跳到垃圾地址;
- 返回地址不像正常代码;
- 64 位线程上下文中的 RIP 暂时落在 Windows 异常分发代码中;
- 异常返回后,应用逻辑又在程序自行映射的代码区域继续执行。
这里不能简单地把所有异常 NOP 掉,因为异常本身承担了控制流切换和状态保存的功能。跳过异常后虽然可能“继续运行”,但很容易进入另一条诱饵路径。
2.3 反调试切换到错误但自洽的算法
这题最有效的一层保护是:附加调试器或者过早打补丁后,MengXin 仍然会运行,而且仍然表现为一套完整的 AES 风格替换-置换网络(Substitution-Permutation Network,简称 SPN)。
最初从调试轨迹中可以恢复出:
- 一个覆盖 256 项的 S 盒;
- 多轮行移位和列混合;
- 一组能正向、逆向自洽的轮密钥。
问题在于,这套模型只能复现被调试运行的输出。把它应用到未修改程序的干净输出时,432 个块会分别推出 432 个不同的“末轮密钥”。
固定分组算法不可能每块都有不同的隐含末轮密钥,所以这直接证明调试状态下得到的是题目故意提供的错误 S 盒,而不是正常成功路径使用的 S 盒。这个保护很容易让分析者停在一个“数学上完全自洽、原版就是不认”的假答案上。
2.4 多层校验互相遮挡
程序不是只做一次正确性判断,而是分成:
编码合法性
↓
分组变换
↓
文本结构检查
↓
Name 对应的数学检查
如果在程序即将调用 GoodGoodStudy 时暂停进程,并直接改写它接收的 6912 字节参数缓冲区,可以让最后的多项式检查成功。后文把这种实验简称为“入口注入”。它只能单独验证最后一层,而且绕过了 check2。
因此:
GoodGoodStudy 成功
≠
完整 Serial 成功
后面六个前导零的坑,就是因为一开始只证明了最后一层。
2.5 数据层面的反自动化
除了控制流混淆,题目还用了几层数据混淆:
- 64 字符自定义 Base64 字母表;
- 每个字符位置再叠加线性偏移;
- 自定义 S 盒和非标准方向的 AES 矩阵;
- 十进制大整数对象;
2^14 行多项式系数表;
- 100 个多项式中单独污染一个一次项;
- 固定 NUL 位置,但允许利用十进制前导零保持数值不变。
这些设计使得“看懂一层”并不足以直接得到答案。
3. 还原主校验流程
把运行时函数按语义重命名后,主流程可以写成下面的伪代码:
int main(void)
{
char name[...];
char serial[9227];
uint8_t buffer[0x1B00];
read_name(name);
read_serial(serial);
if (!outer_format_check(serial))
return Failed;
// 9226-byte Serial -> 6912-byte binary buffer
DayDayUp(serial, buffer);
// 432 independent 16-byte blocks
MengXin(buffer, 0x1B00);
// decimal grammar and exact record length
if (!check2(buffer))
return Failed;
// Name determines 100 rows in a 2^14-row coefficient table
uint32_t states[100];
build_name_states(name, states);
// Every state selects a monic degree-10 polynomial.
// Each group of ten decimal integers must be its ten roots.
if (!GoodGoodStudy(states, buffer))
return Failed;
return Successful;
}
这里最重要的是确定边界:
DayDayUp 输出长度 = 0x1B00 = 6912
MengXin 分组大小 = 16
分组数 = 6912 / 16 = 432
GoodGoodStudy 状态数 = 100
每个状态需要 10 个数
总数 = 1000
有了这些边界,后面的每个实验都能精确到具体字节或具体分组。
4. 先拆 DayDayUp
4.1 Serial 的真实结构
合法 Serial 结构为:
[5-byte prefix] [9216-byte body] [5-byte suffix]
公开样本和最终样本使用同一外壳:
prefix = lI|0O
suffix = Il1|!
正文 9216 个字符刚好可以编码 6912 字节:
9216 × 6 / 8 = 6912
而且 6912 能被 3 整除,因此没有 = 填充。
4.2 自定义字母表
运行时恢复出的字母表是:
Il1|!ijJL`oO0QDSs5$Zz2B8gq96nNmMWwUuVvRrPpCc({tT+7xXKkYyAa4Ee3FH
其中 L 后面是反引号字符 0x60。
仅仅替换 Base64 字母表还不够,程序对每个位置又加了一个偏移。
4.3 推出位置公式
已知公开 Serial 是合法的,所以可以在干净运行中捕获 DayDayUp 的 6912 字节输出。
对该输出做标准 Base64 编码,定义:
u[i] = 公开 Serial 正文字符在自定义字母表中的下标
v[i] = 捕获到的 6912 字节数据经标准 Base64 编码后的 6 位值
d[i] = (v[i] - u[i]) mod 64
检查全部 9216 个位置后发现:
d[0] = 51
d[i+1] - d[i] = 27 mod 64
因此:
v[i] = (u[i] + 51 + 27*i) mod 64
正向解码就是:
def decode_daydayup(serial):
body = serial[5:-5]
sextets = [
(alphabet.index(ch) + 51 + 27*i) & 63
for i, ch in enumerate(body)
]
return standard_base64_decode(sextets)
逆向编码则为:
u[i] = (v[i] - 51 - 27*i) mod 64
对应代码:
def encode_daydayup(data_6912, template):
standard = base64.b64encode(data_6912)
body = bytes(
ALPHABET[
(STANDARD.index(ch) - 51 - 27*i) & 63
]
for i, ch in enumerate(standard)
)
return template[:5] + body.decode() + template[-5:]
这里的“6 位值”就是 Base64 每个字符代表的 0..63 索引,通常也称为 sextet。到这里,Serial 和 6912 字节运行时输入之间已经可以双向转换。剩下的问题变成:怎样构造能通过后两层检查的 6912 字节输入。
5. 确认 MengXin 是固定分组置换
不能因为长度能被 16 整除,就直接假设它是 ECB。这里用合法公开样本做了两个选择输入实验。
实验一:只改一个输入字节
先修改 DayDayUp 解码结果的第 0 字节,再重新编码成合法 Serial。
运行后比较 MengXin 输出:
输入变化:offset 0
输出变化:offset 0..15
后续 431 块:完全不变
这说明没有跨块扩散。
实验二:复制明文块
把输入块 0 原样复制到输入块 1,再运行原版:
P[1] = P[0]
结果:
C[1] = C[0]
这进一步排除了依赖块序号的额外参数(密码分析中常称为 tweak)和每块独立密钥。
因此可以确定:
C[i] = F(P[i])
其中 F 是所有 432 个块共同使用的固定 128 位双射。只要恢复一次 F^-1,就能独立逆转全部块。
6. 不触发反调试地拿到轮状态
6.1 为什么普通调试不行
一开始采用断点和调试异常追踪,确实抓到了大量 XMM 状态,但恢复出的 S 盒属于诱饵路径。
所以需要一个满足下面条件的观察手段:
- 不附加调试器;
- 不下断点;
- 不修改程序代码;
- 不写寄存器;
- 仍然能看到异常驱动代码内部的 XMM 状态。
6.2 利用 Windows 保存的异常 CONTEXT
异常发生时,Windows 已经把 64 位寄存器现场保存在原生 64 位线程栈中;它与 WoW64 程序平时看到的 32 位栈不是同一个上下文。采集器只需要找到这份 CONTEXT 结构并读取它,不需要接管异常。
关键偏移:
CONTEXT + 0x30 = ContextFlags
CONTEXT + 0xF8 = saved RIP
CONTEXT + 0x1A0 = XMM0..XMM3
四个 XMM 寄存器各存四个 32 位分量,Intel 文档中常把这种分量称为 lane。每个 32 位分量的低字节是一个算法状态字节,共组成 16 字节状态。部分值会发生符号扩展,所以读取时只取低 8 位。
寻找保存上下文时使用了几个约束:
ContextFlags 必须合理;
- 保存的 RIP 必须落在已提交、可执行的应用页;
- 结构位于当前 64 位原生线程栈区域;
- 候选中选择距离当前 RSP 最近的一个。
找到地址后,只读轮询这块内存。状态变化就记录,某个 16 字节输出块提交后切换到下一块。
最终得到:
STATE 记录:12592
有状态的块:430
可用完整轮的块:414
S 盒覆盖:256 / 256
更关键的是,同一次采集运行最终仍然输出 Successful!,说明观察过程没有让程序切换到错误算法路径。
7. 还原 MengXin 的替换-置换网络
7.1 状态布局
缓冲区中的每个 16 字节块按列主序保存,而内部轮函数按行主序运算:
state[row*4 + column] = block[column*4 + row]
所以每块必须做:
buffer column-major
↓
internal row-major
↓
替换-置换网络
↓
buffer column-major
漏掉这个转置后,S 盒可能仍然看起来正确,但行移位和列混合永远对不上。
7.2 从变化模式恢复 S 盒
完整轮附近的状态变化数量反复呈现:
4, 16, 16
其中 4 字节变化对应 XMM0 的四个 S 盒分量,后续两次全状态变化对应移位、列混合和加轮密钥。
对所有块收集:
before_byte -> after_byte
块边界会产生少量噪声,因此对每个输入值取出现次数最多的输出。最终:
输入覆盖 = 256
不同输出 = 256
说明恢复结果是一个完整置换,可以直接构造逆 S 盒。
7.3 轮结构
令:
L(x) = InvMixColumns(ShiftRowsRight(x))
ShiftRowsRight 将第 r 行循环右移 r 字节。
列混合使用 GF(2^8),约简多项式为 0x11B。正向轮使用的矩阵为:
0e 0b 0d 09
09 0e 0b 0d
0d 09 0e 0b
0b 0d 09 0e
它的逆矩阵是:
02 03 01 01
01 02 03 01
01 01 02 03
03 01 01 02
完整前向算法:
x0 = plaintext XOR initial_key
for r = 0..8:
x(r+1) =
InvMixColumns(
ShiftRowsRight(
Sbox(xr)
)
) XOR round_key[r]
ciphertext =
ShiftRowsRight(Sbox(x9)) XOR final_key
虽然矩阵来自 AES,但正向方向使用的是 AES 的逆列混合,再配合右移行,因此不能直接调用标准 AES。
7.4 直接恢复后续轮密钥
相邻完整轮状态已知时:
B = L(Sbox(A)) XOR K
所以:
K = L(Sbox(A)) XOR B
末轮同理:
K_final =
ShiftRowsRight(Sbox(last_state)) XOR ciphertext
对多个块重复计算并取一致值,就能过滤采样噪声。
7.5 用跨块差分恢复首轮
首轮输入异或和第一轮密钥无法从一对相邻状态直接同时解出。
设:
A_i = L(Sbox(P_i XOR K_initial)) XOR K_round0
选择两个已知块异或,K_round0 消失:
A_i XOR A_0 =
L(Sbox(P_i XOR K_initial))
XOR
L(Sbox(P_0 XOR K_initial))
因为 L 是线性双射:
L^-1 = ShiftRowsLeft ∘ ForwardMixColumns
应用 L^-1 后,每个字节位置独立满足:
D_i[j] =
Sbox(P_i[j] XOR k[j])
XOR
Sbox(P_0[j] XOR k[j])
于是 128 位问题被拆成 16 个 8 位精确方程。
对每个位置在 S 盒的 256 项定义域中建立差分反查表,再把多个已知块得到的解集求交。使用参考块和另外两个块后,16 个位置都只剩一个解。
这不是搜索 2^128 密钥,更不是爆破 Serial,只是对已经恢复的 256 项 S 盒做精确差分查表。
得到 K_initial 后:
K_round0 =
L(Sbox(P_i XOR K_initial)) XOR A_i
7.6 恢复结果
initial_key = 09594117023c741bce04ca4eb583b1e9
round_key[0] = 34bc76cd758317f29819e3ceafb42e14
round_key[1] = b788cabb38f694e5fc81fa2de51b9a3a
round_key[2] = 0c3f427195ce62710c7d7bd7ebfe81a0
round_key[3] = 63337d33b15bac13517106acbb157f21
round_key[4] = f7504e4e09eaf7bfc32077aa16ae6a5e
round_key[5] = 6ba71e00f1e31d4865e357dd6fb8c434
round_key[6] = 5dccb91e3712fe552f86b48aefd77cf0
round_key[7] = c29175a76e25ecab5ea9323e1138ab8c
round_key[8] = b953e4d28e4bc9471ef79b0c0a299327
final_key = c2513ec6a778a6270083384a234f4330
恢复的模型能够逐字节复现公开成功运行的全部 432 个输出块。这一步非常重要:只验证某一个轨迹块,无法排除又一套局部诱饵。
7.7 逆变换
前向完整轮:
B = InvMixColumns(ShiftRowsRight(Sbox(A))) XOR K
所以逆轮:
A = InvSbox(
ShiftRowsLeft(
ForwardMixColumns(B XOR K)
)
)
完整求逆代码:
def reverse_block(ciphertext):
state = xor(ciphertext, final_key)
state = shift_rows_left(state)
state = inverse_sbox(state)
for key in reversed(round_keys):
state = xor(state, key)
state = forward_mix_columns(state)
state = shift_rows_left(state)
state = inverse_sbox(state)
return xor(state, initial_key)
至此,只要能构造 MengXin 输出端想要的 6912 字节记录,就能逐块逆回 DayDayUp 所需的 6912 字节输入。
8. 逆出 GoodGoodStudy 的数学条件
8.1 Name 并不直接参与多项式计算
KCTF 会先被上游逻辑转换成 100 个互不相同的 14 位状态。这里没有必要完整逆出“任意 Name 到状态数组”的通用算法。对于题目指定的固定 Name,只需在未修改程序进入最终检查前,只读提取已经生成的 100 个 uint32。
100 个值全部满足:
0 < state < 0x4000
8.2 系数表
GoodGoodStudy 接收到一个 28 字节结构:
+0x00 coefficient pool begin
+0x08 coefficient pool end
+0x10 index table
+0x18 count = 11
索引表大小为:
0xB0000 bytes
因此行数为:
0xB0000 / (11 × 4) = 16384 = 2^14
正好与 14 位状态对应。每个状态选择 11 个大整数系数。
8.3 十进制大整数对象
程序没有使用普通 int64,而是自己实现了十进制大整数:
object size = 0x3C8
+0x000 240 个 uint32 十进制位
+0x3C0 有效长度
+0x3C4 符号
数位按小端十进制排列:
value = sign × Σ digit[i] × 10^i
这里的“快照仿真”是指:先在目标进程到达指定位置时暂停它,保存寄存器、可执行页面和相关私有内存,再把同一时刻的数据加载到 Unicorn 中继续执行。仿真时监视索引表读取,并把大整数对象还原为 Python 整数,就能得到每一行的 11 个系数。
8.4 Horner 链
每行系数按升幂排列:
[a0, a1, a2, ..., a9, 1]
执行轨迹中的大整数状态满足:
s10 = x
s9 = x * (s10 + a9)
s8 = x * (s9 + a8)
...
s1 = x * (s2 + a1)
compare s1 == -a0
展开就是:
P(x) =
x^10 + a9*x^9 + ... + a2*x^2 + a1*x + a0
P(x) = 0
所以每个状态对应一个十次首一整系数多项式,每组输入的 10 个数就是它的 10 个整数根。
8.5 精确求根
正常的 99 行可以在整数多项式环中直接精确分解:
P(x) = ∏(x - r_i)
每行得到 10 个互不相同、范围在 0..999999 的整数根。每组内部升序排列,100 组共 1000 个数。
这里不用浮点求根,因为系数非常大,浮点近似既不必要也不可靠。
求根后还要重新展开:
rebuilt = product(x - root for root in roots)
assert rebuilt.coefficients == original_coefficients
这样能确认根、重数和系数方向都没有理解错。
9. 被污染的第 84 组
100 个多项式中,第 84 组,也就是数组下标 83、状态值 14036,无法正常分解。
检查系数后发现,一次项 a1 是异常项:
bad a1 =
5590933876550010499093511454771320894841919276792
但常数项 a0 仍然有效:
a0 =
58518979579699416819874513996049551381298761345274880
对于首一整系数多项式,整数根必须整除常数项。先精确分解 a0,再只生成不大于 999999 的正因子,一共只有 12458 个。
这一步不是:
for x in range(1000000)
而是:
factor(a0)
↓
组合出有界正因子
↓
只处理数学上可能成为整数根的 x
对每个候选根 x,把一次项之外的部分记为:
R(x) = Σ(i != 1) ai*x^i
由 P(x)=0:
a1 = -R(x) / x
只保留整除结果,再按 a1 分组。唯一拥有 10 个不同根的分组给出:
a1 =
-14995339131612996894456453788697728968558244212608
十个根为:
8235, 10291, 41021, 373479, 378744,
415656, 595246, 659736, 813519, 896167
最后重新计算:
P(x) = ∏(x-r_i)
重建结果与修复后的 11 个系数逐项相等,因此修复是唯一的。
10. check2 的六个前导零
把 100 组根按状态顺序连接,每组 10 个、组内升序、使用 - 分隔,得到 1000 个整数。
普通文本长度为:
6891 bytes
把这份记录直接注入 GoodGoodStudy,数学检查可以通过,说明 1000 个根是正确的。
但用它生成完整 Serial 时原版仍然失败。对比公开合法运行的 MengXin 输出后发现:
公开合法记录首个 NUL:offset 6897
KCTF 根文本首个 NUL:offset 6891
差值:6
也就是说,check2 还要求正文恰好占 6897 字节。
十进制解析允许前导零,因此把第一个根:
149982
改写为:
000000149982
增加的是六个 ASCII 0x30,不是六个 NUL。
数值仍然是 149982,但文本长度增加了 6。最终缓冲区布局:
6897 bytes decimal text
15 bytes NUL
-----------------------
6912 bytes total
这样同一份数据可以同时满足:
check2 要求固定文本长度
GoodGoodStudy 要求固定整数值
这是最后一个导致“数学全对但原版失败”的条件。
11. 从目标记录生成 Serial
此时已经有了 MengXin 输出端需要的 6912 字节:
000000149982-182871-188530-...
...
[15 bytes NUL]
剩下的过程完全是确定性逆变换。
第一步:逐块逆 MengXin
daydayup_input = b""
for block in split(target_record, 16):
row_state = column_major_to_row_major(block)
plain_state = reverse_block(row_state)
daydayup_input += row_major_to_column_major(plain_state)
得到 DayDayUp 所需的 6912 字节输入。
然后再正向运行一次 MengXin:
MengXin(daydayup_input) == target_record
第二步:逆 DayDayUp
先对这 6912 字节做标准 Base64 编码,再对每个 6 位 Base64 索引应用:
custom_index[i] =
(standard_sextet[i] - 51 - 27*i) mod 64
最后拼回:
lI|0O + 9216-byte body + Il1|!
得到总长 9226 的最终 Serial。
第三步:原版验证
最终验证不使用 patched EXE,也不使用注入:
Name = KCTF
Serial = generated 9226-byte string
未修改原版输出:
Input user name:
Input password:
Successful!
最终 Serial 太长,不在正文重复粘贴,随 Writeup 附件提供。用于确认文件的 SHA-256:
e34dc3a75754abf44a96d4902bac38b5dd0310fffb2974b27cc84b932ae5847b
原版程序 SHA-256:
338f493766cfc94b6b075138f37cead0a2516d61fc983960c169b7ddf62be760
走过的错误路线
错误一:把内存中相同片段当成变换结果
早期快照中,Serial 串尾和某个运行时区域出现大段重合,一度以为 DayDayUp 只是复制字符串。
问题是,内存相同只能说明数据曾经被复制,不能说明它就是函数最终输出。改进后要求:
模型输出 == 干净运行捕获输出
而且必须逐字节比较完整 6912 字节。
错误二:相信调试轨迹中的自洽 SPN
诱饵 MengXin 可以做到“正向计算后再逆向能够回到原值”,但这种自洽性不是路径正确的充分证据。
真正有效的标准是:
模型必须复现未修改公开成功运行的全部 432 块
错误三:在 GoodGoodStudy 入口注入成功就认为结束
这个实验只证明根正确,绕过了 check2。最后差的六个 ASCII 零正是被绕过的条件。
错误四:混合不同时间的快照
多次运行留下地址重叠的旧内存页,仿真器如果全部映射,会被旧页覆盖当前页。
解决办法是只加载本次内存区域清单中仍被 Windows 标记为“已提交”(MEM_COMMIT)的页面,保证寄存器和内存都来自同一次暂停时刻。
错误五:把“能跑”当成“路径正确”
这题很多错误模型都能跑,甚至能完成漂亮的数学闭环。最终判据必须是:
原版哈希正确
无补丁
无入口注入
完整输入
实际输出 Successful!
防护设计评价
从出题角度看,这题的保护层次很清楚:

最有意思的不是单独某一种保护,而是它们会互相掩护:
- 调试状态下的错误 S 盒让你得到一个“非常像答案”的假密码算法;
- 入口注入让你误以为最后的根已经等于完整答案;
- 单行污染让批量多项式求根在 99% 处失败;
- 固定长度又让完全正确的 1000 个数无法直接通过。

总结
最终解题手段可以压缩成五句话:
- 用公开合法对确定
DayDayUp 的真实输入输出,恢复自定义 Base64 公式。
- 用选择输入证明
MengXin 是固定的 16 字节独立置换。
- 不附加调试器,读取异常栈中保存的 x64
CONTEXT,恢复干净 S 盒和全部轮密钥。
- 把
GoodGoodStudy 还原成 100 个十次首一多项式,精确求出 1000 个整数根,并修复唯一的污染项。
- 用六个十进制前导零满足
check2,再依次执行 MengXin^-1 和 DayDayUp^-1 生成 Serial。
整个过程没有枚举 Serial,没有遍历一百万个六位数,也没有通过修改成功分支拿结果。最终 Serial 是由末端数学条件确定后,沿程序真实数据流逐层逆推出的。
如果你也对这类 CTF 逆向与 Windows 安全攻防 话题感兴趣,欢迎到云栈社区与更多逆向工程爱好者深入交流。