algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

two signatures, one nonce: recovering an ECDSA key

2026-03-224 min readcrypto ctf math

The flag-signing service will sign any message you send and hand back (r, s). It signs two of them before it rotates the nonce. Find the key.

ECDSA is unforgiving about one thing above all others: the per-signature nonce k must be secret and never repeated. Repeat it once and the private key falls out with grade-school algebra.

the signing relation#

For a curve of order n, private key d, message hash z and nonce k, a signature is the pair (r,s) with r=(kG)xmodn and

sk1(z+rd)(modn).

Everything here is mod n, the group order — not the field prime. Mixing the two moduli is the most common way to spend an afternoon debugging a correct attack.

symbol meaning known to me?
n order of the base point G yes (public)
d private key target
k per-signature nonce secret
z H(m) truncated to bits yes
r,s the signature yes

reuse collapses it#

Two signatures (r,s1) and (r,s2) on hashes z1z2 share the same r — which is the tell, since r depends only on k. Equal r means equal k. Subtract the two signing relations and d drops out:

s1s2k1(z1z2)(modn).

Solve for the nonce directly,

k(z1z2)(s1s2)1(modn),

and back-substitute into either signature to recover the key:

dr1(s1kz1)(modn)s2z1s1z2r(s1s2)(modn).

That last form is the one to memorise — given r, two s values and two hashes, it's a single modular division.

recover.pypython
from hashlib import sha256
n  = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551  # secp256r1

def H(m):  # truncated hash -> integer mod n
    return int.from_bytes(sha256(m).digest(), "big") % n

z1, z2 = H(b"transfer 1 coin"), H(b"transfer 9999 coins")
r, s1, s2 = sig1[0], sig1[1], sig2[1]

k = (z1 - z2) * pow(s1 - s2, -1, n) % n
d = (s1 * k - z1) * pow(r, -1, n) % n
print("d =", hex(d))

spotting it in the wild

You don't need the source to know a nonce repeated — you need two signatures with an identical r. Collect a handful, sort by r, look for a collision. The same trick works across different keys if a bad RNG seeds them in lockstep.

when the nonce is only biased#

Real leaks are rarely a clean repeat. More often k is short — the top bits are zero — and one signature tells you nothing, but a few hundred do. This is the Hidden Number Problem: each signature is a noisy linear equation in d,

d·tiuiki(modn),ti=risi1,ui=zisi1,

where every ki is small. Stack the equations as a lattice whose short vectors encode the answer:

B=[n000n0t1t21]

Run LLL (or BKZ for tight bias), and the row closest to the origin spells out the ki — and with them, d. Two-thirds of a leaked nonce over ~80 signatures is plenty.1

the flag

Once you have d, sign the challenge token yourself: segfault{n0nce_r3us3_1s_a_pr1v4te_k3y_l3ak}


  1. The bridge from "biased nonce" to "lattice" is the part worth internalising: a bound on the unknown becomes a short vector, and short vectors are what LLL finds. Most ECDSA CTF crypto is some disguise over this one reduction.