32 位 PE,但真正的逻辑经 Heaven's Gate 跑在 x64 长模式,代码藏在可执行的 .data 段里,
外面裹着 jmp 链、计算跳转、常量掩码,特权指令当 VM opcode 用(in/out 触发 #GP,
int3 触发 VEH,由 exe 导出的 Warning 函数统一分发)。
注册码校验是五层套娃:

三个卡点,都不在算法本身:
main 的骨架一屏可见 —— 五次调用全部经同一个trampoline函数 sub_4010E0,
第 4 个参数是 x64 函数指针:

图 1 main 全貌。红框 ①~⑤ 就是五层套娃。
注意 ⑤ 之前那一大段 mov eax, offset unk_4D40A0 / unk_7A4448 / unk_4240A0 + mov [ebp+var_28], 0Bh ——
把三张表的指针和常数 11打包成一个结构体传给 GoodGoodStudy,
这三张表分别是 CSR 的 data、VM 字节码、CSR 的 row_ptr,11 = 多项式系数个数(10 次首一多项式)。
另外 ② 和 ④ 后面各跟一个 test eax,eax / jz loc_4013DA —— 两个提前失败出口,
后面会看到它们其实不是"校验函数",而是伪装成函数的异常陷阱。
序幕就是一发 anti-VM,而且它同时承担了初始化职责:

*图 2 in eax, dx 在 ring3 必定 #GP。真机走 SEH → int 3 → VEH,
VEH 里那次隐藏初始化把 .data 段建成可执行、注册 Warning 处理器;
而在 VMware 里 in 不 fault,直接 jmp 跳过 int3,初始化不做,后面全是 decoy。
trampoline 本体只有 10 条指令:

图 3 Heaven's Gate。push 0x33 压的是 64 位代码段选择子,
call $+5 + add [esp],5 + retf 把返回地址改成下一条指令再远返回,于是 CPU 切到长模式继续执行。
切过去之后是 mov rcx/rdx/r8 ← 前三个参数、call r9 ← 第四个参数(x64 函数指针)。
所以 sub_4010E0(a,b,c,fn) 的语义就是 fn(a,b,c),只不过 fn 跑在 64 位。
最终判定:

图 4 GoodGoodStudy 的返回值在 esi,非 0 才 cmovnz 选中 Successful!。
没有任何花活,说明*真正的校验全在 ⑤ 里。*
serial 去掉头 lI|0O 和尾 Il1|! 后,payload 每 4 字符解成 3 字节:
它的输出是白噪声,不是明文(熵 7.977 bit/byte,256/256 个字节值都出现)。
我一度以为解码结果就该是整数文本,被这一步误导了很久 —— 真正的明文在 ③ 之后。
unk_7A6920 和 unk_8386B4 各自只有 2 个字节:
in/out 在 ring3 触发 #GP(STATUS_PRIVILEGED_INSTRUCTION),
由 64 位 VEH Warning(exe 的导出函数,VA 0x81139C)按触发指令的 opcode 字节分发:
处理完把结果写进 CONTEXT.Rax、CONTEXT.Rip += 1(跳过那 1 字节),
返回 EXCEPTION_CONTINUE_EXECUTION,于是落到 ret —— 从调用方看就像一个普通函数返回了值。
这也是反调试的核心:挂调试器时 int3 被调试器先吃掉,Warning 根本没机会注册,
后面所有陷阱都会变成未处理异常。
这一层是全题的重心,也是我误判最多的地方(早期在 decoy 路径下观测,一度以为它是 no-op)。
把 Warning 的 0xEC 计算跳转实现出来之后,它才真正跑起来 —— 从 ~80 条指令暴涨到数百万条、
118 次模式切换。去混淆后的结构:
结论:MengXin ≡ AES-128 解密,只是 S 盒换成了自定义的 256 置换,
标准的 InvShiftRows / InvMixColumns 一个没改。
所以我们要的加密方向就是对应的 AES-128 加密。
主密钥来自 main 栈上的 var_54(ebp-0x54,16 字节),
它在 scanf 读输入之前就由 sub_401160 设好,跨运行固定:
S 盒依赖 BeingDebugged,这点极其隐蔽:
0x40 是 Warning 加的,+0x1A 由后面另一处再加、凑成 0x5A。
MengXin 执行时还是 0x40,3.9 秒后才变 0x5A(那是 ⑤ 的取值)。
用错值提出来的 S 盒和正确的一个字节都不重合(0/256)。

