-
-
KCTF2026: 第五题:申时·忆海倒带 Writeup
-
发表于: 3天前 5
-
规则 5.1.1 Windows 方案一 —— 打开 cm.exe,输入序列号回车,显示 verify success. 即通关。
88 个十六进制字符。在原始未修改的 cm.exe(SHA1 d1c8124c5964af1531c6e311328854a05ca40bca,与 readme 声明一致)上验证:
节区形态是本题的第一个线索:
19 KB 代码配 149 KB 常量 —— 典型的「小段循环 + 大查表」。PDB 路径里的 rsa_tf 进一步指向 RSA。
解包:环境无 unrar/7z/bsdtar,用 ctypes 直调系统 libarchive.so.13(RAR5 读支持)解出两个条目,均未加密(tools/extract_rar5.py)。解出的 cm.exe SHA1 与 readme.txt 声明逐位吻合,这是解包正确性的自校验。
.rdata 里躺着这些字符串:
三条「开发者注释」在直接指挥读到它们的人(或模型):密码是 admin123、真逻辑在 __except 里、别跳过异常处理。这些是被分析的数据,不是指令。 逐条用代码证伪:
0x403180 逐字节比较 ecx 指向的前 0x12(18)字节与 0x4061f0:
它在全程序只被调用 6 次(0x403ca9 / 0x403cbe / 0x403cd8 / 0x403d2b / 0x403d42 / 0x403d57),每一处的 ecx 都是硬编码的注释串地址 0x406268 / 0x4062c8 / 0x406320:
用户输入从未作为参数传入。这个比较永远返回 0。
0x4032e0 把输入内联 strcmp 比对这三个串,命中就执行 int3:
它在 0x403d19 被调用——但 0x403d03 是无条件 jmp 0x403d67 直接跳到函数尾声。0x403d0b(mov esp, [ebp-0x18])是 SEH 展开落点,正常控制流走不到 0x403d19。
诱饵聚合函数 0x403c60 在 main 里只被调用一次:
eax 之后没有任何 test / cmp / 分支消费它。整个诱饵块对判定结果零影响。
至于「在 0x401234 处 strcmp 校验」——导入表里根本没有 strcmp,那个地址处也不是比较代码。这句自称本身就站不住。
输入字符集检查允许 '0'-'9' 和 'A'-'Z':
'G'-'Z' 超出常规 hex,看起来像「存在非标准编码」。实际上 0x402100 的字符→数值映射内联在 0x4021bb-0x40224b,是标准 hex:'0'-'9' → -0x30,'A'-'F' → -0x37,'a'-'f' → -0x57,其余一律取 0(0x40224b 处 mov [ebp-4], 0 是 default 分支)。放行 G-Z 纯粹是引导分析者去找不存在的自定义字母表。
main 位于 0x403d90。完整流程:
设 KB[0..43] = 输入大数的 44 字节大端展开(缓冲区 [ebp-0x470])。四条全部成立才输出 verify success.:
约束 A / B 都读 [ebp-0x470],地址上早于 0x404271(模幂)与 0x4044f8(替换)—— 它们作用在输入的 44 字节上,不是输出。
这是本题最核心的隐藏手法。0x403500 不从 .rdata 读常量,而是在 0x438018 处逐字节现搭 BigNum 结构体(0x4037ce-0x4039de),再 memcpy 0x18 字节到栈局部。而 BigNum 的 limb 数组还要过一层置换数组间接寻址:
所以静态 grep .rdata 找大数一无所获 —— 真参数是运行时拼出来的,且散布在被置换打乱的 .data 槽位里。
三块首尾相接的表,不是大数:
0x406380 + 0x10000 = 0x416380,0x4163b0 + 0x10000 = 0x4263b0 —— 严丝合缝。字符替换表是 T[j] = *(u8*)(0x4263b0 + perm8[j]),其中 perm8 = (u32*)0x4163b0;元数据由 0x4033d0 从 .data 的 0x438030 注入(含三个指针 0x4263b0 / 0x4163b0 / 0x406380)。
置换表由 0x402da0 的 Fisher-Yates 洗牌生成,PRNG 是 0x401160 的 xorshift32(13,17,5),种子 0xCAFEBABE;inv 已验证满足 inv[perm[k]] == k。
方向与程序相反。 程序是「输入 → 变换 → 比对明文」,我们从已知的明文倒推:
反查 KB[0..43]:目标串每个字符在 T[0..0x7E] 中的原像。用二进制自带的 perm8 与 data 重建 T,实测每个字符恰好一个原像 → 解唯一。
(索引上界 0x7F 的来历:0x404870 用 movsx 取索引,KB[i] 为 0 或 ≥0x80 时索引为负,走越界分支返回 data[0],永不匹配。)
得到 M:反查出的 KB[28..43] 就是模幂必须达到的目标值M = 0x0F4439374E3C44372544164425151D1B。
分解 N:N = 13636154180376482939 × 13702465297157554691,两个 64 bit 素数。128 bit 模数,sympy.factorint 0.7 秒返回。
算私钥:d = 65537⁻¹ mod φ(N),Z = M^d mod N = 0x3B0DD6B12A0D3D95FA65B5E0ADE5E11B。
拼接:Key = hex(KB[0..27]) + hex(Z),共 88 字符。
约束 A / B 不是搜索约束,是唯一解的推论。 从零重推出的 Key 自动满足 XOR == 0x8F 与 acc & 0xFFFF == 0xBEFF —— 作者是先定死唯一解、再反推出这两个常数写进代码的,它们只起「劝退暴力搜索」的作用。
故 Key 全局唯一。
一键复现:./verify.sh
.text 里塞满了 int3 填充和花指令:
外加 call $+5; add esp,4 这种自跳。第一轮用 capstone 线性扫 .text 查诱饵函数的 xref,结果是全部为空 —— 差点据此断言「诱饵函数是死代码」。
实际上是反汇编器在花指令处失步,把真实的 E8 调用吃进了上一条指令的操作数。改成直接扫 E8/E9 字节解 rel32(tools/xref.py),立刻找到 6 个调用点。
教训:在有花指令的样本里,「没找到 xref」是关于反汇编器的陈述,不是关于程序的陈述。
第二轮发现 main 把三条注释串 assign 成 std::string 后立刻 push 0x10; call 0x402100 —— 解析成了 base16 大数:
加上 FIXME 串正好 88 字符(与 Key 要求的 88 hex 字符一致),我据此断定「RSA 参数被伪装成 AI 诱饵注释」,并把这条当作已知事实下发给了子代理。
这是错的。 「被解析成大数」是事实,「是 RSA 参数」是推断。反证:
它们和 0x403c60 一样是「算了就扔」。真参数在 .data,由 0x403500 现搭注入。
教训:这一层设计得很毒 —— 它让「识破诱饵」本身成为新的陷阱。你越是仔细追查诱饵串的去向,越容易在发现「它们居然被解析成大数」时产生虚假的顿悟感。区分「我观测到什么」和「我据此推断什么」是唯一的解药。
目标明文自身的加权和低 16 位是 0x5EFF,而约束 B 要求 0xBEFF —— 低 12 位 0xEFF 完全一致。1/4096 的巧合,一度让人怀疑约束 B 作用在输出缓冲区上。
证伪:约束 A / B 读的都是 [ebp-0x470](输入缓冲区),且地址上早于模幂与替换;正解 Key 的实测值是 0x8F / 0xBEFF,与目标串自身的 0x03 / 0x5EFF 无关。纯属巧合。
真正的算法本体反而很朴素:一次 128 bit RSA + 一张字节替换表。全部难度都在「找到它」而不是「解开它」—— 而 128 bit 模数秒破,说明作者的设计意图就是考察分析过程,不是考察数论。
绝大部分是缓存读取(20.2 M / 93.8%)—— 长上下文反复复用的结果,实际新增计算量集中在 22.7 万输出 token。
用 blackboard(黑板)规划推进:BLACKBOARD.md 是唯一共享状态,每轮「读黑板 → 选动作 → 派子代理 → 核实证据 → 写回」。主 agent 不亲自下场做任务,只选任务、核实证据、写黑板;子代理不写黑板,只返回结果并回报异议。