首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
1
[原创]KCTF 2026 第七题:戌时·暗能潜流 writeup
发表于: 2026-8-20 19:45
32
[原创]KCTF 2026 第七题:戌时·暗能潜流 writeup
number_Z
2
2026-8-20 19:45
32
## 写在前面:这题最先加密的是我的思路 这题打到一半,官方把附件换成了 `KCTF07_challenge-fix.txt`。 好消息是四组样例和 FLAG 密文都没动,不用从头重算;坏消息是,刚把 `121` 往 CRC、Rule 121、卷积核、GF(16) 等方向各猜了一轮,官方才把工具箱盖子掀开,明确告诉我们算法由这些东西组成: ```text 字节编码 十六进制位运算 模16循环偏移 固定位置重排 固定摘要扰动 ``` 还特意补了一句:摘要扰动只和输入长度有关。 怎么形容呢,大概就是迷宫走到一半,工作人员终于递来一张纸: 吐槽归吐槽,下面的分析也只以修正版题面为准。目标很明确:不是猜一条“长得像”的 FLAG,而是先做出能完整复现四组样例的加解密流程,再去碰目标密文。 题目给出的数据如下: ```text key = 121 TLU -> 94AA48550495 Hello -> 34BB405504B5223594B94C53 2026 -> A48844556485223322356483 abcd! -> 547B475584B5223564BB4553 ``` ## 1. 长度先把块大小卖了 先看最没脾气、也最不容易骗人的东西:长度。 | 明文 | ASCII 长度 | 密文 hex 长度 | |---|---:|---:| | `TLU` | 3 | 12 | | `Hello` | 5 | 24 | | `2026` | 4 | 24 | | `abcd!` | 5 | 24 | 规律很明显: ```text len(cipher_hex) = 12 * ceil(len(plain) / 3) ``` 也就是说,算法以三个 ASCII 字符为一个容量单位,每个字符占四个十六进制 nibble;不满三个字符时,会用某种只依赖长度的东西补足。 这时还不能急着喊“ECB 三字节块”。能确认的只有输出按 12 个 nibble 对齐,块到底怎么排,后面还有坑。 ## 2. 先别管迷宫怎么走,数一数里面有什么 题面明确有“固定位置重排”。重排会改变顺序,但不会改变每种 nibble 出现的次数,所以可以先暂时忘掉位置,只比较 Counter。 设一个 ASCII 字节是: ```text x = 0xHL ``` `H`、`L` 分别是高、低 nibble。按照四位十六进制形式,它原本可以写成: ```text 0, 0, H, L ``` 把四个位置的模 16 偏移放进 `16^4` 的空间里枚举,再用四组样例的 nibble 计数筛选,可以得到一组有效编码: ```text W(0xHL) = [4, 5, (H + 5) mod 16, (L + 4) mod 16] ``` 也就是对 `00HL` 加上: ```text 4554 ``` 举几个例子: ```text T = 0x54 -> W(T) = 45A8 L = 0x4C -> W(L) = 4590 U = 0x55 -> W(U) = 45A9 ``` 于是 `TLU` 在位置重排前的原料是: ```text 45A8 4590 45A9 ``` 继续对比四组明密文的 Counter,还能看到每缺一个字符槽,就会多出: ```text {2, 2, 3, 3} ``` 计数只能确定这四个值,不能确定排列顺序。为了后面写表方便,我固定记成: ```text D = 2233 ``` 换成 `2323` 之类也不是世界末日,只要同步调整后面的置换表,实际效果不变。 此时四组数据可以拆成: ```text TLU: 45A8 4590 45A9 Hello: 459C 45B9 45B0 45B0 45B3 2233 2026: 4586 4584 4586 458A 2233 2233 abcd!: 45B5 45B6 45B7 45B8 4575 2233 ``` 它们分别与对应密文拥有完全相同的 nibble Counter。 到这里,我们已经知道迷宫里用了哪些砖,接下来要找的是每块砖被搬去了哪里。 ## 3. 三字符块到底取哪三个字符 `TLU` 正好三个字符,没有歧义。麻烦的是另外三组:第一个 12-nibble 密文块到底对应明文中的哪三个字符? 这里可以枚举每条明文中的有序三字符选择,并要求四条记录共享同一张 12-position 置换表。最后只剩六组解,而这六组只是同一组三字符的共同 `3!` 换位: ```text TLU llo 026 cd! ``` 选择保持字符原顺序的写法后,分块关系就是: | 明文 | 留到尾块的前缀 | 完整三字符块 | |---|---|---| | `TLU` | 空 | `TLU` | | `Hello` | `He` | `llo` | | `2026` | `2` | `026` | | `abcd!` | `ab` | `cd!` | 换句话说,算法从字符串右边取完整的三字符组;最左边剩下的一两个字符,留到最后和长度摘要一起处理。 ## 4. 完整三字符块的置换 对完整块 `abc`,先拼接: ```text X = W(a) || W(b) || W(c) ``` 用四组样例的首块做列匹配,可以恢复出一张有效的固定位置表。这里用“输出位置取哪个输入位置”的写法: ```text PF = (11, 0, 10, 2, 4, 3, 1, 5, 7, 8, 6, 9) ``` 即: ```python cipher_block[i] = X[PF[i]] ``` 为了看得更直观,把三个字符经过模 16 偏移后的高、低 nibble 分别记为: ```text A_H, A_L B_H, B_L C_H, C_L ``` 展开位置表后,一个完整块其实就是: ```text E3(a,b,c) = C_L, 4, C_H, A_H, 4, A_L, 5, 5, B_L, 4, B_H, 5 ``` 逆向提取也很简单: ```text a.high = (CT[3] - 5) mod 16 a.low = (CT[5] - 4) mod 16 b.high = (CT[10] - 5) mod 16 b.low = (CT[8] - 4) mod 16 c.high = (CT[2] - 5) mod 16 c.low = (CT[0] - 4) mod 16 ``` 剩下的位置应该固定为 `4` 或 `5`,解密时顺手检查即可。 拿 `TLU` 验一次: ```text X = 45A8 4590 45A9 ``` 过 `PF` 后: ```text 94AA48550495 ``` 继续验证另外三个完整块: ```text E3(T,L,U) = 94AA48550495 E3(l,l,o) = 34BB405504B5 E3(0,2,6) = A48844556485 E3(c,d,!) = 547B475584B5 ``` `TLU` 对上时还可以说是巧合,后面三条一起对上,这张表就算能拿来干活了。 ## 5. 尾块:看起来像填充,但不完全是填充 令: ```text r = len(plaintext) % 3 prefix = plaintext[:r] body = plaintext[r:] ``` 当 `r == 1`,尾块的原料是: ```text D || D || W(prefix[0]) ``` 根据 `2026` 的第二块可以恢复出直接表: ```text P1 = (0, 1, 2, 3, 4, 5, 6, 9, 11, 8, 10, 7) ``` 展开后: ```text E1(a) = 2 2 3 3 2 2 3 5 A_L 4 A_H 3 ``` 所以: ```text E1('2') = 223322356483 ``` 当 `r == 2`,尾块原料是: ```text D || W(prefix[0]) || W(prefix[1]) ``` `Hello` 和 `abcd!` 长度相同,长度摘要也相同,正好可以一起把第二张表钉出来: ```text P2 = (0, 1, 2, 5, 11, 4, 10, 6, 8, 7, 9, 3) ``` 展开后: ```text E2(a,b) = 2 2 3 5 B_L 4 B_H A_H 4 A_L 5 3 ``` 验算: ```text E2('H','e') = 223594B94C53 E2('a','b') = 223564BB4553 ``` 加上前面的完整块,四组公开向量已经全部复现: ```text Hello = E3(llo) | E2(H,e) = 34BB405504B5 | 223594B94C53 2026 = E3(026) | E1(2) = A48844556485 | 223322356483 abcd! = E3(cd!) | E2(a,b) = 547B475584B5 | 223564BB4553 ``` `2233` 虽然长得很像普通 padding,但它不是安静地躺在字符串末尾,而是和剩余字符一起进入尾块位置表。直接拿 PKCS#7 去套,只会得到一串很有规律的错答案。 ## 6. 原来迷宫是从右边进的 完整算法从明文右端开始,每次取三个字符。块内字符顺序不变,但完整块列表从右向左输出;最左边剩下的 `prefix` 最后进入尾块: ```text 明文: prefix | B0 | B1 | ... | Bk 密文块顺序: E3(Bk) | ... | E3(B1) | E3(B0) | tail(prefix) ``` 四个公开样例在这样拆分后都只有一个完整块,所以单看样例,其实还看不出“多块消息是否整体逆序”。真正把这个顺序暴露出来的是 FLAG。 把目标密文每 12 个 hex 字符切一块,对前 11 块使用 `E3` 的逆变换,会按密文顺序得到: ```text r!} ste _M4 pt0 Cry 7f_ _Kc 026 u_2 {T1 lag ``` 这已经不像乱码了,更像一句话被人每三个字符切开后,把整摞纸条倒着放在桌上。 最后一个块: ```text 22332235A4B3 ``` 按 `r == 1` 的尾块规则逆回去,得到: ```text D || D || W('f') ``` 所以最左侧前缀是: ```text f ``` 把前 11 个完整块倒序,再把 `f` 放回最前面: ```text f + lag + {T1 + u_2 + 026 + _Kc + 7f_ + Cry + pt0 + _M4 + ste + r!} ``` 得到: ```text flag{T1u_2026_Kc7f_Crypt0_M4ster!} ``` 这次不是“看起来很像 FLAG”,而是重新加密后能完整回到题目给出的 144 个十六进制字符。 ## 7. 密钥 `121` 怎么理解 题目要求解释 `121` 三个数字分别做了什么。现有数据只有这一把密钥,没法唯一恢复作者的 key schedule;不过结合修正版提到的位运算,可以写出一个很自然、也能精确生成 `4554` 和 `2233` 的形式: ```text a, b, c = 1, 2, 1 u = a << b = 4 v = u | c = 5 offset = (u, v, v, u) = (4, 5, 5, 4) digest = (b, b, b | c, b | c) = (2, 2, 3, 3) ``` 这样三个数字都有落点: - 第一个 `1` 作为偏移初始值; - `2` 作为左移位数,同时参与生成摘要常量; - 最后一个 `1` 用来补上最低位,把 `4` 变成 `5`、把 `2` 变成 `3`。 这里留个余地:题目没有给第二把密钥,所以 `<<` 和 `|` 不是唯一可能的源码写法。比如对当前数值来说,`4 | 1`、`4 + 1` 和 `4 ^ 1` 都等于 `5`。能够确定的是最终常量关系 `121 -> 4554 / 2233`;“左移再 OR”是最贴合题目提示的一种解释。 ## 8. 解题脚本 ```python KEY = "121" PF = (11, 0, 10, 2, 4, 3, 1, 5, 7, 8, 6, 9) P1 = (0, 1, 2, 3, 4, 5, 6, 9, 11, 8, 10, 7) P2 = (0, 1, 2, 5, 11, 4, 10, 6, 8, 7, 9, 3) def params(key=KEY): a, b, c = map(int, key) u = (a << b) & 0xF v = (u | c) & 0xF shifts = (u, v, v, u) digest = (b, b, b | c, b | c) return shifts, digest def permute(values, table): return tuple(values[i] for i in table) def unpermute(values, table): result = [0] * len(table) for dst, src in enumerate(table): result[src] = values[dst] return tuple(result) def encode_word(ch): value = ord(ch) if value > 0x7F: raise ValueError("ASCII only") high, low = value >> 4, value & 0xF raw = (0, 0, high, low) shifts, _ = params() return tuple((x + k) & 0xF for x, k in zip(raw, shifts)) def decode_word(word): shifts, _ = params() raw = tuple((x - k) & 0xF for x, k in zip(word, shifts)) if raw[:2] != (0, 0): raise ValueError("bad marker") value = (raw[2] << 4) | raw[3] if value > 0x7F: raise ValueError("not ASCII") return chr(value) def encode_full(block): stage = tuple(x for ch in block for x in encode_word(ch)) return permute(stage, PF) def decode_full(block): stage = unpermute(block, PF) return "".join(decode_word(stage[i:i + 4]) for i in (0, 4, 8)) def encode_tail(prefix): r = len(prefix) _, digest = params() stage = digest * (3 - r) stage += tuple(x for ch in prefix for x in encode_word(ch)) return permute(stage, P1 if r == 1 else P2) def decode_tail(block, r): table = P1 if r == 1 else P2 stage = unpermute(block, table) _, digest = params() digest_size = 4 * (3 - r) if stage[:digest_size] != digest * (3 - r): raise ValueError("bad digest") return "".join( decode_word(stage[i:i + 4]) for i in range(digest_size, 12, 4) ) def encrypt(plaintext): plaintext.encode("ascii") r = len(plaintext) % 3 prefix, body = plaintext[:r], plaintext[r:] blocks = [body[i:i + 3] for i in range(0, len(body), 3)] out = [] for block in reversed(blocks): out.extend(encode_full(block)) if r: out.extend(encode_tail(prefix)) return "".join(f"{x:X}" for x in out) def decrypt(ciphertext): ciphertext = "".join(ciphertext.split()).upper() if len(ciphertext) % 12: raise ValueError("bad ciphertext length") nibbles = tuple(int(x, 16) for x in ciphertext) all_blocks = [nibbles[i:i + 12] for i in range(0, len(nibbles), 12)] candidates = [] for r in (0, 1, 2): try: blocks = list(all_blocks) if r and not blocks: raise ValueError("missing tail block") prefix = decode_tail(blocks.pop(), r) if r else "" body = "".join(decode_full(block) for block in reversed(blocks)) plaintext = prefix + body if encrypt(plaintext) == ciphertext: candidates.append(plaintext) except (ValueError, UnicodeError): pass if len(candidates) != 1: raise ValueError(f"got {len(candidates)} candidates") return candidates[0] SAMPLES = { "TLU": "94AA48550495", "Hello": "34BB405504B5223594B94C53", "2026": "A48844556485223322356483", "abcd!": "547B475584B5223564BB4553", } for plain, cipher in SAMPLES.items(): assert encrypt(plain) == cipher assert decrypt(cipher) == plain flag_cipher = ( "14CC4655547594BC475584C5848A43551495448C445584C5D4C9475564C5" "34A84B55A4B574BA4355F495A48844556485648C495534A5548C4F5584A5" "B4BB405554B522332235A4B3" ) flag = decrypt(flag_cipher) print(flag) assert encrypt(flag) == flag_cipher ``` 运行结果: ```text flag{T1u_2026_Kc7f_Crypt0_M4ster!} ``` ## 9. 一点补充 公开数据毕竟只有四组,没法保证每个表下标和作者源码一模一样。比如 ASCII 编码中的几个槽位永远是相同常量,交换它们并同步修改置换表,不会改变任何实际输出。 同样,四个样例各自只有一个完整三字符块,所以“多个完整块整体逆序”不是靠样例单独钉死的;它是在目标密文逐块解开后暴露出来,再由完整重加密和平台通过共同确认。 这也是这题最值得记住的一点:Counter 对上,只代表原料对了;字符串读起来顺,也不等于解密完成。真正能收工的标准还是那三个——公开样例全部复现、目标密文可以完整往返、平台提交通过。 PS: 乱拼拼成 `flag{Kc7f_2026_Crypt0_M4ster_T1u!}` 也很合理对吧?(笑) 最终 FLAG: ```text flag{T1u_2026_Kc7f_Crypt0_M4ster!} ```
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
1
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
落音吹雪
你的分享对大家帮助很大,非常感谢!
2026-8-21 20:49
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
number_Z
2
6
发帖
16
回帖
125
RANK
关注
私信
他的文章
[原创]KCTF 2026 第七题:戌时·暗能潜流 writeup
32
[原创]KCTF 2026 第五题:申时·忆海倒带 writeup
1127
[原创]KCTF 2026 第二题:巳时·绿光幽语 writeup
750
[原创]看雪2020 KCTF秋季赛 第九题 命悬一线writeup
8054
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部