题面公开样本使用 Name = KCTF 和一个 9226 字符的 Serial;程序本身并未硬编码 KCTF,而是从 Name 动态派生 100 个 Final state。完整校验链可以整理成四层:
原始附件 SHA256 为:
338f493766cfc94b6b075138f37cead0a2516d61fc983960c169b7ddf62be760
附件是 PE32 控制台程序,但主体会从 x86 入口切换到 x64 代码,并通过 VEH 处理大量故意触发的异常。几个关键入口为:
公开的 Name/Serial 是整条链最重要的回归样本。先把公开 Serial 解到 Stage1 payload,再在原生 Windows 进程中观察 Stage3 后的明文,便得到已知输入、已知输出的真实 oracle。后续每个候选模型都先过公开样本,最后才用于 KCTF。
分析过程中曾得到一个在 Wine、Unicorn 和自建模型内正反向自洽的候选,Serial SHA256 为 9b00d7506b8f1cc3c87f229bfadbfe799bf0a88ea47a853ccd8aff428cfa9473。它在公开回归中有 6871 个字节不同,432 个块没有任何一块完全匹配,因此被直接废弃。后续确认旧模型恰好复现了未允许父进程分支的 Stage3 输出,而不是比赛公开样本所处的允许分支。这一步确定了证据优先级:原版 Windows 的 Successful! 最高,其次是原生边界捕获,然后才是离线模型内部自洽。
完整求解方向是从两端向中间推进:
Serial 的结构固定为:
64 字符表为:
Il1|!ijJL`oO0QDSs5$Zz2B8gq96nNmMWwUuVvRrPpCc({tT+7xXKkYyAa4Ee3FH
正文第 i 个字符先映射为字符表下标 a_i,解码用:
v_i = (a_i + 51 + 27*i) mod 64
随后按高位优先把 9216 个 6-bit 值打包为 6912 字节。逆向生成 Serial 时先把目标 payload 拆成 6-bit 值,再计算:
a_i = (v_i - 51 - 27*i) mod 64
这层对 6912 字节 payload 是一一映射,任意 payload 都对应唯一的 9226 字符 Serial。生成结果只使用 64 种可见字符,ASCII 最小值 33、最大值 124,满足题目的 [33,126] 限制。
恢复过程从公开 Serial 的固定首尾开始。去掉各 5 字符后,正文长度恰为 9216,而且所有字符只落入同一张 64 字符表。反汇编中的循环先查表得到下标,再对位置 i 加上线性项。对多个位置记录循环前后的下标,差值依次增加 27,常数项为 51,于是得到上面的仿射式。
每四个 6-bit 值生成三个字节:
这里 9216*6 = 6912*8,没有 Base64 尾部填充,也没有残余位。用公开 Serial 正向解码得到的 payload 长度为 6912,SHA256 为:
c0cbf123af4af396ba69f36148c284b4117f84a5d9aa05d18be076d086233616
它的第一个 16 字节块为 1f658f979b46e33a2ed346f8c6ee799a。这个块后来用于验证 Stage3:真实输出必须是 ASCII 40243-231740-361,即十六进制 34303234332d3233313734302d333631。
程序混用了 x86、x64 Heaven's Gate、VEH 和 IN/OUT 异常门。Stage2 的主体可以概括为 IN EAX,DX; RET 与 OUT DX,AL; RET 触发异常,再由 VEH 修改上下文继续执行。它主要负责控制流混淆,不改变后续数学约束。
x86 到 x64 的桥位于 0x4010E0,x64 VEH 位于 0x8113A0。异常发生后,handler 根据 fault site、寄存器和私有上下文决定下一段代码地址。真实运行中还观察到 R10 XOR R11 = 0x8113A0 一类与 VEH 地址相关的稳定关系,因此把异常入口的易失寄存器简单清零会改变调度。旧模拟器正是在这里与原版发生偏差。
这也解释了常规调试很难稳定复现:断点和线程上下文修改会扰动一个本来就依赖异常与上下文的状态机。动态阶段最初采用两个低扰动方法:
后续控制实验确认标准输入管道本身不会改变结果。同一个 Python 解释器副本命名为 cmd.exe,直接用 PIPE 向子进程写入公开 Name/Serial,仍然输出 Successful!。因此 console 保留方案只是早期降低变量数量的观测手段,不能解释 cmd 与 pwsh 的分歧。
最初的现象是:双击、cmd.exe 和 Windows PowerShell 5.1 可以通过公开样本,而 PowerShell 7 的 pwsh.exe 会失败。先做输入侧控制变量后可以排除粘贴截断、代码页、console/PIPE、工作目录和环境变量:允许父进程下使用普通 PIPE 也能稳定 Successful!,而失败实例的 Stage1 仍解出同一份 6912 字节 payload。因此首次真实分歧位于 Stage3 的隐藏 schedule 输入。
允许环境中的 176 字节 schedule SHA256 为:
dd7f46c38963ee96ae140070eb10d3e4b8ee8a79cf7aefe151af6529476c975f
未允许环境中的 schedule SHA256 为:
ae02ce0b9325225b86908ca5343ed73b1acd7f0b2d571725550544aa1171e3e9
第 0 条 16 字节记录相同,第 1 条立即出现整块 0x40 差异:
父子进程置换确认只看 CrackMe.exe 的直接创建者 。例如 pwsh -> cmd -> CrackMe 成功,cmd -> pwsh -> CrackMe 失败。随后使用完全相同字节的父进程副本,只改变文件名,得到:
这只能说明三个预期名字会命中,不能推出字符串精确比较 。真正实现是大小写不敏感的 32-bit hash 白名单。
当前 Windows 环境中,目标通过私有 syscall stub 调用 NtQuerySystemInformation(SystemExtendedProcessInformation);信息类为 0x39。第一次 64 KiB 查询用于得到所需长度,随后扩容重试。最终缓冲区中可以直接解析出当前 CrackMe.exe 记录、InheritedFromUniqueProcessId 和对应的直接父记录。
白名单输入究竟来自哪里,不能靠“晚改内存没变化”判断。最终采用的因果实验是在第二次进程表查询已经从内核返回、但 caller 尚未执行返回后的第一条指令 时冻结线程,然后只改父记录的 ImageName backing buffer:
所以 basename 的数据源就是最终 SYSTEM_EXTENDED_PROCESS_INFORMATION 中直接父记录的 ImageName 。此前在程序已经消费名字之后再修改原记录,会得到“改了也没用”的假阴性。
对这个 basename 的实际变换为:
实机与静态立即数完全闭合:
原 PE 中三个允许值各只作为一条真实 cmp edx, imm32 出现:
动态 reachability 给出的顺序正是 powershell -> cmd -> explorer -> reject。hash 的 live 实现也逐步闭合:0x81F1DD 装入 seed 0xF13093F0;字符入口能直接看到父 ImageName 当前 UTF-16 字符;CMD.EXE 运行时在字符入口读到 'C',到后续算术块前已经归一化成 'c'。首轮更新的关键指令为:
寄存器现场与公式逐项一致。
因此它不是“大小写不敏感的精确 basename 白名单”。最直接的反例是构造 hash 碰撞:
把同一份父启动器字节复制为 ljfuxk.exe 后,真实文件名完全不同,原附件公开样本仍然 Successful!。这证明 gate 的语义是32-bit hash 白名单 ,理论上允许碰撞 basename。
hash 命中之后,程序才进入另一层“真实父进程映像”检查:
真分支入口静态 VA 0x83132F 的现场 RAX=0x5A4D,正是刚验证的 MZ。随后代码执行:
也就是说父 ImageName hash 决定是否进入真实性验证,父映像验证通过后才把当前进程 native PEB +2(通常称 BeingDebugged 字节)加上 0x40,把它当作题目自己的隐藏状态。CREATE_SUSPENDED 时两条路径该字节都为 0x00,32 位 PEB 的同名字段也始终为零,因此这不是 Windows 自动设置的兼容标志。
这个字节随后进入 Stage3 schedule:强制把拒绝路径的 native PEB +2 从 0x00 改为 0x40,公开样本会翻成 Successful!;反向把允许路径清零则变为 Failed!。因此稳定后台验证只需要保证直接父进程的 basename hash 命中,例如使用 cmd.exe 直接创建目标。Windows PowerShell 5.1 成功而 PowerShell 7 失败,是因为前者 basename 为 powershell.exe,后者为 pwsh.exe。
Stage3 以 ECB 形式处理 432 个 16 字节块。对十组 chosen-input 共 4320 个块做原生边界捕获后,模型与程序输出逐字节完全一致。
只用一组公开输入输出不足以恢复白盒映射,因此构造了完整的单字节真值表。对每个输入位置 p=0..15 和每个字节值 v=0..255,建立仅有该位置为 v、其余位置为零的 16 字节块,共 4096 个。每次程序能处理 432 个块,于是分成 10 次运行:每次放 428 个测试块,末尾再放 4 个由 SHA256 派生的 marker 块,用于确认捕获的 work buffer、块编号和运行批次没有串位。剩余 184 个槽填入固定随机种子的 dense block,专门做独立回归。
观察器先用公开 Stage1 payload 的 32~64 字节片段扫描可读内存,锁定 6912 字节工作区;随后在 Stage3 的 NtGetContextThread 边界高频读取当前块和上下文区。重复输入在不同进程中产生相同输出,任意块的变化也不会影响相邻块,从而确认它是固定的 16 字节置换,432 个块之间没有链式状态。
chosen-input 的作用不只是黑盒查表。设全零输入在某一轮边界的状态为 B,单字节输入表为 T_p(v)。实际捕获满足:
F(x_0,...,x_15) = B XOR xor(p=0..15)(T_p(x_p) XOR B)
所有 dense block 都精确命中这个叠加式。这说明边界之前的非线性按字节独立,之后接一层 GF(2) 线性扩散,可以继续把 64 KiB 真值表压缩为 S-box、密钥和线性层。
把 16 字节状态看作 4×4 的行优先矩阵。定义:
L(x) = InvMixColumns(InvShiftRows(x))
其中 MixColumns 使用 AES 的 GF(2^8) 多项式 0x11B。自定义 S-box 在 Exp 中完整给出。
对单字节差分统计输出支撑集,每个输入字节恰好扩散到一列的四个字节,四个系数落在 AES InvMixColumns 的 (0e,09,0d,0b) 循环排列中。再对捕获差分应用候选 L^-1,每个差分都收缩为一个字节。16 个位置的落点给出严格的 4×4 转置,而不是近似的 AES 布局猜测。
首层存在一个外部输入转置。输入位置 p 对应内部位置:
q = (p mod 4)*4 + floor(p/4)
令外部等效密钥为:
k_in = 0902ceb5593c04834174cab1171b4ee9
首层基准常量为:
B = 0c90f0b16813bc8864c5cebe2dcfbc2f
则首层可以直接写成:
u[q] = S(input[p] XOR k_in[p]) XOR S(k_in[p])
state_0 = B XOR L(u)
这个式子来自首轮 16×256 chosen-input oracle。对每个 oracle 差分应用 L^-1 后,结果只剩转置位置上的一个字节,并且精确符合 S(x XOR k) XOR S(k)。因此不需要在 Exp 中携带 64 KiB oracle。
全零输入的首层边界状态就是 B=0c90f0b16813bc8864c5cebe2dcfbc2f。逐位置枚举 k,要求 256 个输入值同时满足 S(v XOR k) XOR S(k),每个位置都只有一个解,拼出 0902ceb5593c04834174cab1171b4ee9。首层的 forward 与 inverse 因而都可以直接写成 16 次 S-box 和一次线性层。
未允许父进程分支中还能观察到下面这套 canonical round-key 序列:
这组数值来自 pwsh.exe、Python 等未允许父进程选择的 Stage3 实例。其 176 字节 schedule 的末条记录按 dword 反转后正是 R0=c01bf3ba...。允许父进程实例的末条记录按同样方式处理后得到 k_in=0902ceb5...。两者首先是不同父进程 gate 选择的两套运行实例,随后才各自存在内部边界、外编码和状态转置。早期把差异全部归因于表示边界,会让旧的 synthetic 模型在自身内部保持漂亮自洽,却无法通过公开样本。
中间八轮为:
state_(r+1) = L(S(state_r XOR E_r)) XOR 40...40
程序在上下文区生成一段 11×16 字节 schedule。把连续 NtGetContextThread 边界的状态按寄存器中的轮计数对齐,再用 L^-1 和逆 S-box 逐轮消去,得到八个当前 Windows 实例的等效轮密钥:
每一轮在线性层之后还带有 0x40 重复 16 次的仿射常量。把它漏掉时,单轮差分仍会看似正确,但多轮绝对值会从第一轮开始整体偏移;加入后,所有捕获状态闭合。
末轮为逐字节 S-box、固定输出置换和常量异或:
output[perm[p]] = S(state[p] XOR Kf[p]) XOR Cf[perm[p]]
其中:
末轮没有 MixColumns。用单字节扰动检查输出位置,每个内部字节只改变一个输出字节,由此唯一确定 perm;随后枚举 S-box 输入偏移,得到末轮等效密钥和输出常量。
逆变换按下面的顺序执行:
中间轮按 E8 到 E1 逆序执行。完整表模型先对十次原生运行的 4320 个块做回归,结果为 4320/4320 精确命中;压缩后的公式模型又与完整表模型比较 1000 个随机块,forward 和 inverse 全部一致。至此可以从任意 6912 字节 Stage4 目标,逐块反推出唯一的 Stage1 payload。
Stage4 解析 1000 个十进制整数,使用 999 个 - 分隔。每 10 个数属于同一个 Final state。当前附件对每组执行严格递增检查;组与组之间重新开始,因此第二组可以小于第一组末项。
这一层最初通过公开样本的 Stage3 输出识别:work buffer 的开头直接出现 40243-231740-361-...,说明白盒输出并非摘要,而是后续解析器的 ASCII 输入。跟踪字段提交点可以看到索引从 0 增至 999;每提交十项,保存的上一项被重置。最终返回条件可以化简为:
字段只含十进制数字,前导零参与文本长度但不改变整数值。每组十项要求 x[10g+i] < x[10g+i+1],没有跨组比较。
控制变量实验得到精确长度边界:
在有效文本后的 NUL 区域改填 0xFF 也会失败,因此 6912 字节剩余部分保持为零。
边界不是由猜测得出,而是固定同一组 1000 个合法整数,只改变前导零总数并逐个测试。规范根文本长 6891:加入 0~4 个零时仍不足 6896,失败;加入 5~20 个零时依次覆盖 6896~6911,全部成功;加入 21 个零得到 6912 字节且没有 NUL,失败。交换同组前两个根会单独触发顺序失败。由这些实验可分别确定最小长度、终止条件、尾部清零和组内严格递增四条约束。
KCTF 在 Final 入口生成 100 个 14-bit state。每个 state 对应 11 个系数记录。描述符表位于 VA 0x4240A0,数据块位于 VA 0x4D40A0。描述符高 8 位是长度,低 24 位是数据块偏移。
Final 的动态路径规模很大。一次只采样前半段就得到约 80 万个 PC 样本和 1783 个不同地址,主要代码落在 0x77A000~0x791000,另有外部分发区。直接按跳转顺序抄 VM 会产生大量与数学语义无关的 trampoline,因此先跟踪 64 字节大整数对象的分配、复制和乘法关系。
以第一状态 s=11467 为例,固定状态常量为:
C_s = 2011170000000
改变该组输入 x 后,多个对象始终满足:
这表明 VM 同时在构造 x 的幂和处理十进制位,适合从静态大整数记录恢复整体多项式。两个因果实验用于排除伪依赖:
真正稳定的信息来自每个 state 的固定 record 访问。dispatcher 中可见 0x4A/0x49/0x45/0x41 四类 operand,继续追踪其数据源会汇合到静态描述符表。每个 state 使用连续 11 个描述符,正好对应常数项到十次项。此前用错误 keystream seed 解出的“1000 个漂亮整数根”只是能通过表面格式检查的诱饵;换成下面逐字节闭合的 decoder 后,系数、VM 对象和原生成功路径才全部一致。
state 为 s、系数序号为 r 时:
base = (s*0x9E3779B9) XOR (r*0x517CC1B7)
记录第 i 字节的密钥由下式产生,所有计算截断为 32 位:
解码结果是带符号 BCD 整数。首字节最高位为符号,其余 7 位为十进制位数,后续字节按高半字节、低半字节排列十进制数字。
例如 s=11467, r=10 的描述符为 0x021F819B,长度为 2,数据偏移为 0x1F819B。解密后记录是 01 10:首字节表示 1 位正数,BCD 数字为 1,因此最高次系数为 1。r=0 的描述符为 0x1D1F80E9,解密后首字节为 0x38,表示后面有 56 位十进制数,得到常数项:
62193613838163321199553657179547772100784429239606068480
11 个系数按常数项到十次项排列:
P_s(x) = c_0 + c_1*x + ... + c_10*x^10
每个多项式都精确分解为十个互不相同的正整数根。以第一个 state 11467 为例,十个根为:
149982, 182871, 188530, 199607, 206187, 576399, 756853, 789216, 875674, 969329
它的 11 个系数为:
用整数域分解可直接验证:
P_11467(x) = product(r in roots)(x-r)
所有 100 个多项式都满足相同结构。每个候选根还会用 Horner 法代回 11 个系数,要求精确结果为零,避免只依赖因式分解库的返回格式。
这部分不能只把 KCTF 的 100 个值动态抓下来再硬编码,否则求解器仍然只适用于一个 Name。最终把生成器本身恢复出来后,可以从任意 Name 重新计算 state。
第一层是大小写敏感的 32-bit hash31:
例如:
KCTF 的 live 入口寄存器还能看到 R10=0x00231D84、R9=0x00231DCA;前者恰好是处理最后一个 'F' 之前的 h*31,后者等于前者加 'F',与公式一致。这里显然区分大小写 ,与父进程 basename 的 casefold hash 是两套完全不同的逻辑。
随后对 32-bit base 使用一个自定义 avalanche:
两个乘法常量都经过 live 执行确认:0x808D17 执行 imul ecx, ecx, 0x5BD1E995,0x80A07B 执行 imul ecx, ecx, 0xC2B2AE35。以 KCTF 的第一个 candidate 为例,整条数值链为:
然后取低 14 位作为 state 候选:
候选进入一个按 state 索引的已用表:
未出现过才写入最终数组。真正的数组提交点是:
现场 R15=0x7F0000,因此 100 个 state 以 100 个 DWORD 连续保存在 0x7F0000。KCTF 第一次 store 时 RDX=0、ECX=0x2CCB=11467;Name=A 时同一点 ECX=0x3751=14161。
完整递推为:
这里去重不是理论推断。对 KCTF 连续抓取了 101 个 full 32-bit candidate ,公式 101/101 精确重放。第 90 次额外尝试产生 candidate=0x746E6815,低 14 位仍是先前已经出现过的 10261,因此被去重表拒绝、已接受计数不增加;下一次 0x308A2E9F & 0x3FFF = 11935 才继续写数组。最终恰好 101 次尝试得到 100 个唯一 state。
KCTF 开头十项仍为:
11467, 11985, 12162, 1385, 12612, 9749, 22, 7340, 8302, 13426
但 Exp 不再保存这 100 个常量,而是实时调用 derive_states(Name)。交叉验证不仅针对 KCTF:独立抓取的 A 和 AA 两份完整 100×DWORD 数组与离线公式都是 100/100 相等;再用这些动态派生 state 求多项式根并生成新的 9226 字符 Serial,原附件对 Name=A 和 Name=AA 都直接输出 Successful!。
需要注意,能生成 100 个 state 不代表任意 Name 都可注册 。后续每个 state 还必须对应十个可由十进制整数输入满足、且能通过严格递增约束的根。例如 KCTG 的派生序列包含 state=15631,当前附件对应多项式没有十个互异整数根,因此这个 Name 无法按正确版本的 10 根组约束构造有效 Serial。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 21小时前
被mxym_编辑
,原因:
上传的附件: