arrow_backUS Cyber Games 2026
Crypto / AEADinsane

House Account

by Pavel Knespl (capyplivl)

The Arclight Payments gateway seals every record with AES-GCM but reuses one nonce for all of them, and it acts on any record you submit with a valid tag. Two known plaintexts under the same nonce enable the GCM forbidden attack (Joux): recover the GHASH subkey H and mask E, then forge a tag for a privileged command.

Solve

  1. The gateway gives two single-block records under nonce 20b65e2f015753f173f8d1e9 with known plaintext, ciphertext, and tag, plus the privileged command reveal-escrowkey. Same nonce means same keystream S = pt1 XOR ct1, so any plaintext can be encrypted as ct = P XOR S.
  2. For one 16-byte block with no associated data, T = E XOR C*H^2 XOR L*H in GF(2^128). Subtracting the two tag equations cancels E and L*H, leaving a linear equation in H^2:
H^2 = (T1 XOR T2) * (C1 XOR C2)^-1
H   = sqrt(H^2) = (H^2)^(2^127)
E   = T1 XOR C1*H^2 XOR L*H
  1. GCM uses bit-reflected GF(2^128) with reduction x^128 + x^7 + x^2 + x + 1; the identity is 1<<127. solve.py connects, parses the two records, implements the field, recovers H and E, then forges and submits:
import socket, sys

ID = 1 << 127
RED = 0xe1 << 120
L = 128

def mul(x, y):
    acc, v = 0, y
    for i in range(128):
        if (x >> (127 - i)) & 1:
            acc ^= v
        v = (v >> 1) ^ RED if v & 1 else v >> 1
    return acc

def power(a, e):
    out, base = ID, a
    while e:
        if e & 1:
            out = mul(out, base)
        base = mul(base, base)
        e >>= 1
    return out

inv = lambda a: power(a, (1 << 128) - 2)
root = lambda a: power(a, 1 << 127)
ib = lambda b: int.from_bytes(b, "big")
bi = lambda x: x.to_bytes(16, "big")
fields = lambda line: dict(t.split("=", 1) for t in line.split()[1:])

host, port = sys.argv[1], int(sys.argv[2])
fp = socket.create_connection((host, port)).makefile("rwb")

rec, nonce, cmd = {}, None, None
for raw in fp:
    line = raw.decode().strip()
    if line.startswith("nonce "):
        nonce = bytes.fromhex(line.split()[1])
    elif line.startswith("record1"):
        rec["1"] = fields(line)
    elif line.startswith("record2"):
        rec["2"] = fields(line)
    elif line.startswith("privileged_command_hex="):
        cmd = bytes.fromhex(line.split("=", 1)[1])
    elif line.startswith("submit a forged"):
        break

p1 = bytes.fromhex(rec["1"]["plaintext_hex"])
c1, t1 = ib(bytes.fromhex(rec["1"]["ct"])), ib(bytes.fromhex(rec["1"]["tag"]))
c2, t2 = ib(bytes.fromhex(rec["2"]["ct"])), ib(bytes.fromhex(rec["2"]["tag"]))

h2 = mul(t1 ^ t2, inv(c1 ^ c2))
h = root(h2)
ghash = lambda c: mul(c, h2) ^ mul(L, h)
pad = t1 ^ ghash(c1)

stream = ib(p1) ^ c1
cf = ib(cmd) ^ stream
tf = ghash(cf) ^ pad

fp.write(("submit %s %s %s\n" % (nonce.hex(), bi(cf).hex(), bi(tf).hex())).encode())
fp.flush()
print(fp.readline().decode().strip())
  1. Run it against the live gateway. It recovers H and E, passes the record2 consistency check, builds the keystream S = pt1 XOR ct1, sets Cf = reveal-escrowkey XOR S, forges Tf = Cf*H^2 XOR L*H XOR E, and submits:
$ python3 solve.py challenge.ctf.uscybergames.com <port>
[+] H = ...
[+] E = ...
[+] consistency on record2: OK
[+] forging: submit <nonce> <Cf_hex> <Tf_hex>
[+] gateway response: authorized: SVIUSCG{...}

Flag: SVIUSCG{65b6a008a4c0fb775f3b929bea45a108}