Flag: GPNCTF{sOMTImES_a11_YOU_neED_I5_lUck}
Connection: ncat --ssl <instance>.gpn24.ctf.kitctf.de 443
Files: fortune2.py, fortuneUtils2.py
TL;DR
The challenge looks like NTRU over the dihedral group ring Z[D_100] and dares you to
break a lattice. It's a red herring. The encryption multiplies the noise term by p = 3
without reducing modulo q, and only then adds the ternary message. So every ciphertext
coefficient satisfies
c_k = 3·t_k + m_k with m_k ∈ {-1, 0, 1}
⇒ c_k ≡ m_k (mod 3)
The message is recovered directly as c mod 3. No key, no lattice.
The challenge
On connect, the server prints public parameters, a public key h, and a ciphertext c,
then gives you 60 seconds to send back the encoded message:
got params N=100 p=3 d=33 q=512
h= [...] # 200 integers in [0, 512)
c= [...] # 200 integers in [-1, 1534]
Give me the message:
If your guess equals msg_to_guess(msg), you get the flag.
The cryptosystem
fortuneUtils2.py implements a group ring. A FortuneWheel is an element of the dihedral
group D_100 (rotations r^k and reflections s·r^k), and a FortuneForest is a formal
integer combination of them — i.e. an element of the group ring Z[D_100]. The
to_vector() encoding is a length-2N = 200 coefficient vector: the first 100 entries are
the rotation coefficients, the last 100 are the reflection coefficients.
fortune2.py then builds a textbook NTRU instance over this ring:
def generatePattern(n, p, d, q):
while True:
f = P(d+1, d, n) # ternary, d+1 ones and d minus-ones
fq = f.inv(mod=q)
fp = f.inv(mod=p)
if fp is None or fq is None: continue
g = P(d, d, n) # ternary
h = (fq * g) % q # public key h = g / f (mod q)
return f, fq, fp, g, h, p, q
with N = 100, p = 3, d = 33, q = 2**floor(log2((6d+1)·p)) = 2**floor(log2(597)) = 512.
Encryption (hideInPattern) is standard NTRU c = p·r·h + m:
def hideInPattern(pattern, message, d):
h, p, q = pattern
doubt = P(d, d, h.segments) # ternary blinding r
t = (h * doubt) % q # coeffs in [0, q)
return (t).rescale(p) + message # << the bug lives here
The decrypt routine is provided but disabled, with the hint:
"The fortunate don't need such functions to see the pattern."
That is the whole challenge talking to you: you are not supposed to recover the private
key. There is a pattern in c itself.
The vulnerability
Look closely at rescale and __mod__ in fortuneUtils2.py:
def rescale(self, modifier): # multiplies every coefficient by `modifier`
...
w_copy.modifier = w.modifier * modifier # NO modular reduction
...
def __mod__(self, other): # this one DOES reduce
w_copy.modifier = w.modifier % other # coeffs land in [0, other)
So in hideInPattern:
t = (h * doubt) % q→ every coefficientt_kis an integer in[0, q) = [0, 512).t.rescale(p)→ every coefficient becomes3·t_k, an exact integer multiple of 3, with no wrap-around.... + message→ addsm_k ∈ {-1, 0, 1}.
Therefore, over the integers:
c_k = 3·t_k + m_k
Reducing modulo 3 kills the noise entirely (3·t_k ≡ 0):
c_k ≡ m_k (mod 3)
Because the message alphabet is exactly {-1, 0, 1}, this congruence uniquely determines
each m_k. The dihedral group ring, the NTRU key, g, fq, fp — all irrelevant.
This is consistent with the observed ciphertext range [-1, 3·511+1] = [-1, 1534] (the live
data even contained a -1, which maps cleanly via -1 ≡ 2 (mod 3) ⇒ m = -1).
Decoding table
msg_to_guess uses MAPPING = {-1: "A", 0: "C", 1: "B"}. Combining with c_k mod 3:
c_k % 3 |
m_k |
letter |
|---|---|---|
| 0 | 0 | C |
| 1 | 1 | B |
| 2 | -1 | A |
The answer is simply:
guess = "".join({0: "C", 1: "B", 2: "A"}[c_k % 3] for c_k in c)
(Python's % already maps -1 → 2, so negative coefficients are handled for free.)
Solver
Pure socket + ssl, no dependencies. The whole solve is microseconds; the 60-second timer
is a non-issue.
import socket, ssl, re
HOST = "<instance>.gpn24.ctf.kitctf.de"
PORT = 443
LETTER = {0: "C", 1: "B", 2: "A"} # c_k % 3 -> letter
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # ncat --ssl presents a self-signed cert
s = socket.create_connection((HOST, PORT), timeout=30)
ss = ctx.wrap_socket(s, server_hostname=HOST)
ss.settimeout(90)
data = b""
while b"message:" not in data:
chunk = ss.recv(8192)
if not chunk:
break
data += chunk
c = [int(x) for x in re.search(r"c=\s*\[([^\]]*)\]", data.decode()).group(1).split(",")]
guess = "".join(LETTER[ci % 3] for ci in c)
ss.sendall(guess.encode() + b"\n")
print(ss.recv(8192).decode())
Output:
You are lucky!
here is your flag GPNCTF{sOMTImES_a11_YOU_neED_I5_lUck}
The rabbit hole (what NOT to do)
Before spotting the mod 3 leak I tried to actually break the NTRU instance — recovering the
private key / message by lattice reduction. It's a trap, and a costly one:
- The dihedral group ring is genuinely non-commutative, so the lattice is dimension 400
(
2·2N). Key recovery(f, g)and message recovery(r, m)both live there. - The
fplll/fpylllbuilds available (0.6.4 from pip, conda-forge, and even Sage'slibfplll.so.9) all hit an"infinite loop in babai"on these NTRU lattices with floating-point BKZ — a hardSIGABRTin some builds, a catchableRuntimeError: Abortedin others. Onlympfrprecision survives, and at dim-400 a single LLL there takes ~30 s — too slow for the 60 s budget. - I tried to halve the dimension with the two dihedral-to-cyclic homomorphisms
φ_1: Z[D_100] → Z[D_50](r^50 ↦ +1, cyclic fold) andφ_2(r^50 ↦ -1, negacyclic fold), recombiningm_k = (m̄_1 ± m̄_2)/2overZ. The homomorphisms are correct and the recombination works, but folding doubles the NTRU noise and the folded lattice acquires a spurious vector shorter than the message — so the message is no longer the shortest vector. Dead end.
None of it was necessary. The intended path is the one-liner c mod 3. The flag —
"sometimes all you need is luck" — is a wink at exactly this: stop trying to be clever and
read the ciphertext.
Key takeaways
rescale(p)does not reduce modq. Multiplying the masking term bypand then adding a message whose alphabet is{-1,0,1} ⊂ (-p/2, p/2]leaks the entire message modp. In real NTRU the message is added before the public-key product is reduced, and the "small" message lives inside the lattice noise — never as an exactc ≡ m (mod p)channel.- When a challenge ships a decryption routine but disables it with a nudge to "see the pattern", look for an algebraic shortcut before reaching for BKZ.
- The impressive-looking crypto (non-abelian group ring, NTRU) was pure misdirection.