CO was a crypto challenge at WannaGame Championship 2025, where our team blackpinker took first place in the international division. It took me most of a night, and the part that cost the time was not the attack but recognising the object: the group is not presented as an elliptic curve, and until you see that it is one, there is nothing familiar to aim at. This write-up first appeared on HackMD; I have filled in the reasoning I only half understood at the time.
What the service gives you
The challenge works over the surface
in
where the scalar is the flag read as an integer. The flag has the shape W1{ + 32 characters + }, so the secret is a 256-bit number, and it is the same in every connection.
That the surface is a cubic surface is what makes the group law look unfamiliar: a surface is two-dimensional, and there is no group law on a cubic surface as a whole. There is one on a cubic curve, which is where the rest of the challenge comes from.
Cutting the surface into a curve
A hyperplane in
and intersecting it with the cubic surface leaves a plane cubic curve. A smooth plane cubic with a rational point is an elliptic curve, and it carries the usual chord-and-tangent group law: three collinear points sum to zero.
Three points in
F = GF(p)
L = matrix(F, [G, O, P]).right_kernel_matrix()[0].change_ring(QQ)
Substituting
x_sub = -(L[1]*y + L[2]*z + L[3]*w) / L[0]
cubic = x_sub*y^2 + y*z^2 + z*w^2 + w*x_sub^2
Sage turns a plane cubic with a chosen base point into Weierstrass form, and returns the change of variables with it:
f = EllipticCurve_from_cubic(cubic, O[1:], morphism=True)
E = f.codomain()
Taking
TIP
EllipticCurve_from_cubicwants a cubic over a field Sage can do algebra in comfortably, and it does not take kindly to being handed one overdirectly. The way around it is to build the cubic and the morphism over , then push the defining polynomials down to and evaluate the points there. It is a workaround for a library boundary, not for anything mathematical.
def topoint(polys, W):
return [poly(*W) for poly in polys]
Ep = E.change_ring(F)
polys = [poly.change_ring(F) for poly in f.defining_polynomials()]
G2 = Ep(topoint(polys, G[1:]))
P2 = Ep(topoint(polys, P[1:]))
Now
which is a discrete logarithm and nothing more exotic.
Why one instance is not enough, and why many are
A 256-bit curve with a near-prime order is not something to solve directly. The first thing I tried was to pick the hyperplane myself, through
What does break it is that the secret never changes. The flag is the scalar in every instance, while we choose
- Compute
and factor it as far as is cheap, with a bound like . - For each small prime power
, map both points into the subgroup of order by multiplying by , and solve the small discrete logarithm there. That gives . - Keep the residues and their moduli; drop the large leftover cofactor, which is the part that is genuinely hard.
def residues(R, P, N, factors):
out = []
for prime, power in factors:
q = prime**power
n = N // q
out.append((( n*R ).log( n*P ), q)) # flag mod q
return out
One instance hands over a handful of residues with a product of maybe forty bits. Reconnect with a fresh prime and you get a different curve, a different order, a different set of small factors, and more residues about the same scalar. Collect them in a dictionary keyed by modulus, multiply up the distinct moduli, and stop once the product exceeds
if cur_m.bit_length() >= 32*8 + 15:
break
Then one CRT finishes it:
k = crt(list(rs.values()), list(rs.keys()))
print("W1{" + long_to_bytes(int(k)).decode() + "}")
The loop around all of this is unglamorous: open a connection, send a random 256-bit prime, build the curve, harvest residues, close, repeat. Instances where Sage threw on a degenerate hyperplane or a singular cubic were skipped and retried, which costs nothing because another prime is one connection away.
WARNING
The script I ran during the contest caught failures from the small discrete logarithm and recorded a residue of
0for that modulus instead of dropping it. That is a silent corruption: one wrong residue makes the CRT output a wrong scalar with no sign that anything went wrong. It happened not to fire on the run that produced the flag, but the right behaviour is to skip the modulus entirely, and to check the recovered scalar against a fresh instance before trusting it.
The scalar that comes out decodes to the flag. (My notes from the night kept the method and the script but not the flag string itself, so it is not reproduced here.)
What the challenge was really testing
The cubic surface is the costume. Strip it off and this is a textbook failure mode with a specific name: a long-term secret used as a scalar in groups the attacker gets to choose.
Every instance is a fresh group, so nothing repeats and nothing looks reused; but the scalar is the same, so every instance leaks a few more bits of it through whatever small factors that group happens to have. Nobody has to break a 256-bit discrete logarithm. The attack cost is the number of connections, which is free.
Three things would each have stopped it:
- Do not let the client choose the group. A fixed curve with a known prime order removes the whole supply of small factors, and Pohlig–Hellman has nothing to work with.
- Do not reuse the secret scalar. If the scalar were a per-session nonce and the flag were revealed only on a correct proof, residues from different sessions would be about different numbers, and the CRT would have nothing to assemble.
- Validate the order. Rejecting a group whose order is not a large prime, or whose subgroup structure the server did not intend, is the standard defence and it applies here unchanged.
The mathematics was interesting and the recognition was the hard part, but the vulnerability is the ordinary one: the secret outlived the parameters it was used with.