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
- The gateway gives two single-block records under nonce
20b65e2f015753f173f8d1e9with known plaintext, ciphertext, and tag, plus the privileged commandreveal-escrowkey. Same nonce means same keystreamS = pt1 XOR ct1, so any plaintext can be encrypted asct = P XOR S. - For one 16-byte block with no associated data,
T = E XOR C*H^2 XOR L*Hin GF(2^128). Subtracting the two tag equations cancelsEandL*H, leaving a linear equation inH^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
- GCM uses bit-reflected GF(2^128) with reduction
x^128 + x^7 + x^2 + x + 1; the identity is1<<127.solve.pyconnects, 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())
- Run it against the live gateway. It recovers H and E, passes the record2 consistency check, builds the keystream
S = pt1 XOR ct1, setsCf = reveal-escrowkey XOR S, forgesTf = 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}