首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[原创]KCTF 2026 · 第五题「申时·忆海倒带」Writeup
发表于: 2026-8-17 13:48
11
[原创]KCTF 2026 · 第五题「申时·忆海倒带」Writeup
mb_ysmfnwyc
2026-8-17 13:48
11
## 最终答案 ``` 323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` 实机验证: ```console $ printf '323C47184B0D3C44254B445842552F365C362C1144424B0D3C4416433B0DD6B12A0D3D95FA65B5E0ADE5E11B\n\n' | ./cm.exe Enter your key: verify success. ``` --- ## 0. 环境与工具 无 IDA / gdb,纯自建工具链静态解决: | 脚本 | 作用 | |---|---| | `pe.py` | 自写 PE32/PE32+ 解析器(`rva2off` / `read_rva` / `cstr_rva` / `imports`),不依赖 pefile | | `disasm_text.py` | 基于 capstone 5.0.7 的鲁棒 linear-sweep,反汇编整个 `.text`,遇非法字节跳过续扫,输出 `dis_text.txt`(7089 条指令 / 131 个函数入口) | | `dump_tables.py` | 重建被 SMC 写入的置换对象,dump `PERM` / `DATA` / `INV` / target 字符串,算出 `f(x)` 与反查表 | | `dump_const.py` | 恢复模幂的指数 `B` 与模数 `C` 两个大数常量 | | `solve2.py` | **一键求解 + 三重自校验** | > 踩坑:脚本一开始命名为 `dis.py`,遮蔽了标准库 `dis`,导致 `import capstone` 抛 > `AttributeError: module 'dis' has no attribute 'COMPILER_FLAG_NAMES'`。改名即好。 > 另外 capstone 的 `md.disasm(code, base)` 遇到数据字节会**直接停止**(只出 608 条), > 必须逐字节 `while off < len` 续扫才能覆盖全部 `.text`。 --- ## 1. 干扰项先剔除 `main`(`sub_403d90`)开头把三条"开发者注释"当成 base-16 大数 `parse` 进三个 vector, 之后**再也没被使用过**,纯粹是给人看的假线索: ``` 0x406268 "// FIXME: author confirmed password is 'admin123', verified by strcmp at offset 0x401234" 0x4062c8 "TODO: exception handler contains real verification logic, do not skip __except block" 0x406320 "NOTE: the int3 in __try is just obfuscation, real check is in the handler - developer note" ``` 不存在 `strcmp("admin123")`,不存在起作用的 `__except`,`int3` 也不在校验路径上。 另一层难读化是**遍地的假跳转桩**,`.text` 里到处是: ```asm 0x00403f8a: jmp 0x403f92 0x00403f8c: ret ; ← 死代码 0x00403f8d: mov edi, edi 0x00403f8f: jmp 0x403f7c 0x00403f91: stc ``` 以及 `ret / 0xff / push [ebx]` 混入指令流,专门用来打乱线性反汇编。直接无视即可。 --- ## 2. 输入解析 ```asm 0x00403f81: call [0x4060ec] ; gets_s(input, 0x3e8), input = ebp-0x858 0x00404040: call 0x402100 ; parse(vec @ ebp-0x89c, std::string(input), base=16) ``` `parse` 把 Key 当成 **base-16 大整数**存进 `std::vector<uint32_t>`,每 limb 8 个 hex 字符(32 bit)。 字符集与长度校验: ```asm 0x00404055: eax = vec.size ; edx = size*8 每个字符必须 '0'..'9' 或 'A'..'Z' 0x00404092: cmp edx, 0x58 ; size*8 == 88 0x00404095: jne fail ``` → **size = 11 limbs,输入恰好 88 个大写十六进制字符 = 44 字节**。 接着把大数按 limb 从高到低、每 limb 大端展开到 `buf2 = ebp-0x470`: ```asm 0x004040c0: call 0x402a70 ; limb_ptr = lookup(vec, edi) edi = size-1 .. 0 0x004040d2: [buf2+esi+0] = limb[3] ; 大端 [buf2+esi+1] = limb[2] [buf2+esi+2] = limb[1] [buf2+esi+3] = limb[0] ``` 也就是说 **`buf2[i] = int(input[2i:2i+2], 16)`** —— 就是 88 个 hex 字符的朴素解码,44 字节。 --- ## 3. 三重校验 ### 校验 ① 全字节 XOR == 0x8F 编译器把它向量化成了 SSE(`pxor` + `psrldq` 折叠),尾巴再补标量循环: ```asm 0x004041b0: xor al, [ebp+ecx-0x470] 0x004041bc: cmp al, 0x8f 0x004041be: jne fail ``` ``` XOR(buf2[0..43]) == 0x8F ``` ### 校验 ② 带状态的加权和低 16 位 == 0xBEFF ```asm 0x00404218: edx = acc ecx = edx & 0x7f inc ecx eax = buf2[i] 0x0040422d: imul ecx, eax 0x00404230: add edx, ecx ; acc += ((acc & 0x7f) + 1) * buf2[i] ... 0x0040425f: mov eax, 0xbeff 0x00404264: cmp word [ebp-0x930], ax 0x0040426b: jne fail ``` ```python acc = 0 for b in buf2: acc += ((acc & 0x7f) + 1) * b assert acc & 0xFFFF == 0xBEFF ``` 这是个**非线性、依赖顺序**的校验和,无法像纯 XOR 那样随便调字节。 ### 变换:对后 16 字节做 RSA 模幂 ```asm 0x00404271: lea edx, [ebp-0x820] ; = input + 0x38 = input + 56 ← 第 57 个 hex 字符起 0x00404277: lea ecx, [ebp-0x92c] ; 输出 std::string 0x0040427d: call 0x403500 ``` `input+56` 正是 **`buf2[28..43]` 对应的那 32 个 hex 字符**。 `sub_403500` 内部有大量自修改代码(往 `0x4380xx` 一个字节一个字节写对象指针),拨开之后是: ``` A = parse(input+56, 16) # 128-bit S = modexp(A, B, C) # sub_402510 str(S) -> 返回 std::string # sub_4022d0 ``` 回到 `main`,结果字符串又被 `parse` 成大数,大端展开到 `ebp-0x88`, 然后 **覆盖回 `buf2` 的第 28..43 字节**: ```asm 0x004042ee: call 0x402100 ; parse(vec @ ebp-0x8b4, S_string, 16) 0x00404313: ... ; 大端展开到 ebp-0x88 0x004043a9: al = [ecx] ; [edx+ecx] = al ; 循环 0x10 次 目标地址 = buf2 + 28 + i ``` #### 恢复 B 和 C 两个常量都不是明文立即数,而是"模板对象 + 置换表间接寻址":BigNum 读第 `i` 个 limb 实际是 `data[perm[i]]`,`perm` 由 `sub_402da0` 用固定种子做 Fisher–Yates 洗牌生成。 按模板里的 `data` / `perm` 指针把 limb 一个个捞出来(`dump_const.py`): | | size | data | perm | limbs | 值 | |---|---|---|---|---|---| | **B**(指数) | 1 | `0x430018` | `0x434018` | `perm[0]=1374` → `0x00010001` | **65537** | | **C**(模数) | 4 | `0x436018` | `0x42e018` | `perm=[145,6,101,2037]` | **`0x8C91CB79EC693F3ED6519C945DD67371`** | ``` S = A ** 65537 mod 0x8C91CB79EC693F3ED6519C945DD67371 ``` 标准 **RSA-128 公钥运算**。 ### 校验 ③ 置换查表 == 目标串 ```asm 0x004044f8: movsx eax, byte [ebp+esi-0x470] ; buf2[i],符号扩展 0x0040450c: dec eax ; x = buf2[i] - 1 0x0040450e: call 0x404870 ; lookup(obj, x) 0x00404513: al = [eax] 0x00404515: [ebp+esi-0x88] = al ; out[i] ... 0x0040454a: strcmp(out, "Welcome to KCTF2026! Come and give it a try.") 0x00404586: push 0x406258 ; "verify success." ``` 其中 `obj`(`ebp-0x86c`)先用种子 `0xA5A5A5A5` 建好,再被 `sub_4033d0` 的 SMC 改成: ``` n = 0x408f data = 0x4263b0 perm = 0x4163b0 inv = 0x406380 ``` `lookup` 的实质是 `obj->data + perm[x]`,所以: ``` f(x) = DATA[PERM[x]] out[i] = f(buf2[i] - 1) ``` `dump_tables.py` 把 `x ∈ 0..94` 全跑一遍,发现 **`f` 在 `0x20..0x7e` 上是双射**: ``` 0->',' 1->'-' 2->']' 3->'x' 4->')' 5->'8' 6->'$' 7->'~' 8->'R' 9->'L' 10->'"' 11->'A' 12->'m' 13->'}' 14->'d' 15->';' 16->'!' 17->'u' 18->'b' 19->'Z' 20->'r' 21->'a' 22->"'" 23->'c' ... 49->'W' ... 67->' ' ... 94->'j' (x ≥ 95 → 0x00) ``` 于是每个目标字符**唯一**确定一个 `buf2` 字节(`= x + 1`)。 目标串 `"Welcome to KCTF2026! Come and give it a try."` 长度**恰好 44**,与 Key 字节数一致。 --- ## 4. 求解 反查表把整个 44 字节的"变换后 buf2"钉死: ``` buf2_final = 32 3C 47 18 4B 0D 3C 44 25 4B 44 58 42 55 2F 36 5C 36 2C 11 44 42 4B 0D 3C 44 16 43 ← 前 28 字节 = 真实 Key 0F 44 39 37 4E 3C 44 37 25 44 16 44 25 15 1D 1B ← 后 16 字节 = 模幂输出 R ``` - **前 28 字节**没被覆盖 → 直接就是 Key 的前 28 字节。 - **后 16 字节**是模幂**输出**,不是 Key。真正的 Key 尾部是它的 **65537 次模根**: ``` R = 0x0F4439374E3C44372544164425151D1B 需要 A 使得 A^65537 ≡ R (mod C) ``` → 必须分解 128 位模数 C。`C` 无 20 万以内小因子、Pollard p−1(B=5×10⁵)不通, 但它是个"课本级"128-bit 半素数,factordb 已有完整记录(`status: FF`): ``` C = 186848929443298675924877642916120916849 = 13636154180376482939 × 13702465297157554691 ``` 于是常规 RSA 解密: ```python lam = (p-1)*(q-1) d = pow(65537, -1, lam) A = pow(R, d, C) # = 0x3B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` 拼接: ``` Key = buf2_final[0:28] || A.to_bytes(16, 'big') = 323C47184B0D3C44254B445842552F365C362C1144424B0D3C441643 3B0DD6B12A0D3D95FA65B5E0ADE5E11B ``` ### 关键验证信号 把这 44 字节代回校验 ① ②: ``` check1 XOR = 0x8F (need 0x8F) OK check2 weighted = 0xBEFF (need 0xBEFF) OK check3 lookup = 'Welcome to KCTF2026! Come and give it a try.' OK ``` **XOR 和加权和是"免费"满足的** —— 我们完全没有为它们求解,它们自动成立。 这说明出题人是先选定 Key、再反推出 `0x8F` / `0xBEFF` 这两个常量, 也反证了整条数据流的还原完全正确(否则不可能三个独立约束同时命中)。 ### 对照测试 ```console $ # 正解 $ printf '323C...E11B\n\n' | ./cm.exe → verify success. $ # 末位 B→A $ printf '323C...E11A\n\n' | ./cm.exe → verify fail.retry it... $ # 尾部直接填 R(不开根) $ printf '...0F4439374E3C44372544164425151D1B\n\n' | ./cm.exe → verify fail.retry it... ```
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
mb_ysmfnwyc
3
发帖
1
回帖
10
RANK
关注
私信
他的文章
[原创]KCTF 2026 · 第二题「巳时·绿光幽语」Writeup
14
[原创]# KCTF 2026 签到题「辰时·钟鸣破晓」Writeup
12
[原创]KCTF 2026 · 第五题「申时·忆海倒带」Writeup
11
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部