首页
社区
课程
招聘
[分享]KCTF 2026 · cm.exe 逆向分析
发表于: 2026-8-17 13:22 43

[分享]KCTF 2026 · cm.exe 逆向分析

2026-8-17 13:22
43

一个把"误导"当作主要防线的 crackme。假注释、花指令、假容器、假计时器层层堆叠,
而真正的算法只有三件事:一张双射查表、两个校验和、一次 128 位 RSA。

序列号

验证逻辑(剔除全部诱饵后):

由于第 6 步的表是双射,目标字符串唯一确定了那 44 个字节;再反解 RSA 即可得到唯一的 key。
第 3、4 步的两个校验和无需参与求解——解出 key 之后回代,它们自动成立。

难点不在算法,在于三层遮蔽:花指令让线性反汇编错位;假注释把分析引向不存在的 strcmp;
置换存储让数据段的静态 dump 完全失真。

节表:

注意 .text 只有 19 KB,而 .rdata 有 152 KB —— 代码很少,数据很多。这个比例本身就提示
"核心是查表"。

残留的 PDB 路径是全程唯一一条没有说谎的提示:

rsa_tf 点出了 RSA。

.text 通篇是同一个模式——一条短跳转越过若干合法但不可达的字节:

至少有三种变体交替出现:

objdump -d 会忠实地把这些垃圾字节解码成指令,导致后续几条真实指令的边界全错。

不需要去花指令,只要换一种遍历方式:从函数入口开始,遇到无条件 jmp 跟着立即数走,
遇到条件跳转把目标压栈,遇到 ret 停止。垃圾字节因为不可达,自然不会被解码。

约 40 行。main 从 6975 行噪声收敛到 609 条真实指令,之后的分析全部基于这份输出。

.rdata 里躺着三条措辞极其可信的"开发者备注":

三条全是假的。0x401234 甚至不在任何函数边界上;程序里不存在对 admin123 的 strcmp;
唯一带 __try/__except 的函数返回值被丢弃。

真正精巧的地方:这三条备注本身被当作数据消费。它们被送进 0x402100
的十六进制解析器转成三个大整数,而这三个大整数从头到尾没有被读过一次,
最后在 main 尾部被直接释放。诱饵套着诱饵。

确认这些只需要看结果流向哪里:

三条各花不到一分钟确认,却砍掉了大半个攻击面。

这一层针对的是谁

把权威口吻的注释埋进 .rdata,成本极低,却精准打击两类分析:一是习惯先 strings
再定位的人工流程,二是会把工具输出当作可信指令的自动化分析。正确的姿势是把二进制里
读到的一切都当作数据而不是指令——它们只描述作者想让你相信什么,
不描述程序做什么。

main 位于 0x403D90。剔除全部诱饵后,验证链只剩九步:

加粗的五步会直接跳到 verify fail。

关键在于它不跳过非十六进制字符,而是把它们当成数字 0,同时照常执行 ×16。
所以 G–Z 在数值上完全等价于 0,却仍然能通过第 3 步的字符集检查。
这留下了一个可证伪的预测,见 §9。

size 是有效 limb 数。size*8 == 88 ⟹ size == 11 limb ⟹ 352 位 ⟹ 88 位十六进制。

一个状态相关的非线性累加,s 的低 7 位参与下一项的权重。注意最终只比较低 16 位。

一个隐藏的坑:movsx 是符号扩展。若 buf[i] >= 0x80,idx 变成负数,
按无符号解释后远大于 size,operator[] 会退化成返回 data[0]。
答案里所有字节都 ≤ 95,不触发这条路径,但爆破时很容易在这里踩空。

这是本题真正的机关。大数类和查表类共用一个容器,构造函数(0x402BD0)暴露了全部设计:

洗牌用的随机源是 xorshift32(0x401160),外层 rand(n)(0x4011A0)
用拒绝采样消除取模偏差。不过对求解而言这些都不重要——真正用到的两张
perm[] 是静态烘焙在文件里的
,静态对象绕过了构造函数,洗牌从未对它们执行过。

还原成 C:

也就是说,内存里看到的 limb 顺序和逻辑顺序完全无关。直接 dump .data
只会得到一片乱序噪声。我第一次 dump 那几个数组时看到的是:

一堆全部小于 0x800 的小数值——当时以为是垃圾,其实那正是 perm[] 本身。

更狠的是,RSA 参数和查表都不走构造函数,而是把结构体逐字节拼进 .data
的一块暂存区,再整体拷进目标对象。逐字节写指针可以躲开静态分析工具的交叉引用识别:

拼装完成后的两个 RSA 对象:

.data 里一共 6 个 0x2000 字节的数组,两两配对成 (data, perm)。

