首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
2
[原创]第四题:未时·车流困城 从异常控制流到代数逆推 9226 位 Serial
发表于: 2026-8-17 10:37
445
[原创]第四题:未时·车流困城 从异常控制流到代数逆推 9226 位 Serial
Kazusa
2
2026-8-17 10:37
445
## 0x00 题目概况 题目给出一个 Windows CrackMe 和一组公开的合法 Name/Serial: ```text Name = 338F493766CFC94B Serial = 一条长度为 9226 的字符串 ``` 目标是求出: ```text Name = KCTF ``` 对应的合法 Serial,使未修改的原版程序输出: ```text Successful! ``` 这题最唬人的地方不是计算量,而是它同时布置了多套代码和多层真假校验: - 直接从原始 `CrackMe.exe` 文件中反汇编出的 32 位代码,并不是完整真实逻辑; - 程序启动后会在进程内存中生成并执行另一套 64 位异常驱动代码; - 调试器或补丁会让核心加密函数切换到诱饵 S 盒; - 即使最后的数学条件算对了,前面的文本长度检查仍会让程序失败; - 100 个多项式中还单独污染了一个系数,防止直接批量因式分解。 最终没有爆破 Serial,也没有遍历六位数空间。整个解法是先确定程序的正向数据流,再从最后的数学约束逐层逆回输入。 ## 0x01 总体思路 去掉异常跳转、壳代码和诱饵后,真正与 Serial 有关的数据流可以抽象为: ```text Serial │ 9226 bytes ▼ DayDayUp │ 自定义 Base64 ▼ DayDayUp 输出缓冲区 │ 6912 bytes = 432 × 16 ▼ MengXin │ 自定义 128-bit 替换-置换网络 ▼ decimal record │ 1000 个十进制数 ▼ check2 │ 格式、长度、NUL 位置 ▼ GoodGoodStudy(Name) │ 100 个十次多项式 ▼ Successful! ``` 求解时反过来走: ```text KCTF ▼ 提取 100 个 14-bit 状态 ▼ 恢复 100 个十次多项式 ▼ 求出 1000 个整数根 ▼ 构造满足 check2 的 6912 字节记录 ▼ MengXin^-1 ▼ DayDayUp^-1 ▼ 最终 Serial ``` 这样做的好处是,每一层都有明确的输入和输出,可以单独验证,不需要在 9226 个字符上做任何猜测。 ## 0x02 题目用了哪些防护 ### 2.1 32 位文件态代码与 64 位内存态代码 程序表面上是一个运行在 WoW64 下的 32 位 PE。这里先解释两个后文会反复使用的词: - **文件态代码**:直接从原始 `CrackMe.exe` 文件中读取和反汇编的内容; - **内存态代码**:程序启动后,从目标进程的虚拟内存中读取到的实际可执行内容。 PE 文件的 `.text` 一般用于保存程序机器码。把原始 `CrackMe.exe` 作为普通 PE 载入 IDA 时,首先看到的是文件中 `.text` 节保存的 32 位静态代码。它包含一套看似合理的函数、调用约定和异常处理逻辑。 但把原始文件字节与程序启动后的相应内存区域逐字节比较,会发现两者并不相同: ```text 原始 PE 文件中保存的代码 != 进程内存中实际执行的代码 ``` 关键逻辑是在运行过程中生成或恢复出来的,而且实际执行的是 64 位代码。程序借助 WoW64 环境和异常分发在 32 位与 64 位上下文之间切换。原始文件中的 32 位代码主要承担入口、调度和迷惑分析的作用,`DayDayUp`、`MengXin`、`check2`、`GoodGoodStudy` 的算法判断必须以内存态代码和真实运行数据为准。 这类设计会制造两个常见误区: 1. 把 32 位反编译结果当成真实算法; 2. 用 32 位寄存器和调用约定解释实际的 64 位现场。 后续分析中,我把证据按来源分成四类: ```text 文件态 直接从原始 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 多层校验互相遮挡 程序不是只做一次正确性判断,而是分成: ```text 编码合法性 ↓ 分组变换 ↓ 文本结构检查 ↓ Name 对应的数学检查 ``` 如果在程序即将调用 `GoodGoodStudy` 时暂停进程,并直接改写它接收的 6912 字节参数缓冲区,可以让最后的多项式检查成功。后文把这种实验简称为“入口注入”。它只能单独验证最后一层,而且绕过了 `check2`。 因此: ```text GoodGoodStudy 成功 ≠ 完整 Serial 成功 ``` 后面六个前导零的坑,就是因为一开始只证明了最后一层。 ### 2.5 数据层面的反自动化 除了控制流混淆,题目还用了几层数据混淆: - 64 字符自定义 Base64 字母表; - 每个字符位置再叠加线性偏移; - 自定义 S 盒和非标准方向的 AES 矩阵; - 十进制大整数对象; - `2^14` 行多项式系数表; - 100 个多项式中单独污染一个一次项; - 固定 NUL 位置,但允许利用十进制前导零保持数值不变。 这些设计使得“看懂一层”并不足以直接得到答案。 ## 0x03 还原主校验流程 把运行时函数按语义重命名后,主流程可以写成下面的伪代码: ```c 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; } ``` 这里最重要的是确定边界: ```text DayDayUp 输出长度 = 0x1B00 = 6912 MengXin 分组大小 = 16 分组数 = 6912 / 16 = 432 GoodGoodStudy 状态数 = 100 每个状态需要 10 个数 总数 = 1000 ``` 有了这些边界,后面的每个实验都能精确到具体字节或具体分组。 ## 0x04 先拆 DayDayUp ### 4.1 Serial 的真实结构 合法 Serial 结构为: ```text [5-byte prefix] [9216-byte body] [5-byte suffix] ``` 公开样本和最终样本使用同一外壳: ```text prefix = lI|0O suffix = Il1|! ``` 正文 9216 个字符刚好可以编码 6912 字节: ```text 9216 × 6 / 8 = 6912 ``` 而且 6912 能被 3 整除,因此没有 `=` 填充。 ### 4.2 自定义字母表 运行时恢复出的字母表是: ```text Il1|!ijJL`oO0QDSs5$Zz2B8gq96nNmMWwUuVvRrPpCc({tT+7xXKkYyAa4Ee3FH ``` 其中 `L` 后面是反引号字符 `0x60`。 仅仅替换 Base64 字母表还不够,程序对每个位置又加了一个偏移。 ### 4.3 推出位置公式 已知公开 Serial 是合法的,所以可以在干净运行中捕获 `DayDayUp` 的 6912 字节输出。 对该输出做标准 Base64 编码,定义: ```text u[i] = 公开 Serial 正文字符在自定义字母表中的下标 v[i] = 捕获到的 6912 字节数据经标准 Base64 编码后的 6 位值 d[i] = (v[i] - u[i]) mod 64 ``` 检查全部 9216 个位置后发现: ```text d[0] = 51 d[i+1] - d[i] = 27 mod 64 ``` 因此: ```text v[i] = (u[i] + 51 + 27*i) mod 64 ``` 正向解码就是: ```python 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) ``` 逆向编码则为: ```text u[i] = (v[i] - 51 - 27*i) mod 64 ``` 对应代码: ```python 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 字节输入。 ## 0x05 确认 MengXin 是固定分组置换 不能因为长度能被 16 整除,就直接假设它是 ECB。这里用合法公开样本做了两个选择输入实验。 ### 实验一:只改一个输入字节 先修改 `DayDayUp` 解码结果的第 0 字节,再重新编码成合法 Serial。 运行后比较 `MengXin` 输出: ```text 输入变化:offset 0 输出变化:offset 0..15 后续 431 块:完全不变 ``` 这说明没有跨块扩散。 ### 实验二:复制明文块 把输入块 0 原样复制到输入块 1,再运行原版: ```text P[1] = P[0] 结果: C[1] = C[0] ``` 这进一步排除了依赖块序号的额外参数(密码分析中常称为 tweak)和每块独立密钥。 因此可以确定: ```text C[i] = F(P[i]) ``` 其中 `F` 是所有 432 个块共同使用的固定 128 位双射。只要恢复一次 `F^-1`,就能独立逆转全部块。 ## 0x06 不触发反调试地拿到轮状态 ### 6.1 为什么普通调试不行 一开始采用断点和调试异常追踪,确实抓到了大量 XMM 状态,但恢复出的 S 盒属于诱饵路径。 所以需要一个满足下面条件的观察手段: - 不附加调试器; - 不下断点; - 不修改程序代码; - 不写寄存器; - 仍然能看到异常驱动代码内部的 XMM 状态。 ### 6.2 利用 Windows 保存的异常 CONTEXT 异常发生时,Windows 已经把 64 位寄存器现场保存在原生 64 位线程栈中;它与 WoW64 程序平时看到的 32 位栈不是同一个上下文。采集器只需要找到这份 `CONTEXT` 结构并读取它,不需要接管异常。 关键偏移: ```text 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 字节输出块提交后切换到下一块。 最终得到: ```text STATE 记录:12592 有状态的块:430 可用完整轮的块:414 S 盒覆盖:256 / 256 ``` 更关键的是,同一次采集运行最终仍然输出 `Successful!`,说明观察过程没有让程序切换到错误算法路径。 ## 0x07 还原 MengXin 的替换-置换网络 ### 7.1 状态布局 缓冲区中的每个 16 字节块按列主序保存,而内部轮函数按行主序运算: ```text state[row*4 + column] = block[column*4 + row] ``` 所以每块必须做: ```text buffer column-major ↓ internal row-major ↓ 替换-置换网络 ↓ buffer column-major ``` 漏掉这个转置后,S 盒可能仍然看起来正确,但行移位和列混合永远对不上。 ### 7.2 从变化模式恢复 S 盒 完整轮附近的状态变化数量反复呈现: ```text 4, 16, 16 ``` 其中 4 字节变化对应 XMM0 的四个 S 盒分量,后续两次全状态变化对应移位、列混合和加轮密钥。 对所有块收集: ```text before_byte -> after_byte ``` 块边界会产生少量噪声,因此对每个输入值取出现次数最多的输出。最终: ```text 输入覆盖 = 256 不同输出 = 256 ``` 说明恢复结果是一个完整置换,可以直接构造逆 S 盒。 ### 7.3 轮结构 令: ```text L(x) = InvMixColumns(ShiftRowsRight(x)) ``` `ShiftRowsRight` 将第 `r` 行循环右移 `r` 字节。 列混合使用 GF(2^8),约简多项式为 `0x11B`。正向轮使用的矩阵为: ```text 0e 0b 0d 09 09 0e 0b 0d 0d 09 0e 0b 0b 0d 09 0e ``` 它的逆矩阵是: ```text 02 03 01 01 01 02 03 01 01 01 02 03 03 01 01 02 ``` 完整前向算法: ```text 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 直接恢复后续轮密钥 相邻完整轮状态已知时: ```text B = L(Sbox(A)) XOR K ``` 所以: ```text K = L(Sbox(A)) XOR B ``` 末轮同理: ```text K_final = ShiftRowsRight(Sbox(last_state)) XOR ciphertext ``` 对多个块重复计算并取一致值,就能过滤采样噪声。 ### 7.5 用跨块差分恢复首轮 首轮输入异或和第一轮密钥无法从一对相邻状态直接同时解出。 设: ```text A_i = L(Sbox(P_i XOR K_initial)) XOR K_round0 ``` 选择两个已知块异或,`K_round0` 消失: ```text A_i XOR A_0 = L(Sbox(P_i XOR K_initial)) XOR L(Sbox(P_0 XOR K_initial)) ``` 因为 `L` 是线性双射: ```text L^-1 = ShiftRowsLeft ∘ ForwardMixColumns ``` 应用 `L^-1` 后,每个字节位置独立满足: ```text 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` 后: ```text K_round0 = L(Sbox(P_i XOR K_initial)) XOR A_i ``` ### 7.6 恢复结果 ```text 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 逆变换 前向完整轮: ```text B = InvMixColumns(ShiftRowsRight(Sbox(A))) XOR K ``` 所以逆轮: ```text A = InvSbox( ShiftRowsLeft( ForwardMixColumns(B XOR K) ) ) ``` 完整求逆代码: ```python 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 字节输入。 ## 0x08 逆出 GoodGoodStudy 的数学条件 ### 8.1 Name 并不直接参与多项式计算 `KCTF` 会先被上游逻辑转换成 100 个互不相同的 14 位状态。 这里没有必要完整逆出“任意 Name 到状态数组”的通用算法。对于题目指定的固定 Name,只需在未修改程序进入最终检查前,只读提取已经生成的 100 个 `uint32`。 100 个值全部满足: ```text 0 < state < 0x4000 ``` ### 8.2 系数表 `GoodGoodStudy` 接收到一个 28 字节结构: ```text +0x00 coefficient pool begin +0x08 coefficient pool end +0x10 index table +0x18 count = 11 ``` 索引表大小为: ```text 0xB0000 bytes ``` 因此行数为: ```text 0xB0000 / (11 × 4) = 16384 = 2^14 ``` 正好与 14 位状态对应。每个状态选择 11 个大整数系数。 ### 8.3 十进制大整数对象 程序没有使用普通 `int64`,而是自己实现了十进制大整数: ```text object size = 0x3C8 +0x000 240 个 uint32 十进制位 +0x3C0 有效长度 +0x3C4 符号 ``` 数位按小端十进制排列: ```text value = sign × Σ digit[i] × 10^i ``` 这里的“快照仿真”是指:先在目标进程到达指定位置时暂停它,保存寄存器、可执行页面和相关私有内存,再把同一时刻的数据加载到 Unicorn 中继续执行。仿真时监视索引表读取,并把大整数对象还原为 Python 整数,就能得到每一行的 11 个系数。 ### 8.4 Horner 链 每行系数按升幂排列: ```text [a0, a1, a2, ..., a9, 1] ``` 执行轨迹中的大整数状态满足: ```text s10 = x s9 = x * (s10 + a9) s8 = x * (s9 + a8) ... s1 = x * (s2 + a1) compare s1 == -a0 ``` 展开就是: ```text P(x) = x^10 + a9*x^9 + ... + a2*x^2 + a1*x + a0 P(x) = 0 ``` 所以每个状态对应一个十次首一整系数多项式,每组输入的 10 个数就是它的 10 个整数根。 ### 8.5 精确求根 正常的 99 行可以在整数多项式环中直接精确分解: ```text P(x) = ∏(x - r_i) ``` 每行得到 10 个互不相同、范围在 `0..999999` 的整数根。每组内部升序排列,100 组共 1000 个数。 这里不用浮点求根,因为系数非常大,浮点近似既不必要也不可靠。 求根后还要重新展开: ```python rebuilt = product(x - root for root in roots) assert rebuilt.coefficients == original_coefficients ``` 这样能确认根、重数和系数方向都没有理解错。 ## 0x09 被污染的第 84 组 100 个多项式中,第 84 组,也就是数组下标 83、状态值 14036,无法正常分解。 检查系数后发现,一次项 `a1` 是异常项: ```text bad a1 = 5590933876550010499093511454771320894841919276792 ``` 但常数项 `a0` 仍然有效: ```text a0 = 58518979579699416819874513996049551381298761345274880 ``` 对于首一整系数多项式,整数根必须整除常数项。先精确分解 `a0`,再只生成不大于 999999 的正因子,一共只有 12458 个。 这一步不是: ```text for x in range(1000000) ``` 而是: ```text factor(a0) ↓ 组合出有界正因子 ↓ 只处理数学上可能成为整数根的 x ``` 对每个候选根 `x`,把一次项之外的部分记为: ```text R(x) = Σ(i != 1) ai*x^i ``` 由 `P(x)=0`: ```text a1 = -R(x) / x ``` 只保留整除结果,再按 `a1` 分组。唯一拥有 10 个不同根的分组给出: ```text a1 = -14995339131612996894456453788697728968558244212608 ``` 十个根为: ```text 8235, 10291, 41021, 373479, 378744, 415656, 595246, 659736, 813519, 896167 ``` 最后重新计算: ```text P(x) = ∏(x-r_i) ``` 重建结果与修复后的 11 个系数逐项相等,因此修复是唯一的。 ## 0x0A check2 的六个前导零 把 100 组根按状态顺序连接,每组 10 个、组内升序、使用 `-` 分隔,得到 1000 个整数。 普通文本长度为: ```text 6891 bytes ``` 把这份记录直接注入 `GoodGoodStudy`,数学检查可以通过,说明 1000 个根是正确的。 但用它生成完整 Serial 时原版仍然失败。对比公开合法运行的 `MengXin` 输出后发现: ```text 公开合法记录首个 NUL:offset 6897 KCTF 根文本首个 NUL:offset 6891 差值:6 ``` 也就是说,`check2` 还要求正文恰好占 6897 字节。 十进制解析允许前导零,因此把第一个根: ```text 149982 ``` 改写为: ```text 000000149982 ``` 增加的是六个 ASCII `0x30`,不是六个 NUL。 数值仍然是 149982,但文本长度增加了 6。最终缓冲区布局: ```text 6897 bytes decimal text 15 bytes NUL ----------------------- 6912 bytes total ``` 这样同一份数据可以同时满足: ```text check2 要求固定文本长度 GoodGoodStudy 要求固定整数值 ``` 这是最后一个导致“数学全对但原版失败”的条件。 ## 0x0B 从目标记录生成 Serial 此时已经有了 `MengXin` 输出端需要的 6912 字节: ```text 000000149982-182871-188530-... ... [15 bytes NUL] ``` 剩下的过程完全是确定性逆变换。 ### 第一步:逐块逆 MengXin ```python 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`: ```text MengXin(daydayup_input) == target_record ``` ### 第二步:逆 DayDayUp 先对这 6912 字节做标准 Base64 编码,再对每个 6 位 Base64 索引应用: ```text custom_index[i] = (standard_sextet[i] - 51 - 27*i) mod 64 ``` 最后拼回: ```text lI|0O + 9216-byte body + Il1|! ``` 得到总长 9226 的最终 Serial。 ### 第三步:原版验证 最终验证不使用 patched EXE,也不使用注入: ```text Name = KCTF Serial = generated 9226-byte string ``` 未修改原版输出: ```text Input user name: Input password: Successful! ``` 最终 Serial 太长,不在正文重复粘贴,随 Writeup 附件提供。用于确认文件的 SHA-256: ```text e34dc3a75754abf44a96d4902bac38b5dd0310fffb2974b27cc84b932ae5847b ``` 原版程序 SHA-256: ```text 338f493766cfc94b6b075138f37cead0a2516d61fc983960c169b7ddf62be760 ``` ## 0x0C 走过的错误路线 ### 错误一:把内存中相同片段当成变换结果 早期快照中,Serial 串尾和某个运行时区域出现大段重合,一度以为 `DayDayUp` 只是复制字符串。 问题是,内存相同只能说明数据曾经被复制,不能说明它就是函数最终输出。 改进后要求: ```text 模型输出 == 干净运行捕获输出 ``` 而且必须逐字节比较完整 6912 字节。 ### 错误二:相信调试轨迹中的自洽 SPN 诱饵 `MengXin` 可以做到“正向计算后再逆向能够回到原值”,但这种自洽性不是路径正确的充分证据。 真正有效的标准是: ```text 模型必须复现未修改公开成功运行的全部 432 块 ``` ### 错误三:在 GoodGoodStudy 入口注入成功就认为结束 这个实验只证明根正确,绕过了 `check2`。最后差的六个 ASCII 零正是被绕过的条件。 ### 错误四:混合不同时间的快照 多次运行留下地址重叠的旧内存页,仿真器如果全部映射,会被旧页覆盖当前页。 解决办法是只加载本次内存区域清单中仍被 Windows 标记为“已提交”(`MEM_COMMIT`)的页面,保证寄存器和内存都来自同一次暂停时刻。 ### 错误五:把“能跑”当成“路径正确” 这题很多错误模型都能跑,甚至能完成漂亮的数学闭环。最终判据必须是: ```text 原版哈希正确 无补丁 无入口注入 完整输入 实际输出 Successful! ``` ## 0x0D 防护设计评价 从出题角度看,这题的保护层次很清楚: | 防护 | 目的 | 最终处理方式 | | --- | --- | --- | | 32 位文件态代码 | 让静态反编译看不到完整真实算法 | 对比并分析进程内存中的实际代码 | | WoW64/64 位异常代码 | 打乱架构和调用约定 | 读取 64 位原生线程上下文 | | 异常驱动控制流 | 破坏线性反汇编和普通单步 | 不跳过异常,观察保存 CONTEXT | | 调试状态下切换 S 盒 | 让调试结果自洽但错误 | 使用无调试附加的干净轨迹 | | 自定义 Base64 | 隐藏 Serial 与 6912 字节数据的关系 | 用公开对恢复位置仿射公式 | | 自定义替换-置换网络 | 阻止直接构造目标明文 | 选择输入 + 状态差分恢复 | | 14 位多项式表 | 把 Name 约束转成大整数问题 | 动态提取目标 Name 的状态 | | 污染一个系数 | 阻止批量自动因式分解 | 整数根定理恢复一次项 | | 固定 NUL 位置 | 防止只满足数学条件 | 用前导零保持数值、补足长度 | 最有意思的不是单独某一种保护,而是它们会互相掩护: - 调试状态下的错误 S 盒让你得到一个“非常像答案”的假密码算法; - 入口注入让你误以为最后的根已经等于完整答案; - 单行污染让批量多项式求根在 99% 处失败; - 固定长度又让完全正确的 1000 个数无法直接通过。 ## 0x0E 总结 最终解题手段可以压缩成五句话: 1. 用公开合法对确定 `DayDayUp` 的真实输入输出,恢复自定义 Base64 公式。 2. 用选择输入证明 `MengXin` 是固定的 16 字节独立置换。 3. 不附加调试器,读取异常栈中保存的 x64 `CONTEXT`,恢复干净 S 盒和全部轮密钥。 4. 把 `GoodGoodStudy` 还原成 100 个十次首一多项式,精确求出 1000 个整数根,并修复唯一的污染项。 5. 用六个十进制前导零满足 `check2`,再依次执行 `MengXin^-1` 和 `DayDayUp^-1` 生成 Serial。 整个过程没有枚举 Serial,没有遍历一百万个六位数,也没有通过修改成功分支拿结果。最终 Serial 是由末端数学条件确定后,沿程序真实数据流逐层逆推出的。
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
2
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
zhuzhu_biu
你的分享对大家帮助很大,非常感谢!
2026-8-17 16:00
backer
为你点赞!
2026-8-17 12:57
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
落音吹雪
雪 币:
205
活跃值:
(135)
能力值:
( LV2,RANK:10 )
在线值:
发帖
1
回帖
41
粉丝
0
关注
私信
落音吹雪
2
楼
棋差一招
2026-8-17 14:24
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Kazusa
2
6
发帖
21
回帖
90
RANK
关注
私信
他的文章
[原创]第七题:戌时·暗能潜流
37
[原创]第六题:酉时·书院迷局 从双分支耦合到代数逆推
399
[原创]第四题:未时·车流困城 从异常控制流到代数逆推 9226 位 Serial
445
[求助]关于全局hook键盘钩子后出现的一个问题
4016
[求助]关于第7章win内核部分。。
3567
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部