图 5 0x808D19 处的 95 E9 D1 5B 即 0x5BD1E995。
这段代码在 IDA 里是未定义字节(合成 PE32+ 里 .data 没被自动分析成代码,
何况它被 jmp 链彻底打散),所以这里用 Hex View 给常量本身当证据,
控制流靠自写的去混淆追踪器还原。
拿到 100 个 14-bit 组号后,每组去 CSR 表取 11 个系数,拼成 10 次首一多项式,
把解析出的整数代回去做 Horner 求值,要求 P(x) == 0:

图 6 row_ptr 紧跟在 Successful! 字符串之后。每项 (offset & 0xFFFFFF) | (count << 24),
共 11×16384 项。全表 180224 项里 count == 0 的一个都没有。

图 7 系数本体。看上去完全随机 —— 因为每个字节都被
mixbyte(idx*0x9E3779B9 ^ row*0x517CC1B7 ^ i*0x85EBCA6B) 异或过。
解密后才是「符号 + 位数 + 压缩 BCD」。这正是题面"路线矩阵"伪装成随机矩阵的地方。
解析规则:- 分隔,空格/换行忽略,NUL 结束,必须恰好 1000 个整数;
第 k 组用 nums[10k..10k+9]。它不检查升序、不检查组内互异,比的是数值不是文本。
反解方向很轻松:多项式已知且首一,直接对每组求整数根即可,不需要任何搜索。
Warning 里那条 BeingDebugged += 0x40 挂在一串检查后面,决定性的一环是:

图 8 0x8136A1 处小端 F1 37 13 9D。我按 djb2(seed 0xF13093F0、乘数 33、tolower)
正向算了一遍:hash33("explorer.exe") == 0x9D1337F1、hash33("powershell.exe") == 0x8EED4105,
两个都对得上,确认了这是父进程名白名单。
Readme 里那句「运行方式:双击运行」不是废话,是硬约束。
这解释了我前期所有的"诡异现象":从 python/PowerShell 用 subprocess 拉起 CrackMe 时,
父进程不在白名单 → BeingDebugged 只到 0x1A → VM 字节码解密成垃圾 →
连作者公布的合法 Name/Serial 都显示 Failed。我曾因此怀疑样例损坏、怀疑 VBS、
甚至关掉 Hyper-V 重启机器,全是白费。
对应的本地验证器:用 explorer.exe 启动,AttachConsole + WriteConsoleInput 注入,
再读屏拿结论(verify_via_explorer.py)。这样验证不消耗看雪的提交次数。
pev2(自写的模式感知静态部分求值器)能完整跑通 MengXin,但它的 key schedule 依赖
ntdll 导出遍历,算出的轮密钥和真机不同。而真机的轮密钥是固定的,于是两边互补:
拼出来的干净实现一次通过全部硬验证:
最强的一次验证 —— 用完整链条逐字符重现作者公开的 Serial:
这一步把"每一环都对"钉死了,后面出问题就只可能是 KCTF 特有的东西。
GoodGoodStudy 会检查完全部 100 组(不提前返回),某组失败时内层 break 会少算几个数,
所以耗时本身就是"通过了多少组"的探针。测「MengXin 完成 → 出结论」这一段:
三档分离得很干净,一下就把锅从 ⑤ 摘到了 ④。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 1天前
被星野安全编辑
,原因: