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.

Challenge
We are provided with a single file:
challenge.py
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:
Guided Overview
Let’s start by analyzing the global variables.
| |
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:
| |
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.
| |
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.
| |
print_help simply prints the options available to the user:
| |
This brings us to the main() function, which orchestrates the interaction. It can be simplified as follows:
| |
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:
- The plaintext cannot contain the ADMIN_SECRET.
- The provided nonce cannot be present in USED_NONCES.
| |
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.
| |
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.
| |
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:
Leak the Nonce (Compression Oracle): Exploit the
zlibcompression size leak to recover the hidden nonce from test_init().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.
| |
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).
- If we inject a string into
NORMAL_LOGSthat matches a string inADMIN_LOGS, the compressor will compress it efficiently, resulting in a smaller total size. - If we inject a string that does not match, the compressor has to store the raw literals, resulting in a larger total size.
We know the ADMIN_LOGS format is: [+] Nonce: <BASE64>.
We can brute-force the nonce character by character.
The Attack:
- Assume we know the prefix
[+] Nonce: - Try appending every possible base64 character (e.g.,
A,B,C, …) to our known prefix. - Send this guess as our
pt(which goes intoNORMAL_LOGS). - Check
get_size(). The character that yields the smallest size is the next correct character of the nonce.
For example:
[+] Nonce: A-> Size: 80 bytes (No match)[+] Nonce: 0-> Size: 78 bytes (Match! ‘0’ is the first char)
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:
- Encryption (CTR Mode): $C = P ⊕ AES_k(N∣∣Counter)$
- Authentication (GHASH): $\text{Tag} = \text{GHASH}_H(A,C)⊕AES_k(N∣∣0)$.
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.
Calculate Keystream: We know the plaintext “Hello GlacierCTF” corresponds to the test_init ciphertext. $$ \text{Keystream}=\text{Plaintex}t_{init}⊕\text{Ciphertex}t_{init} $$
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).
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.