首页
社区
课程
招聘
[原创]第四题:未时·车流困城 从异常控制流到代数逆推 9226 位 Serial
发表于: 3天前 432

[原创]第四题:未时·车流困城 从异常控制流到代数逆推 9226 位 Serial

3天前
432

题目给出一个 Windows CrackMe 和一组公开的合法 Name/Serial:

目标是求出:

对应的合法 Serial,使未修改的原版程序输出:

这题最唬人的地方不是计算量,而是它同时布置了多套代码和多层真假校验:

最终没有爆破 Serial,也没有遍历六位数空间。整个解法是先确定程序的正向数据流,再从最后的数学约束逐层逆回输入。

去掉异常跳转、壳代码和诱饵后,真正与 Serial 有关的数据流可以抽象为:

求解时反过来走:

这样做的好处是,每一层都有明确的输入和输出,可以单独验证,不需要在 9226 个字符上做任何猜测。

程序表面上是一个运行在 WoW64 下的 32 位 PE。这里先解释两个后文会反复使用的词:

PE 文件的 .text 一般用于保存程序机器码。把原始 CrackMe.exe 作为普通 PE 载入 IDA 时,首先看到的是文件中 .text 节保存的 32 位静态代码。它包含一套看似合理的函数、调用约定和异常处理逻辑。

但把原始文件字节与程序启动后的相应内存区域逐字节比较,会发现两者并不相同:

关键逻辑是在运行过程中生成或恢复出来的,而且实际执行的是 64 位代码。程序借助 WoW64 环境和异常分发在 32 位与 64 位上下文之间切换。原始文件中的 32 位代码主要承担入口、调度和迷惑分析的作用,DayDayUpMengXincheck2GoodGoodStudy 的算法判断必须以内存态代码和真实运行数据为准。

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

后续分析中,我把证据按来源分成四类:

只有干净路径产生的状态和常量进入最终模型。

真实代码大量依赖故意异常、int3、非法内存访问和异常返回。普通的线性反汇编会看到:

这里不能简单地把所有异常 NOP 掉,因为异常本身承担了控制流切换和状态保存的功能。跳过异常后虽然可能“继续运行”,但很容易进入另一条诱饵路径。

这题最有效的一层保护是:附加调试器或者过早打补丁后,MengXin 仍然会运行,而且仍然表现为一套完整的 AES 风格替换-置换网络(Substitution-Permutation Network,简称 SPN)。

最初从调试轨迹中可以恢复出:

问题在于,这套模型只能复现被调试运行的输出。把它应用到未修改程序的干净输出时,432 个块会分别推出 432 个不同的“末轮密钥”。

固定分组算法不可能每块都有不同的隐含末轮密钥,所以这直接证明调试状态下得到的是题目故意提供的错误 S 盒,而不是正常成功路径使用的 S 盒。

这个保护很容易让分析者停在一个“数学上完全自洽、原版就是不认”的假答案上。

程序不是只做一次正确性判断,而是分成:

如果在程序即将调用 GoodGoodStudy 时暂停进程,并直接改写它接收的 6912 字节参数缓冲区,可以让最后的多项式检查成功。后文把这种实验简称为“入口注入”。它只能单独验证最后一层,而且绕过了 check2

因此:

后面六个前导零的坑,就是因为一开始只证明了最后一层。

除了控制流混淆,题目还用了几层数据混淆:

这些设计使得“看懂一层”并不足以直接得到答案。

把运行时函数按语义重命名后,主流程可以写成下面的伪代码:

这里最重要的是确定边界:

有了这些边界,后面的每个实验都能精确到具体字节或具体分组。

合法 Serial 结构为:

公开样本和最终样本使用同一外壳:

正文 9216 个字符刚好可以编码 6912 字节:

而且 6912 能被 3 整除,因此没有 = 填充。

运行时恢复出的字母表是:

其中 L 后面是反引号字符 0x60

仅仅替换 Base64 字母表还不够,程序对每个位置又加了一个偏移。

