GlacierCTF 2025 - AES Zippy

This year, I once again had the opportunity to contribute to Glacier CTF for its fourth edition. The challenge, AES Zippy, is designed for players with intermediate familiarity with cryptography, specifically focusing on AES-GCM.

If you tried to solve the challenge during the CTF, feel free to skip directly to the Solution. Otherwise, I recommend you take a look at the provided files or read through the Guided Overview and try to figure it out yourself first.

Alt text

Challenge

We are provided with a single file:

This contains approximately 100 lines of Python code. All other provided files are simply for easy deployment on your local system. All files can be downloaded here:

aes-zippy.tar.gz

Guided Overview

Let’s start by analyzing the global variables.

1
2
3
4
5
6
KEY = os.urandom(16)
USED_NONCES = []
ADMIN_SECRET = b"Glacier CTF Open"
ADMIN_LOGS = ""
NORMAL_LOGS = ""
MAX_STORAGE = 1 << 16

We have a global KEY (which remains static) and log variables ADMIN_LOGS and NORMAL_LOGS. The other variables will become important later, but for now, let’s move on to the functions.

We have 6 functions in total:

1
2
3
4
5
6
def decrypt(ct: bytes, nonce: bytes, tag: bytes) -> bytes: ...
def encrypt(pt: bytes, nonce: bytes = os.urandom(16)): ...
def test_init(): ...
def get_size() -> int: ...
def print_help(): ...
def main(): ...

The encrypt() and decrypt() functions are simple but at the core of the challenge. They take in a ciphertext ct or a plaintext pt and process this data using AES-GCM. Importantly, the encrypt() function defaults to using a secure, random nonce if one is not provided.

The test_init() function performs a sanity check to ensure encryption and decryption are working as intended. Crucially, it adds the produced nonce and tag to the global variable ADMIN_LOGS.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def test_init():
    global ADMIN_LOGS

    pt = b"Hello GlacierCTF"

    ct, nonce, tag = encrypt(pt)
    assert pt == decrypt(ct, nonce, tag)

    ADMIN_LOGS += f"[+] Nonce: {base64.b64encode(nonce).decode()}\n"
    ADMIN_LOGS += f"[+] Tag: {base64.b64encode(tag).decode()}\n"

The get_size() function calculates the current storage usage. It compresses the concatenated global log variables (ADMIN_LOGS and NORMAL_LOGS) using zlib and adds the size of the USED_NONCES list.

1
2
3
4
5
6
def get_size() -> int:
    global ADMIN_LOGS, NORMAL_LOGS, USED_NONCES

    full_state: bytes = ADMIN_LOGS.encode() + NORMAL_LOGS.encode()

    return len(zlib.compress(full_state)) + len(USED_NONCES)*16

print_help simply prints the options available to the user:

1
2
3
def print_help():
    print("[0] Encrypt a file")
    print("[1] Access admin files")

This brings us to the main() function, which orchestrates the interaction. It can be simplified as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def main():
    print("[+] Welcome to the Glacier encryption service")
    test_init()
    while get_size() < MAX_STORAGE:
        choice = int(input("Choose action:\n> "))
        if choice == 0:
            # Encrypt data using a NONCE and PT provided by the user
        elif choice == 1:
            # Print the flag if specific conditions are met
    return

We effectively have two options:

User Option 1: Encrypt

We can provide a plaintext pt and a nonce to encrypt using AES-GCM under the static global KEY. However, there are restrictions:

  1. The plaintext cannot contain the ADMIN_SECRET.
  2. The provided nonce cannot be present in USED_NONCES.
1
2
3
4
if nonce in USED_NONCES or ADMIN_SECRET in pt:
    return

USED_NONCES.append(nonce)

Every used nonce is appended to USED_NONCES. The program encrypts our plaintext, prints the resulting ciphertext and tag, and updates the logs. Finally, it prints the exact remaining storage space.

1
2
3
4
5
6
7
ct, nonce, tag = encrypt(pt, nonce)

NORMAL_LOGS = f"{pt.hex()=} = {ct.hex()=}, {tag.hex()=}"
print(NORMAL_LOGS)
NORMAL_LOGS = f"{pt}"

print(f"[+] Storage left: {get_size()}/{MAX_STORAGE} bytes")

User Option 2: Print Flag

To retrieve the flag, we must provide a valid ciphertext, nonce, and tag that decrypt to the ADMIN_SECRET. If successful, we win.

1
2
3
4
5
6
pt = decrypt(ct, nonce, tag)