main 在使用查表前先 malloc 了一个 size=1 的假容器,再整体覆盖:

最后那次还原是必须的——否则 free 会拿到 .rdata 里的静态地址而崩溃。
作者把这个细节处理得很干净。

0x4033D0 拷进来的 20 字节(同样先逐字节打补丁)就是真正的查表容器:

注意这个容器存的是字节,所以取值公式不乘 4:

把 i = 0…126 全部展开(table[i] = data[perm[i]]):

字节 1..95 与 95 个可打印 ASCII 字符构成双射(idx = 字节值 - 1),
96..127 全部映射到 \0。

既然是双射,目标字符串就唯一确定了那 44 个字节。把
Welcome to KCTF2026! Come and give it a try.(正好 44 字符)逐字符反查:

一个需要确认的边界条件:第 8 步写回的字节数是 BN.size * 4。
若 RSA 结果的最高 limb 为 0(size < 4),写回的就不是 16 字节,
后续拷贝会发生错位。这里密文最高字节是 0x0F ≠ 0 ⟹ 最高 limb
0x0F443937 ≠ 0 ⟹ size == 4 ⟹ 恰好 16 字节,不触发错位。

必须按 data[perm[i]] 取,顺序读取会得到完全错误的模数:

模幂在 0x402510,调用形式 powmod(this = 输入, exp = e, mod = N);
结果经 0x4022D0 转成大写十六进制字符串(字符表 "0123456789ABCDEF" 在 0x4061B4),
再被 main 重新解析成大数——一次无意义的往返,但不影响数值。

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 位,必须显式接住进位。

正确性依据:REDC 的真值 < 2N < 2¹²⁹。若发生溢出,真值 = res + 2¹²⁸ > N,
减一次 N 后 = res + 2¹²⁸ − N < N < 2¹²⁸,而 res - N 在 u128
下的回绕结果恰好等于它。

gcc -O3 -march=native,几十秒出结果:

解密:

X 即输入的后 32 个字符。

把前 28 字节和解出的 16 字节拼起来,得到 RSA 覆写之前的缓冲区,
也就是 key 本身的字节表示:

这两个值全程没有参与求解,是解完之后回代验证的:

这是整个过程里最有说服力的一步。它们不是约束条件,是验算——
如果对 perm、对字节序、对覆写顺序中任何一处理解有偏差,
这两个独立的校验和不可能同时对上。

分析还顺带推出两个副作用,都在真机上验证通过。能预测"哪些错误的 key 会成功",
比"正确的 key 会成功"更能说明模型是对的:

因此严格来说 key 不唯一:所有值为 0 的十六进制位都可以替换成 G–Z 中任意字符,
共 21 种选择。纯十六进制形式是其中的规范解。

连续 30 次运行全部 verify success.,无抖动。结合 §3
中确认的"GetTickCount 返回值被全部丢弃",可以确定不存在时间或环境依赖。

除 p、q 两个因子来自离线分解外,其余参数全部在运行时从 cm.exe 现读,
不含任何硬编码中间值。

输出:

cm.exe 的算法强度其实很有限:一张固定查表、两个线性校验和、一个 128 位 RSA,
每一项单拎出来都不难。它的难度几乎全部押在误导上,而且分层很清楚:

最省力的判据是:跟着数据流走,不跟着叙述走。 任何字符串、注释、
看起来很关键的 API 调用,只要它的结果没有流向最终比较,就可以直接划掉。

反过来,这题最值得学的设计是置换存储:它不增加算法复杂度,
不引入任何反调试,却让"dump 数据段找常量"这个最常用的静态手段彻底失效。
N 就明明白白躺在 .data 里,但你必须先读懂 operator[] 才能把它拼出来。

配套文件:

序列号

项 值
文件 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
位置 看上去是什么 真相
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 的目标
# 步骤 位置 说明
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! …")
偏移 字段 模数 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
手段 防的是 破解成本
花指令 线性反汇编 低——换递归下降即可
假注释 / 假字符串 "先 strings 再定位"的人工流程;把观察到的文本当可信指令的自动化分析 低——但会浪费大量时间
假计时器 / 假异常处理 动态跟踪时的注意力 低——看返回值流向即可排除
置换存储 静态 dump 数据段 高——唯一真正拖慢进度的设计
假容器(先 malloc 再覆盖) 调试器里的第一现场 中
逐字节拼指针 静态交叉引用识别 中
地址 作用
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)

冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
点赞 ・6
打赏
分享
最新回复 (1)
雪    币: 3
活跃值: (245)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2 楼
大佬我我这边有很多棋牌只有我希望可以认识你合作共赢共同发展 看到回复越秀区真心求合作一天上百个w 
2026-8-21 23:43
0
游客
登录 | 注册 方可回帖
返回