已知公开 Serial 是合法的,所以可以在干净运行中捕获 DayDayUp 的 6912 字节输出。

对该输出做标准 Base64 编码,定义:

检查全部 9216 个位置后发现:

因此:

正向解码就是:

逆向编码则为:

对应代码:

这里的“6 位值”就是 Base64 每个字符代表的 0..63 索引,通常也称为 sextet。到这里,Serial 和 6912 字节运行时输入之间已经可以双向转换。剩下的问题变成:怎样构造能通过后两层检查的 6912 字节输入。

不能因为长度能被 16 整除,就直接假设它是 ECB。这里用合法公开样本做了两个选择输入实验。

先修改 DayDayUp 解码结果的第 0 字节,再重新编码成合法 Serial。

运行后比较 MengXin 输出:

这说明没有跨块扩散。

把输入块 0 原样复制到输入块 1,再运行原版:

这进一步排除了依赖块序号的额外参数(密码分析中常称为 tweak)和每块独立密钥。

因此可以确定:

其中 F 是所有 432 个块共同使用的固定 128 位双射。只要恢复一次 F^-1,就能独立逆转全部块。

一开始采用断点和调试异常追踪,确实抓到了大量 XMM 状态,但恢复出的 S 盒属于诱饵路径。

所以需要一个满足下面条件的观察手段:

异常发生时,Windows 已经把 64 位寄存器现场保存在原生 64 位线程栈中;它与 WoW64 程序平时看到的 32 位栈不是同一个上下文。采集器只需要找到这份 CONTEXT 结构并读取它,不需要接管异常。

关键偏移:

四个 XMM 寄存器各存四个 32 位分量,Intel 文档中常把这种分量称为 lane。每个 32 位分量的低字节是一个算法状态字节,共组成 16 字节状态。部分值会发生符号扩展,所以读取时只取低 8 位。

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

找到地址后,只读轮询这块内存。状态变化就记录,某个 16 字节输出块提交后切换到下一块。

最终得到:

更关键的是,同一次采集运行最终仍然输出 Successful!,说明观察过程没有让程序切换到错误算法路径。

缓冲区中的每个 16 字节块按列主序保存,而内部轮函数按行主序运算:

所以每块必须做:

漏掉这个转置后,S 盒可能仍然看起来正确,但行移位和列混合永远对不上。

完整轮附近的状态变化数量反复呈现:

其中 4 字节变化对应 XMM0 的四个 S 盒分量,后续两次全状态变化对应移位、列混合和加轮密钥。

对所有块收集:

块边界会产生少量噪声,因此对每个输入值取出现次数最多的输出。最终:

说明恢复结果是一个完整置换,可以直接构造逆 S 盒。

令:

ShiftRowsRight 将第 r 行循环右移 r 字节。

列混合使用 GF(2^8),约简多项式为 0x11B。正向轮使用的矩阵为:

它的逆矩阵是:

完整前向算法:

虽然矩阵来自 AES,但正向方向使用的是 AES 的逆列混合,再配合右移行,因此不能直接调用标准 AES。

相邻完整轮状态已知时:

所以:

末轮同理:

对多个块重复计算并取一致值,就能过滤采样噪声。

首轮输入异或和第一轮密钥无法从一对相邻状态直接同时解出。

设:

选择两个已知块异或,K_round0 消失:

因为 L 是线性双射:

应用 L^-1 后,每个字节位置独立满足:

于是 128 位问题被拆成 16 个 8 位精确方程。

对每个位置在 S 盒的 256 项定义域中建立差分反查表,再把多个已知块得到的解集求交。使用参考块和另外两个块后,16 个位置都只剩一个解。

这不是搜索 2^128 密钥,更不是爆破 Serial,只是对已经恢复的 256 项 S 盒做精确差分查表。

得到 K_initial 后:

恢复的模型能够逐字节复现公开成功运行的全部 432 个输出块。这一步非常重要:只验证某一个轨迹块,无法排除又一套局部诱饵。

前向完整轮:

所以逆轮:

完整求逆代码:

至此,只要能构造 MengXin 输出端想要的 6912 字节记录,就能逐块逆回 DayDayUp 所需的 6912 字节输入。

KCTF 会先被上游逻辑转换成 100 个互不相同的 14 位状态。

这里没有必要完整逆出“任意 Name 到状态数组”的通用算法。对于题目指定的固定 Name,只需在未修改程序进入最终检查前,只读提取已经生成的 100 个 uint32

100 个值全部满足:

GoodGoodStudy 接收到一个 28 字节结构:

索引表大小为:

因此行数为:

正好与 14 位状态对应。每个状态选择 11 个大整数系数。

程序没有使用普通 int64,而是自己实现了十进制大整数:

数位按小端十进制排列:

这里的“快照仿真”是指:先在目标进程到达指定位置时暂停它,保存寄存器、可执行页面和相关私有内存,再把同一时刻的数据加载到 Unicorn 中继续执行。仿真时监视索引表读取,并把大整数对象还原为 Python 整数,就能得到每一行的 11 个系数。

每行系数按升幂排列:

执行轨迹中的大整数状态满足:

展开就是:

所以每个状态对应一个十次首一整系数多项式,每组输入的 10 个数就是它的 10 个整数根。

正常的 99 行可以在整数多项式环中直接精确分解:

每行得到 10 个互不相同、范围在 0..999999 的整数根。每组内部升序排列,100 组共 1000 个数。

这里不用浮点求根,因为系数非常大,浮点近似既不必要也不可靠。

求根后还要重新展开:

这样能确认根、重数和系数方向都没有理解错。

100 个多项式中,第 84 组,也就是数组下标 83、状态值 14036,无法正常分解。

检查系数后发现,一次项 a1 是异常项:

但常数项 a0 仍然有效:

对于首一整系数多项式,整数根必须整除常数项。先精确分解 a0,再只生成不大于 999999 的正因子,一共只有 12458 个。

这一步不是:

而是:

对每个候选根 x,把一次项之外的部分记为:

P(x)=0

只保留整除结果,再按 a1 分组。唯一拥有 10 个不同根的分组给出:

十个根为:

最后重新计算:

重建结果与修复后的 11 个系数逐项相等,因此修复是唯一的。

把 100 组根按状态顺序连接,每组 10 个、组内升序、使用 - 分隔,得到 1000 个整数。

普通文本长度为:

把这份记录直接注入 GoodGoodStudy,数学检查可以通过,说明 1000 个根是正确的。

但用它生成完整 Serial 时原版仍然失败。对比公开合法运行的 MengXin 输出后发现:

也就是说,check2 还要求正文恰好占 6897 字节。

十进制解析允许前导零,因此把第一个根:

改写为:

增加的是六个 ASCII 0x30,不是六个 NUL。

数值仍然是 149982,但文本长度增加了 6。最终缓冲区布局:

这样同一份数据可以同时满足:

这是最后一个导致“数学全对但原版失败”的条件。

此时已经有了 MengXin 输出端需要的 6912 字节:

剩下的过程完全是确定性逆变换。

得到 DayDayUp 所需的 6912 字节输入。

然后再正向运行一次 MengXin

先对这 6912 字节做标准 Base64 编码,再对每个 6 位 Base64 索引应用:

最后拼回:

得到总长 9226 的最终 Serial。

最终验证不使用 patched EXE,也不使用注入:

未修改原版输出:

最终 Serial 太长,不在正文重复粘贴,随 Writeup 附件提供。用于确认文件的 SHA-256:

原版程序 SHA-256:

早期快照中,Serial 串尾和某个运行时区域出现大段重合,一度以为 DayDayUp 只是复制字符串。

问题是,内存相同只能说明数据曾经被复制,不能说明它就是函数最终输出。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
免费 2
打赏
分享
最新回复 (1)
雪    币: 225
活跃值: (45)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
棋差一招
3天前
0
游客
登录 | 注册 方可回帖
返回