Cryptography / Sea of Secrets CTF 2026

ARGON: The Silent Vault

01 Nov 2026 · 3 min read

We get challenge.py (a format spec), public.json (curve, public key Q, salts, Argon2 params, channel map), signatures.jsonl (96 ECDSA signatures over secp256k1), diagnostics.bin (384…

Crypto, hard

Flag: SOS{argon_48cec92a70e93a55b4e232c2b8a733a9}

We get challenge.py (a format spec), public.json (curve, public key Q, salts, Argon2 params, channel map), signatures.jsonl (96 ECDSA signatures over secp256k1), diagnostics.bin (384 telemetry frames), and two AES-256-GCM blobs, recovery.enc and vault.enc. The chain is: ECDSA nonce leak gives the private key d, d opens the recovery blob, which holds an RSA instance whose plaintext feeds Argon2id to unlock the vault.

Nonce leak to private key

Each telemetry frame is 20 bytes: a 16-byte sig_id, a big-endian uint16 channel and a uint16 payload. Only channel 0x0042 matters, since it leaks the top 10 bits of the nonce, h = k >> 246. Matching on sig_id gives a leak for all 96 signatures.

python
for i in range(0, len(data), 20):
    sid, ch, payload = struct.unpack(">16sHH", data[i:i+20])
    if ch == 0x0042:
        leaks[sid.hex()] = payload

With z = SHA256(msg) mod n, ECDSA gives k = (r/s)*d + z/s mod n. Write t_i = r_i/s_i, u_i = z_i/s_i, and a_i = h_i*2^246 + 2^245 (the leaked part, centered). Then t_i*d + (u_i - a_i) = e_i mod n with |e_i| <= 2^245. That is a hidden number problem. With K = 2^246 and c_i = (u_i - a_i) mod n, use the (m+2)-dimensional lattice with m = 96:

text
rows 0..m-1:  n^2 on the diagonal
row m:        (n*t_1, ..., n*t_m, K, 0)
row m+1:      (n*c_1, ..., n*c_m, 0, n*K)

The combination d*row_m + row_{m+1} - sum(q_i * row_i) is (n*e_1, ..., n*e_m, d*K, n*K), which is short, so LLL finds it and d = (row[m] / K) mod n.

python
B = IntegerMatrix(m + 2, m + 2)
for i in range(m):
    B[i, i] = n * n
    B[m, i] = n * t[i]
    B[m + 1, i] = n * c[i]
B[m, m] = K
B[m + 1, m + 1] = n * K
LLL.reduction(B, delta=0.999)
# pick the row with |B[j, m+1]| == n*K, d = (B[j, m] // K) % n, check d*G == Q

This gives d = 0x4793d79ca3bf6a2336b40e62cdff8519384c9bdf2aa4fb4a20c70ae963acd0d1, and d*G == Q confirms it.

Recovery blob

Layout is nonce(12) | tag(16) | ciphertext, AAD ARGON/RECOVERY/v1. The key is HKDF-SHA256(ikm = d as 32 bytes big-endian, salt = recovery_bundle.wrap_salt, info = "ARGON/RECOVERY/v1", 32). Decrypting with AES-GCM gives JSON with modulus_N (2047 bits), e1 = 196611, e2 = 300009, c1, c2.

Common-modulus RSA with gcd(e1, e2) = 3

The same message is encrypted under both exponents, but gcd(e1, e2) = 3 rather than 1. Bezout gives s*e1 + t*e2 = 3 with s = -20737, t = 13590, so

text
c1^s * c2^t = m^3 mod N

The message is 32 bytes, so m^3 < 2^768, far below N. The residue is the real integer cube, and an exact integer cube root recovers m without factoring N.

python
m3 = (pow(c1, s, N) * pow(c2, t, N)) % N   # negative exponent uses the modular inverse
m, exact = integer_nthroot(m3, 3)
T = m.to_bytes(32, "big")

T = da25997f6e32dc2040c1c848e62829e9605a4226fa46e5814c22c9c704f312ab.

Argon2id and the vault

Per challenge.py, P = SHA256("ARGON/FINAL/v1" || d.to_bytes(32, "big") || T), then Argon2id over P with final_salt = f998916ae4b80eaa8baa06f6ab8d44ef, 65536 KiB memory, 3 passes, 4 lanes, 32-byte output, version 0x13. I called the system libargon2 through ctypes:

python
lib = ctypes.CDLL("libargon2.so")
out = ctypes.create_string_buffer(32)
lib.argon2id_hash_raw(3, 65536, 4, P, 32, final_salt, 16, out, 32)

vault.enc has the same nonce | tag | ciphertext layout as the recovery blob. Decrypting with out.raw as the AES-256-GCM key and AAD ARGON/VAULT/v1 gives:

text
SOS{argon_48cec92a70e93a55b4e232c2b8a733a9}

Argon2id is not the weak point here. The parameters are fine, but the password it stretches is built from values that have already leaked.