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.

One instance gives a curve; many instances give the scalar what the server publishes S: xy² + yz² + zw² + wx² = 0 a cubic surface in P³ we choose the prime p, and the points G and O it returns P = [flag] · G cut it with a plane the hyperplane L through G, O and P meets the surface in a plane cubic: an elliptic curve with O as its point at infinity an ordinary DLP G₂, P₂ on E(F_p) find the scalar k with [k] · G₂ = P₂ k is the flag, the same in every instance each instance leaks the flag modulo the small factors of that curve's order p₁ → #E = 2³·5·97·…·(big) k mod 8, 5, 97 p₂ → #E = 3·11·29·… k mod 3, 11, … p₃ → … k mod … CRT k once ∏q > 2²⁵⁶ why it works at all The scalar is the flag, so it is the same secret in every instance, while the group changes with every prime we send. A residue is never recomputed, only collected. Pohlig–Hellman needs no large factor to be broken: it needs enough small ones, across enough groups.
Each instance is a different group, but the scalar is always the flag, so the residues accumulate.

What the service gives you

The challenge works over the surface

S:xy2+yz2+zw2+wx2=0

in P3, a cubic surface in projective 3-space. Per connection you send a prime p and two points G and O, and the server returns a third point

P=[flag]⋅G,

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 P3 is

L:ax+by+cz+dw=0,

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 P3 generically determine one hyperplane through them, so the hyperplane through G, O and P is forced, and it is the one the service is implicitly working in. Finding it is a kernel computation: the coefficient vector (a,b,c,d) is orthogonal to all three points, so it spans the right kernel of the 3×4 matrix whose rows are G, O and P.

F = GF(p)
L = matrix(F, [G, O, P]).right_kernel_matrix()[0].change_ring(QQ)

Substituting x=−(by+cz+dw)/a into S eliminates one variable and leaves a cubic in y,z,w: the plane cubic to work with.

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 O as the base point is what makes O the identity, which matches the service: O is the point you hand it as the neutral element.

TIP

EllipticCurve_from_cubic wants a cubic over a field Sage can do algebra in comfortably, and it does not take kindly to being handed one over Fp directly. The way around it is to build the cubic and the morphism over Q, then push the defining polynomials down to Fp 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 G2 and P2 are points on an ordinary elliptic curve over Fp with

[flag]⋅G2=P2,

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 G and O only, hoping to land on a curve as tame as y2=x3+x. That was the wrong idea twice over: the hyperplane is pinned by the three points, not two, and a curve being pretty does not make its discrete logarithm easy.

What does break it is that the secret never changes. The flag is the scalar in every instance, while we choose p, and a new prime means a new curve with a new group order. Pohlig–Hellman does not need the order to be smooth; it gives the scalar modulo every factor you can actually handle, and discards the rest:

  • Compute N=#⟨G2⟩ and factor it as far as is cheap, with a bound like 230.
  • For each small prime power q∥N, map both points into the subgroup of order q by multiplying by N/q, and solve the small discrete logarithm there. That gives flagmodq.
  • 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 2256 with margin to spare:

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 0 for 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.