Fortune
gpnctf
Task: custom NTRU-like cryptosystem over the dihedral group ring Z[D_N]; server prints public key h and ciphertext c, asks for the ternary message. Solution: ciphertext c[i] = 3*t[i] + msg[i] is printed without reducing mod q, so msg[i] = c[i] mod 3 leaks the entire plaintext — no lattice, no private key.
$ ls tags/ techniques/
Fortune — GPNCTF 2024 (KitCTF)
Description
You can't eat the entire time, so why not gamble a bit?
A remote SSL service (ncat --ssl <host> 443) implements a custom NTRU-like
cryptosystem and challenges you to recover a hidden ternary "fortune wheel"
message within a 60-second SIGALRM window. The server prints the public key
h and a ciphertext c, then asks for the message encoded as a string over
{A, C, B}. Get it right → flag.
Provided files: fortune2.py (service) and fortuneUtils2.py (crypto classes).
Analysis
The cryptosystem
The scheme is a faithful NTRU port, but instead of the usual cyclic polynomial
ring Z[x]/(x^N - 1) it operates over the group ring (group algebra) of the
dihedral group, Z[D_N].
FortuneWheel— an element ofD_N. The base elements areNrotationsr^0 … r^{N-1}(labels are cyclic shifts of[0..N-1]) andNreflectionss·r^k(mirrored labels).__mul__composes the underlying permutations, i.e. it is group multiplication inD_N.FortuneForest— an element ofZ[D_N].to_vector()returns a length2Ncoefficient vector: the firstNentries are coefficients on the rotation wheels, the nextNare coefficients on the reflection wheels. It supports+,*(group-algebra convolution),% q(coefficient reduction),rescale(s)(scalar multiply of all coefficients), andinv(mod)— a group-ring inverse computed via the regular representation: it builds the2N × 2Nblock matrix[[M1, M0], [M0, M1]]and inverts it modulo the given modulus.
Parameters: N=100, p=3, d=floor(N/3)=33,
q=2^floor(log2((6d+1)*p))=2^9=512.
The NTRU flow
P(t1,t2) random ternary forest: t1 coeffs = +1, t2 = -1 on distinct wheels
keygen: f = P(d+1,d); fq = f.inv(q); fp = f.inv(p); g = P(d,d)
h = (fq * g) % q # public key
message: msg = P(d,d) # ternary, 33×(+1), 33×(-1) of 200
encrypt: doubt = P(d,d)
t = (h * doubt) % q
c = t.rescale(p) + msg # c[i] = p*t[i] + msg[i] = 3*t[i] + msg[i]
The server prints h= and c= (both as raw Python lists from .to_vector()),
asks for msg mapped through MAPPING = {-1:"A", 0:"C", 1:"B"}.
A decrypt() function exists in the source but is commented out with the
hint:
The fortunate don't need such functions to see the pattern
That is a direct tell: you are not supposed to use the private key f.
The vulnerability — the unreduced ciphertext
Encryption computes c = t.rescale(p) + msg, i.e. coefficient-wise
c[i] = p * t[i] + msg[i] = 3 * t[i] + msg[i]
In a correct NTRU implementation the ciphertext would be reduced mod q
before transmission. Here it is printed raw, straight from to_vector(),
with no % q. Because t[i] ∈ [0, q) and msg[i] ∈ {-1, 0, 1}, the message is
simply the residue of each coefficient mod 3:
msg[i] ≡ c[i] (mod 3) # since 3*t[i] ≡ 0 (mod 3)
Mapping residues back to the ternary alphabet:
c[i] mod 3 == 0 → msg[i] = 0
c[i] mod 3 == 1 → msg[i] = +1
c[i] mod 3 == 2 → msg[i] = -1 (because -1 ≡ 2 mod 3)
The entire dihedral-group-ring machinery — h, fq, fp, the regular
representation inverse, the lattice structure — is a red herring. The whole
break is a single mod 3. This is the "luck/gamble" theme: you "guess" the
fortune-wheel message, but in fact compute it deterministically.
Critical reproducibility detail: if you reduce
cmodq(512) before applying the trick, the3*t[i]term wraps and the leak is destroyed. You must use the RAW printedcvalues.
Solution
- Connect over SSL to port 443.
- Read the banner up to
Give me the message:. - Extract the
c=Python list (it appears after theh=vector — take the part after the lastc=) and parse it withast.literal_eval. - Compute
msg[i] = {0:0, 1:1, 2:-1}[c[i] % 3]for every coefficient. - Encode with
MAPPING = {-1:"A", 0:"C", 1:"B"}and send within 60 s. - Server replies
You are lucky!and prints the flag.
#!/usr/bin/env python3 import sys, re, ast from pwn import remote, context context.log_level = "info" MAP = {-1: "A", 0: "C", 1: "B"} def cmap(x): # residue of (3*t + msg) mod 3 -> ternary message symbol return {0: 0, 1: 1, 2: -1}[x % 3] def parse_vec(line): m = re.search(r"\[.*\]", line, re.S) return ast.literal_eval(m.group(0)) def main(): host, port = sys.argv[1], int(sys.argv[2]) ssl = (len(sys.argv) > 3 and sys.argv[3] == "ssl") or port == 443 io = remote(host, port, ssl=ssl) buf = io.recvuntil(b"Give me the message:", timeout=30).decode(errors="replace") idx = buf.rindex("c=") # the c vector comes after the h vector c = parse_vec(buf[idx:]) guess = "".join(MAP[cmap(x)] for x in c) # use RAW c, never reduce mod q io.sendline(guess.encode()) print(io.recvall(timeout=10).decode(errors="replace")) main()
Run:
python3 solve.py <host> 443 ssl
Verification
The trick was validated offline by reimplementing the dihedral group-ring
multiplication in pure Python, generating a sample (h, c, msg) instance, and
confirming msg[i] == cmap(c[i]) for all 200/200 coefficients. It then worked
first try against the live SSL instance.
Lessons Learned / Key Indicators
Use this technique when:
- An NTRU-style ciphertext has the form
c = p·t + msg(here3·t + msg) with a small ternary message and is transmitted/printed without reduction mod q. - The output is a raw Python list (or raw integers) rather than values bounded in
[0, q)— a sign the implementation skipped the final% q. - A
decrypt/private-key path is present but explicitly commented out or hinted as unnecessary ("the fortunate don't need such functions"). - The challenge wraps the real arithmetic in an exotic, intimidating algebra
(here
Z[D_N], dihedral group ring, regular-representation inverse) that has nothing to do with the actual leak — a classic "scary math is a red herring" pattern.
Core insight: when plaintext is added to a multiple of p, plaintext = c mod p
recovers it directly, regardless of all the surrounding lattice/group-ring
structure. The only trap is reducing mod q first, which destroys the leak.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [crypto][Pro]Cyber— volgactf
- [crypto][free]Just Follow the Recipe— kitctf
- [crypto][Pro]Mental flow— duckerz
- [crypto][Pro]Coloring Fraud— scarlet
- [crypto][free]COMpetition— gpn24