Contents

Root-me XMAS 2025 CTF day 10, AES Telegram secret solution + Unintended

Introduction - Root-me XMAS

In this article, I am going to present my solution for one challenge from Root-me XMAS CTF. It is a CTF organized by Root-Me where each day from 1 december to 24 december, a new challenge is proposed.

Other days

I am going to try to keep the list up to date, but you can get the write-ups for the others interesting challenges there:

Day 10, AES, Telegram’s secret

First, let’s present a bit the challenge. It was created by 3xpl01t, the description is:

RootMe's mischievous elves have deployed an unusual encryption mode to encrypt user tokens. 
You obtained the Christmas password wordlist they are using.

And we are given a wordlist of 59 passwords, so we guess there would be something to bruteforce. The mischievous elves also give us the possibility to connect to their service through TCP: dyn-02.xmas.root-me.org:18062. We can play a little bit with it using netcat to see what they propose:

$ nc dyn-02.xmas.root-me.org 18062

   *    .  *       .        *       .       *
    .    *         *        .        *    .      
           \\ || //       
            \\||//         AES - IGE 
    *    *   ||||    *    
            / || \         3xpl01t is a mischievous elf - XMAS2025 - Brute force is not the solution
   .    *  /  ||  \   .    
    *    /____||____\   *  Did you know? Telegram uses AES-IGE x)
        |    |  |    |     You're just a guest for now... Can you change that?
    .   |____|  |____|  .  IV = SHA256(password || find_the_salt)
    
  Rules: admin/root/superuser/flag banned, 10 encryptions max


  1. Encrypt (0/10)  2. Verify (custom IV)  3. Exit
  > 1
  Token format: role=user&name=3xpl01t
  Enter your token: role=user&name=eclipse
  Ciphertext: d98797d55d5cdf65c9fa88ae0892633e24d494d3580d7e5ebe5f6e77a125e791

  1. Encrypt (1/10)  2. Verify (custom IV)  3. Exit
  > 2
  IV (hex, 32 bytes): 2e58d26d243791a23c453e4ab300dec419620b9b5efaa0ae1c51ad7fd9db6b84
  Ciphertext (hex): d98797d55d5cdf65c9fa88ae0892633e24d494d3580d7e5ebe5f6e77a125e791

  Invalid token. No role found in token.

  1. Encrypt (1/10)  2. Verify (custom IV)  3. Exit
  > 3

  Merry Christmas! 🎄

So to summarize, it seems we can encrypt with AES-IGE a token like role=user&name=eclipse that contains our name and role. And we can get verified by inserting the correct IV, and the ciphertext. In our case, we don’t know the IV, so during the verification it says “Invalid token”. We also notice that we are limited in the number of encryption (10 max), and thus we guess that the IV and/or the key is randomly generated everytime we connect to the service.

That being said, our current goal is to first get verified. For that there are two ways:

  • By bruteforcing the IV.

  • By defining the IV (unintended)

For now, let us look at the bruteforce method.

Get the correct IV: Bruteforce method

When we connect to the service, they indicate us that

IV = SHA256(password || find_the_salt)

This means that the IV is the sha256 of the concatenation of the password and the salt.

We guess that the correct password is present in the given wordlist. However the difficulty is to find the salt. Actually, the salt was literally before our eyes since the beginning. In the banner, we see:

3xpl01t is a mischievous elf - XMAS2025 - Brute force is not the solution

XMAS2025 is the salt. Well, that is the guessy part, I don’t really know how to present it, either you find it, or you don’t. Personally I did not find it. But even so, there is still a way to solve the challenge without using the wordlist, and the salt.

So the salt is predefined and given to us like a backdoor organized by the elves, however we guess that the password is randomly generated at every connection, thus we have to bruteforce it.

The following python code bruteforce the correct IV using the wordlist:

from pwn import * 
import re
import hashlib

password_list = open('./wordlist-xmas.txt', 'r').read().split('\n')
salt = 'XMAS2025'


iv_list = [hashlib.sha256((password + salt).encode()).hexdigest() for password in password_list]

# Send a request and receive.
def send_and_receive(r, to_send):
    r.send(to_send)

    return r.recv()

HOST = "dyn-02.xmas.root-me.org"
port = 18062

r = remote(HOST, port, level = "debug")

# We receive the banner.
banner = r.recv()
# We ask for an encryption.
send_and_receive(r, b'1\n')
# We encrypt our name and role.
encryption_result = send_and_receive(r, b'role=user&name=eclipse\n').decode()

print(encryption_result)


