题目给出一个 Windows CrackMe 和一组公开的合法 Name/Serial:
目标是求出:
对应的合法 Serial,使未修改的原版程序输出:
这题最唬人的地方不是计算量,而是它同时布置了多套代码和多层真假校验:
最终没有爆破 Serial,也没有遍历六位数空间。整个解法是先确定程序的正向数据流,再从最后的数学约束逐层逆回输入。
去掉异常跳转、壳代码和诱饵后,真正与 Serial 有关的数据流可以抽象为:
求解时反过来走:
这样做的好处是,每一层都有明确的输入和输出,可以单独验证,不需要在 9226 个字符上做任何猜测。
程序表面上是一个运行在 WoW64 下的 32 位 PE。这里先解释两个后文会反复使用的词:
PE 文件的 .text 一般用于保存程序机器码。把原始 CrackMe.exe 作为普通 PE 载入 IDA 时,首先看到的是文件中 .text 节保存的 32 位静态代码。它包含一套看似合理的函数、调用约定和异常处理逻辑。
但把原始文件字节与程序启动后的相应内存区域逐字节比较,会发现两者并不相同:
关键逻辑是在运行过程中生成或恢复出来的,而且实际执行的是 64 位代码。程序借助 WoW64 环境和异常分发在 32 位与 64 位上下文之间切换。原始文件中的 32 位代码主要承担入口、调度和迷惑分析的作用,DayDayUp、MengXin、check2、GoodGoodStudy 的算法判断必须以内存态代码和真实运行数据为准。
这类设计会制造两个常见误区:
后续分析中,我把证据按来源分成四类:
只有干净路径产生的状态和常量进入最终模型。
真实代码大量依赖故意异常、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 只是复制字符串。
问题是,内存相同只能说明数据曾经被复制,不能说明它就是函数最终输出。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!