ctf-agent 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +131 -0
- package/Dockerfile +192 -0
- package/README.md +522 -0
- package/agents/ctf-analyzer.md +58 -0
- package/agents/ctf-controller.md +151 -0
- package/agents/ctf-speedrun.md +74 -0
- package/bin/ctf-agent.js +119 -0
- package/docker-compose.yml +13 -0
- package/mcp_config.json +19 -0
- package/package.json +44 -0
- package/references/ctf-triage-ladder.md +68 -0
- package/references/exploit-databases.md +575 -0
- package/references/llm-safety-and-policy-compliance.md +173 -0
- package/references/multi-agent-orchestration-and-policy-routing.md +278 -0
- package/references/security-events-and-intelligence.md +566 -0
- package/references/version-matrix.md +59 -0
- package/rules/ctf-execution-rules.md +44 -0
- package/rules/ctf-safety-framing-rules.md +140 -0
- package/rules/flag-validation-rules.md +25 -0
- package/scripts/__init__.py +1 -0
- package/scripts/ctf-tools.lock +205 -0
- package/scripts/ctf_init.py +1026 -0
- package/scripts/ctf_update.py +583 -0
- package/scripts/ctfd_client.py +81 -0
- package/scripts/cve_lookup.py +453 -0
- package/scripts/extract_flags.py +155 -0
- package/scripts/install_as_agent.py +278 -0
- package/scripts/install_ctf_tools.sh +603 -0
- package/scripts/parallel_triage.py +395 -0
- package/scripts/prompt_policy_sanitizer.py +283 -0
- package/scripts/scope_guard.py +444 -0
- package/scripts/skill_validator.py +504 -0
- package/scripts/workspace_cleaner.py +141 -0
- package/skills/ctf-ai-ml/SKILL.md +120 -0
- package/skills/ctf-ai-ml/adversarial-ml.md +623 -0
- package/skills/ctf-ai-ml/llm-attacks.md +487 -0
- package/skills/ctf-ai-ml/model-attacks.md +422 -0
- package/skills/ctf-crypto/SKILL.md +320 -0
- package/skills/ctf-crypto/advanced-math.md +798 -0
- package/skills/ctf-crypto/classic-ciphers.md +651 -0
- package/skills/ctf-crypto/ecc-attacks.md +347 -0
- package/skills/ctf-crypto/exotic-crypto-2.md +380 -0
- package/skills/ctf-crypto/exotic-crypto.md +528 -0
- package/skills/ctf-crypto/historical.md +113 -0
- package/skills/ctf-crypto/lattice-and-lwe.md +524 -0
- package/skills/ctf-crypto/modern-ciphers-2.md +563 -0
- package/skills/ctf-crypto/modern-ciphers-3.md +453 -0
- package/skills/ctf-crypto/modern-ciphers.md +649 -0
- package/skills/ctf-crypto/prng-attacks.md +257 -0
- package/skills/ctf-crypto/prng.md +664 -0
- package/skills/ctf-crypto/rsa-attacks-2.md +792 -0
- package/skills/ctf-crypto/rsa-attacks.md +487 -0
- package/skills/ctf-crypto/stream-ciphers.md +390 -0
- package/skills/ctf-crypto/zkp-and-advanced.md +456 -0
- package/skills/ctf-forensics/3d-printing.md +121 -0
- package/skills/ctf-forensics/SKILL.md +379 -0
- package/skills/ctf-forensics/disk-advanced.md +497 -0
- package/skills/ctf-forensics/disk-and-memory.md +491 -0
- package/skills/ctf-forensics/disk-recovery.md +699 -0
- package/skills/ctf-forensics/linux-forensics.md +511 -0
- package/skills/ctf-forensics/network-advanced.md +583 -0
- package/skills/ctf-forensics/network.md +645 -0
- package/skills/ctf-forensics/peripheral-capture.md +287 -0
- package/skills/ctf-forensics/signals-and-hardware.md +713 -0
- package/skills/ctf-forensics/steganography.md +694 -0
- package/skills/ctf-forensics/stego-advanced-2.md +475 -0
- package/skills/ctf-forensics/stego-advanced.md +481 -0
- package/skills/ctf-forensics/stego-image.md +691 -0
- package/skills/ctf-forensics/windows.md +625 -0
- package/skills/ctf-malware/SKILL.md +181 -0
- package/skills/ctf-malware/c2-and-protocols.md +274 -0
- package/skills/ctf-malware/pe-and-dotnet.md +108 -0
- package/skills/ctf-malware/scripts-and-obfuscation.md +449 -0
- package/skills/ctf-misc/SKILL.md +498 -0
- package/skills/ctf-misc/bashjails.md +323 -0
- package/skills/ctf-misc/ctfd-navigation.md +465 -0
- package/skills/ctf-misc/dns.md +255 -0
- package/skills/ctf-misc/encodings-advanced.md +504 -0
- package/skills/ctf-misc/encodings.md +431 -0
- package/skills/ctf-misc/games-and-vms-2.md +254 -0
- package/skills/ctf-misc/games-and-vms-3.md +690 -0
- package/skills/ctf-misc/games-and-vms-4.md +229 -0
- package/skills/ctf-misc/games-and-vms.md +529 -0
- package/skills/ctf-misc/linux-privesc.md +333 -0
- package/skills/ctf-misc/pyjails.md +671 -0
- package/skills/ctf-misc/rf-sdr.md +91 -0
- package/skills/ctf-osint/SKILL.md +198 -0
- package/skills/ctf-osint/geolocation-and-media.md +464 -0
- package/skills/ctf-osint/social-media.md +312 -0
- package/skills/ctf-osint/web-and-dns.md +341 -0
- package/skills/ctf-pwn/SKILL.md +214 -0
- package/skills/ctf-pwn/advanced-exploits-2.md +579 -0
- package/skills/ctf-pwn/advanced-exploits-3.md +598 -0
- package/skills/ctf-pwn/advanced-exploits-4.md +590 -0
- package/skills/ctf-pwn/advanced-exploits-5.md +119 -0
- package/skills/ctf-pwn/advanced-exploits.md +773 -0
- package/skills/ctf-pwn/advanced.md +326 -0
- package/skills/ctf-pwn/field-notes.md +245 -0
- package/skills/ctf-pwn/format-string.md +694 -0
- package/skills/ctf-pwn/heap-fsop.md +285 -0
- package/skills/ctf-pwn/heap-techniques-2.md +333 -0
- package/skills/ctf-pwn/heap-techniques.md +513 -0
- package/skills/ctf-pwn/kernel-bypass.md +421 -0
- package/skills/ctf-pwn/kernel-techniques.md +366 -0
- package/skills/ctf-pwn/kernel.md +636 -0
- package/skills/ctf-pwn/overflow-basics.md +611 -0
- package/skills/ctf-pwn/rop-advanced.md +725 -0
- package/skills/ctf-pwn/rop-and-shellcode.md +659 -0
- package/skills/ctf-pwn/sandbox-escape.md +313 -0
- package/skills/ctf-reverse/SKILL.md +163 -0
- package/skills/ctf-reverse/anti-analysis-ctf.md +204 -0
- package/skills/ctf-reverse/anti-analysis.md +693 -0
- package/skills/ctf-reverse/field-notes.md +376 -0
- package/skills/ctf-reverse/languages-compiled.md +666 -0
- package/skills/ctf-reverse/languages-platforms.md +592 -0
- package/skills/ctf-reverse/languages.md +553 -0
- package/skills/ctf-reverse/patterns-ctf-2.md +397 -0
- package/skills/ctf-reverse/patterns-ctf-3.md +797 -0
- package/skills/ctf-reverse/patterns-ctf.md +670 -0
- package/skills/ctf-reverse/patterns-runtime.md +274 -0
- package/skills/ctf-reverse/patterns.md +572 -0
- package/skills/ctf-reverse/platforms-hardware.md +387 -0
- package/skills/ctf-reverse/platforms.md +664 -0
- package/skills/ctf-reverse/tools-advanced-2.md +421 -0
- package/skills/ctf-reverse/tools-advanced.md +407 -0
- package/skills/ctf-reverse/tools-dynamic.md +679 -0
- package/skills/ctf-reverse/tools-emulation.md +319 -0
- package/skills/ctf-reverse/tools.md +573 -0
- package/skills/ctf-web/SKILL.md +153 -0
- package/skills/ctf-web/auth-and-access-2.md +82 -0
- package/skills/ctf-web/auth-and-access.md +783 -0
- package/skills/ctf-web/auth-infra.md +321 -0
- package/skills/ctf-web/auth-jwt.md +186 -0
- package/skills/ctf-web/client-side-advanced.md +739 -0
- package/skills/ctf-web/client-side.md +529 -0
- package/skills/ctf-web/cves.md +373 -0
- package/skills/ctf-web/field-notes.md +482 -0
- package/skills/ctf-web/node-and-prototype.md +200 -0
- package/skills/ctf-web/server-side-2.md +337 -0
- package/skills/ctf-web/server-side-advanced-2.md +559 -0
- package/skills/ctf-web/server-side-advanced-3.md +125 -0
- package/skills/ctf-web/server-side-advanced-4.md +480 -0
- package/skills/ctf-web/server-side-advanced.md +378 -0
- package/skills/ctf-web/server-side-deser.md +443 -0
- package/skills/ctf-web/server-side-exec-2.md +799 -0
- package/skills/ctf-web/server-side-exec.md +457 -0
- package/skills/ctf-web/server-side.md +629 -0
- package/skills/ctf-web/sql-injection.md +790 -0
- package/skills/ctf-web/web3.md +374 -0
- package/skills/ctf-writeup/SKILL.md +90 -0
- package/skills/solve-challenge/SKILL.md +269 -0
- package/skills.json +16 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
# CTF Crypto - Exotic Algebraic Structures
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [Braid Group DH — Alexander Polynomial Multiplicativity (DiceCTF 2026)](#braid-group-dh--alexander-polynomial-multiplicativity-dicectf-2026)
|
|
5
|
+
- [Monotone Function Inversion with Partial Output](#monotone-function-inversion-with-partial-output)
|
|
6
|
+
- [Tropical Semiring Residuation Attack (BearCatCTF 2026)](#tropical-semiring-residuation-attack-bearcatctf-2026)
|
|
7
|
+
- [Paillier Cryptosystem Attack (SECCON 2015)](#paillier-cryptosystem-attack-seccon-2015)
|
|
8
|
+
- [Hamming Code Error Correction with Helical Interleaving (Sharif CTF 2016)](#hamming-code-error-correction-with-helical-interleaving-sharif-ctf-2016)
|
|
9
|
+
- [ElGamal Universal Re-encryption (Sharif CTF 2016)](#elgamal-universal-re-encryption-sharif-ctf-2016)
|
|
10
|
+
- [Paillier Oracle Size Bypass via Ciphertext Factoring (BSidesSF 2025)](#paillier-oracle-size-bypass-via-ciphertext-factoring-bsidessf-2025)
|
|
11
|
+
- [Format-Preserving Encryption Feistel Brute-Force (BSidesSF 2026)](#format-preserving-encryption-feistel-brute-force-bsidessf-2026)
|
|
12
|
+
- [Icosahedral Symmetry Group Cipher (BSidesSF 2026)](#icosahedral-symmetry-group-cipher-bsidessf-2026)
|
|
13
|
+
- [Goldwasser-Micali Ciphertext Replication Oracle (BSidesSF 2026)](#goldwasser-micali-ciphertext-replication-oracle-bsidessf-2026)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Braid Group DH — Alexander Polynomial Multiplicativity (DiceCTF 2026)
|
|
18
|
+
|
|
19
|
+
**Pattern (Plane or Exchange):** Diffie-Hellman key exchange built over mathematical braids. Public keys are derived by connecting a private braid to public info, then scrambled with Reidemeister-like moves. Shared secret = `sha256(normalize(calculate(connect(my_priv, their_pub))))`. The `calculate()` function computes the Alexander polynomial of the braid.
|
|
20
|
+
|
|
21
|
+
**Protocol structure:**
|
|
22
|
+
```python
|
|
23
|
+
import sympy as sp
|
|
24
|
+
import hashlib
|
|
25
|
+
|
|
26
|
+
t = sp.Symbol('t')
|
|
27
|
+
|
|
28
|
+
def compose(p1, p2):
|
|
29
|
+
return [p1[p2[i]] for i in range(len(p1))]
|
|
30
|
+
|
|
31
|
+
def inverse(p):
|
|
32
|
+
inv = [0] * len(p)
|
|
33
|
+
for i, j in enumerate(p):
|
|
34
|
+
inv[j] = i
|
|
35
|
+
return inv
|
|
36
|
+
|
|
37
|
+
def connect(g1, g2):
|
|
38
|
+
"""Concatenate two braids with a swap at the junction."""
|
|
39
|
+
x1, o1 = g1
|
|
40
|
+
x2, o2 = g2
|
|
41
|
+
l = len(x1)
|
|
42
|
+
new_x = list(x1) + [v + l for v in x2]
|
|
43
|
+
new_o = list(o1) + [v + l for v in o2]
|
|
44
|
+
# Swap at junction
|
|
45
|
+
new_x[l-1], new_x[l] = new_x[l], new_x[l-1]
|
|
46
|
+
return (new_x, new_o)
|
|
47
|
+
|
|
48
|
+
def sweep(ap):
|
|
49
|
+
"""Compute winding number matrix from arc presentation."""
|
|
50
|
+
l = len(ap)
|
|
51
|
+
current_row = [0] * l
|
|
52
|
+
matrix = []
|
|
53
|
+
for pair in ap:
|
|
54
|
+
c1, c2 = sorted(pair)
|
|
55
|
+
diff = pair[1] - pair[0]
|
|
56
|
+
s = 1 if diff > 0 else (-1 if diff < 0 else 0)
|
|
57
|
+
for c in range(c1, c2):
|
|
58
|
+
current_row[c] += s
|
|
59
|
+
matrix.append(list(current_row))
|
|
60
|
+
return matrix
|
|
61
|
+
|
|
62
|
+
def mine(point):
|
|
63
|
+
x, o = point
|
|
64
|
+
return sweep([*zip(x, o)])
|
|
65
|
+
|
|
66
|
+
def calculate(point):
|
|
67
|
+
"""Compute Alexander polynomial from braid."""
|
|
68
|
+
mat = sp.Matrix([[t**(-x) for x in y] for y in mine(point)])
|
|
69
|
+
return mat.det(method='bareiss') * (1 - t)**(1 - len(point[0]))
|
|
70
|
+
|
|
71
|
+
def normalize(calculation):
|
|
72
|
+
"""Convert Laurent polynomial to standard form."""
|
|
73
|
+
poly = sp.expand(sp.simplify(calculation))
|
|
74
|
+
all_exp = [term.as_coeff_exponent(t)[1] for term in poly.as_ordered_terms()]
|
|
75
|
+
min_exp = min(all_exp)
|
|
76
|
+
poly = sp.expand(sp.simplify(poly * t**(-min_exp)))
|
|
77
|
+
if poly.coeff(t, 0) < 0:
|
|
78
|
+
poly *= -1
|
|
79
|
+
return poly
|
|
80
|
+
|
|
81
|
+
# Key exchange:
|
|
82
|
+
# alice_pub = scramble(connect(pub_info, alice_priv), 1000)
|
|
83
|
+
# bob_pub = scramble(connect(pub_info, bob_priv), 1000)
|
|
84
|
+
# shared = sha256(str(normalize(calculate(connect(alice_priv, bob_pub)))))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**The fatal vulnerability — Alexander polynomial multiplicativity:**
|
|
88
|
+
|
|
89
|
+
The Alexander polynomial satisfies `Δ(β₁·β₂) = Δ(β₁) × Δ(β₂)` under braid concatenation. This makes the scheme abelian:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# Eve computes shared secret from public values only:
|
|
93
|
+
calc_pub = normalize(calculate(pub_info))
|
|
94
|
+
calc_alice = normalize(calculate(alice_pub))
|
|
95
|
+
calc_bob = normalize(calculate(bob_pub))
|
|
96
|
+
|
|
97
|
+
# Recover Alice's private polynomial
|
|
98
|
+
calc_alice_priv = sp.cancel(calc_alice / calc_pub) # exact division
|
|
99
|
+
|
|
100
|
+
# Shared secret = calc(alice_priv) * calc(bob_pub) = calc(bob_priv) * calc(alice_pub)
|
|
101
|
+
shared_poly = normalize(sp.expand(calc_alice_priv * calc_bob))
|
|
102
|
+
shared_hex = hashlib.sha256(str(shared_poly).encode()).hexdigest()
|
|
103
|
+
|
|
104
|
+
# Decrypt XOR stream cipher
|
|
105
|
+
key = bytes.fromhex(shared_hex)
|
|
106
|
+
while len(key) < len(ciphertext):
|
|
107
|
+
key += hashlib.sha256(key).digest()
|
|
108
|
+
plaintext = bytes(a ^ b for a, b in zip(ciphertext, key))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Computational trick for large matrices:**
|
|
112
|
+
|
|
113
|
+
Direct sympy Bareiss on rational-function matrices (e.g., 30×30 with entries `t^(-w)`) is extremely slow. Clear denominators first:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
# Winding numbers range from w_min to w_max (e.g., -1 to 5)
|
|
117
|
+
# Multiply all entries by t^w_max to get polynomial matrix
|
|
118
|
+
k = max(abs(w) for row in winding_matrix for w in row)
|
|
119
|
+
n = len(winding_matrix)
|
|
120
|
+
|
|
121
|
+
# Original: M[i][j] = t^(-w[i][j])
|
|
122
|
+
# Scaled: M'[i][j] = t^(k - w[i][j]) (all non-negative powers)
|
|
123
|
+
mat_poly = sp.Matrix([[t**(k - w) for w in row] for row in winding_matrix])
|
|
124
|
+
det_scaled = mat_poly.det(method='bareiss') # Much faster!
|
|
125
|
+
|
|
126
|
+
# Recover true determinant: det(M) = det(M') / t^(k*n)
|
|
127
|
+
det_true = sp.cancel(det_scaled / t**(k * n))
|
|
128
|
+
# Then: (1-t)^(n-1) divides det_true (topological property)
|
|
129
|
+
result = sp.cancel(det_true * (1 - t)**(1 - n))
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Validation — palindromic property:**
|
|
133
|
+
All valid Alexander polynomials are palindromic (coefficients read the same forwards and backwards). Use this as a sanity check on intermediate results:
|
|
134
|
+
```python
|
|
135
|
+
def is_palindromic(poly, var=t):
|
|
136
|
+
coeffs = sp.Poly(poly, var).all_coeffs()
|
|
137
|
+
return coeffs == coeffs[::-1]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**When to recognize:** Challenge mentions braids, knots, permutation pairs, winding numbers, Reidemeister moves, or "topological key exchange." The key mathematical insight is that the Alexander polynomial — while a powerful knot/braid invariant — is multiplicative, making it fundamentally unsuitable as a one-way function for Diffie-Hellman.
|
|
141
|
+
|
|
142
|
+
**Key lessons:**
|
|
143
|
+
- **Diffie-Hellman requires non-abelian hardness.** If the invariant used for the shared secret is multiplicative/commutative under the group operation, Eve can compute it from public values.
|
|
144
|
+
- **Scrambling (Reidemeister moves) doesn't help** — the Alexander polynomial is an invariant, so scrambled braids produce the same polynomial.
|
|
145
|
+
- **Large symbolic determinants** need the denominator-clearing trick: multiply by `t^k` to get polynomials, compute det, divide back.
|
|
146
|
+
|
|
147
|
+
**References:** DiceCTF 2026 "Plane or Exchange"
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Monotone Function Inversion with Partial Output
|
|
152
|
+
|
|
153
|
+
**Pattern:** A flag is converted to a real number, pushed through an invertible/monotone function (e.g., iterated map, spiral), then some output digits are masked/erased. Recover the masked digits to invert and get the flag.
|
|
154
|
+
|
|
155
|
+
**Identification:**
|
|
156
|
+
- Output is a high-precision decimal number with some digits replaced by `?`
|
|
157
|
+
- The transformation is smooth/monotone (invertible via root-finding)
|
|
158
|
+
- Flag format constrains the input to a narrow range
|
|
159
|
+
- Challenge hints like "brute won't cut it" or "binary search"
|
|
160
|
+
|
|
161
|
+
**Key insight:** For a monotone function `f`, knowing the flag format (e.g., `0xL4ugh{...}`) constrains the output to a tiny interval. Many "unknown" output digits are actually **fixed** across all valid inputs and can be determined immediately.
|
|
162
|
+
|
|
163
|
+
**Attack: Hierarchical Digit Recovery**
|
|
164
|
+
|
|
165
|
+
1. **Determine fixed digits:** Compute `f(flag_min)` and `f(flag_max)` for all valid flags. Digits that are identical in both outputs are fixed regardless of flag content.
|
|
166
|
+
|
|
167
|
+
2. **Sequential refinement:** Determine remaining unknown digits one at a time (largest contribution first). For each candidate value (0-9), invert `f` and check if the result is a valid flag (ASCII, correct format).
|
|
168
|
+
|
|
169
|
+
3. **Validation:** The correct digit produces readable ASCII text; wrong digits produce garbage bytes in the flag.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import mpmath
|
|
173
|
+
|
|
174
|
+
# Match SageMath's RealField(N) precision exactly:
|
|
175
|
+
# RealField(256) = 256-bit MPFR mantissa
|
|
176
|
+
mpmath.mp.prec = 256 # BINARY precision (not decimal!)
|
|
177
|
+
# For decimal: mpmath.mp.dps = N sets decimal places
|
|
178
|
+
|
|
179
|
+
phi = (mpmath.mpf(1) + mpmath.sqrt(mpmath.mpf(5))) / 2
|
|
180
|
+
|
|
181
|
+
def forward(x0):
|
|
182
|
+
"""The challenge's transformation (e.g., iterated spiral)."""
|
|
183
|
+
x = x0
|
|
184
|
+
for i in range(iterations):
|
|
185
|
+
r = mpmath.mpf(i) / mpmath.mpf(iterations)
|
|
186
|
+
x = r * mpmath.sqrt(x*x + 1) + (1 - r) * (x + phi)
|
|
187
|
+
return x
|
|
188
|
+
|
|
189
|
+
def invert(y_target, x_guess):
|
|
190
|
+
"""Invert via root-finding (Newton's method)."""
|
|
191
|
+
def f(x0):
|
|
192
|
+
return forward(x0) - y_target
|
|
193
|
+
return mpmath.findroot(f, x_guess, tol=mpmath.mpf(10)**(-200))
|
|
194
|
+
|
|
195
|
+
# Hierarchical search: determine unknown digits sequentially
|
|
196
|
+
masked = "?7086013?3756162?51694057..."
|
|
197
|
+
unknown_positions = [0, 8, 16, 25, 33, ...]
|
|
198
|
+
|
|
199
|
+
# Step 1: Fix digits that are constant across all valid flags
|
|
200
|
+
# (compute forward for min/max valid flag, compare)
|
|
201
|
+
|
|
202
|
+
# Step 2: For each remaining unknown (largest positional weight first):
|
|
203
|
+
for pos in remaining_unknowns:
|
|
204
|
+
for digit in range(10):
|
|
205
|
+
# Set this digit, others to middle value (5)
|
|
206
|
+
output_val = construct_number(known_digits | {pos: digit})
|
|
207
|
+
x_inv = invert(output_val, x_guess=0.335)
|
|
208
|
+
flag_int = int(x_inv * mpmath.power(10, flag_digits))
|
|
209
|
+
flag_bytes = flag_int.to_bytes(30, 'big')
|
|
210
|
+
|
|
211
|
+
# Check: starts with prefix? Ends with suffix? All ASCII?
|
|
212
|
+
if is_valid_flag(flag_bytes):
|
|
213
|
+
known_digits[pos] = digit
|
|
214
|
+
break
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Why it works:** Each unknown digit affects a different decimal scale in the output number. The largest unknown (earliest position) shifts the inverted value by the most, determining several bytes of the flag. Fixing it and moving to the next unknown reveals more bytes. Total work: `10 * num_unknowns` inversions (linear, not exponential).
|
|
218
|
+
|
|
219
|
+
**Precision matching:** SageMath's `RealField(N)` uses MPFR with N-bit mantissa. In mpmath, set `mp.prec = N` (NOT `mp.dps`). The last few output digits are precision-sensitive and will only match with the correct binary precision.
|
|
220
|
+
|
|
221
|
+
**Derivative analysis:** For the spiral-type map `x → r*sqrt(x²+1) + (1-r)*(x+φ)`, the per-step derivative is `r*x/sqrt(x²+1) + (1-r) ≈ 1`, so the total derivative stays near 1 across all 81 iterations. This means precision is preserved through inversion — 67 known output digits give ~67 digits of input precision.
|
|
222
|
+
|
|
223
|
+
**References:** 0xL4ugh CTF "SpiralFloats"
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Tropical Semiring Residuation Attack (BearCatCTF 2026)
|
|
228
|
+
|
|
229
|
+
**Pattern (Tropped):** Diffie-Hellman key exchange using tropical matrices (min-plus algebra). Per-character shared secret XOR'd with encrypted flag.
|
|
230
|
+
|
|
231
|
+
**Tropical algebra:**
|
|
232
|
+
- Addition = `min(a, b)`
|
|
233
|
+
- Multiplication = `a + b`
|
|
234
|
+
- Matrix multiply: `(A*B)[i,j] = min_k(A[i,k] + B[k,j])`
|
|
235
|
+
|
|
236
|
+
**Tropical residuation recovers shared secret from public data:**
|
|
237
|
+
```python
|
|
238
|
+
def tropical_residuate(M, Mb, aM, n):
|
|
239
|
+
"""Recover shared secret from public matrices.
|
|
240
|
+
M = public matrix, Mb = M*b (Bob's public), aM = a*M (Alice's public)
|
|
241
|
+
"""
|
|
242
|
+
# Right residual: b*[j] = max_i(Mb[i] - M[i][j])
|
|
243
|
+
b_star = [max(Mb[i] - M[i][j] for i in range(n)) for j in range(n)]
|
|
244
|
+
# Shared secret: aMb = min_j(aM[j] + b*[j])
|
|
245
|
+
aMb = min(aM[j] + b_star[j] for j in range(n))
|
|
246
|
+
return aMb
|
|
247
|
+
|
|
248
|
+
# Decrypt per-character: key = aMb % 32; plaintext = key ^ ciphertext
|
|
249
|
+
for i, enc_char in enumerate(encrypted):
|
|
250
|
+
key = shared_secret % 32
|
|
251
|
+
plaintext_char = chr(key ^ ord(enc_char))
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Key insight:** Tropical DH is broken because the min-plus semiring lacks cancellation — given `M` and `M*b`, the "residual" `b*` can be computed directly via `max(Mb[i] - M[i][j])`. Unlike standard DH where recovering `b` from `g^b` is hard, tropical residuation recovers enough of `b`'s effect to compute the shared secret. This makes tropical matrix DH insecure for any matrix size.
|
|
255
|
+
|
|
256
|
+
**Detection:** Challenge mentions "tropical", "min-plus", "exotic algebra", or defines custom matrix multiplication using `min` and `+`.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Paillier Cryptosystem Attack (SECCON 2015)
|
|
261
|
+
|
|
262
|
+
The Paillier cryptosystem is a homomorphic encryption scheme where `c = g^m * r^n mod n^2`. When given oracle equations involving c, o, h values:
|
|
263
|
+
|
|
264
|
+
1. **Recover n:** Compute lower bound `sqrt(max(c, o, h))` to approximate n, then brute-force nearby values
|
|
265
|
+
2. **Validate n:** Check equation `h = (c * o) % (n^2)` for correctness
|
|
266
|
+
3. **Factor n:** Use standard methods (e.g., factordb) to find p, q
|
|
267
|
+
4. **Decrypt:** Apply Paillier decryption:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from sympy import lcm, mod_inverse
|
|
271
|
+
|
|
272
|
+
# n = p * q (factored)
|
|
273
|
+
lam = lcm(p - 1, q - 1) # Carmichael function
|
|
274
|
+
n2 = n * n
|
|
275
|
+
|
|
276
|
+
def L(x):
|
|
277
|
+
return (x - 1) // n
|
|
278
|
+
|
|
279
|
+
# Compute mu
|
|
280
|
+
g_lam = pow(g, lam, n2)
|
|
281
|
+
mu = mod_inverse(L(g_lam), n)
|
|
282
|
+
|
|
283
|
+
# Decrypt
|
|
284
|
+
c_lam = pow(c, lam, n2)
|
|
285
|
+
m = (L(c_lam) * mu) % n
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Key insight:** Paillier operates mod n^2, so ciphertext values are much larger than RSA. The homomorphic property `E(m1) * E(m2) = E(m1 + m2)` can leak relationships between plaintexts.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Hamming Code Error Correction with Helical Interleaving (Sharif CTF 2016)
|
|
293
|
+
|
|
294
|
+
When data is protected by Hamming(31,26) codes with helical scan interleaving:
|
|
295
|
+
|
|
296
|
+
1. **Determine matrix dimensions:** Brute-force width/height (30x30 search space) by testing which dimensions produce valid Hamming codewords
|
|
297
|
+
2. **Read data in helical pattern:** Extract bits diagonally from the interleaved matrix
|
|
298
|
+
3. **Apply Hamming parity check:** Multiply codeword by parity check matrix H to detect/correct errors
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
import numpy as np
|
|
302
|
+
|
|
303
|
+
def check_hamming(codeword, H):
|
|
304
|
+
"""Syndrome = H * c^T; zero syndrome means valid codeword"""
|
|
305
|
+
syndrome = np.dot(H, codeword) % 2
|
|
306
|
+
return np.all(syndrome == 0)
|
|
307
|
+
|
|
308
|
+
# Brute-force dimensions
|
|
309
|
+
for w in range(1, 31):
|
|
310
|
+
for h in range(1, 31):
|
|
311
|
+
# Reshape data into w x h matrix
|
|
312
|
+
matrix = data[:w*h].reshape(h, w)
|
|
313
|
+
# Read diagonals (helical scan)
|
|
314
|
+
bits = read_helical(matrix)
|
|
315
|
+
# Check if bits form valid Hamming codewords
|
|
316
|
+
if validate_hamming_stream(bits, H):
|
|
317
|
+
print(f"Dimensions: {w}x{h}")
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Key insight:** Try 8 different bit alignment offsets when the start position is unknown. Valid Hamming codewords have zero syndrome under multiplication by the parity check matrix.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## ElGamal Universal Re-encryption (Sharif CTF 2016)
|
|
325
|
+
|
|
326
|
+
Given an ElGamal-like ciphertext tuple (a, b, c, d) = (g^r, h^r, g^s, m*h^s), produce a different valid ciphertext decrypting to the same message without knowing the private key:
|
|
327
|
+
|
|
328
|
+
Transform exponents r -> 2r, s -> r+s:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
def reencrypt(a, b, c, d, p):
|
|
332
|
+
return [
|
|
333
|
+
(a * a) % p, # g^(2r)
|
|
334
|
+
(b * b) % p, # h^(2r)
|
|
335
|
+
(a * c) % p, # g^(r+s)
|
|
336
|
+
(d * b) % p # m*h^(r+s)
|
|
337
|
+
]
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Key insight:** ElGamal's homomorphic property allows re-randomizing ciphertexts by multiplying components. The relationship between exponents must remain consistent: both pairs must share the same exponent offset.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Paillier Oracle Size Bypass via Ciphertext Factoring (BSidesSF 2025)
|
|
345
|
+
|
|
346
|
+
When a Paillier decryption oracle rejects messages exceeding a size limit (e.g., >2000 bits), exploit the homomorphic property to factor the encrypted flag into smaller pieces:
|
|
347
|
+
|
|
348
|
+
1. **Paillier additive homomorphism:** `E(m1) * E(m2) mod n^2 = E(m1 + m2 mod n)`
|
|
349
|
+
2. **Multiplicative (scalar):** `E(m)^k mod n^2 = E(k*m mod n)`
|
|
350
|
+
3. **Factoring ciphertext:** Divide n into small ranges, query oracle with `E(flag) * E(-offset)^1` to determine which range contains the flag
|
|
351
|
+
4. **Chunk extraction:** Split the flag value into pieces that each fit within the oracle's size limit, decrypt individually, sum to recover original
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from Crypto.Util.number import inverse
|
|
355
|
+
|
|
356
|
+
def paillier_sub(c, plaintext_sub, n):
|
|
357
|
+
"""Compute E(m - plaintext_sub) from E(m) using homomorphic property"""
|
|
358
|
+
n2 = n * n
|
|
359
|
+
# E(-plaintext_sub) = E(n - plaintext_sub) = (n+1)^(n-plaintext_sub) * r^n mod n^2
|
|
360
|
+
neg_enc = pow(n + 1, n - plaintext_sub, n2)
|
|
361
|
+
return (c * neg_enc) % n2
|
|
362
|
+
|
|
363
|
+
# Binary search for flag value using oracle
|
|
364
|
+
def recover_flag(enc_flag, n, oracle_decrypt):
|
|
365
|
+
low, high = 0, n
|
|
366
|
+
while high - low > 1:
|
|
367
|
+
mid = (low + high) // 2
|
|
368
|
+
test_ct = paillier_sub(enc_flag, mid, n)
|
|
369
|
+
result = oracle_decrypt(test_ct)
|
|
370
|
+
if result < n // 2: # Positive (flag > mid)
|
|
371
|
+
low = mid
|
|
372
|
+
else: # Negative (flag < mid, wraps around)
|
|
373
|
+
high = mid
|
|
374
|
+
return low
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**Key insight:** Paillier's additive homomorphism allows computing `E(flag - offset)` without decryption. If the oracle reveals whether the decrypted value is "small" (within limit) or "large" (rejected/wraps), binary search recovers the flag in O(log n) queries.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Format-Preserving Encryption Feistel Brute-Force (BSidesSF 2026)
|
|
382
|
+
|
|
383
|
+
**Pattern (tokencrypt):** Format-preserving encryption (FPE) using a Feistel network with a small round key. The 96-bit key splits into three components with different roles: a brute-forceable core, a GF(2) mixing matrix, and an affine offset.
|
|
384
|
+
|
|
385
|
+
**Key structure:**
|
|
386
|
+
- `s` (16 bits): Feistel round subkey — only 2^16 = 65536 possibilities
|
|
387
|
+
- `seed56` (56 bits): Generates an invertible GF(2) affine mixing matrix `M` (24x24)
|
|
388
|
+
- `b24` (24 bits): Affine offset applied after mixing
|
|
389
|
+
|
|
390
|
+
**Attack:**
|
|
391
|
+
1. **Collect encrypt pairs:** Get multiple `(plaintext, ciphertext)` pairs from the FPE oracle
|
|
392
|
+
2. **Brute-force `s`:** For each of 65536 candidate round keys, run the Feistel network on known plaintexts. If the Feistel core is correct, the remaining transformation is affine over GF(2)
|
|
393
|
+
3. **Solve linear system:** With correct `s`, the relationship `ciphertext = M * feistel_output XOR b24` is linear. Collect 24+ pairs, build a GF(2) matrix equation, solve for `M` and `b24` via Gaussian elimination
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
import numpy as np
|
|
397
|
+
|
|
398
|
+
def feistel_encrypt(pt_24bit, s, rounds=3):
|
|
399
|
+
"""24-bit Feistel with 16-bit round key s."""
|
|
400
|
+
L, R = pt_24bit >> 12, pt_24bit & 0xFFF
|
|
401
|
+
for r in range(rounds):
|
|
402
|
+
f = (R * s + r) & 0xFFF # Round function (example)
|
|
403
|
+
L, R = R, L ^ f
|
|
404
|
+
return (L << 12) | R
|
|
405
|
+
|
|
406
|
+
# Brute-force s (16-bit)
|
|
407
|
+
for s_candidate in range(1 << 16):
|
|
408
|
+
feistel_outputs = [feistel_encrypt(pt, s_candidate) for pt in known_pts]
|
|
409
|
+
# Check if feistel_outputs -> known_cts is affine over GF(2)
|
|
410
|
+
# Build system: for each bit position, collect equations
|
|
411
|
+
# If consistent -> found correct s, solve for M and b24
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**When to recognize:** Challenge mentions "format-preserving encryption", "FPE", or uses a Feistel structure with suspiciously small key components. Any round key under 32 bits is brute-forceable.
|
|
415
|
+
|
|
416
|
+
**Key lessons:**
|
|
417
|
+
- FPE with small Feistel round keys is trivially broken despite the total key looking large (96 bits)
|
|
418
|
+
- After recovering the Feistel core, the remaining affine layer is solvable as a linear system over GF(2)
|
|
419
|
+
- Collect enough plaintext-ciphertext pairs to overdetermine the linear system
|
|
420
|
+
|
|
421
|
+
**References:** BSidesSF 2026 "tokencrypt"
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Icosahedral Symmetry Group Cipher (BSidesSF 2026)
|
|
426
|
+
|
|
427
|
+
**Pattern (dodecacrypt):** Encryption maps message bytes to face permutations of a dodecahedron. The icosahedral symmetry group has order 120 (the rotation group of a regular dodecahedron/icosahedron), so each "digit" in base-120 encodes one group element as a specific arrangement of 12 face labels.
|
|
428
|
+
|
|
429
|
+
**How it works:**
|
|
430
|
+
1. Message is converted to a large integer and expressed in base 120
|
|
431
|
+
2. Each base-120 digit selects one of 120 possible face permutations
|
|
432
|
+
3. The dodecahedron is rendered from a fixed viewing angle, showing only 6 of 12 faces
|
|
433
|
+
4. Despite only 6 faces being visible, collisions between the 120 permutations are rare enough for unique recovery
|
|
434
|
+
|
|
435
|
+
**Attack:**
|
|
436
|
+
1. **Build lookup table:** Probe the encryption API with all 120 single-digit inputs (0-119 in base 120), capture the rendered face arrangement for each
|
|
437
|
+
2. **Match visible faces:** For each encrypted symbol in the ciphertext, compare the visible face pattern against the lookup table to recover the base-120 digit
|
|
438
|
+
3. **Reconstruct message:** Convert the sequence of base-120 digits back to an integer, then to bytes
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
import itertools
|
|
442
|
+
|
|
443
|
+
# Build lookup: probe API with single-digit values
|
|
444
|
+
lookup = {}
|
|
445
|
+
for digit in range(120):
|
|
446
|
+
# Send digit, capture 6 visible face labels from rendered image
|
|
447
|
+
visible = get_visible_faces(encrypt_single(digit))
|
|
448
|
+
lookup[tuple(visible)] = digit
|
|
449
|
+
|
|
450
|
+
# Decrypt ciphertext
|
|
451
|
+
base120_digits = []
|
|
452
|
+
for symbol in ciphertext_symbols:
|
|
453
|
+
visible = get_visible_faces(symbol)
|
|
454
|
+
base120_digits.append(lookup[tuple(visible)])
|
|
455
|
+
|
|
456
|
+
# Convert base-120 to bytes
|
|
457
|
+
value = sum(d * 120**i for i, d in enumerate(reversed(base120_digits)))
|
|
458
|
+
plaintext = value.to_bytes((value.bit_length() + 7) // 8, 'big')
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**When to recognize:** Challenge involves polyhedra, dodecahedra, icosahedra, or mentions "120 rotations", "symmetry group", or shows 3D-rendered geometric objects with labeled faces.
|
|
462
|
+
|
|
463
|
+
**Key insight:** The icosahedral rotation group is small enough (order 120) that a complete lookup table fits easily in memory. Even with partial information (only 6 of 12 faces visible), the permutations are sufficiently distinct to avoid collisions in practice.
|
|
464
|
+
|
|
465
|
+
**References:** BSidesSF 2026 "dodecacrypt"
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Goldwasser-Micali Ciphertext Replication Oracle (BSidesSF 2026)
|
|
470
|
+
|
|
471
|
+
**Pattern (kproof):** A "proof of knowledge" protocol encrypts a user-chosen AES key using Goldwasser-Micali (GM) bit-by-bit encryption. The service decrypts GM ciphertext bits to reconstruct the AES key, then uses it to decrypt and hash a probe payload. The vulnerability: individual GM ciphertext values can be replayed, and 128 copies of the same GM-encrypted bit produce an AES key of either `0x00...00` or `0xFF...FF`.
|
|
472
|
+
|
|
473
|
+
**Goldwasser-Micali basics:**
|
|
474
|
+
- Encrypts one bit at a time: bit 0 → quadratic residue mod n, bit 1 → non-residue
|
|
475
|
+
- Decryption tests whether each ciphertext value is a quadratic residue
|
|
476
|
+
- Each ciphertext value independently encodes exactly one bit
|
|
477
|
+
|
|
478
|
+
**The vulnerability:**
|
|
479
|
+
The service accepts 128 GM ciphertext lines as the AES key. By sending the SAME GM ciphertext value 128 times, the decrypted key is either all-zeros (if the bit was 0) or all-ones (if the bit was 1). Since you control the probe plaintext and IV, you can precompute both possible SHA-256 hashes and compare against the service response.
|
|
480
|
+
|
|
481
|
+
**Attack (128 oracle queries for full key recovery):**
|
|
482
|
+
|
|
483
|
+
```python
|
|
484
|
+
from Crypto.Cipher import AES
|
|
485
|
+
import hashlib
|
|
486
|
+
|
|
487
|
+
def recover_bit(gm_ciphertext_line, probe_ct, probe_iv, oracle):
|
|
488
|
+
"""Determine if a single GM ciphertext encodes 0 or 1."""
|
|
489
|
+
# Replicate the single GM bit 128 times as the AES key
|
|
490
|
+
key_all_zero = b'\x00' * 16
|
|
491
|
+
key_all_ones = b'\xff' * 16
|
|
492
|
+
|
|
493
|
+
# Precompute expected hashes for both possible keys
|
|
494
|
+
hash0 = hashlib.sha256(
|
|
495
|
+
AES.new(key_all_zero, AES.MODE_CBC, probe_iv).decrypt(probe_ct)
|
|
496
|
+
).hexdigest()
|
|
497
|
+
hash1 = hashlib.sha256(
|
|
498
|
+
AES.new(key_all_ones, AES.MODE_CBC, probe_iv).decrypt(probe_ct)
|
|
499
|
+
).hexdigest()
|
|
500
|
+
|
|
501
|
+
# Query oracle with replicated GM line
|
|
502
|
+
result_hash = oracle.query(gm_ciphertext_line, copies=128)
|
|
503
|
+
|
|
504
|
+
if result_hash == hash0:
|
|
505
|
+
return 0
|
|
506
|
+
elif result_hash == hash1:
|
|
507
|
+
return 1
|
|
508
|
+
|
|
509
|
+
# Recover all 128 bits of the AES key
|
|
510
|
+
captured_gm_lines = parse_transcript(transcript) # 128 GM ciphertext values
|
|
511
|
+
key_bits = [recover_bit(line, probe_ct, probe_iv, oracle)
|
|
512
|
+
for line in captured_gm_lines]
|
|
513
|
+
|
|
514
|
+
# Reconstruct AES key and decrypt the captured payload
|
|
515
|
+
aes_key = bits_to_bytes(key_bits)
|
|
516
|
+
plaintext = AES.new(aes_key, AES.MODE_CBC, captured_iv).decrypt(captured_ct)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
**Key insight:** Goldwasser-Micali's bit-by-bit encryption means each ciphertext value independently encodes one bit. If a protocol allows replaying individual GM values as components of a larger key, each bit can be isolated and determined via a distinguishing oracle (here, SHA-256 hash comparison). This reduces key recovery from 2^128 brute-force to 128 linear queries.
|
|
520
|
+
|
|
521
|
+
**When to recognize:** Challenge uses bit-by-bit public-key encryption (GM, Rabin) combined with a symmetric key derivation step. The service decrypts individual ciphertext values without binding them to a position or preventing replay.
|
|
522
|
+
|
|
523
|
+
**Broader principle:** Any protocol that (1) encrypts a key bit-by-bit and (2) provides an oracle on the reconstructed key is vulnerable to bit-by-bit recovery via replication. The specific oracle (hash, decryption check, timing) varies but the attack structure is the same.
|
|
524
|
+
|
|
525
|
+
**References:** BSidesSF 2026 "kproof"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
See [exotic-crypto-2.md](exotic-crypto-2.md) for 2017+ era exotic crypto attacks (BB-84, ElGamal variants, Paillier oracles, Cayley-Purser, BIP39, Asmuth-Bloom, Rabin polynomial, Vandermonde).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# CTF Crypto - Historical Ciphers
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [Lorenz SZ40/42 (Tunny) Cipher](#lorenz-sz4042-tunny-cipher)
|
|
5
|
+
- [Book Cipher Brute Force (Nullcon 2026)](#book-cipher-brute-force-nullcon-2026)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Lorenz SZ40/42 (Tunny) Cipher
|
|
10
|
+
|
|
11
|
+
The Lorenz cipher uses 12 wheels to encrypt 5-bit ITA2/Baudot characters. With known plaintext, a structured attack recovers all wheel settings.
|
|
12
|
+
|
|
13
|
+
**Machine structure:**
|
|
14
|
+
- 5 χ (chi) wheels: periods 41, 31, 29, 26, 23 — advance every step
|
|
15
|
+
- 5 Ψ (psi) wheels: periods 43, 47, 51, 53, 59 — advance only when μ37=1
|
|
16
|
+
- μ61 wheel: period 61 — advances every step, controls μ37 stepping
|
|
17
|
+
- μ37 wheel: period 37 — advances only when μ61=1, controls Ψ stepping
|
|
18
|
+
|
|
19
|
+
**Encryption:** `ciphertext[i] = plaintext[i] XOR chi[i] XOR psi[i]` (per 5-bit character)
|
|
20
|
+
|
|
21
|
+
**CRITICAL: The delta (Δ) approach is the fundamental technique:**
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
# Step 1: Get keystream from known plaintext
|
|
25
|
+
key_stream = [pt[i] ^ ct[i] for i in range(N)]
|
|
26
|
+
|
|
27
|
+
# Step 2: Compute delta keystream (THE key insight)
|
|
28
|
+
delta_k = [key_stream[i] ^ key_stream[i+1] for i in range(N-1)]
|
|
29
|
+
# delta_k = delta_chi XOR delta_psi
|
|
30
|
+
# Since psi only moves ~25% of the time, delta_k BIASES toward delta_chi
|
|
31
|
+
|
|
32
|
+
# Step 3: Recover delta_chi via majority vote at each wheel position
|
|
33
|
+
# Assume wheels start at position 1
|
|
34
|
+
for bit in range(5):
|
|
35
|
+
P = chi_periods[bit] # [41, 31, 29, 26, 23]
|
|
36
|
+
delta_chi = []
|
|
37
|
+
for phase in range(P):
|
|
38
|
+
# Collect all delta_k values at this wheel phase
|
|
39
|
+
vals = [delta_k_bit[i] for i in range(phase, len(delta_k_bit), P)]
|
|
40
|
+
delta_chi.append(1 if sum(vals) > len(vals)/2 else 0)
|
|
41
|
+
|
|
42
|
+
# Step 4: Integrate delta_chi to get chi (2 candidates per wheel, start 0 or 1)
|
|
43
|
+
chi = [start] # start = 0 or 1
|
|
44
|
+
for i in range(P-1):
|
|
45
|
+
chi.append(chi[-1] ^ delta_chi[i])
|
|
46
|
+
# Circular consistency: chi[0] ^ chi[-1] should equal delta_chi[P-1]
|
|
47
|
+
|
|
48
|
+
# Step 5: Subtract chi from keystream to get psi contribution
|
|
49
|
+
# Identify when psi steps: delta_psi = delta_k XOR delta_chi
|
|
50
|
+
# When ALL 5 bits of delta_psi are 0 → μ37 was off (psi didn't step)
|
|
51
|
+
# (Statistically very rare for all 5 cams to not change when stepping)
|
|
52
|
+
|
|
53
|
+
# Step 6: From stepping pattern, determine μ61 (period 61)
|
|
54
|
+
# μ61[pos] = 1 when we see psi resume stepping after being stopped
|
|
55
|
+
|
|
56
|
+
# Step 7: Cross-reference to get μ37 (period 37)
|
|
57
|
+
# μ37 position advances only when μ61=1
|
|
58
|
+
|
|
59
|
+
# Step 8: Determine psi wheels from delta_psi values when stepping occurs
|
|
60
|
+
# Look for repeating patterns with periods 43, 47, 51, 53, 59
|
|
61
|
+
|
|
62
|
+
# Step 9: Brute force remaining ambiguity
|
|
63
|
+
# Total candidates: 2^5 (chi) × 2^5 (psi) × 61×37 (μ positions) = 2,313,472
|
|
64
|
+
# Trivially brutable - decrypt and check if known plaintext matches
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Common mistakes to avoid:**
|
|
68
|
+
- Do NOT assume psi is "period 2" or just alternating — it has real wheels with periods 43-59
|
|
69
|
+
- Do NOT spend time on statistical period-finding for the motor — just use the structured Δ approach
|
|
70
|
+
- Do NOT try LFSR analysis on the step sequence — the stepping is from mechanical wheels, not LFSRs
|
|
71
|
+
- The "step rate" (~35%) is a consequence of μ37 being on ~50% and μ61 on ~50% = ~25% psi stepping
|
|
72
|
+
- Always assume standard wheel periods unless evidence says otherwise
|
|
73
|
+
- Total brute force space is tiny (<3M) — don't over-optimize
|
|
74
|
+
|
|
75
|
+
**ITA2/Baudot encoding (5-bit):**
|
|
76
|
+
```python
|
|
77
|
+
# Standard ITA2 mapping used in Lorenz challenges
|
|
78
|
+
char_to_code = {
|
|
79
|
+
'A': 24, 'B': 19, 'C': 14, 'D': 18, 'E': 16, 'F': 22, 'G': 11,
|
|
80
|
+
'H': 5, 'I': 12, 'J': 26, 'K': 30, 'L': 9, 'M': 7, 'N': 6,
|
|
81
|
+
'O': 3, 'P': 13, 'Q': 29, 'R': 10, 'S': 20, 'T': 1, 'U': 28,
|
|
82
|
+
'V': 15, 'W': 25, 'X': 23, 'Y': 21, 'Z': 17,
|
|
83
|
+
'9': 4, '5': 27, '8': 31, '3': 8, '4': 2, '/': 0,
|
|
84
|
+
}
|
|
85
|
+
# Code 27 = FIGS shift, Code 31 = LTRS shift
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Book Cipher Brute Force (Nullcon 2026)
|
|
91
|
+
|
|
92
|
+
**Pattern (Booking Key):** Book cipher encodes password as list of "steps forward" in reference text.
|
|
93
|
+
|
|
94
|
+
**Key insight:** Charset constraint drastically reduces candidate starting positions:
|
|
95
|
+
```python
|
|
96
|
+
def decode_book_cipher(cipher_distances, book_text, valid_chars):
|
|
97
|
+
"""Brute-force starting position; filter by charset."""
|
|
98
|
+
candidates = []
|
|
99
|
+
for start_key in range(len(book_text)):
|
|
100
|
+
pos = start_key
|
|
101
|
+
password = []
|
|
102
|
+
valid = True
|
|
103
|
+
for dist in cipher_distances:
|
|
104
|
+
pos = (pos + dist) % len(book_text)
|
|
105
|
+
ch = book_text[pos]
|
|
106
|
+
if ch not in valid_chars:
|
|
107
|
+
valid = False
|
|
108
|
+
break
|
|
109
|
+
password.append(ch)
|
|
110
|
+
if valid:
|
|
111
|
+
candidates.append((start_key, ''.join(password)))
|
|
112
|
+
return candidates # Typically 3-4 candidates out of ~56k positions
|
|
113
|
+
```
|