ciphertext = re.findall(r"Ciphertext: ([0-9a-f]+)", encryption_result)[0]

# Decryption part, we bruteforce IV
for iv in iv_list:
    decryption_banner = send_and_receive(r, b'2\n')
    # send the IV
    send_and_receive(r, iv.encode() + b'\n')
    # send the ciphertext
    result = send_and_receive(r, ciphertext.encode() + b'\n').decode()

    # if our token is valid. We found the IV!
    if(result.find('Invalid token.') == -1):
        print("Correct IV: ", iv)
        print(result)
        break

Basically, we encrypt our token role=user&name=eclipse using the mischievous elves’ service, and then using the verification option, we test every IV we can define using the wordlist (59 in total). When we find it, we print the IV and the output. At the end, we obtain:

Correct IV:  98ddd716ad8eab9e38502d06b2af0f33b1b16c6cc97debfd93b7659fd9d871b4

  Welcome, your role is "user". You are not admin.

This opens the second part of the challenge: how to be admin. But for now, we are going to talk about how to get verified without bruteforcing.

Get the correct IV: define it (unintended)

If like me you did not find the salt, you can actually do without. For that, we need to understand a bit how works AES-IGE, the encryption used by Telegram.

AES-IGE, some (mild) theory

AES-IGE is AES encryption algorithm using IGE (Infinite Garble Extension) as block cipher mode. The encryption scheme is the following:

Our message is cut into blocks of the same size (often 16 bytes), it is xored with a first IV_1, then classical AES encryption is performed on the xored output. And then another xor is performed with the second IV_2. So there are two IV parts of 16 bytes, that is why the service asks us for a general IV of 32 bytes (the concatenation of the two). The other particularity of AES-IGE, as we can see in the scheme, is that a ciphertext block becomes the first IV_1 for the next block, and a message block becomes the second IV_2 for the next block.

We can also define it mathematically. Let $m_i$ the message blocks of 16 bytes and $c_i$ the corresponding ciphertext blocks, for $i \in {1, \dots, N}$, with N the number of blocks. We also define $c_0$ the first IV, and $m_0$ the second IV. Both of them are predefined. AES-IGE defines the next ciphertext block as:

$$c_i = f_k(m_i \oplus c_{i-1}) \oplus m_{i-1}, \quad \text{for $i \in {1, \dots, N}$,}$$

where $f_k$ is AES encryption using the key $k$. In our case we are going to treat $f_k$ as a black box, as we don’t need to know the key of even how the encryption works.

The IV (Initialization Vector) is originally used to avoid that a same plaintext block gives the same ciphertext block, which would give information on the original message. For IGE, it has also the property to make detectable the ciphertext modification. In AES-CBC, the decryption formula is $m_i = f_k^{-1}(c_i) \oplus c_{i-1}$, which makes it possible to do bit-flipping attack (see here), but in the case of IGE, $m_i$ will be used as IV in the next block, which would propagate the modification in an unpredictable way in the following blocks!

Define the IV.

From the way AES-IGE works, we can deduce that actually, we don’t need to bruteforce the IV! That is because the message block and the ciphertext block becomes the IV for the next block. We don’t know $(c_0, m_0)$, but we know $(c_1, m_1)$, that is the IV for second block.

The idea is to encrypt: AAAAAAAAAAAAAAAArole=user&name=eclipse, where the ‘A’ block is 16 bytes long and will correspond to $m_1$.

The service answers with the encrypted token: 1dec1df58bdf83a5ae10ac1e31960fe93f42d4685af95782091e15c669dc9e0ab23f43fcdc1535402b4cdb76ab5ac939. We can split it into two to extract $(c_1)$ and $(c_2, c_3, …)$

1dec1df58bdf83a5ae10ac1e31960fe9
3f42d4685af95782091e15c669dc9e0ab23f43fcdc1535402b4cdb76ab5ac939

Thus we obtain $c_1 =$ 1dec1df58bdf83a5ae10ac1e31960fe9 and $m_1 =$ 41414141414141414141414141414141. The entire IV is then the concatenation of the two: $c_1||m_1$ = 1dec1df58bdf83a5ae10ac1e31960fe941414141414141414141414141414141 and the ciphertext is the remaining blocks: 3f42d4685af95782091e15c669dc9e0ab23f43fcdc1535402b4cdb76ab5ac939. We can see the theory working on the service:

