首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
1
KCTF 2026 第五题:申时·忆海倒带 WP
发表于: 2026-8-18 14:38
1504
KCTF 2026 第五题:申时·忆海倒带 WP
geekfire
2026-8-18 14:38
1504
# KCTF 2026 Windows 方案一 cm.exe 解题报告 ## 一、题目信息 看雪 KCTF 2026,规则 5.1.1 Windows 方案一。 附件 `cm.exe`,SHA1:`d1c8124c5964af1531c6e311328854a05ca40bca`。 判胜条件:运行 `cm.exe`,输入序列号(Key) 回车,输出 `verify success.` 即通过。 ## 二、初识程序 先跑一下看看它要什么: ``` Enter your key: abcdef verify failed. ``` 随便输个短的提示失败。试长一点的 88 位 hex: ``` Enter your key: 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 verify failed. ``` 也是失败。但能看出输入是 **88 个十六进制字符**,也就是说序列号是 44 字节的大数。这个长度有点意思——既不是常见 16/32,也不是任意长度,固定 44 字节多半是为了配合后面的固定串长度。 顺手 strings 看一眼,发现一句很关键的明文: ``` Welcome to KCTF2026! Come and give it a try. ``` 长度刚好 44 字符。基本可以确认:**输入被某种变换成 44 字节,再映射成字符,和这句话 strcmp**。这就是攻击入口——目标串已知,问题变成"怎么把这句话逆推回输入"。 ## 三、IDA 静态分析 把 `cm.exe` 拖进 IDA。x86 32-bit PE,没加壳,符号被 strip 但函数边界清晰。主校验函数大概这么几段(变量名我按位置重命名了): ```c // 1. 读取 88 hex → 大整数 → 44 字节 ArgList get_hex_input(buf, 88); big = parse_hex(buf); to_bytes(big, ArgList, 44); // 高位在前 // 2. XOR 校验 if (xor_all(ArgList, 44) != 0x8F) goto fail; // 3. checksum 校验(带置换) transform(ArgList, tmp); if (checksum(tmp) != 0xBEFF) goto fail; // 4. RSA:取 ArgList[28..43] 当 c,算 m=c^e mod N,覆盖回去 rsa(ArgList + 28, ArgList + 28); // 5. 逐字节 lookup → out_str for (i=0; i<44; i++) out_str[i] = lookup_table[ArgList[i]]; out_str[44] = 0; // 6. strcmp if (strcmp(out_str, "Welcome to KCTF2026! Come and give it a try.") == 0) puts("verify success."); else puts("verify failed."); ``` 整个校验链有四道独立检查:XOR 和、checksum、RSA、strcmp。重点是它们之间不是线性依赖那么简单——RSA 只动后 16 字节,前 28 字节从输入到 lookup 一直没变。这个分工很关键,后面会用到。 ## 四、几个必须先确认的细节 到这里有几个点不能靠猜,必须动态确认: 1. **ArgList 字节序**:hex→bytes→int 到底是大端还是小端?反编译里看着像大端,但 32 位程序里这种东西经常看错。 2. **RSA 方向**:程序算的是 `c^e mod N` 还是 `c^d mod N`?反编译只能看到调用了一个大数运算函数,指数藏在结构体里,光看反编译很容易猜反。 3. **lookup 表**:256 项的字节→字符映射,得完整 dump 下来才能反查。 4. **RSA 覆盖范围**:反编译里那个 `v53` 循环到底是写回 `ArgList[0..15]` 还是 `ArgList[28..43]`?早先看错过一次,绕了远路。 这些都用 Frida 在校验点 dump 实际内存来定,不静态推导。 ## 五、Frida 动态确认 ### 5.1 字节序 hook 主校验函数入口(只用 `Interceptor.attach(module.base.add(offset))` hook 函数 entry,不 inline hook 任意地址——x86 上 inline hook 极易破坏相邻指令,这点吃过亏),在 onEnter 里读 `ebp-0x470` 处的 ArgList 缓冲,与 Python 端按大端拼出来的 int 对比。 结果:**大端,正向**。`ArgList[0]` 是输入 hex 的第 0 个字节。早先猜过反向,浪费了半小时。 ### 5.2 RSA 方向 给两组不同输入(比如 c1=0x11...11, c2=0x22...22),hook RSA 函数入口/出口,捕获 `(c, m)` 对。然后 Python 算两个方向: ```python pow(c, e, N) == m # 如果成立 → 程序用 e pow(c, d, N) == m # 如果成立 → 程序用 d ``` 实测:**程序执行 `m = c^e mod N`**。也就是说它把输入当密文,用公钥指数 e 去"解密"。这有点反直觉,但逆向题里很常见——出题人让你填的"序列号"其实是合法的密文。 所以**逆运算**是 `c = m_required ^ d mod N`,需要私钥 d。好在 N 是 128-bit,p/q 在反编译里能直接看到分解(出题人没藏,就放在常量区),d 一行 Python 算出来。 ### 5.3 lookup 表 hook lookup 函数入口,for 循环跑 256 次,让程序自己把 `in_byte = 0..255` 全走一遍,onEnter 记 in,onLeave 记 out。得到完整的 256 → ASCII 映射表。 对目标串 `"Welcome to KCTF2026! Come and give it a try."` 每个字符反查 preimage,得到 **required[0..43]** 共 44 字节。这一步是整个题的锚点——有了 required 这 44 字节,剩下就是逆推输入怎么填才能让校验链各关都过。 ### 5.4 RSA 覆盖范围 hook RSA 函数,onEnter 记 `ArgList[28..43]` 的原值,onLeave 再读一次,看哪 16 字节变了。结果:**覆盖 `ArgList[28..43]`**,前 28 字节不动。 至此整个数据流彻底清楚: ``` 输入 88 hex → ArgList[0..43] (44 字节, 大端) → XOR 和 = 0x8F (关卡 A) → transform + checksum = 0xBEFF (关卡 B) → ArgList[28..43] = (ArgList[28..43])^e mod N (关卡 C: RSA 覆盖后段) → out_str[i] = lookup[ArgList[i]] (关卡 D: 查表) → strcmp(out_str, "Welcome...") == 0 (关卡 E) ``` ## 六、逆推 ### 6.1 分段 由于 RSA 只动后 16 字节,前 28 字节在 XOR/checksum 之后直接进 lookup,所以: - **`ArgList[0..27]`**:lookup 反查直接得到,`输入前 56 hex = required[0..27]` 的 hex - **`ArgList[28..43]`**:进入 lookup 的是 RSA 覆盖后的值 = `required[28..43]`,而**输入填的应该是覆盖前的 c**,满足 `c^e mod N = m_required` ### 6.2 后半段做 RSA 逆运算 ```python m_required = int.from_bytes(required[28:44], 'big') c = pow(m_required, d, N) # d 是 RSA 私钥 back = c.to_bytes(16, 'big') ``` `back` 就是输入的后 32 个 hex 应填的值。 ### 6.3 检查 XOR / checksum 是否还满足 这是最容易翻车的一步。XOR 和 checksum 是对**整 44 字节**算的,而我们刚刚通过 RSA 逆运算改了后 16 字节。万一新算出来的 `back` 让 XOR 和 ≠ 0x8F 或者 checksum ≠ 0xBEFF,就还要在 16 字节自由度里调整。 先把候选 key 整 44 字节拼出来,算一遍: ``` XOR(ArgList) = 0x8F ✅ checksum = 0xBEFF ✅ ``` 都自动满足。这其实是出题人的设计——RSA 那段 16 字节的自由度足够大,p/q 又是出题人选定的,让正确解恰好能凑齐 XOR 和 checksum。所以无需额外搜索,key 唯一确定。 > 这里有个教训:之前动态调试时试过一个"非预期解"——把 `m_required` 本身当作输入 c 填进去(没做 RSA 逆运算),在 patch 过的 cm.exe 上居然也打印 `verify success.`。后来在原始 binary 上一试直接 `verify failed.`,因为那个 key 的 XOR 和 = 0x68 ≠ 0x8F,第一关就过不了。所以多约束题一定要每道检查独立验证,不能只看最后一关。 ### 6.4 拼出 key ```python required = b"..." # 44 字节,由 lookup 反查得到 front = required[0:28] # 28 bytes → 56 hex m_required = int.from_bytes(required[28:44], 'big') c = pow(m_required, d, N) back = c.to_bytes(16, 'big') # 16 bytes → 32 hex key = (front + back).hex().upper() ``` 得到: ``` 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` ## 七、验证 最终验证必须在原始未修改的 binary 上做。这一点很重要——动态调试过程中 `cm.exe` 被 patch 过,`verify success.` 输出已经不可信。 ``` PS> Get-FileHash .\cm.exe.bak -Algorithm SHA1 Algorithm Hash --------- ---- SHA1 D1C8124C5964AF1531C6e311328854A05CA40BCA ← 与 readme 一致 ✅ PS> echo "323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B" | .\cm.exe.bak Enter your key: verify success. ``` 通过。 ## 八、最终序列号 ``` 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B ```
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-8-18 14:44 被kanxue编辑 ,原因:
收藏
・
0
点赞
・
1
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
mb_shzsxtje
感谢你分享这么好的资源!
2026-8-19 08:16
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
geekfire
10
发帖
4
回帖
143
RANK
关注
私信
他的文章
[推荐]看雪CTF2026 第9题WP
57
[推荐]看雪CTF2026 第8题WP
33
[推荐]看雪CTF2026 第七题 WP
24
KCTF 2026 第五题:申时·忆海倒带 WP
1504
[推荐]第二题:巳时·绿光幽语 WP
1817
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部