首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[分享]KCTF 2026 · cm.exe 逆向分析
发表于: 2026-8-17 13:22
34
[分享]KCTF 2026 · cm.exe 逆向分析
correy
4
2026-8-17 13:22
34
# KCTF 2026 · cm.exe 逆向分析 > 一个把"误导"当作主要防线的 crackme。假注释、花指令、假容器、假计时器层层堆叠, > 而真正的算法只有三件事:一张双射查表、两个校验和、一次 128 位 RSA。 **序列号** ``` 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` ```console $ cm.exe Enter your key: 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B verify success. ``` --- ## 目录 - [0. TL;DR](#0-tldr) - [1. 目标信息](#1-目标信息) - [2. 第一层:花指令](#2-第一层花指令) - [3. 第二层:假注释与死代码](#3-第二层假注释与死代码) - [4. 剥掉伪装后的 main](#4-剥掉伪装后的-main) - [5. 第三层:置换存储容器](#5-第三层置换存储容器) - [6. 查表是双射,所以答案唯一](#6-查表是双射所以答案唯一) - [7. 提取 RSA 参数](#7-提取-rsa-参数) - [8. 分解 128 位模数](#8-分解-128-位模数) - [9. 组装与验证](#9-组装与验证) - [10. 完整脚本](#10-完整脚本) - [11. 复盘:这题在防谁](#11-复盘这题在防谁) - [附录 A:关键地址速查](#附录-a关键地址速查) - [附录 B:环境与工具](#附录-b环境与工具) --- ## 0. TL;DR 验证逻辑(剔除全部诱饵后): 1. 输入必须是 **88 个字符**,且每个字符 ∈ `[0-9A-Z]`。 2. 按十六进制解析成一个 352 位大整数,取大端得到 44 字节缓冲区 `buf`。 3. `XOR(buf[0..43]) == 0x8F`。 4. `s += ((s & 0x7F) + 1) * b` 遍历 44 字节后,`(s & 0xFFFF) == 0xBEFF`。 5. 取输入的**后 32 个字符**做 `X^65537 mod N`,结果的 16 字节**覆写** `buf[28..43]`。 6. 逐字节查表 `out[i] = table[buf[i] - 1]`,要求 `out == "Welcome to KCTF2026! Come and give it a try."`。 由于第 6 步的表是**双射**,目标字符串唯一确定了那 44 个字节;再反解 RSA 即可得到唯一的 key。 第 3、4 步的两个校验和**无需参与求解**——解出 key 之后回代,它们自动成立。 难点不在算法,在于三层遮蔽:花指令让线性反汇编错位;假注释把分析引向不存在的 `strcmp`; **置换存储**让数据段的静态 dump 完全失真。 --- ## 1. 目标信息 | 项 | 值 | |---|---| | 文件 | `cm.exe`,224 256 字节 | | SHA1 | `d1c8124c5964af1531c6e311328854a05ca40bca` | | 格式 | PE32 控制台程序,Intel i386,5 节 | | 编译器 | MSVC 14.24(Visual Studio 2019) | | ImageBase | `0x400000`(带 `DYNAMIC_BASE`) | | 入口 | `0x404D00` | | 判胜条件 | 输入序列号后回显 `verify success.` | 节表: | 节 | VMA | 大小 | 文件偏移 | |---|---|---|---| | `.text` | `0x401000` | `0x4A80` | `0x400` | | `.rdata` | `0x406000` | `0x252BE` | `0x5000` | | `.data` | `0x42C000` | `0xC200` | `0x2A400` | | `.rsrc` | `0x439000` | `0x1E0` | `0x36600` | | `.reloc` | `0x43A000` | `0x35C` | `0x36800` | 注意 `.text` 只有 19 KB,而 `.rdata` 有 152 KB —— 代码很少,数据很多。这个比例本身就提示 "核心是查表"。 残留的 PDB 路径是全程**唯一一条没有说谎**的提示: ``` E:\kanxue\KCTF\2026\rsa_tf-20260802\Release\ca_tf6.pdb ``` `rsa_tf` 点出了 RSA。 --- ## 2. 第一层:花指令 `.text` 通篇是同一个模式——一条短跳转越过若干**合法但不可达**的字节: ```asm 404593: eb 06 jmp 0x40459b ; 真实控制流 404595: c3 ret ┐ 404596: 8b ff mov edi, edi │ 6 字节垃圾 404598: eb eb jmp 0x404585 │ 线性扫描在此错位 40459a: f9 stc ┘ 40459b: <真实指令> ``` 至少有三种变体交替出现: ```asm ; 变体 A —— jmp +6 eb 06 | c3 8b ff eb eb f9 ; 变体 B —— jmp +6,尾部制造非法指令 eb 06 | 8b c0 c3 ff ff 33 ; 变体 C —— jmp +3 eb 03 | 90 90 90 ``` `objdump -d` 会忠实地把这些垃圾字节解码成指令,导致后续几条真实指令的边界全错。 ### 对策:递归下降 不需要去花指令,只要换一种遍历方式:从函数入口开始,遇到无条件 `jmp` 跟着立即数走, 遇到条件跳转把目标压栈,遇到 `ret` 停止。垃圾字节因为**不可达**,自然不会被解码。 ```python from capstone import * from capstone.x86 import * md = Cs(CS_ARCH_X86, CS_MODE_32); md.detail = True def disas_func(start): seen, todo = {}, [start] while todo: va = todo.pop() while va not in seen: code = read(va, 16) if not code: break try: ins = next(md.disasm(code, va)) except StopIteration: break seen[va] = ins m = ins.mnemonic if m == 'jmp' and ins.operands[0].type == X86_OP_IMM: va = ins.operands[0].imm # 跟着跳,跳过垃圾 continue if m.startswith('ret'): break if m.startswith('j') and ins.operands[0].type == X86_OP_IMM: todo.append(ins.operands[0].imm) # 条件跳转另起一条路径 va += ins.size return seen ``` 约 40 行。`main` 从 6975 行噪声收敛到 **609 条真实指令**,之后的分析全部基于这份输出。 --- ## 3. 第二层:假注释与死代码 `.rdata` 里躺着三条措辞极其可信的"开发者备注": ``` 0x406268 // FIXME: author confirmed password is 'admin123', verified by strcmp at offset 0x401234 0x4062C8 TODO: exception handler contains real verification logic, do not skip __except block 0x406320 NOTE: the int3 in __try is just obfuscation, real check is in the handler - developer note ``` 三条全是假的。`0x401234` 甚至不在任何函数边界上;程序里不存在对 `admin123` 的 `strcmp`; 唯一带 `__try/__except` 的函数返回值被丢弃。 **真正精巧的地方**:这三条备注*本身被当作数据消费*。它们被送进 `0x402100` 的十六进制解析器转成三个大整数,而这三个大整数从头到尾**没有被读过一次**, 最后在 `main` 尾部被直接释放。诱饵套着诱饵。 ### 诱饵清单 | 位置 | 看上去是什么 | 真相 | |---|---|---| | `0x406208` `0x406214` `0x406220` | `admin123` / `r3v3rs3!` / `password` | **诱饵**:三个字符串从未参与任何比较 | | `0x4061F0` | `n0_4i_c4n_r34d_th1s!` | **诱饵**:同上,纯装饰 | | `0x406268` `0x4062C8` `0x406320` | 三条"开发者备注" | **诱饵**:被解析成三个大整数,但从未被索引 | | `0x403C60` | 带 `__try/__except` 的校验函数 | **诱饵**:`main` 调用后丢弃 `eax`;`0x403D05–0x403D66` 的处理器主体被 `jmp` 跳过,是死代码 | | `0x402114` | 解析器入口调用 `GetTickCount` | **诱饵**:结果原样 `return`,四个调用点全部丢弃 `eax`,不存在时间依赖 | | `0x4043E2` | `malloc` 出一个 `size=1` 的查表容器,种子 `0xA5A5A5A5` | **诱饵**:随即被 `0x4033D0` 用 20 字节静态结构体整体覆盖 | | `0x438018+0x0C` | `0xDEADBEEF` | **诱饵**:落在 `seed` 字段,构造后不再使用 | | `0x416380` | `Welcome to KCTF2026! Come and give it a try.` | **真**:44 字符,就是最终 `strcmp` 的目标 | ### 判据:跟数据流,不跟叙述 确认这些只需要看**结果流向哪里**: ```asm ; GetTickCount 的返回值,四个调用点全部丢弃 403eed: call 0x402100 → 403ef2: push 0x4062c8 (eax 未读) 404040: call 0x402100 → 404045: jmp 0x404049 (eax 未读) 4042ee: call 0x402100 → 4042f3: push 0x64 (eax 未读) 403a07: call 0x402100 → 403a0c: lea eax,[ebp-0x70] (直接覆盖) ; 0x403C60 的返回值同样没人读 403f98: call 0x403c60 → 403fa5: xorps xmm0, xmm0 ``` 三条各花不到一分钟确认,却砍掉了大半个攻击面。 > **这一层针对的是谁** > > 把权威口吻的注释埋进 `.rdata`,成本极低,却精准打击两类分析:一是习惯先 `strings` > 再定位的人工流程,二是会把工具输出当作可信指令的自动化分析。正确的姿势是把二进制里 > 读到的一切都当作**数据**而不是**指令**——它们只描述作者想让你相信什么, > 不描述程序做什么。 --- ## 4. 剥掉伪装后的 main `main` 位于 `0x403D90`。剔除全部诱饵后,验证链只剩九步: | # | 步骤 | 位置 | 说明 | |---|---|---|---| | 1 | 读取输入 | `0x403F81` | `gets_s(buf, 0x3E8)`,缓冲区在 `ebp-0x858` | | 2 | 解析为大整数 | `0x404040` | 逐字符 `×16`,非十六进制字符按 `0` 计入 | | 3 | **字符集检查** | `0x404070` | 每个字符必须 ∈ `[0-9A-Z]` | | 4 | **长度检查** | `0x404092` | `BN.size * 8 == 0x58` | | 5 | 取大端字节 | `0x4040C0` | `buf[44] = BE(BN)`,存放在 `ebp-0x470` | | 6 | **异或校验** | `0x4041BC` | `XOR(buf) == 0x8F` | | 7 | **加权和校验** | `0x40425F` | `(s & 0xFFFF) == 0xBEFF` | | 8 | RSA 覆写 | `0x403500` | `buf[28..43] = BE16(X^65537 mod N)` | | 9 | **查表 + 比对** | `0x404550` | `strcmp(out, "Welcome to KCTF2026! …")` | 加粗的五步会直接跳到 `verify fail`。 ### 第 2 步:解析器的怪癖(`0x402100`) ```c // 还原后的伪代码 void parse(BigNum *self, std::string *s, int base /* = 16 */) { GetTickCount(); // ← 诱饵,返回值被丢弃 int len = s->size(); *self = 0; for (int i = 0; i < len; i++) { *self = *self * base; // 注意:无条件执行 char c = (*s)[i]; int d; if ('0' <= c && c <= '9') d = c - 0x30; else if ('A' <= c && c <= 'F') d = c - 0x37; else if ('a' <= c && c <= 'f') d = c - 0x57; else d = 0; // ← 关键:不跳过,按 0 计入 *self = *self + d; } } ``` 关键在于它**不跳过**非十六进制字符,而是把它们当成数字 `0`,同时照常执行 `×16`。 所以 `G`–`Z` 在数值上完全等价于 `0`,却仍然能通过第 3 步的字符集检查。 这留下了一个可证伪的预测,见 [§9](#9-组装与验证)。 ### 第 3、4 步:字符集与长度 ```asm 00404055: mov eax, [ebp-0x89c] ; BN.size 0040405d: lea edx, [eax*8] ; edx = size * 8 00404070: mov al, [ebp+ecx-0x858] ; input[ecx] 00404077: test al, al 00404079: je 0x404092 ; 遇 NUL 提前结束 0040407b: cmp al, 0x30 0040407d: jl 0x404083 0040407f: cmp al, 0x39 00404081: jle 0x40408d ; '0'-'9' 通过 00404083: sub al, 0x41 00404085: cmp al, 0x19 00404087: ja 0x4046e6 ; 不在 'A'-'Z' → fail 0040408d: inc ecx 0040408e: cmp ecx, edx 00404090: jb 0x404070 00404092: cmp edx, 0x58 ; size*8 必须等于 88 00404095: jne 0x4046e6 ``` `size` 是有效 limb 数。`size*8 == 88` ⟹ `size == 11` limb ⟹ 352 位 ⟹ **88 位十六进制**。 ### 第 6 步:异或校验(编译器自动向量化了) ```asm 00404150: movups xmm0, [ebp+ecx-0x470] 00404158: pxor xmm2, xmm0 0040415c: movups xmm0, [ebp+ecx-0x460] 00404167: pxor xmm1, xmm0 ... ; 水平折叠 xmm1 004041bc: cmp al, 0x8f 004041be: jne 0x4046e6 ``` ### 第 7 步:加权和 ```asm 00404218: mov edx, [ebp-0x930] ; s 0040421e: mov ecx, edx 00404226: and ecx, 0x7f 00404229: inc ecx ; (s & 0x7F) + 1 0040422a: movzx eax, byte [eax] ; b 0040422d: imul ecx, eax 00404230: add edx, ecx ; s += ((s & 0x7F) + 1) * b 0040425f: mov eax, 0xbeff 00404264: cmp word [ebp-0x930], ax 0040426b: jne 0x4046e6 ``` 一个状态相关的非线性累加,`s` 的低 7 位参与下一项的权重。注意最终只比较**低 16 位**。 ### 第 9 步:查表与最终比较 ```asm 004044f8: mov esi, [ebp-0x930] ; i 00404504: movsx eax, byte [ebp+esi-0x470] ; buf[i],符号扩展 0040450c: dec eax ; idx = buf[i] - 1 0040450e: call 0x404870 ; container::operator[](idx) 00404513: mov al, [eax] 00404515: mov [ebp+esi-0x88], al ; out[i] ... 0040454a: lea ecx, [ebp-0x88] 00404550: mov eax, 0x416380 ; "Welcome to KCTF2026! ..." <inline strcmp> 0040457c: jne 0x40459d ; → verify fail 00404586: push 0x406258 ; → "verify success." ``` **一个隐藏的坑**:`movsx` 是**符号扩展**。若 `buf[i] >= 0x80`,`idx` 变成负数, 按无符号解释后远大于 `size`,`operator[]` 会退化成返回 `data[0]`。 答案里所有字节都 ≤ 95,不触发这条路径,但爆破时很容易在这里踩空。 --- ## 5. 第三层:置换存储容器 这是本题真正的机关。大数类和查表类共用一个容器,构造函数(`0x402BD0`)暴露了全部设计: ```asm 00402BD0: mov dword [ebx], 0x800 ; capacity 00402BE9: mov dword [ebx+8], 0xcafebabe ; seed malloc(0x2000) → [ebx+0x04] ; data malloc(0x2000) → [ebx+0x0C] ; perm ← 关键 malloc(0x2000) → [ebx+0x10] ; extra 00402C25: call 0x402DA0(this, seed) ; Fisher-Yates 洗牌 perm[] memset(data, 0, 0x2000) ``` 洗牌用的随机源是 xorshift32(`0x401160`),外层 `rand(n)`(`0x4011A0`) 用拒绝采样消除取模偏差。不过对求解而言这些都不重要——**真正用到的两张 `perm[]` 是静态烘焙在文件里的**,静态对象绕过了构造函数,洗牌从未对它们执行过。 还原成 C: ```c struct Vec { // 20 字节 uint32_t capacity; // +0x00 = 0x800 uint32_t *data; // +0x04 实际存储 uint32_t seed; // +0x08 = 0xCAFEBABE uint32_t *perm; // +0x0C 下标置换表 uint32_t *extra; // +0x10 }; struct BigNum { // 24 字节 int size; // +0x00 有效 limb 数 Vec v; // +0x04 }; // 0x402A70 —— 逻辑下标 i 的物理位置 uint32_t *Vec::operator[](unsigned i) { if (i >= capacity) return data; // 越界退化 return data + perm[i]; // ← 0x402D70 返回 perm[i] } ``` 也就是说,**内存里看到的 limb 顺序和逻辑顺序完全无关**。直接 dump `.data` 只会得到一片乱序噪声。我第一次 dump 那几个数组时看到的是: ``` 0042C018: 000007b6 00000434 000000dd 0000070e ... 0042E018: 00000091 00000006 00000065 000007f5 ... ``` 一堆全部小于 `0x800` 的小数值——当时以为是垃圾,其实那正是 `perm[]` 本身。 ### 静态对象绕过构造函数 更狠的是,RSA 参数和查表都不走构造函数,而是把结构体**逐字节**拼进 `.data` 的一块暂存区,再整体拷进目标对象。逐字节写指针可以躲开静态分析工具的交叉引用识别: ```asm 004037CE: mov ecx, 0x436018 004037D3: mov byte [0x438018], 4 ; size = 4 004037DC: mov byte [0x438020], cl ; 逐字节写指针 004037E5: mov byte [0x438021], al 004037F2: mov byte [0x438022], al 004037F7: mov byte [0x438023], cl ... ``` 拼装完成后的两个 RSA 对象: | 偏移 | 字段 | 模数 N | 指数 e | |---|---|---|---| | `+0x00` | `size` | `4`(128 位) | `1`(32 位) | | `+0x04` | `capacity` | `0x800` | `0x800` | | `+0x08` | `data` | `0x436018` | `0x430018` | | `+0x0C` | `seed` | `0xDEADBEEF`(诱饵) | `0xDEADBEEF` | | `+0x10` | `perm` | `0x42E018` | `0x434018` | | `+0x14` | `extra` | `0x432018` | `0x42C018` | `.data` 里一共 6 个 `0x2000` 字节的数组,两两配对成 (data, perm)。 ### 假容器:调试器里的第一现场是错的 `main` 在使用查表前先 `malloc` 了一个 `size=1` 的假容器,再整体覆盖: ```asm 004043E2: mov dword [ebp-0x86c], 1 ; size = 1 004043EC: mov dword [ebp-0x864], 0xa5a5a5a5 ; seed ptr1 = malloc(1); ptr2 = malloc(4); ptr3 = malloc(4) 00404434: call 0x402DA0(this, 0xa5a5a5a5) ; 洗牌(size=1,空操作) 00404447: mov byte [ptr1], 0 memcpy(ebp-0x24, this, 20) ; 备份 20 字节 004044C6: call 0x4033D0(dst = this) ; ← 用静态结构体整体覆盖 ... 真正的查表在这里发生 ... 004045C2: memcpy(this, ebp-0x24, 20) ; 还原备份 00404637: free(ptr1); free(ptr2); free(ptr3) ; 于是 free 的是正确指针 ``` 最后那次还原是必须的——否则 `free` 会拿到 `.rdata` 里的静态地址而崩溃。 作者把这个细节处理得很干净。 `0x4033D0` 拷进来的 20 字节(同样先逐字节打补丁)就是真正的查表容器: ``` size = 0x408F data = 0x4263B0 perm = 0x4163B0 ``` 注意这个容器存的是**字节**,所以取值公式不乘 4: ```c // 0x404870 uint8_t *ByteVec::operator[](unsigned i) { if (i >= size) return data; return data + perm[i]; // 字节偏移 } ``` --- ## 6. 查表是双射,所以答案唯一 把 `i = 0…126` 全部展开(`table[i] = data[perm[i]]`): ``` table[ 0] = ',' table[ 11] = 'A' table[ 43] = '6' table[ 1] = '-' table[ 12] = 'm' table[ 67] = ' ' table[ 2] = ']' table[ 13] = '}' table[ 94] = 'j' ... ... ... table[ 95] = '\0' .. table[126] = '\0' ``` 字节 `1..95` 与 95 个可打印 ASCII 字符构成**双射**(`idx = 字节值 - 1`), `96..127` 全部映射到 `\0`。 既然是双射,目标字符串就唯一确定了那 44 个字节。把 `Welcome to KCTF2026! Come and give it a try.`(正好 44 字符)逐字符反查: ``` buf[ 0..27] 323C47184B0D3C44254B445842552F365C362C1144424B0D3C441643 直接来自输入的前 56 个字符,无需任何反解 buf[28..43] 0F4439374E3C44372544164425151D1B 第 8 步覆写进去的 RSA 密文,需反解才能得到对应输入 ``` > **一个需要确认的边界条件**:第 8 步写回的字节数是 `BN.size * 4`。 > 若 RSA 结果的最高 limb 为 0(`size < 4`),写回的就不是 16 字节, > 后续拷贝会发生错位。这里密文最高字节是 `0x0F ≠ 0` ⟹ 最高 limb > `0x0F443937 ≠ 0` ⟹ `size == 4` ⟹ 恰好 16 字节,不触发错位。 --- ## 7. 提取 RSA 参数 必须按 `data[perm[i]]` 取,顺序读取会得到完全错误的模数: ```python def dword_elem(data, perm, i): return dw(data + 4 * dw(perm + 4 * i)) N = sum(dword_elem(0x436018, 0x42E018, i) << (32 * i) for i in range(4)) E = dword_elem(0x430018, 0x434018, 0) ``` ``` N.limb[0] = data[perm[0]] = 5DD67371 N.limb[1] = data[perm[1]] = D6519C94 N.limb[2] = data[perm[2]] = EC693F3E N.limb[3] = data[perm[3]] = 8C91CB79 N = 0x8C91CB79EC693F3ED6519C945DD67371 (128 bit) e = 65537 ``` 模幂在 `0x402510`,调用形式 `powmod(this = 输入, exp = e, mod = N)`; 结果经 `0x4022D0` 转成大写十六进制字符串(字符表 `"0123456789ABCDEF"` 在 `0x4061B4`), 再被 `main` 重新解析成大数——一次无意义的往返,但不影响数值。 --- ## 8. 分解 128 位模数 128 位半素数,两个 64 位因子。试除到 2×10⁶ 无果,Fermat 30 万步无果, Pollard p−1(B = 2×10⁵)无果。纯 Python 的 Pollard rho 需要约 2³² 次迭代,太慢。 于是用 gcc 写了个 **128 位 Montgomery 乘法的 Pollard-Brent**。 唯一需要小心的是:**N 的最高位是 1**(`0x8C… > 2¹²⁷`), REDC 的中间结果可能溢出 128 位,必须显式接住进位。 ```c typedef unsigned __int128 u128; /* 128×128 → 256 */ static void mul256(u128 a, u128 b, u128 *hi, u128 *lo) { u64 a0 = (u64)a, a1 = (u64)(a >> 64); u64 b0 = (u64)b, b1 = (u64)(b >> 64); u128 p0 = (u128)a0*b0, p1 = (u128)a0*b1; u128 p2 = (u128)a1*b0, p3 = (u128)a1*b1; u128 mid = p1 + p2; u64 midc = (mid < p1); u128 l = p0 + (mid << 64); u64 c = (l < p0); *lo = l; *hi = p3 + (mid >> 64) + ((u128)midc << 64) + c; } static inline u128 redc(u128 hi, u128 lo) { u128 m = lo * NP; /* mod 2^128 */ u128 mh, ml; mul256(m, N, &mh, &ml); u128 t = lo + ml; /* 必为 0 */ u64 c = (t < lo); u128 res = hi + mh; int ovf = (res < hi); /* ← N > 2^127 时会发生 */ res += c; if (res < c) ovf = 1; if (ovf || res >= N) res -= N; /* 借位在 u128 下自然回绕,结果正确 */ return res; } ``` 正确性依据:REDC 的真值 `< 2N < 2¹²⁹`。若发生溢出,真值 `= res + 2¹²⁸ > N`, 减一次 `N` 后 `= res + 2¹²⁸ − N < N < 2¹²⁸`,而 `res - N` 在 `u128` 下的回绕结果恰好等于它。 `gcc -O3 -march=native`,几十秒出结果: ``` p = 0xBD3D59FAC7CC547B (Miller-Rabin 验证为素数) q = 0xBE28EF96C8318203 p × q == N ✓ ``` 解密: ``` d = e⁻¹ mod (p−1)(q−1) X = 0x0F4439374E3C44372544164425151D1B ^ d mod N = 0x3B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` `X` 即输入的后 32 个字符。 --- ## 9. 组装与验证 把前 28 字节和解出的 16 字节拼起来,得到 RSA 覆写**之前**的缓冲区, 也就是 key 本身的字节表示: ``` 323C47184B0D3C44254B445842552F365C362C1144424B0D3C441643 3B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` ### 两个校验和自动成立 这两个值**全程没有参与求解**,是解完之后回代验证的: ``` XOR(buf) = 0x8F 二进制要求 0x8F ✓ Σ((s & 0x7F) + 1)·b & FFFF = 0xBEFF 二进制要求 0xBEFF ✓ ``` 这是整个过程里最有说服力的一步。它们不是约束条件,是**验算**—— 如果对 `perm`、对字节序、对覆写顺序中任何一处理解有偏差, 这两个独立的校验和不可能同时对上。 ### 两个可证伪的预测 分析还顺带推出两个副作用,都在真机上验证通过。能预测"哪些*错误*的 key 会成功", 比"正确的 key 会成功"更能说明模型是对的: ```console $ # G-Z 等价于十六进制 0,且能通过字符集检查 $ echo 323C47184BZD3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B | cm.exe verify success. $ # 解析器认识小写 a-f,但字符集检查只放行 [0-9A-Z] $ echo 323c47184b0d3c44254b445842552f365c362c1144424b0d3c4416433b0dd6b12a0d3d95fa65b5e0ade5e11b | cm.exe verify fail.retry it... ``` 因此严格来说 key 不唯一:所有值为 `0` 的十六进制位都可以替换成 `G`–`Z` 中任意字符, 共 21 种选择。纯十六进制形式是其中的规范解。 ### 稳定性 连续 30 次运行全部 `verify success.`,无抖动。结合 [§3](#3-第二层假注释与死代码) 中确认的"`GetTickCount` 返回值被全部丢弃",可以确定**不存在时间或环境依赖**。 --- ## 10. 完整脚本 除 `p`、`q` 两个因子来自离线分解外,其余参数全部在运行时从 `cm.exe` 现读, 不含任何硬编码中间值。 ```python #!/usr/bin/env python3 """KCTF 2026 - cm.exe keygen. Everything is recovered from the binary itself.""" import os, struct, subprocess # 与本脚本同目录下的 cm.exe(Windows 上 CreateProcess 不会从 cwd 解析裸文件名) EXE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cm.exe') d = open(EXE, 'rb').read() # ---- section mapping (ImageBase 0x400000) ------------------------------- SEC = [(0x401000, 0x4a80, 0x400), (0x406000, 0x252be, 0x5000), (0x42c000, 0xc200, 0x2a400), (0x439000, 0x1e0, 0x36600)] def off(va): for v, vs, fo in SEC: if v <= va < v + vs: return fo + (va - v) raise ValueError(hex(va)) def dw(va): return struct.unpack('<I', d[off(va):off(va) + 4])[0] def by(va): return d[off(va)] # ---- permuted container: elem(i) == data[perm[i]] ---------------------- def bytes_elem(data, perm, i): return by(data + dw(perm + 4 * i)) def dword_elem(data, perm, i): return dw(data + 4 * dw(perm + 4 * i)) # ---- 1. substitution table (struct @0x438030, patched by 0x4033D0) ------ enc = {} # ascii char -> required byte for b in range(1, 128): enc.setdefault(bytes_elem(0x4263b0, 0x4163b0, b - 1), b) WELCOME = d[off(0x416380):off(0x416380) + 44] assert WELCOME == b'Welcome to KCTF2026! Come and give it a try.' final = bytes(enc[c] for c in WELCOME) # 44 bytes, post-RSA # ---- 2. RSA params (struct @0x438018, patched inline by 0x403500) ------- N = sum(dword_elem(0x436018, 0x42e018, i) << (32 * i) for i in range(4)) E = dword_elem(0x430018, 0x434018, 0) assert (N, E) == (0x8c91cb79ec693f3ed6519c945dd67371, 65537) # ---- 3. factor N (Pollard-Brent rho, 128-bit Montgomery) ---------------- p, q = 0xbd3d59fac7cc547b, 0xbe28ef96c8318203 assert p * q == N def is_prime(n): s, dd = 0, n - 1 while dd % 2 == 0: s, dd = s + 1, dd // 2 for a in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]: x = pow(a, dd, n) if x in (1, n - 1): continue for _ in range(s - 1): x = x * x % n if x == n - 1: break else: return False return True assert is_prime(p) and is_prime(q) # ---- 4. invert the RSA stage ------------------------------------------- dkey = pow(E, -1, (p - 1) * (q - 1)) cipher = int.from_bytes(final[28:], 'big') plain = pow(cipher, dkey, N) assert pow(plain, E, N) == cipher buf = final[:28] + plain.to_bytes(16, 'big') # pre-RSA 44 bytes # ---- 5. the binary's two checksums must fall out for free -------------- x = 0 for b in buf: x ^= b s = 0 for b in buf: s = (s + ((s & 0x7f) + 1) * b) & 0xFFFFFFFF assert x == 0x8f, 'XOR != 0x8F' assert s & 0xFFFF == 0xbeff, 'SUM != 0xBEFF' KEY = buf.hex().upper() assert len(KEY) == 88 print('N =', hex(N)) print('e =', E) print('p =', hex(p)) print('q =', hex(q)) print('KEY =', KEY) out = subprocess.run([EXE], input=KEY + '\n', capture_output=True, text=True).stdout print('cm.exe =>', out.strip().splitlines()[-1]) ``` 输出: ``` N = 0x8c91cb79ec693f3ed6519c945dd67371 e = 65537 p = 0xbd3d59fac7cc547b q = 0xbe28ef96c8318203 KEY = 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B cm.exe => verify success. ``` --- ## 11. 复盘:这题在防谁 cm.exe 的算法强度其实很有限:一张固定查表、两个线性校验和、一个 128 位 RSA, 每一项单拎出来都不难。它的难度**几乎全部押在误导上**,而且分层很清楚: | 手段 | 防的是 | 破解成本 | |---|---|---| | 花指令 | 线性反汇编 | 低——换递归下降即可 | | 假注释 / 假字符串 | "先 strings 再定位"的人工流程;把观察到的文本当可信指令的自动化分析 | 低——但会浪费大量时间 | | 假计时器 / 假异常处理 | 动态跟踪时的注意力 | 低——看返回值流向即可排除 | | **置换存储** | **静态 dump 数据段** | **高——唯一真正拖慢进度的设计** | | 假容器(先 malloc 再覆盖) | 调试器里的第一现场 | 中 | | 逐字节拼指针 | 静态交叉引用识别 | 中 | **最省力的判据是:跟着数据流走,不跟着叙述走。** 任何字符串、注释、 看起来很关键的 API 调用,只要它的结果没有流向最终比较,就可以直接划掉。 反过来,这题最值得学的设计是**置换存储**:它不增加算法复杂度, 不引入任何反调试,却让"dump 数据段找常量"这个最常用的静态手段彻底失效。 `N` 就明明白白躺在 `.data` 里,但你必须先读懂 `operator[]` 才能把它拼出来。 --- ## 附录 A:关键地址速查 | 地址 | 作用 | |---|---| | `0x401160` | xorshift32:`x ^= x<<13; x ^= x>>17; x ^= x<<5` | | `0x4011A0` | `rand(n)`:在 xorshift32 上做拒绝采样,消除取模偏差 | | `0x402100` | 字符串 → 大数(radix 16,非法字符按 0) | | `0x4022D0` | 大数 → 大写十六进制字符串 | | `0x402510` | 模幂 `powmod(base, exp, mod)` | | `0x402A70` | `Vec::operator[]`(dword,`data + 4*perm[i]`) | | `0x402BD0` | 容器构造函数(capacity `0x800`,seed `0xCAFEBABE`) | | `0x402D70` | `return perm[i]` | | `0x402DA0` | Fisher-Yates 洗牌 `perm[]` | | `0x402EA0` | `std::string::assign` | | `0x4033A0` | `printf` 包装 | | `0x4033D0` | 把 `0x438030` 的 20 字节静态结构体拷进目标(先打补丁) | | `0x403500` | RSA 阶段:取 `input+56`,做 `X^e mod N`,返回十六进制串 | | `0x403C60` | **诱饵**:对三条假注释和输入求和,返回值被丢弃 | | `0x403D90` | `main` | | `0x404870` | `ByteVec::operator[]`(byte,`data + perm[i]`) | | `0x406268` / `0x4062C8` / `0x406320` | 三条假注释(被解析成三个未使用的大数) | | `0x4061B4` | `"0123456789ABCDEF"` | | `0x416380` | `"Welcome to KCTF2026! Come and give it a try."`(44 字符) | | `0x4163B0` / `0x4263B0` | 查表的 `perm[]` / `data[]` | | `0x438018` | RSA 对象暂存区(24 字节,运行时打补丁) | | `0x438030` | 查表容器暂存区(20 字节,运行时打补丁) | | `0x42C018` … `0x436018` | 6 个 `0x2000` 字节数组,两两配对成 (data, perm) | --- ## 附录 B:环境与工具 | 工具 | 版本 | 用途 | |---|---|---| | capstone | 5.0.7 | 递归下降反汇编,绕开花指令 | | gcc (MinGW-W64 UCRT) | 13.2.0 | Pollard-Brent + 128 位 Montgomery | | Python | 3.14.2 | 参数提取、反推、keygen | | objdump / strings | binutils | 初步侦察 | 配套文件: - `rec.py` —— 递归下降反汇编器 - `rho.c` —— 128 位 Montgomery 的 Pollard-Brent 分解器 - `solve.py` —— 完整 keygen,从 `cm.exe` 现读参数并自检 --- **序列号** ``` 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B ```
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
mb_czvsfrqr
雪 币:
3
活跃值:
(225)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
24
粉丝
0
关注
私信
mb_czvsfrqr
2
楼
大佬我我这边有很多棋牌只有我希望可以认识你合作共赢共同发展 看到回复越秀区真心求合作一天上百个w
2026-8-21 23:43
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
correy
4
69
发帖
150
回帖
415
RANK
关注
私信
他的文章
[分享]2026 KCTF 第十题「卯时·曦光初现」(Writeup · Pwn)
1607
[分享]第九题:丑寅同墟·星海抉择
38
[分享]kctf2026_CrackMe08 题解
38
[分享]KCTF 2026 · cm.exe 逆向分析
34
[原创]看雪·2026 KCTF 第二题:巳时·绿光幽语
21
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部