Flash CTF – Elliptic Curve Conundrum

Overview

This is a crypto challenge built around ECDSA nonce reuse. The server signs whatever message a client sends, but it seeds Python’s random module with the current wall-clock second rather than a real source of randomness. Two connections opened within the same second draw an identical sequence of “random” nonces, so signing one message on each connection reuses the same nonce twice. That’s enough to solve for the private key algebraically — and in this challenge, the private key is the flag.

Background

ECDSA signs a message hash h using a private key d and a single-use random value called a nonce, k. The signature is the pair (r, s) where r comes from the nonce’s curve point and s = k^-1 * (h + r*d) mod n (n is the curve’s group order). The security of the whole scheme hinges on k being unpredictable and never reused — if you know ks‘s equation has only one unknown, d, and you can solve for it directly.

Reuse is the classic failure mode. If two different messages, h1 and h2, are signed with the same k, both r values are identical (since r only depends on k), and subtracting the two s equations cancels out d*r, leaving k in terms of known quantities. Once k is recovered, plugging it back into either s equation isolates d. This is exactly how Sony’s PS3 signing key and multiple Bitcoin wallets have leaked in the wild — reused or low-entropy nonces are a real-world, not just academic, bug class.

Reconnaissance

Connecting to the service shows a two-option menu:

Welcome to our ECDSA server!
You have 2 options for your queries — make them count:
    1) View your public key.
    2) Retrieve a message.

option >

Option 1 dumps the public key point dG. Option 2 signs an arbitrary attacker-chosen message and returns (msg, r, s). Nothing here is unusual on its own — a signing oracle is normal. The bug is in how the nonce is generated: chal.py calls random.seed(int(time.time())) once per connection, then draws the nonce with random.getrandbits(255). Since socat forks a fresh chal.py process per connection, two connections made in the same second seed Python’s PRNG identically and therefore draw the same first nonce.

Exploitation

The attack needs two simultaneous connections, each signing one (arbitrary) message, so both hit their “retrieve a message” branch inside the same one-second window and reuse k:

c1 = remote(host, port)
c2 = remote(host, port)
c1.sendlineafter(b"option > ", b"2")
c2.sendlineafter(b"option > ", b"2")
c1.sendlineafter(b"msg > ", b"random")
c2.sendlineafter(b"msg > ", b"random")

Each connection prints its padded message integer and its (r, s) pair. Padding is random per query, so the two msg integers differ even though the request text was identical — that’s fine, the attack only needs r1 == r2 to confirm nonce reuse, not equal messages:

assert r1 == r2

With r1 == r2 == r, subtracting the two signing equations s1 = k^-1(m1 + r*d) and s2 = k^-1(m2 + r*d) mod n gives:

s1 - s2 = k^-1 * (m1 - m2)  (mod n)
k = (s1 - s2)^-1 * (m1 - m2)  (mod n)

Once k is known, either signature equation is rearranged to solve for d:

d = r^-1 * (k*s1 - m1)  (mod n)

d is the private key — and in this challenge, d was never a random scalar. The server built it directly from the flag’s inner phrase (d = bytes_to_long(flag)), so recovering d and converting it back to bytes hands you the flag text directly.

Getting the Flag

solve.py hardcodes localhost:1337 and takes no arguments, so point it at a running instance and run it directly:

python3 writeup/solve.py
b'R3u51ng_n0nc3_4nd_c0nn3ct1on?!?!'

The recovered d decodes to the flag’s inner phrase, which goes inside the wrapper:

MetaCTF{R3u51ng_n0nc3_4nd_c0nn3ct1on?!?!}

Solve Script

The full solver, solve.py — two connections in the same second, confirm r1 == r2, then recover the nonce and the private key:

from pwn import *
from Crypto.Util.number import *
from os import urandom

import ecdsa

gen = ecdsa.SECP256k1.generator
order = gen.order()
context.log_level = 'error'

c1 = remote('localhost',1337)
c2 = remote('localhost',1337)

c1.sendlineafter('> ',b'2')
c2.sendlineafter('> ',b'2')
c1.sendlineafter('msg > ',b'random')
c2.sendlineafter('msg > ',b'random')

exec(c1.recvline())
msg1 = msg
exec(c2.recvline())
msg2 = msg

exec(c1.recvline())
r1,s1 = r,s
exec(c2.recvline())
r2,s2 = r,s

assert(r1 == r2)

nonce = inverse((s1-s2),order)*(msg1-msg2)

flag = (inverse(r1,order)*(nonce * s1 - msg1))%order
flag = long_to_bytes(flag)
print(flag)

Key Takeaways

  • Never seed a PRNG used for cryptographic nonces from a low-entropy, guessable, or externally observable value like the current time — reuse becomes a matter of timing, not brute force.
  • ECDSA (and DSA/Schnorr) nonce reuse across two signatures is enough to solve for the private key algebraically; it doesn’t require any weakness in the curve or hash function itself.
  • Use a cryptographically secure random source (os.urandomsecrets, or a deterministic-but-unpredictable scheme like RFC 6979) for every nonce, and never let two invocations of the same process share PRNG state.