找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4490

积分

0

好友

582

主题
发表于 1 小时前 | 查看: 2| 回复: 0

赛事概况

8月10日12:00,2026 KCTF 攻防对抗赛正式开赛。本届赛事沿用多维度积分体系,难度、火力、精致度三重积分同步核算,攻防双向计分机制兼顾趣味性与竞技性。

签到题《辰时·钟鸣破晓》赛事周期内持续开放,比赛全程都可以提交 flag 获取对应攻击积分。

截止今天中午12点,第四题《未时·车流困城》答题通道已关闭。本题有14支战队攻破,『F1ux』战队用时6小时51分7秒拿下本题“一血”,『您的名称过长』战队用时9小时位列第二,『0xCCCCCC』战队用时11小时17分7秒稳居第三。

2026 KCTF第四题未时·车流围城赛博朋克风格题图

攻破此题的战队排名截图

官方点评

本题将 Heaven's Gate、VEH 异常驱动、反调试、完整性校验、自定义编码与分组变换、多项式约束等机制串成了一条完整验证链。真正的难点并不在某个孤立算法,而在于识别“看似自洽、实际错误”的诱饵路径,并通过原版程序建立可靠的证据闭环。

首解约6小时51分,最终14支队伍完成攻破,既体现了赛题的分析深度,也说明自动化与 AI 工具正在显著提升复杂逆向问题的求解效率。整体是一道层次丰富、对逆向基本功和实验验证能力要求都很高的高质量赛题。

Rock on手势图

设计思路

出题战队:Archaia
战队成员ID:RascallyDog、TeddyBe4r

Archaia战队信息界面截图

本题的整体设计思路可以概括为一句话:

把“算法求解”和“逆向对抗”深度绑定,让选手不仅要看懂程序在算什么,还要先判断自己看到的究竟是不是真实算法。

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 提供。

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 位代码主要承担入口、调度和迷惑分析的作用,DayDayUpMengXincheck2GoodGoodStudy 的算法判断必须以内存态代码和真实运行数据为准。

这类设计会制造两个常见误区:

  1. 把 32 位反编译结果当成真实算法;
  2. 用 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 位。

寻找保存上下文时使用了几个约束:

  1. ContextFlags 必须合理;
  2. 保存的 RIP 必须落在已提交、可执行的应用页;
  3. 结构位于当前 64 位原生线程栈区域;
  4. 候选中选择距离当前 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^-1DayDayUp^-1 生成 Serial。

整个过程没有枚举 Serial,没有遍历一百万个六位数,也没有通过修改成功分支拿结果。最终 Serial 是由末端数学条件确定后,沿程序真实数据流逐层逆推出的。

如果你也对这类 CTF 逆向与 Windows 安全攻防 话题感兴趣,欢迎到云栈社区与更多逆向工程爱好者深入交流。




上一篇:npm供应链攻击:ChainDrop蠕虫投毒444个软件包,月安装量超20亿次
下一篇:Threema遭动态DDoS攻击服务中断4小时,私有化部署零受损
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-18 08:16 , Processed in 2.264909 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表