trytodecrypt.com — too much! (19-23)

trytodecrypt.com

Text 19

5F70017FDD92B75AA6668648B404223663157787B35686FA165A8193E5075777F

与 Text 16 类似,每 4 位 hex 一组(偏移 + 编码字符)。前 13 位 hex(8 字节)是前缀/校验,有效数据从第 14 位开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! "

# ============================================================
# Text 19 — Too Much 1
# Known solution: R2D2:C3PO:BB8
# Structure: each char encoded as 5 hex chars
# First n hex chars = separators (1 per char)
def decode_text19(ct):
result = ""
if len(ct) == 65:
data = ct[13:]
else:
raise ValueError(f"Unexpected ciphertext length: {len(ct)}")

for i in range(0, len(data), 4):
pair = data[i : i + 4]
if len(pair) < 4:
break
offset = int(pair[0:2], 16)
enc = int(pair[2:4], 16)
diff = (enc - offset) % len(CHARSET)
if 0 <= diff < len(CHARSET):
result += CHARSET[diff]
else:
result += "?"

# Fixed version has 2 key chars prefix
if len(ct) == 76 and len(result) > 2:
return result[2:]
return result
R2D2:C3PO:BB8

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pos group  p   a    b    (b-a)%71
0 8221E 8 34 30 67
1 4F217 4 242 23 65
2 3368D 3 54 141 16
3 6B6B6 6 182 182 0
4 E5050 E 80 80 0
5 935D9 9 53 217 22
6 86A8C 8 106 140 34
7 4CA76 4 202 118 58
8 4CF8A 4 207 138 2
9 8C4B7 8 196 183 58
10 34E99 3 78 153 4
11 80714 8 7 20 13
12 0B19D 0 177 157 51
13 B6919 B 105 25 62
14 98095 9 128 149 21
15 CC4E3 C 196 227 31
16 D6C60 D 108 96 59
17 D6E91 D 110 145 35

这题是 randomized encryption:同一 plaintext 每次加密都不同。简单把 b-aa-bxorprefix 当 key 都不成立。

1
2
3
prefixes = ct[:n]
pairs = ct[n:]
item_i = (prefixes[i], pairs[4*i:4*i+2], pairs[4*i+2:4*i+4])

在这个 layout 下,相邻 token 的 transition 有强信号:

1
delta_i = b_i - a_{i+1} mod 71

对 one-hot plaintext(例如 aaaaaaaaaaaaaaaaaawwwwwwwwwwwwwwwwww)采样时,前 13 个 transition 的 delta_i 对字符有明显泄露。它不是完美映射,会有错字和缺位,但不是随机噪声。把目标密文的前 13 个 transition 丢进这个映射,得到:

1
Par!2Lan6aaND

这个结果已经足够说明几件事:

1
2
3
Par      -> 很像 Paris 的开头
Lan -> 很像 London 的中段/开头线索
aaND -> 有明显的 N/D 大写结构,像 NewYork 这类拼接地名的残片

也就是说,算法至少泄露出“城市名串”的轮廓。再结合 Text 20 明文长度必须是 18 字符,最自然的补全是:

1
ParisLondonNewYork

用 solve API 验证:

1
solve?id=20&solution=ParisLondonNewYork -> 1
ParisLondonNewYork

已排除的方向

这些方向已经用 oracle 样本和 held-out 测试排除,不值得无新假设地重复:

1
2
3
4
5
6
7
8
exact 5-hex token dictionary
inline/front/split-half/first-byte key 布局
(b-a)%71, (a-b)%71, +/- prefix, xor, raw byte
first K hex as global key/nonce
affine forms mod 71/72/128/256
per-position naive Bayes / local feature classifier
Text20 -> Text23 子块投票迁移
solve API 低置信候选枚举

尤其是统计模型很容易在 constant corpus 上过拟合。用 random plaintext 做 5-fold held-out 后,top5 基本等于随机基线:

1
2
3
4
5
6
fold0 top1=0.0139 top5=0.0736
fold1 top1=0.0181 top5=0.0833
fold2 top1=0.0181 top5=0.0806
fold3 top1=0.0097 top5=0.0542
fold4 top1=0.0194 top5=0.0833
random_top5 baseline = 5/71 = 0.0704

一个有价值但尚未破解的结构

把密文看成 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
2
3
w -> w  基本总是 0
0 -> 0 主要是 163 / 162
a -> a 主要是 114 / 115

