CSAW 2021 Finals CTF Crypto Challenge : iBad Write-up

Mohammed Seddik Mehanneche
6 min readNov 23, 2021

Our team, Heaven’s Birds, got the 3rd place at the CSAW CTF 2021 Finals MENA Region, and 26th internationally. This CTF was organized by members of NYU Tandon School of Engineering’s OSIRIS Lab.

This is the Write-up for the Cryptanalysis challenge “iBad”.

We are given a Rocket web application. Rocket is a Rust web framework.

This is the index page :

The site says it upgraded from using AES-CBC to AES-GCM to encrypt its authentication cookie.

When we login, we provide a username, the app returns an auth cookie, and redirects us to the profile page :

AES-GCM uses counter-mode of encryption (like AES-CTR), and it also provides a way to verify the integrity of the data (The authentication TAG).

Let’s take a look at the main.rs file :

The auth Cookie :

What we’re interested in are the creation and verification of the auth cookie.

The cookie creation : when we post a username, the app generates a random 12 bytes nonce for AES-GCM. The cipher key is SECRET_KEY. Then it encrypts the string : “{username}|regular|{FLAG}”. And the auth cookie is :

AES-GCM-Tag.nonce.ciphertext (each base64 encoded )

So the flag is part of the cookie. We can assume that ‘regular’ is the user role.

If we send an empty username, we get a 60 length ciphertext, so the flag’s length is 51( subtracting ‘|regular|’).

Now to the cookie verification. The app checks if we are an admin by :

It takes the cookie, splits it on the ‘.’ separator. If we have 3 parts, it decrypts the ciphertext (AES-GCM) and checks if the string “|admin|” is part of the plaintext. It then redirects us to either the profile page, or the admin page.

So we can simply access the admin page by submitting a username that has “|admin|” in it. This apparently doesn’t give us anything.

Now back to the token verification. We said before that if the cookie has 3 parts, it goes to the “Upgraded encryption” path. But if it has only 2 parts, it goes to the “Legacy path”.

The Legacy Path :

In the legacy path, our token is considered an AES-CBC ciphertext, so the first part is the IV, and the second is the ciphertext. And the app will attempt to decrypt this ciphertext.

Now, we notice that if we modify the auth token in the AES-GCM path, it returns a 500 response, This is probably because the verification of the Tag failed.

If we are to submit a 2 parts token (for the AES-CBC path), the server returns a 500 response. Some unhandled error occurred.

Let ‘s try to send a valid ciphertext, that would be decrypted to a valid plaintext. How can we do that? Well everything lies within the implementation of these 2 modes of operation.

AES-GCM Encryption
AES-CBC decryption

AES-GCM will first concatenate the 12 bytes nonce with the current block counter, although it starts with counter 0x02 (extended to 4 bytes). It then encrypts this 16 bytes nonce_ctr with AES, then it XORs it with the current plaintext block Mi. So :

Ci = AES_Encrypt(nonce_ctri) ^ Mi.

And in AES-CBC decryption, the current ciphertext block Ci is decrypted with AES first, then it is XORed with the previous ciphertext block Ci-1(IV for C0).

Mi = AES_Decrypt(Ci) ^ Ci-1.

Since we know the nonce_ctr, and we know the first part of the plaintext ({username}|regular|), we can get the AES_Encrypt(nonce_ctri) by :

AES_Encrypt(nonce_ctr0) = C0 ^ M0. (where ctr0 is 0x2)

If we send this, the server (AES-CBC path) will decrypt this Encrypted nonce_ctr to the valid nonce_ctr. If we send only one ciphertext block, The server will then Xor it with the IV. So we can construct an IV :

IV = M0 ^ nonce_ctr.

This means that the final decrypted block will be :

nonce_ctr ^ IV = nonce_ctr ^ M0 ^ nonce_ctr= M0.

This should decrypt to a valid message.

However, changing the token to this also returns a 500 response.

Aside from the implementation, there is another difference between these 2 modes: AES-GCM doesn’t use padding while AES-CBC uses it. The server error is produced because our ciphertext doesn’t have a valid padding.

So we have a padding Oracle that we can use to decrypt our flag.

However, this is not a simple Oracle AES-CBC padding attack.

Constructing a valid padding :

Let’s first send a valid ciphertext that would decrypt to a valid padded plaintext.

A valid PKCS7 padded one block plaintext is 16 bytes of 0x10.

So, since we are sending only IV and one ciphertext block : we get C0 like before :

C0 = AES_Encrypt(nonce_ctr0) = token_C0 ^ M0. (where ctr0 is 0x2)

but IV should be :

IV =( ‘\x10’ * 16 )^ nonce_ctr.

So that the plaintext the server will get is :

M0 = AES_Decrypt(C0) ^ IV

= nonce_ctr ^ ( ‘\x10’ * 16 )^ nonce_ctr = (‘\x10’ * 16)

Which is a valid padded plaintext.

We send : b64encode(IV).b64encode(C0) , and the server indeed returns a 200 response.

Now how can we use this to decrypt the flag ?

Decrypting the flag :

We will brute force it one byte at a time. In the previous example we knew the M0 which is for example = aaaaaaa|regular| , where username=’a’*7.

and we got C0(The encrypted nonce_ctr) by : C0 = token_C0 ^ M0.

If M0 is wrong, the decrypted nonce will be wrong, so the padding will also be invalid.

So instead of sending a valid M0, lets send : ‘a’*6|regular|{char}

where we bruteforce char with printable characters, and if the server returns a 200 response, that’s our character. After that, M0 is ‘a’*5|regular|{first found char}{char}. And we repeat.

However, this will enable us to get only the first 7 chars of the flag (we can’t control ‘|regular|’ part) and the flag’s length is 51.

We simply pad more ‘a’ (‘a’ * 54) and we use the 4th block to bruteforce the flag (The 4th block ctr is 0x5):

our input will look like this :

‘a’*54|regular|{char}

If we get a valid char, the next M0 will be :

‘a’*53|regular|{first_char}{char}. And so on, Until we get the full flag.

However, what if the server happens to randomly decrypt to a valid padding, an invalid guess can also return a valid padding (say the server decrypts to 0x01, or 0x02 0x02, and not 0x10 * 16, this is still a valid padding for an invalid guess).

Here we need to perform a second check, where we will verify if the decryption also result in, let’s say 0x01. For this we make IV2, where instead of Xor the last byte of nonce_ctr with 0x10, we Xor it with 0x1.(Or we simply Xor IV[15] with 0x11). Now, unless the server hits the jackpot and still decrypts to a valid padding in both cases, we are safe.

This is the python script for that :

Slowly, but surely, we get the flag. (If the connection gets interrupted, we just put the part of the flag we found until now in the flag variable, so we don’t have to start from the beginning).

--

--