trytodecrypt.com — too much! (19-23)
trytodecrypt.com
Text 19
5F70017FDD92B75AA6668648B404223663157787B35686FA165A8193E5075777F
与 Text 16 类似,每 4 位 hex 一组(偏移 + 编码字符)。前 13 位 hex(8 字节)是前缀/校验,有效数据从第 14 位开始。
1 | CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! " |
Text 20
8221E4F2173368D6B6B6E5050935D986A8C4CA764CF8A8C4B734E99807140B19DB691998095CC4E3D6C60D6E91
结构
目标密文长度是 90 hex,明文长度应为 18 字符。API 加密满足:
1 | len(encrypt(text)) == 5 * len(text) |
每 5 hex token 可以按下面方式观察:
1 | [prefix 1 hex][a 2 hex][b 2 hex] |
目标密文按这个 layout 拆开:
1 | pos group p a b (b-a)%71 |
这题是 randomized encryption:同一 plaintext 每次加密都不同。简单把
b-a、a-b、xor、prefix
当 key 都不成立。
1 | prefixes = ct[:n] |
在这个 layout 下,相邻 token 的 transition 有强信号:
1 | delta_i = b_i - a_{i+1} mod 71 |
对 one-hot plaintext(例如
aaaaaaaaaaaaaaaaaa、wwwwwwwwwwwwwwwwww)采样时,前
13 个 transition 的 delta_i
对字符有明显泄露。它不是完美映射,会有错字和缺位,但不是随机噪声。把目标密文的前
13 个 transition 丢进这个映射,得到:
1 | Par!2Lan6aaND |
这个结果已经足够说明几件事:
1 | Par -> 很像 Paris 的开头 |
也就是说,算法至少泄露出“城市名串”的轮廓。再结合 Text 20 明文长度必须是 18 字符,最自然的补全是:
1 | ParisLondonNewYork |
用 solve API 验证:
1 | solve?id=20&solution=ParisLondonNewYork -> 1 |
已排除的方向
这些方向已经用 oracle 样本和 held-out 测试排除,不值得无新假设地重复:
1 | exact 5-hex token dictionary |
尤其是统计模型很容易在 constant corpus 上过拟合。用 random plaintext 做 5-fold held-out 后,top5 基本等于随机基线:
1 | fold0 top1=0.0139 top5=0.0736 |
一个有价值但尚未破解的结构
把密文看成 front layout:前 n 个 hex 是 prefix,后面每字符 4 hex 是两个 byte。这个视角下,前 13 个相邻 transition 有明显结构:
1 | delta_i = b_i - a_{i+1} mod 256 |
对 repeated char 样本,前 13 个 transition 很稳定:
1 | w -> w 基本总是 0 |
但从 pos13 之后,这个 transition 会退化成近随机。也就是说,算法里可能存在一段链式状态,长度或边界不是简单的 18 字符全程一致。
尝试把 target 的 transition 当成普通 pair dictionary
解路径失败:高分候选都不是合理明文,少量提交也返回
0。因此这里的结构更像某种 state/nonce/PRNG relation,而不是
F(ch_i, ch_{i+1}) 这种直接查表。
当前结论
Text 20 的答案已通过 solve API 验证。它的核心不是 per-token decode,也不是纯靠猜;而是:
1 | 1. 通过长度确认明文 18 字符。 |
所以这里确实有语义猜测,但不是 blind guess。更准确地说,它是“结构泄露 + 人类模式识别 + oracle 验证”。后 5 位没有找到独立稳定 channel,因此没有把完整公式还原出来。
Text 21
333131353156333131323231305230363135315631333151342F3430313131323154342F
每字符加密为 4 位 hex(2 ASCII 字节),固定替换表。密文 hex 解码后每 2 字节对应一个明文字符。
1 | #!/usr/bin/env python3 |
1 | # ============================================================ |
Text 22
00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9
结构分析
99 hex,每字符加密为 9 hex(3 组 × 3 hex),共 11 字符。
加密是确定性的——同一输入永远返回同一输出——但算法是位置相关的:同一个字符在不同位置产生完全不同的密文。
加密 0(charset index 0)和 a(index
10)在不同位置的输出:
1 | 位置 0: '0' → [001, 003, 008] 'a' → [001, 003, 050] 仅 group2 变化 |
关键观察:
- group0 只与位置有关,与字符无关(同一位置所有字符共享同一个 group0,如位置 0 永远是 001、位置 1 永远是 00A)
- group1 + group2 共同编码字符,但公式复杂且各位置不同
- 不存在简单的线性公式——尝试过
(b2-b1) % 71、(b1+b2-K) % 71、(b1 XOR b2) % 71等均不成立
解法:progressive guessing(渐进猜解)
不需要理解加密公式也能解密——只要能调用加密工具,就可以暴力猜解。
核心依赖密文的前缀保持性质:
1 | encrypt("a") → 001003050 (9 hex) |
密文的前 N×9 hex 完全由明文的前 N
个字符决定。后续字符不影响前面的密文段。
算法:
- 从空字符串开始
- 对位置 i,已有正确前缀
guess(前 i 个字符已破解) - 遍历 charset 中全部 71 个候选字符
c - 通过 API 加密
guess + c - 如果返回的密文以目标密文的前
(i+1)×9hex 开头,则c就是第 i 位字符 - 重复至 11 位全部破解
最坏 11 × 71 = 781 次 API 调用,几分钟跑完。
关于反爬
最初尝试用 web
端加密(POST https://www.trytodecrypt.com/decrypt.php)做猜解,但非浏览器请求被服务端
bot 检测拦截,始终返回 503 Service
Unavailable。即使带上 PHPSESSID cookie 和 User-Agent
也无济于事。
切换到独立 API 接口即解决:
1 | # 有反爬(503) |
API 端用 key 做身份认证,不做 bot 检测。key 在登录后从 API 页面 获取。
1 | import urllib.request, urllib.parse |
运行过程:
1 | [1/11] m |
Text 23
E3F59F001361B62958E551B9702F2C6B25F9E3FC350062295A1A20182041493C447BA0767A393A1F278DB14268565F51575C65212A8386494B383F7375676845472F30494C737A406890988B8D50577A835960476B6F73686E6367668B787A494C33357EA4555E191C18216A6F353A173E2026474A8A8C3F481416759D
这题最后的关键不是 PRNG,也不是统计分类,而是 递归套壳:Text 23 的整段 250-hex ciphertext 可以先按 Text19/Text20 那种 front-prefix layout 解出一层 50-hex 中间密文;这个中间密文再用同一个规则解一次,得到真正 plaintext。
第一层:把 250 hex 当成 50 个 5-hex token
目标密文长度是 250 hex。把它整体看成 50 个 5-hex token。
沿用前面 Text 19 / Text 20 里反复出现的 layout:
1 | prefixes = ct[:n] |
对 Text
23,n = 250 / 5 = 50。第一层解出来不是最终明文,而是一个仍然全为
hex 的 50 字符串:
1 | 6888B418AC9699327212137E82797A464B232C93955D63292E |
这一点非常反直觉,因为 API 行为确实显示:
1 | len(encrypt(text)) == 25 * len(text) |
所以目标明文长度看起来应是 10 字符。但真正结构是:外层把「内层 50-hex 密文」再包了一次。
第二层:对 50-hex 中间密文再解一次
中间密文长度 50 hex,同样可看成 10 个 5-hex token。再跑同一个 decode:
完整复现代码:
1 | CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! ' |