但从 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
2
3
4
5
1. 通过长度确认明文 18 字符。
2. 改用 front layout 拆出 prefix 和 byte pairs。
3. 从相邻 transition 中恢复出前 13 位附近的带噪声骨架:Par!2Lan6aaND。
4. 根据骨架和 18 字符长度,提出城市名串候选 ParisLondonNewYork。
5. 用 solve API 验证,返回 1。

所以这里确实有语义猜测,但不是 blind guess。更准确地说,它是“结构泄露 + 人类模式识别 + oracle 验证”。后 5 位没有找到独立稳定 channel,因此没有把完整公式还原出来。

Text 21

333131353156333131323231305230363135315631333151342F3430313131323154342F

每字符加密为 4 位 hex(2 ASCII 字节),固定替换表。密文 hex 解码后每 2 字节对应一个明文字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/env python3
"""Build character mapping from trytodecrypt.com encrypt API.

Usage: trytodecrypt_get_char_map.py <text_id> <api_key>

Encrypts each character in the charset via the API, maps
full response -> character. Parallel requests for speed.
Outputs a sorted Python dict literal.
"""
import sys
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed

C = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! "
URL = "http://api.trytodecrypt.com/encrypt?key={key}&id={id}&text={text}"


def encrypt(ch, text_id, api_key):
url = URL.format(key=api_key, id=text_id, text=urllib.parse.quote(ch))
try:
with urllib.request.urlopen(url, timeout=10) as resp:
return resp.read().decode().strip()
except Exception as e:
return None


if __name__ == "__main__":
text_id = sys.argv[1]
api_key = sys.argv[2]

mapping = {}
total = len(C)
with ThreadPoolExecutor(max_workers=12) as pool:
fut_map = {pool.submit(encrypt, ch, text_id, api_key): ch for ch in C}
for i, fut in enumerate(as_completed(fut_map), 1):
ch = fut_map[fut]
enc = fut.result()
if enc:
mapping[enc] = ch
print(f"\r [{i}/{total}] {repr(ch)} -> {enc or 'FAIL'}" + " " * 10,
end="", file=sys.stderr, flush=True)
print(file=sys.stderr)

if mapping:
print({k: mapping[k] for k in sorted(mapping.keys())})
else:
print("{}")
sys.exit(1)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# ============================================================
# Text 21 — Too Much 3
# Known solution: TryToDecrypt! now!
# Encryption: each plaintext char maps to 4 fixed hex chars
# (step=4, simple substitution)
# ============================================================
def decode_text21(ct):
"""Decode Text 21 — fixed 4-hex substitution."""
# The known mapping (built from encrypt oracle):
# Each char -> exactly 4 hex chars
mapping = {
'0': '2F54',
'1': '2F55',
'2': '2F56',
'3': '302D',
'4': '302E',
'5': '302F',
'6': '3030',
'7': '3031',
'8': '3032',
'9': '3033',
'a': '3034',
'b': '3035',
'c': '3036',
'd': '3051',
'e': '3052',
'f': '3053',
'g': '3054',
'h': '3055',
'i': '3056',
'j': '312D',
'k': '312E',
'l': '312F',
'm': '3130',
'n': '3131',
'o': '3132',
'p': '3133',
'q': '3134',
'r': '3135',
's': '3136',
't': '3151',
'u': '3152',
'v': '3153',
'w': '3154',
'x': '3155',
'y': '3156',
'z': '322D',
'A': '322E',
'B': '322F',
'C': '3230',
'D': '3231',
'E': '3232',
'F': '3233',
'G': '3234',
'H': '3235',
'I': '3236',
'J': '3251',
'K': '3252',
'L': '3253',
'M': '3254',
'N': '3255',
'O': '3256',
'P': '332D',
'Q': '332E',
'R': '332F',
'S': '3330',
'T': '3331',
'U': '3332',
'V': '3333',
'W': '3334',
'X': '3335',
'Y': '3336',
'Z': '3351',
'-': '3352',
'_': '3353',
'.': '3354',
',': '3355',
';': '3356',
':': '342D',
'?': '342E',
'!': '342F',
' ': '3430',
}
reverse_mapping = {v: k for k, v in mapping.items()}

result = ""
for i in range(0, len(ct), 4):
chunk = ct[i : i + 4]
if chunk in reverse_mapping:
result += reverse_mapping[chunk]
else:
result += "?"
return result
TryToDecrypt! now!

Text 22

00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9

结构分析

99 hex,每字符加密为 9 hex3 组 × 3 hex),共 11 字符。

