Interesting Signing was a crypto challenge at WannaGame Championship 2025, where our team blackpinker took first place in the international division. It opens looking like a memory-safety bug and closes as a lattice problem: the overflow is real, but all it buys is one byte of the modulus, and the work is in seeing what that byte is worth. This write-up first appeared on HackMD; the explanation of the lattice step is the one I owed my notes.
The service
The server holds an RSA key whose modulus
Per connection you get 21 rounds. In each round you either ask it to sign a message you supply, or ask it to verify one. The goal is a valid signature on a fixed 64-byte challenge string that the server will never sign for you:
1_d4r3_y0u_70_519n_7h15_3x4c7_51x7y_f0ur_by73_57r1n9_w17h_my_k3y
So you need the private key, which means factoring
The overflow, and what it is actually worth
The message buffer holds 64 bytes, and the modulus sits immediately after it. A 65-byte message writes one byte past the end, into the most significant byte of
That sounds like a disaster for the server and it is, but not in the obvious way. It does not leak a prime and it does not let you choose a convenient modulus: the private primes are stored separately and are untouched, and so are the CRT coefficients, which this implementation precomputes once from the real
A CRT signature is assembled as
Call the sum before the reduction
With the overflow they are not. Sign the same message twice, once with the modulus byte set to one value and once to another:
recovered = crt(sigs, moduli) # the exact integer, no reduction left
Two signatures, one equation over the integers. That is the whole value of the overflow.
Ten equations, and the shape of the unknowns
Repeat with ten different messages, two signatures each. Twenty rounds, and the twenty-first is the one that submits the forgery.
or
Ten equations, thirty-three unknowns: hopeless as linear algebra. What makes it tractable is the size gap. The entries of
TIP
I first tried this with nine equations and got nothing usable. The attack needs enough rows that the orthogonal lattice has room: with
equations and three unknown columns, the vectors orthogonal to that are also orthogonal to all of 's columns form a sublattice of rank , and that margin is what lets LLL separate them from the rest. At nine it was too tight; at ten it fell out.
The orthogonal lattice, step by step
Find vectors orthogonal to
b = matrix(recovered_sigs).T # 10 x 1
M = block_matrix([[b, identity_matrix(10)]])
W = diagonal_matrix([2**4000] + [1]*10)
L = ((M * W).LLL()) / W # rows: (0, u) with u . b = 0
Why those
Take the first seven. The vectors orthogonal to
O = matrix(ZZ, [row[1:] for row in L[:7]])
Now invert the relation. The columns of
M = block_matrix(ZZ, [[O.T, identity_matrix(O.ncols())]])
W = diagonal_matrix([2**2000]*O.nrows() + [1]*O.ncols())
Enumerate a small box. The columns of
for coord in product(range(-3, 4), repeat=3):
v = vector(ZZ, coord) * M_reduced[:3]
if all(abs(i) < 2**1024 for i in v):
candidates.append(v)
I did not understand this bound when I used it, and the honest version is that it is a heuristic, not a theorem: LLL in dimension three returns a basis within a small factor of the shortest vectors, so the coefficients needed are small, and
From
for cols in combinations(candidates, 3):
try:
y = column_matrix(cols).solve_right(b)
except (ValueError, ArithmeticError):
continue
primes = [N // gcd(int(yi[0]), N) for yi in y]
if prod(primes) == N:
break
The check prod(primes) == N is what makes the enumeration safe: wrong candidates simply fail it.
Forging the signature
With three primes in hand the rest is bookkeeping:
phi = prod(p - 1 for p in primes)
d = pow(65537, -1, phi)
sig = pow(bytes_to_long(sha256(target).digest()), d, N)
payload = b85_encode(long_to_bytes(sig).rjust(384, b"\x00")) # 480 characters
Then the twenty-first round verifies it and the server gives up the flag. (My notes kept the method and the script but not the flag string, so it is not reproduced here.)
What to take from it
The bug is not the overflow; it is what the overflow reaches. One byte of a public value looks harmless, and in a sense it is: the attacker learns nothing from
Fault attacks on CRT-RSA are an old family, and this is a gentle member of it. The classic one (Boneh, DeMillo and Lipton) needs only a single faulty signature to factor
And bound the buffer. The reason the modulus was reachable at all is a 64-byte write into a 64-byte buffer followed by one more. Everything above is downstream of that single missing length check.