if ADMIN_SECRET in pt:
    with open("/flag.txt", "r") as flag:
        print(f"{flag.read()}")
    return

Tips for Solving

The goal is clear: forge a valid ciphertext, nonce, and tag for the ADMIN_SECRET. Since we cannot simply ask the oracle to encrypt the ADMIN_SECRET (due to the input filter), we must forge it.

A quick search on AES-GCM vulnerabilities points to Nonce Reuse. If nonces are repeated, AES-GCM fails catastrophically. But how can we trigger a reuse if the program checks USED_NONCES?

Hint: We cannot reuse our nonces, but what about the nonce used in test_init()? It was never added to the USED_NONCES list.

Solution

To solve this challenge, we need to chain two distinct attacks:

  1. Leak the Nonce (Compression Oracle): Exploit the zlib compression size leak to recover the hidden nonce from test_init().

  2. AES-GCM Forgery: Reuse that recovered nonce to forge a valid encryption of the ADMIN_SECRET.

Part 1: Leak the Nonce via Compression Oracle

During test_init(), a random nonce is generated. While it isn’t added to USED_NONCES, it is written into ADMIN_LOGS.

1
ADMIN_LOGS += f"[+] Nonce: {base64.b64encode(nonce).decode()}\n"

These logs are concatenated with our NORMAL_LOGS and then compressed. We are also told the exact size of the result via get_size(). This creates a classic side-channel vulnerability similar to CRIME or BREACH.

The Intuition: zlib (using the DEFLATE algorithm) reduces file size by replacing repeated strings with back-references (pointers to previous occurrences).

We know the ADMIN_LOGS format is: [+] Nonce: <BASE64>. We can brute-force the nonce character by character.

The Attack:

  1. Assume we know the prefix [+] Nonce:
  2. Try appending every possible base64 character (e.g., A, B, C, …) to our known prefix.
  3. Send this guess as our pt (which goes into NORMAL_LOGS).
  4. Check get_size(). The character that yields the smallest size is the next correct character of the nonce.

For example:

We repeat this until we have recovered the full Base64 string of the nonce.

Part 2: AES-GCM Nonce Reuse

Some great resources on AES-GCM that explain it in greater detail are:

Now that we have the nonce used in test_init() (let’s call it N), we can reuse it. Because the application only checks USED_NONCES (which N is not in), we can ask the server to encrypt arbitrary data using N. Why is this fatal for AES-GCM?

AES-GCM consists of two parts:

If we reuse the nonce N, the keystream ($AES_k(N∣∣Counter)$) remains identical. Furthermore, the “authentication mask” ($AES_k(N∣∣0)$) used to mask the tag is also identical.

Recovering the Auth Key (H)

The GCM tag is calculated as a polynomial evaluation over the Galois Field $GF(2^{128})$. Let $T$ be the tag, $H$ be the authentication key, and $g(X)$ be the polynomial derived from ciphertext and associated data. $$ T=g(H)⊕M $$ Where $M$ is the mask generated by the nonce.

If we have two messages encrypted with the same nonce (the test_init message and our own chosen message), we have: $$ T_1=g_1(H)⊕M \\\ T_2=g_2(H)⊕M $$

If we XOR them, the unknown mask $M$ cancels out: $$ T_1⊕T_2=g_1(H)⊕g_2(H) \\\ g_1(H)⊕g_2(H)⊕T_1⊕T_2=0 $$

This results in a polynomial equation where $H$ is a root. We can solve for the roots of this polynomial over $GF(2^{128})$. Usually, this yields a small set of candidates for the authentication key $H$.

Forging the Message

Once we recover $H$, we can forge a valid tag for any ciphertext.

  1. Calculate Keystream: We know the plaintext “Hello GlacierCTF” corresponds to the test_init ciphertext. $$ \text{Keystream}=\text{Plaintex}t_{init}⊕\text{Ciphertex}t_{init} $$

  2. Create Target Ciphertext: We want to encrypt “Glacier CTF Open”. $$ \text{Ciphertext}_{new} = "\text{Glacier CTF Open}" \oplus \text{Keystream} $$ (Note: This works because the new message is the same length as the old one).

  3. Forge Tag: Using our recovered $H$ and the standard GCM formulas, we calculate the GHASH of our new ciphertext. Finally, we XOR this with the mask M (which we can derive from the original tag and message) to generate the valid tag.

Summary

By combining the side-channel leak to get the nonce and the mathematical breakdown of GCM nonce reuse, we can successfully impersonate the admin and retrieve the flag.