加密是确定性的——同一输入永远返回同一输出——但算法是位置相关的:同一个字符在不同位置产生完全不同的密文。

加密 0(charset index 0)和 a(index 10)在不同位置的输出:

1
2
位置 0: '0' → [001, 003, 008]   'a' → [001, 003, 050]   仅 group2 变化
位置 1: '0' → [00A, 00F, 01F] 'a' → [00A, 012, 01C] group1/2 都变

关键观察:

  • group0 只与位置有关,与字符无关(同一位置所有字符共享同一个 group0,如位置 0 永远是 001、位置 1 永远是 00A)
  • group1 + group2 共同编码字符,但公式复杂且各位置不同
  • 不存在简单的线性公式——尝试过 (b2-b1) % 71(b1+b2-K) % 71(b1 XOR b2) % 71 等均不成立

解法:progressive guessing(渐进猜解)

不需要理解加密公式也能解密——只要能调用加密工具,就可以暴力猜解。

核心依赖密文的前缀保持性质

1
2
3
encrypt("a")     → 001003050          (9 hex)
encrypt("ab") → 00100305000A01201F (18 hex,前 9 hex 与 "a" 一致)
encrypt("abc") → 00100305000A01201F... (前 18 hex 与 "ab" 一致)

密文的前 N×9 hex 完全由明文的前 N 个字符决定。后续字符不影响前面的密文段。

算法:

  1. 从空字符串开始
  2. 对位置 i,已有正确前缀 guess(前 i 个字符已破解)
  3. 遍历 charset 中全部 71 个候选字符 c
  4. 通过 API 加密 guess + c
  5. 如果返回的密文以目标密文的前 (i+1)×9 hex 开头,则 c 就是第 i 位字符
  6. 重复至 11 位全部破解

最坏 11 × 71 = 781 次 API 调用,几分钟跑完。

关于反爬

最初尝试用 web 端加密(POST https://www.trytodecrypt.com/decrypt.php)做猜解,但非浏览器请求被服务端 bot 检测拦截,始终返回 503 Service Unavailable。即使带上 PHPSESSID cookie 和 User-Agent 也无济于事。

切换到独立 API 接口即解决:

1
2
3
4
5
6
# 有反爬(503)
POST https://www.trytodecrypt.com/decrypt.php?id=22
body: text=a&encrypt=Encrypt

# 无反爬(正常)
GET http://api.trytodecrypt.com/encrypt?key=KEY&id=22&text=a

API 端用 key 做身份认证,不做 bot 检测。key 在登录后从 API 页面 获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import urllib.request, urllib.parse
API_KEY = 'YOUR_KEY_HERE'
CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! '
target = '00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9'
guess = ''
for pos in range(11):
for ch in CHARSET:
test = guess + ch
url = f'http://api.trytodecrypt.com/encrypt?key={API_KEY}&id=22&text={urllib.parse.quote(test)}'
resp = urllib.request.urlopen(url).read().decode().strip()
if resp.upper().startswith(target[:len(test)*9]):
guess = test
break
print(f' [{pos+1}/11] {guess}')
"

运行过程:

1
2
3
4
5
6
7
8
9
10
11
[1/11] m
[2/11] mi
[3/11] mis
[4/11] miss
[5/11] missi
[6/11] missis
[7/11] mississ
[8/11] mississi
[9/11] mississip
[10/11] mississipp
[11/11] mississippi
mississippi

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
2
3
4
prefixes = ct[:n]
pairs = ct[n:]
item_i = (prefixes[i], pairs[4*i:4*i+2], pairs[4*i+2:4*i+4])
plain_i = CHARSET[(b_i - a_i) % 71]

对 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! '


def decode_once(ct):
n = len(ct) // 5
prefixes = ct[:n]
pairs = ct[n:]

out = ''
for i in range(n):
a = int(pairs[4*i:4*i+2], 16)
b = int(pairs[4*i+2:4*i+4], 16)
out += CHARSET[(b - a) % len(CHARSET)]
return out


target = 'E3F59F001361B62958E551B9702F2C6B25F9E3FC350062295A1A20182041493C447BA0767A393A1F278DB14268565F51575C65212A8386494B383F7375676845472F30494C737A406890988B8D50577A835960476B6F73686E6367668B787A494C33357EA4555E191C18216A6F353A173E2026474A8A8C3F481416759D'

mid = decode_once(target)
plain = decode_once(mid)

print(mid)
print(plain)
3.14159265