一个把"误导"当作主要防线的 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) |
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!