CodeShell.kr - Marginalia

Challenge

258 个字母的古典密文,按 5 字母分组。难点在于整体 IC 近乎平坦,只能靠逐列 IC 定出密钥长度,再用词表逐列精修。

The note remains

字条还在。

1
https://codeshell.kr/challenges/marginalia/

Solution

附件 marginalia-v2.txt 是 258 个大写字母,按 5 字母分组。整体 IC 只有 0.0380 (英语约 0.066,随机 0.0385)→ 多表密码。

Step 1:按密钥长度分组算平均 IC,找峰值:

1
2
L= 1  0.0380   L= 5  0.0344   L=11  0.0510   L=22  0.0689  <-- 峰值
L= 2 0.0385 L= 8 0.0406 L=33 0.0556 L=43 0.0527

L=22 的列 IC 达到 0.0689,已经高于英语本身,这是正确的密钥长度。

Step 2:逐列做卡方检验取最优 Caesar 位移,得到密钥与近乎可读的明文:

1
2
key = QMVJHLQXTNPSKOEACZBGFW
THEARCIIVEWASFUIETAFTERMIDNIHHTINTHTEMPTYREADINGROPMSOMEOCEH...

个别列还差几个位移,因为 258 个字母分到 22 列后每列只有 12 个字符,统计量不足。

Step 3:用 8813 词的词表做逐列局部搜索(每轮固定其余列、对每列试 26 个位移, 取"被词表覆盖的字符数"最大者),3 轮收敛到 191/258,密钥修正为:

1
key = QMVJHLRXTNPSKDEACZBGFW        (第 7、14 位各改 1)

明文完全可读:

1
2
3
4
5
THE ARCHIVE WAS QUIET AFTER MIDNIGHT IN THE EMPTY READING ROOM SOMEONE
HAD LEFT A SINGLE PAGE UNDER THE LAMP THE PAGE HELD NO SIGNATURE AND NO
DATE ITS LAST LINE WAS THE ONLY PART THAT MATTERED THE LAST LINE READ
THE LAMP KEPT ITS SECRET WRITE THOSE FIVE WORDS IN LOWERCASE WITH
UNDERSCORES INSIDE CODESHELL BRACES

最后一句直接给出答案:THE LAMP KEPT ITS SECRET → 五个词、小写、下划线。

两个脚本从同一起点独立跑,收敛到同一密钥与同一明文。

Script

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
#!/usr/bin/env python3
"""CodeShell.kr — Marginalia (Crypto, 50p) solver.

Ciphertext: 258 uppercase letters (51 groups of five plus a trailing 3-letter
group). Overall IC 0.0380 -> polyalphabetic.

1. Average column IC peaks at key length 22 (0.0689, the English value), so it
is a repeating-key Vigenere over 22 columns.
2. Chi-square per column gives a nearly readable plaintext; local search over
the 22 shifts (fitness = dictionary word coverage) fixes the remaining
columns and yields a fully readable note.

The note's last line names five words, and the final sentence instructs the
reader to write them in lowercase with underscores inside CodeShell braces.

The ciphertext is read from the challenge asset (never transcribed by hand --
two hand-typed characters once produced a subtly wrong plaintext).
"""
import sys
from pathlib import Path

ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
KEY = 'QMVJHLRXTNPSKDEACZBGFW'
ASSET = Path(''
'assets/challenge-files/marginalia-v2.txt')


def decode(text, key):
L = len(key)
return ''.join(ALPHA[(ALPHA.index(c) - ALPHA.index(key[i % L])) % 26]
for i, c in enumerate(text))


def main():
path = Path(sys.argv[1]) if len(sys.argv) > 1 else ASSET
text = ''.join(c for c in path.read_text().upper() if c in ALPHA)
assert len(text) == 258, len(text)
plain = decode(text, KEY)
print(f"key length {len(KEY)} key {KEY}")
for i in range(0, len(plain), 60):
print('%3d %s' % (i, plain[i:i + 60]))
assert plain.endswith('IDECODESHELLBRACES'), plain[-20:]
assert 'THELAMPKEPTITSSECRET' in plain
print('ANSWER CodeShell{the_lamp_kept_its_secret}')


if __name__ == '__main__':
main()
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
#!/usr/bin/env python3
"""CodeShell.kr — Marginalia: refine the 22-column Vigenere key.

Stage 1 (marginalia.py) found key length 22 by column-IC and produced a nearly
readable plaintext, but chi-square mis-picks a few columns because each column
only holds ~12 characters.

Stage 2 (here): local search over the 22 shifts, scoring a candidate plaintext
by how much of it is covered by dictionary words. Starting from the chi-square
key, try every shift for every column, keep improvements, repeat until stable.
"""
import sys
from pathlib import Path

ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
WORDS = None


def load_words(path):
return {w.strip().upper() for w in Path(path).read_text().split()
if 2 <= len(w.strip()) <= 20 and w.strip().isalpha()}


def coverage(text, words):
"""Greedy longest-match word coverage: fraction of chars inside words."""
i, covered, n = 0, 0, len(text)
while i < n:
best = 0
for L in range(min(20, n - i), 1, -1):
if text[i:i + L] in words:
best = L
break
if best:
covered += best
i += best
else:
i += 1
return covered


def decode(text, key):
"""key: iterable of int shifts (or a letter string)."""
shifts = [ALPHA.index(k) if isinstance(k, str) else k for k in key]
L = len(shifts)
return ''.join(ALPHA[(ALPHA.index(c) - shifts[i % L]) % 26]
for i, c in enumerate(text))


def refine(text, key, words, passes=12):
key = list(key)
best = coverage(decode(text, key), words)
print(f"start coverage {best}/{len(text)}")
for p in range(passes):
changed = 0
for col in range(len(key)):
cur = key[col]
local_best, local_shift = best, cur
for s in range(26):
if s == cur:
continue
key[col] = s
sc = coverage(decode(text, key), words)
if sc > local_best:
local_best, local_shift = sc, s
key[col] = local_shift
if local_shift != cur:
changed += 1
best = local_best
print(f"pass {p}: {changed} columns changed, coverage {best}/{len(text)}")
if changed == 0:
break
return ''.join(ALPHA[s] if isinstance(s, int) else s for s in key)


def main():
global WORDS
text = Path('marg.txt').read_text().strip()
text = ''.join(c for c in text.upper() if c in ALPHA)
WORDS = load_words(sys.argv[1] if len(sys.argv) > 1 else
'~/wordlists/words_alpha.txt')
print(f"words {len(WORDS)} ciphertext {len(text)}")
key = refine(text, 'QMVJHLQXTNPSKOEACZBGFW', WORDS)
print("key:", key)
plain = decode(text, key)
for i in range(0, len(plain), 60):
print('%3d %s' % (i, plain[i:i + 60]))


if __name__ == '__main__':
main()
CodeShell{the_lamp_kept_its_secret}