Contents

N0PS CTF 2025

Break my stream

Description

CrypTopia is testing their next gen encryption algorithm. We believe that the way they implemented it may have a flaw…

We are given a Python file main.py.

Code analysis

The flag is encrypted with a key, which is selected randomly:

1
2
flag = b"XXX"
key = os.urandom(256)

The encryption consists in a XOR with a keystream derived from the key:

1
2
3
4
5
6
7
def encrypt(self, message):
    result = []
    for char in message:
        key = next(self.KeyGenerator)
        ...
        result.append(char ^ key)
    return bytes(result)

The keystream is created at initialization. I haven’t look in detail at the functions PRGA and KSA, because .. we don’t need to 😄

1
2
3
4
5
6
def __init__(self, key, n=256):
    # this generates a keystream of length n, based on the input key
    # if the key expansion is secure, this is the right way to do it
    # because then, the XOR key has the same length as the message
    # and XOR becomes very secure
    self.KeyGenerator = self.PRGA(self.KSA(key, n), n)

The program always show the encrypted flag:

1
2
3
encrypted_flag = CrypTopiaSC(key).encrypt(flag)
..
print(f"Oh, one last thing: {encrypted_flag.hex()}")

And then, we can have it encrypt our message. We don’t know the key, but we’ll have the plaintext and ciphertext of our message.

1
2
3
pt = input("Enter your message: ").encode()
ct = CrypTopiaSC(key).encrypt(pt)
print(ct.hex())

The flaw relies on the fact the key is chosen once and for all, and consequently same for the keystream. So we have the same keystream to encrypt the flag and to encrypt our message. XOR encryption is secure if the key is long (okay) and only used once (ouch!).

Let’s suppose we encrypt a long plaintext (longer than the flag). Then, we have ct = pt ^ keystream. We can work out the keystream: keystream = ct ^ pt.

Then, we can easily decrypt the encrypted flag: flag = enc_flag ^ keystream.

Solution

We run the program a first time and see that the encrypted flag is, in hex: 5ec5b97e7b2996d72381fd988f2e4483ceac4466412075. This means the flag is 23 characters long. So, we chose a plaintext of 23 characters (or more).

1
2
3
4
5
6
7
plaintext = b"12345678901234567890123"
ciphertext = bytes.fromhex("21c7da19356a949a2efd93e78d69258192a72e785e3c3b")
keystream = bytes([p ^ c for p, c in zip(plaintext, ciphertext)])

encrypted_flag = bytes.fromhex("5ec5b97e7b2996d72381fd988f2e4483ceac4466412075")
flag = bytes([c ^ k for c, k in zip(encrypted_flag, keystream)])
print("Recovered flag:", flag.decode())

Solution:

1
Recovered flag: N0PS{u5u4L_M1sT4k3S...}

Key Exchange

Description

We have located a secret endpoint of CrypTopia. If we manage to establish a communication with it, we should be able to get sensitive information!

We are given a source code: main.py

Code analysis

