Text 19
5F70017FDD92B75AA6668648B404223663157787B35686FA165A8193E5075777F
与 Text 16 类似,每 4 位 hex 一组(偏移 + 编码字符)。前 13 位 hex(8 字节)是前缀/校验,有效数据从第 14 位开始。
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 resultR2D2:C3PO
Text 20
8221E4F2173368D6B6B6E5050935D986A8C4CA764CF8A8C4B734E99807140B19DB691998095CC4E3D6C60D6E91
结构
目标密文长度是 90 hex,明文长度应为 18 字符。API 加密满足:
len(encrypt(text)) == 5 * len(text)每 5 hex token 可以按下面方式观察:
[prefix 1 hex][a 2 hex][b 2 hex]目标密文按这个 layout 拆开:
pos group p a b (b-a)%710 8221E 8 34 30 671 4F217 4 242 23 652 3368D 3 54 141 163 6B6B6 6 182 182 04 E5050 E 80 80 05 935D9 9 53 217 226 86A8C 8 106 140 347 4CA76 4 202 118 588 4CF8A 4 207 138 29 8C4B7 8 196 183 5810 34E99 3 78 153 411 80714 8 7 20 1312 0B19D 0 177 157 5113 B6919 B 105 25 6214 98095 9 128 149 2115 CC4E3 C 196 227 3116 D6C60 D 108 96 5917 D6E91 D 110 145 35这题是 randomized encryption:同一 plaintext 每次加密都不同。简单把 b-a、a-b、xor、prefix 当 key 都不成立。
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 有强信号:
delta_i = b_i - a_{i+1} mod 71对 one-hot plaintext(例如 aaaaaaaaaaaaaaaaaa、wwwwwwwwwwwwwwwwww)采样时,前 13 个 transition 的 delta_i 对字符有明显泄露。它不是完美映射,会有错字和缺位,但不是随机噪声。把目标密文的前 13 个 transition 丢进这个映射,得到:
Par!2Lan6aaND这个结果已经足够说明几件事:
Par -> 很像 Paris 的开头Lan -> 很像 London 的中段/开头线索aaND -> 有明显的 N/D 大写结构,像 NewYork 这类拼接地名的残片也就是说,算法至少泄露出“城市名串”的轮廓。再结合 Text 20 明文长度必须是 18 字符,最自然的补全是:
ParisLondonNewYork用 solve API 验证:
solve?id=20&solution=ParisLondonNewYork -> 1ParisLondonNewYork
已排除的方向
这些方向已经用 oracle 样本和 held-out 测试排除,不值得无新假设地重复:
exact 5-hex token dictionaryinline/front/split-half/first-byte key 布局(b-a)%71, (a-b)%71, +/- prefix, xor, raw bytefirst K hex as global key/nonceaffine forms mod 71/72/128/256per-position naive Bayes / local feature classifierText20 -> Text23 子块投票迁移solve API 低置信候选枚举尤其是统计模型很容易在 constant corpus 上过拟合。用 random plaintext 做 5-fold held-out 后,top5 基本等于随机基线:
fold0 top1=0.0139 top5=0.0736fold1 top1=0.0181 top5=0.0833fold2 top1=0.0181 top5=0.0806fold3 top1=0.0097 top5=0.0542fold4 top1=0.0194 top5=0.0833random_top5 baseline = 5/71 = 0.0704一个有价值但尚未破解的结构
把密文看成 front layout:前 n 个 hex 是 prefix,后面每字符 4 hex 是两个 byte。这个视角下,前 13 个相邻 transition 有明显结构:
delta_i = b_i - a_{i+1} mod 256对 repeated char 样本,前 13 个 transition 很稳定:
w -> w 基本总是 00 -> 0 主要是 163 / 162a -> 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. 通过长度确认明文 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 字节对应一个明文字符。
#!/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, mapsfull response -> character. Parallel requests for speed.Outputs a sorted Python dict literal."""import sysimport urllib.parseimport urllib.requestfrom 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))21 collapsed lines
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)# ============================================================# 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',65 collapsed lines
'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 resultTryToDecrypt! now!
Text 22
00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9
结构分析
99 hex,每字符加密为 9 hex(3 组 × 3 hex),共 11 字符。
加密是确定性的(同一输入永远返回同一输出),但算法是位置相关的:同一个字符在不同位置产生完全不同的密文。
加密 0(charset index 0)和 a(index 10)在不同位置的输出:
位置 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(渐进猜解)
不需要理解加密公式也能解密:只要能调用加密工具,就可以暴力猜解。
核心依赖密文的前缀保持性质:
encrypt("a") → 001003050 (9 hex)encrypt("ab") → 00100305000A01201F (18 hex,前 9 hex 与 "a" 一致)encrypt("abc") → 00100305000A01201F... (前 18 hex 与 "ab" 一致)密文的前 hex 完全由明文的前 个字符决定。后续字符不影响前面的密文段。
算法:
- 从空字符串开始
- 对位置 i,已有正确前缀
guess(前 i 个字符已破解) - 遍历 charset 中全部 71 个候选字符
c - 通过 API 加密
guess + c - 如果返回的密文以目标密文的前 hex 开头,则
c就是第 位字符 - 重复至 11 位全部破解
脚本为排除多重匹配,每个位置遍历全部字符,共 次 API 调用;实际用时取决于接口延迟。离线测试仅验证前缀恢复逻辑、零匹配/多匹配和截断响应的中止行为,未调用 API。
关于反爬
最初尝试用 web 端加密(POST https://www.trytodecrypt.com/decrypt.php)做猜解,但非浏览器请求被服务端 bot 检测拦截,始终返回 503 Service Unavailable。即使带上 PHPSESSID cookie 和 User-Agent 也无济于事。
切换到独立 API 接口即解决:
# 有反爬(503)POST https://www.trytodecrypt.com/decrypt.php?id=22body: text=a&encrypt=Encrypt
# 无反爬(正常)GET http://api.trytodecrypt.com/encrypt?key=KEY&id=22&text=aAPI 端用 key 做身份认证,不做 bot 检测。key 在登录后从 API 页面 获取。
import reimport urllib.requestimport urllib.parse
API_KEY = 'YOUR_KEY_HERE'CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! 'target = '00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9'
def recover(target, encrypt): target = target.upper() if not target or len(target) % 9 or not re.fullmatch(r'[0-9A-F]+', target): raise ValueError('Target must contain complete 9-hex tokens') guess = '' for pos in range(len(target) // 9): matches = [] for ch in CHARSET: test = guess + ch response = encrypt(test).strip().upper() if len(response) != len(test) * 9 or not re.fullmatch(r'[0-9A-F]+', response): raise ValueError('Invalid or truncated encryption response') if response == target[:len(test) * 9]: matches.append(test) if len(matches) != 1: raise ValueError(f'Position {pos}: expected one match, got {len(matches)}') guess = matches[0] print(f' [{pos + 1}/{len(target) // 9}] {guess}') return guess
def encrypt(text): query = urllib.parse.urlencode({'key': API_KEY, 'id': 22, 'text': text}) url = 'http://api.trytodecrypt.com/encrypt?' + query with urllib.request.urlopen(url, timeout=10) as response: return response.read().decode('ascii')
if __name__ == '__main__': if API_KEY == 'YOUR_KEY_HERE': raise SystemExit('Set API_KEY before making authorized requests') print(recover(target, encrypt))运行过程:
[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] mississippimississippi
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:
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,。第一层解出来不是最终明文,而是一个仍然全为 hex 的 50 字符串:
6888B418AC9699327212137E82797A464B232C93955D63292E这一点非常反直觉,因为 API 行为确实显示:
len(encrypt(text)) == 25 * len(text)所以目标明文长度看起来应是 10 字符。但真正结构是:外层把「内层 50-hex 密文」再包了一次。
第二层:对 50-hex 中间密文再解一次
中间密文长度 50 hex,同样可看成 10 个 5-hex token。再跑同一个 decode:
完整复现代码:
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