$ nc dyn-01.xmas.root-me.org 14318

   *    .  *       .        *       .       *
    .    *         *        .        *    .      
           \\ || //       
            \\||//         AES - IGE 
    *    *   ||||    *    
            / || \         3xpl01t is a mischievous elf - XMAS2025 - Brute force is not the solution
   .    *  /  ||  \   .    
    *    /____||____\   *  Did you know? Telegram uses AES-IGE x)
        |    |  |    |     You're just a guest for now... Can you change that?
    .   |____|  |____|  .  IV = SHA256(password || find_the_salt)
    
  Rules: admin/root/superuser/flag banned, 10 encryptions max


  1. Encrypt (0/10)  2. Verify (custom IV)  3. Exit
  > 1
  Token format: role=user&name=3xpl01t
  Enter your token: AAAAAAAAAAAAAAAArole=user&name=eclipse
  Ciphertext: 1dec1df58bdf83a5ae10ac1e31960fe93f42d4685af95782091e15c669dc9e0ab23f43fcdc1535402b4cdb76ab5ac939

  Encrypt (1/10)  2. Verify (custom IV)  3. Exit
  > 2
  IV (hex, 32 bytes): 1dec1df58bdf83a5ae10ac1e31960fe941414141414141414141414141414141
  Ciphertext (hex): 3f42d4685af95782091e15c669dc9e0ab23f43fcdc1535402b4cdb76ab5ac939

  Welcome, your role is "user". You are not admin.

We can do it automatically using a python script:

from pwn import * 
import re
import hashlib
from binascii import hexlify

# Send a request and receive.
def send_and_receive(r, to_send):
    r.send(to_send)

    return r.recv()

HOST = "dyn-01.xmas.root-me.org"
port = 14318

padding = b'A'*16
padding_hex = hexlify(padding) # 41414141...
token = b'role=user&name=eclipse\n'


r = remote(HOST, port)

# We receive the banner.
banner = r.recv()
# We ask for an encryption.
send_and_receive(r, b'1\n')
# We encrypt our name and role.
encryption_result = send_and_receive(r, padding + token).decode()

ciphertext = re.findall(r"Ciphertext: ([0-9a-f]+)", encryption_result)
if(len(ciphertext) == 0):
    print(f"[Error] The service was not able to encrypt. Output: {encryption_result}")
    exit()
ciphertext = ciphertext[0].encode()

print(f"Encryption of {padding + token} gives the ciphertext {ciphertext}")
iv_1 = ciphertext[0:32] # IV_1 is the first block of the ciphertext
iv_2 = padding_hex # IV_2 is the first block of the message.
iv = iv_1 + iv_2 # IV is the concatenation of the two iv
ciphertext = ciphertext[32:] # the ciphertext of our token starts at the second block.

print (f"We deduce the IV for the second block {iv}")

decryption_banner = send_and_receive(r, b'2\n')
result = send_and_receive(r, iv + b'\n').decode() # send iv
result = send_and_receive(r, ciphertext + b'\n').decode() # send ciphertext

print(f"Output: {result}")

Executing it gives the output:

[+] Opening connection to dyn-01.xmas.root-me.org on port 14318: Done
Encryption of b'AAAAAAAAAAAAAAAArole=user&name=eclipse\n' gives the ciphertext b'c4bb3f96dd73700bfb2f5e03f47e552cbd2fbeee61f6668f008f2c2a489f2edd70b4c301c66d139661a8b890eee733ed'
We deduce the IV for the second block b'c4bb3f96dd73700bfb2f5e03f47e552c41414141414141414141414141414141'
Output: 
  Welcome, your role is "user". You are not admin.

  1. Encrypt (1/10)  2. Verify (custom IV)  3. Exit
  > 
[*] Closed connection to dyn-01.xmas.root-me.org port 14318

Perfect! We are at the same step than before but without bruteforcing. Now we have to become admin :D

Let’s become admin