The code implements Diffie-Hellman key exchange. I have commented the code main.py below

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def gen_pub_key(size):
    q = number.getPrime(size)
    # searches for p that divides p-1
    k = 1
    p = k*q + 1
    while not number.isPrime(p):
        k += 1
        p = k*q + 1
    # searches for a generator g, order q modulo p
    h = randint(2, p-1)
    g = pow(h, (p-1)//q, p)
    while g == 1:
        h = randint(2, p-1)
        g = pow(h, (p-1)//q, p)
    # return public key pair
    return p, g

def get_encrypted_flag(k):
    # hash the shared secret to have adequate key length for AES
    k = sha256(k).digest()
    iv = get_random_bytes(AES.block_size)
    # read flag file and encrypt
    data = open("flag", "rb").read()
    cipher = AES.new(k, AES.MODE_CBC, iv)
    padded_data = pad(data, AES.block_size)
    # prefixes IV to ciphertext
    encrypted_data = iv + cipher.encrypt(padded_data)
    return encrypted_data

if __name__ == '__main__':
    # generate public key pair
    p, g = gen_pub_key(N)    
    # a is the private key - chosen randomly
    a = randint(2, p-1)
    # k_a = g^a mod p is the secret to share with the other end
    k_a = pow(g, a, p)
    # show p, then g, then k_a
    sys.stdout.buffer.write(p.to_bytes(N))
    sys.stdout.buffer.write(g.to_bytes(N))
    sys.stdout.buffer.write(k_a.to_bytes(N))
    sys.stdout.flush()
    # read N bytes: k_b = g^b mod p
    k_b = int.from_bytes(sys.stdin.buffer.read(N))
    # compute k = k_b ^ a mod p = g^b^a mod p
    k = pow(k_b, a, p)
    # send encrypted flag
    sys.stdout.buffer.write(get_encrypted_flag(k.to_bytes((k.bit_length() + 7) // 8)))

The program initiates its own keys for Diffie-Hellman, then it expects my own secret (k_b) to compute a shared secret k. Finally, it encrypts the flag with the shared secret.

Solution

So, this is nearly “not a challenge”, but simply implementing Diffie-Hellman on our side!

We need to (1) generate our private key b, (2) compute the shared secret: k=k_a ^ b mod p, and (3) create an AES key from SHA256(k) and (4) decrypt the flag.

We adapt to what the other ends sends us:

  1. Read p
  2. Read g
  3. Read k_a

Those are bytes, we convert them to integers. Diffie-Hellman works with integers.

  1. Generate b
  2. Compute k_b
  3. Send k_b
  4. Compute k
  5. Read the flag
  6. Decrypt using AES

I encountered silly issues reading from the socket… because I was only reading 128 bytes, not 1024. Took me a while to see my mistake.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import socket
from random import randint
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from hashlib import sha256

N = 1024  # Same as server

def main():
    # Connect to the server
    host = '0.cloud.chals.io'
    port = 26625
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((host, port))
        
        # Receive p, g, and k_a from server - need to receive exactly N bytes each
        p_bytes = b''
        while len(p_bytes) < N:
            chunk = s.recv(N - len(p_bytes))
            if not chunk:
                break
            p_bytes += chunk
            
        g_bytes = b''
        while len(g_bytes) < N:
            chunk = s.recv(N - len(g_bytes))
            if not chunk:
                break
            g_bytes += chunk
            
        k_a_bytes = b''
        while len(k_a_bytes) < N:
            chunk = s.recv(N - len(k_a_bytes))
            if not chunk:
                break
            k_a_bytes += chunk
        
        print(f"Received {len(p_bytes)} bytes for p")
        print(f"Received {len(g_bytes)} bytes for g")
        print(f"Received {len(k_a_bytes)} bytes for k_a")
        
        # Convert bytes to integers
        p = int.from_bytes(p_bytes, 'big')
        g = int.from_bytes(g_bytes, 'big')
        k_a = int.from_bytes(k_a_bytes, 'big')
        
        print(f"Received p: {hex(p)}")
        print(f"Received g: {hex(g)}")
        print(f"Received k_a: {hex(k_a)}")
        
        # Generate our private key
        b = randint(2, p-1)
        print(f"Generated private key b: {hex(b)}")
        
        # Compute our public key
        k_b = pow(g, b, p)
        print(f"Computed public key k_b: {hex(k_b)}")
        
        # Send our public key to server
        k_b_bytes = k_b.to_bytes(N, 'big')
        s.send(k_b_bytes)
        print(f"Sent {len(k_b_bytes)} bytes for k_b")
        
        # Compute shared secret
        shared_secret = pow(k_a, b, p)
        print(f"Computed shared secret: {hex(shared_secret)}")
        
        encrypted_flag = b''
        s.settimeout(2.0)
        try:
            while True:
                chunk = s.recv(1024)
                if not chunk:
                    break
                encrypted_flag += chunk
        except socket.timeout:
            pass
        
        print(f"Received encrypted flag length: {len(encrypted_flag)}")
        print(f"Encrypted flag (hex): {encrypted_flag.hex()}")
        
        if len(encrypted_flag) < AES.block_size:
            print("Error: Received data is too short to contain IV")
            return
        
        # Decrypt the flag
        # Convert shared secret to bytes (same way as server)
        k_bytes = shared_secret.to_bytes((shared_secret.bit_length() + 7) // 8, 'big')
        print(f"Shared secret bytes length: {len(k_bytes)}")
        print(f"Shared secret bytes (hex): {k_bytes.hex()}")
        
        key = sha256(k_bytes).digest()
        print(f"AES key (hex): {key.hex()}")
        iv = encrypted_flag[:AES.block_size]
        ciphertext = encrypted_flag[AES.block_size:]    
        print(f"IV (hex): {iv.hex()}")
        print(f"Ciphertext length: {len(ciphertext)}")
        print(f"Ciphertext (hex): {ciphertext.hex()}")
        
        if len(ciphertext) % AES.block_size != 0:
            print(f"Warning: Ciphertext length ({len(ciphertext)}) is not a multiple of block size ({AES.block_siz
e})")
        
        # Decrypt
        try:
            cipher = AES.new(key, AES.MODE_CBC, iv)
            decrypted_padded = cipher.decrypt(ciphertext)            
            print(f"Decrypted padded (hex): {decrypted_padded.hex()}")
            # Remove padding
            flag = unpad(decrypted_padded, AES.block_size)
            print(f"Decrypted flag: {flag.decode('utf-8')}")
            # Write flag to file
            with open('decrypted_flag.txt', 'w') as f:
                f.write(flag.decode('utf-8'))
            print("Flag written to decrypted_flag.txt")
            
        except Exception as e:
            print(f"Decryption error: {e}")

if __name__ == '__main__':
    main()

Initially, I wasn’t writing the flag to a file, but I immediately recognized in the decrypted flag the PNG header. So I dumped it to a file, and the PNG gave the flag.

Press Me If You Can

Description

This is a basic website with a silly button that’s moving all the time.

The challenge is to click on it nevertheless.

Web page analysis

This is the source code of the webpage. Pretty simple.

``html

Press me if you can
</body>
```

The logic behind the flying button is in script.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const btn = document.querySelector("button");
...
btn.style.left = endPoint.x + "px";
btn.style.top = endPoint.y + "px";

btn.disabled = true;
...

// Add an event listener for mouse movement
document.addEventListener('mousemove', (event) => {
    const { clientX: mouseX, clientY: mouseY } = event;
    ...

Solution

I first try simply to click on the button, through the developer console (F12):

1
document.querySelector('form').submit();

But that doesn’t work, probably because it doesn’t do the real mouse event, just calls submit.

So, I disable the fact the button moves:

1
2
3
4
getEventListeners(window).mousemove.forEach(h => window.removeEventListener("mousemove", h.listener));
const button = document.querySelector("button");
button.style.position = "fixed"; button.style.left = "50%"; button.style.top = "50%";
button.style.transform = "none"; button.disabled = false;

And then I click:

1
Well Done. You can validate this challenge with this flag : N0PS{W3l1_i7_w4S_Ju5T_F0r_Fun}

As I’m nuts with Javascript, this was achieved with the help of ChatGPT.