In this write-up
These notes document the October 2026 CTF challenge. Commands, addresses and outputs refer to the supplied challenge environment.
Challenge Summary#
We are given DSA public parameters and two signatures that reuse the same ephemeral nonce k. Because DSA nonces must never repeat, we can recover the private key x, then derive the XOR key and decrypt the sealed flag.
Files:
bundle.zipsignatures.json
Step 1 — Inspect the JSON#
After extracting, signatures.json contains:
- DSA parameters:
p,q,g,y - Two signatures with the same
r - A
sealedhex string - Notes:
hash_convention:SHA256 interpreted big-endian, reduced modulo qsealing:repeat SHA256(minimal unsigned big-endian x) as XOR key
The repeated r is a clue to nonce reuse. The recovered key is checked against the public key below to validate the hypothesis.
Step 2 — DSA Nonce Reuse Math#
For DSA:
s = k^(-1)(h + x r) (mod q)
For two signatures with the same nonce k:
s_1 = k^(-1)(h_1 + x r) (mod q)
s_2 = k^(-1)(h_2 + x r) (mod q)
Subtract:
s_1 - s_2 = k^(-1)(h_1 - h_2) (mod q)
So:
k = (h_1 - h_2) / (s_1 - s_2) (mod q)
Once k is known, recover the private key:
x = (s_1 k - h_1) / (r) (mod q)
Then verify:
y ≡ g^x (mod p)
Step 3 — Solver Script#
Create solve.py:
import json, hashlib
d = json.load(open('signatures.json'))
p = int(d['p'], 16)
q = int(d['q'], 16)
g = int(d['g'], 16)
y = int(d['y'], 16)
sigs = d['signatures']
h1 = int(sigs[0]['h'], 16)
r1 = int(sigs[0]['r'], 16)
s1 = int(sigs[0]['s'], 16)
h2 = int(sigs[1]['h'], 16)
r2 = int(sigs[1]['r'], 16)
s2 = int(sigs[1]['s'], 16)
assert r1 == r2, "r values differ, nonce not reused?"
# k = (h1 - h2) / (s1 - s2) mod q
k = ((h1 - h2) * pow((s1 - s2) % q, -1, q)) % q
# x = (s1*k - h1) / r mod q
x = ((s1 * k - h1) * pow(r1, -1, q)) % q
print("k =", hex(k))
print("x =", hex(x))
print("check y == g^x mod p:", pow(g, x, p) == y)
# minimal unsigned big-endian encoding of x
xb = x.to_bytes((x.bit_length() + 7) // 8, 'big')
key = hashlib.sha256(xb).digest()
print("x_bytes =", xb.hex())
print("key =", key.hex())
ct = bytes.fromhex(d['sealed'])
pt = bytes(b ^ key[i % len(key)] for i, b in enumerate(ct))
print("plaintext hex:", pt.hex())
print("FLAG:", pt.decode())
Run it:
python3 solve.py
Step 4 — Output#
k = 0x7a885ee1251149a79799bc45d8fd294c4acdde1c
x = 0x3c5952db0d792e7fc0bd4e577c72d905b9b087ed
check y == g^x mod p: True
x_bytes = 3c5952db0d792e7fc0bd4e577c72d905b9b087ed
key = f41a700468089f602599f4cbadc21324a3defa8799d15690e6ffdcd0c74e9f5c
plaintext hex: 74696265627b6e6f6e63655f6563686f5f62633431663938303131333165633838393464647d
FLAG: tibeb{nonce_echo_bc41f9801131ec8894dd}
Step 5 — Flag#
tibeb{nonce_echo_bc41f9801131ec8894dd}
Tricky Parts to Note#
-
Reused nonce is the whole challenge.
The two signatures sharer, suggesting nonce reuse. Equalralone is not unconditional proof. The nonce-reuse equations produce a candidate key, and the successfulg^x mod p == ycheck supports the recorded recovery. -
Modular arithmetic must be reduced modulo
q.
When computing(s1 - s2) % q, do not forget the modulo. Negative differences must be reduced properly. -
Private key encoding matters.
The sealing note says:SHA256(minimal unsigned big-endian x)This means you must convert the integer
xto the shortest possible big-endian byte string. Do not hash the ASCII hex string. Do not pad with leading zeros unless necessary. -
XOR key is repeated.
The SHA-256 digest is 32 bytes. The sealed data may be longer, so the key is repeated cyclically:key[i % len(key)] -
hvalues are already provided.
The JSON giveshfor each message, already interpreted as SHA-256 big-endian reduced moduloq. You do not need to hash the messages yourself. -
sealedis hex.
Convert it withbytes.fromhex(...)before XOR. -
Python version.
pow(a, -1, q)requires Python 3.8+. On Parrot OS this is normally fine. If not, usepow(a, q-2, q)sinceqis prime.
Final Answer#
tibeb{nonce_echo_bc41f9801131ec8894dd}