To become admin, naively we can try to change token = b'role=user&name=eclipse\n' to token = b'role=admin&name=eclipse\n' to give us the role admin in the python code. Unfortunately, the word admin seems to be blocked :(

[+] Opening connection to dyn-01.xmas.root-me.org on port 14318: Done
[Error] The service was not able to encrypt. Output:   Blocked word!

  1. Encrypt (0/10)  2. Verify (custom IV)  3. Exit
  > 
[*] Closed connection to dyn-01.xmas.root-me.org port 14318

That is fine! We can still do something, again thanks to the IV. As described in the theory section, the decryption scheme looks like:

$$m_i = f_k^{-1}(c_i \oplus m_{i-1}) \oplus c_{i-1}$$

And we control $c_{i-1}$! It means that we can flip some bits inside the ciphertext to manipulate the decrypted message. It is exactly the bit-flipping attack in AES-CBC, except that the decrypted block $m_i$ will become the IV for the next block. Thus is will propagate some errors. But actually, that is fine to do bit flipping in the last block. To avoid corruption on the next blocks, we can put the role to modify into the last block. Actually, the name field is completely optional, so we can send the token role=XXXXX&a=aaa, which is exactly 16 bytes long, and XXXXX is the same length than admin.

Let’s be more concrete to flip the bits to obtain the correct role. Currently we have $$c_{i-1} \oplus f_k^{-1}(c_i \oplus m_{i-1}) = X$$ but we want $Y$ instead of $X$, we can xor in both sides of the equation: $$f_k^{-1}(c_i \oplus m_{i-1}) \oplus c_{i-1} \oplus X \oplus Y = X \oplus X \oplus Y$$

This is equivalent to: $$f_k^{-1}(c_i \oplus m_{i-1}) \oplus c^\prime_{i-1} = Y$$

with

$$ c^\prime_{i-1} := c_{i-1} \oplus X \oplus Y$$

We can write this modification in the python code:

from pwn import * 
import re
import hashlib
from binascii import hexlify
from Crypto.Util.number import long_to_bytes

# Send a request and receive.
def send_and_receive(r, to_send):
    r.send(to_send)

    return r.recv()

HOST = "dyn-01.xmas.root-me.org"
port = 14318

padding = b'A'*16
padding_hex = hexlify(padding) # 41414141...
#token = b'role=admin&name=eclipse\n'
fake_role = b"XXXXX"
true_role = b"admin"
assert(len(fake_role) == len(true_role))

token = f'role={fake_role.decode()}&a=aaa\n'.encode()

to_modify_position = 5 # indicate the position of fake_role.
end_modify = to_modify_position + len(fake_role)

r = remote(HOST, port)

# We receive the banner.
banner = r.recv()
# We ask for an encryption.
send_and_receive(r, b'1\n')
# We encrypt our name and role.
encryption_result = send_and_receive(r, padding + token).decode()

ciphertext = re.findall(r"Ciphertext: ([0-9a-f]+)", encryption_result)
if(len(ciphertext) == 0):
    print(f"[Error] The service was not able to encrypt. Output: {encryption_result}")
    exit()
ciphertext = ciphertext[0].encode()

print(f"Encryption of {padding + token} gives the ciphertext {ciphertext}")
iv_1 = ciphertext[0:32] # IV_1 is the first block of the ciphertext
iv_2 = padding_hex # IV_2 is the first block of the message.

print(f"iv_1 before {iv_1}")
# We modify XXXXX into admin through modifications in the IV
iv_1_bytes = bytes.fromhex(iv_1.decode())
modif_iv = long_to_bytes(int.from_bytes(fake_role) ^ int.from_bytes(true_role) ^ int.from_bytes(iv_1_bytes[to_modify_position:end_modify], "big"))

iv_1_bytes_modified = iv_1_bytes[:to_modify_position] + modif_iv + iv_1_bytes[end_modify:]
iv_1 = iv_1_bytes_modified.hex().encode()

print(f"iv_1 after {iv_1}")


iv = iv_1 + iv_2 # IV is the concatenation of the two iv
ciphertext = ciphertext[32:] # the ciphertext of our token starts at the second block.
print (f"We deduce the IV for the second block {iv}")

decryption_banner = send_and_receive(r, b'2\n')
result = send_and_receive(r, iv + b'\n').decode() # send iv
result = send_and_receive(r, ciphertext + b'\n').decode() # send ciphertext

print(f"Output: {result}")

Executing it gives:

[+] Opening connection to dyn-01.xmas.root-me.org on port 14318: Done
Encryption of b'AAAAAAAAAAAAAAAArole=XXXXX&a=aaa\n' gives the ciphertext b'95beca4a914a7e2d023d8cbc0787189b2f5cbecc0067030a74970159860d621b57bec3492d7cff5c30bc6b6d429b436e'
iv_1 before b'95beca4a914a7e2d023d8cbc0787189b'
iv_1 after b'95beca4a91734218330b8cbc0787189b'
We deduce the IV for the second block b'95beca4a91734218330b8cbc0787189b41414141414141414141414141414141'
Output: 
  Welcome admin. FLAG: RM{3xpl01t_1g3_w34k_1v_xm4s}


  1. Encrypt (1/10)  2. Verify (custom IV)  3. Exit
  > 
[*] Closed connection to dyn-01.xmas.root-me.org port 14318

We obtain the flag RM{3xpl01t_1g3_w34k_1v_xm4s}. Thanks to 3xpl01t for its challenge, and to RM for the CTF.

Some references