Skip to content

GCM Nonce Reuse

Nonce (Number used ONCE) is a random or pseudo-random number that are intended for singular use in cryptographic operations.

On AES-GCM, AES is used to generate a keystream(the pseudo-random bit sequence used to encrypt the plaintext), and then XORs that against the plaintext, same as any stream cipher. This is the “Counter mode” part of GCM (Galois/Counter Mode).

The mechanism:

  • Take a nonce and a counter starting at 0.
  • Encrypt nonce || counter with AES. Bump the counter. Encrypt again. Repeat.
  • String those outputs together — that’s the keystream.
  • XOR that keystream against the plaintext to get ciphertext.

keystream = AES(key, nonce||0), AES(key, nonce||1), AES(key, nonce||2), … ciphertext = plaintext XOR keystream

So, the keystream depends on exactly two things: the key and the nonce.

Same key and same nonce → same keystream, every time.

That’s why the nonce exists — it’s literally the only thing making each message’s keystream different when reusing the same key over and over.


Now, this exercise has the following text on visiting the site:

Welcome to our chat system:
admin: The key for this challenge is : N/K/gMqTARuuqaYzhO2URo0K9iez1Nvi5QO/wT/cvhIMA3p/
# For transparency, you can find our military grade encryption code below:
cipher = OpenSSL::Cipher.new('aes-256-gcm')
cipher.encrypt
cipher.iv = '000000000000'
cipher.key = $key
Base64.encode64(cipher.update(str) + cipher.final)

The code hardcodes the IV (nonce) to '000000000000', which is used for every encryption:

cipher.iv = '000000000000'

This creates catastrophic cryptographic failure, as in AES-GCM, reusing the same key + nonce pair, leads to recovering the keystream and decrypting any ciphertext encrypted with that pair.


AES-GCM is essentially a stream cipher: ciphertext = plaintext XOR keystream. If the nonce never changes, the keystream is always the same.

Given two messages encrypted with the same key+nonce:

C1 = P1 XOR KS
C2 = P2 XOR KS

XORing them cancels the keystream: C1 XOR C2 = P1 XOR P2

More usefully: if we can recover the keystream, we can decrypt anything:

KS = C1 XOR P1
P2 = C2 XOR KS

  • Register a user and send a message with known plaintext (e.g., “aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”).
  • Capture the ciphertext of that message.
  • Extract the keystream by XORing the known plaintext with its ciphertext.
  • Use the keystream to decrypt the admin’s ciphertext.
import base64
# Known/Sent plaintext
known_plaintext = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
# Our message's ciphertext (base64)
ct_b64 = "b6a90c3BVh7irf5h0KHBQYlauiew09iutFHvxD3fu0VYUX56wVFVjIYlAIoKgfY/vjUDRrqrLck7ZKcgoRqKI8uDTaDaF+YJfA=="
# Admin's ciphertext (To decrypt)
admin_ct_b64 = "N/K/gMqTARuuqaYzhO2URo0K9iez1Nvi5QO/wT/cvhIMA3p/"
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# base64 Decode both ciphertexts
ct = base64.b64decode(ct_b64)
admin_ct = base64.b64decode(admin_ct_b64)
# Step 1: Recover keystream
# KS = CT XOR PT
keystream = xor_bytes(ct, known_plaintext)
# Step 2: Decrypt admin ciphertext
decrypted = xor_bytes(admin_ct, keystream)
print(f"[+] Decrypted: {decrypted.decode('utf-8', errors='replace')}")

Make the nonce unique, every single time.

cipher.iv = SecureRandom.random_bytes(12) # fresh nonce per message
# send the nonce along with the ciphertext — it doesn't need to be secret, just unique

A strictly incrementing counter works too, as long as it never resets and never overlaps across restarts or multiple servers. The one thing that can never happen is a nonce that’s fixed, predictable, or reused across messages.