首页
社区
课程
招聘
KCTF2026: 第五题:申时·忆海倒带 Writeup
发表于: 3天前 5

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 直接跳到函数尾声。0x403d0bmov 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其余一律取 00x40224bmov [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 = 0x4163800x4163b0 + 0x10000 = 0x4263b0 —— 严丝合缝。字符替换表是 T[j] = *(u8*)(0x4263b0 + perm8[j]),其中 perm8 = (u32*)0x4163b0;元数据由 0x4033d0.data0x438030 注入(含三个指针 0x4263b0 / 0x4163b0 / 0x406380)。

置换表由 0x402da0 的 Fisher-Yates 洗牌生成,PRNG 是 0x401160 的 xorshift32(13,17,5),种子 0xCAFEBABEinv 已验证满足 inv[perm[k]] == k

方向与程序相反。 程序是「输入 → 变换 → 比对明文」,我们从已知的明文倒推:

反查 KB[0..43]:目标串每个字符在 T[0..0x7E] 中的原像。用二进制自带的 perm8data 重建 T,实测每个字符恰好一个原像 → 解唯一。
(索引上界 0x7F 的来历:0x404870movsx 取索引,KB[i] 为 0 或 ≥0x80 时索引为负,走越界分支返回 data[0],永不匹配。)

得到 M:反查出的 KB[28..43] 就是模幂必须达到的目标值
M = 0x0F4439374E3C44372544164425151D1B

分解 NN = 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 == 0x8Facc & 0xFFFF == 0xBEFF —— 作者是先定死唯一解、再反推出这两个常数写进代码的,它们只起「劝退暴力搜索」的作用。

Key 全局唯一

一键复现:./verify.sh

.text 里塞满了 int3 填充和花指令:

外加 call $+5; add esp,4 这种自跳。第一轮用 capstone 线性扫 .text 查诱饵函数的 xref,结果是全部为空 —— 差点据此断言「诱饵函数是死代码」。

实际上是反汇编器在花指令处失步,把真实的 E8 调用吃进了上一条指令的操作数。改成直接扫 E8/E9 字节解 rel32tools/xref.py),立刻找到 6 个调用点。

教训:在有花指令的样本里,「没找到 xref」是关于反汇编器的陈述,不是关于程序的陈述。

第二轮发现 main 把三条注释串 assignstd::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 不亲自下场做任务,只选任务、核实证据、写黑板;子代理不写黑板,只返回结果并回报异议。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

上传的附件:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回