#crypto #web #osint #python #numpy The least solved challenge of the [[GCC 2024]] CTF. [Challenge source code](https://github.com/GCC-ENSIBS/GCC-CTF-2024/tree/main/Crypto/trust_issues) # Website ## Login ![[trust_issues_login.png]] Clicking `login as anonymous` sends a `POST` request to `/connect` with the following data: ```json {"username":"anonymous","password":"anonymous"} ``` And receives the following response: ```json {"token":"anonymous"} ``` The `user` and `token` are set in local storage. Trying other basic username and password combinations yield no results. ## Send a Secret ![[trust_issues_send_a_secret.png]] On opening the page, it sends a `POST` request to `/put/change_key` with the following data: ```json {"reason":"Staring"} ``` And receives the following response: ```json {"success":true} ``` After that, it sends a `GET` request to `/get/public_key` and receives the following response: ```json {"public_key":{"n":"14398615113252020556088959048173271338408139659807907082636583434751653712998913282250747225070770542926847440318172714927219067726254670880063402671673743096964124837970979230948335586261304594595389083918412818647527794953009787949153027482530238815881688630715857042994787305489287757391753257000708882145232231519307756186065833903449987343689436351849120609098792801460297136002902200879893334825751491867835794729381936497112731336113767428927602699280596079773504503717410326191999344705180747333386252345162268399857410230415603399832552747097414717093435808993983108758632824012410895986968861524405447229011","e":"65537"}} ``` The `/get/public_key` endpoint returns the same data until `/put/change_key` is called again. Changing `reason` does not seem to effect anything. ## Get My Secret ![[trust_issues_get_my_secret.png]] On opening the page, it sends a `GET` request to `/get/user_rights/anonymous?token=anonymous` and receives the following response: ```json {"user_rights":"0"} ``` After that, it sends a `GET` request to `/get/secret?token=anonymous&user_id=0` and receives the following response: ```json {"detail":"An error occured while getting secret"} ``` After a bit of experimentation, we can see that modifying the initial a `GET` request to `/get/user_rights/admin?token=anonymous` returns the following response: ```json {"user_rights":"2"} ``` And that modifying the second `GET` request to `/get/secret?token=anonymous&user_id=2` returns the following response: ```json {"secret":"2818005734247882148815606395390871241521030744414817841649744983604476983899126565908202628561318654805757519966662998265595012013887511026685341960649803379877020372374994272115027683083041074977627162975571468878179978979940631953326671820834686717048686780600989599774636338444403508271736797389404555484439987299859340485701736192764555478373442967697603436980567678476748829635276361371493814850746895759859577854680451597256104754344670308638174035606430450975872257123291306801017820834671371829676548941386992071919337321809988274026303745872325609428280838419359036931879311556552339458164369024569361736108","message":"But it may be encrypted :)"} ``` It seems likely that this is the flag encrypted with the public key from the [[#Send a Secret]] page. It doesn't seem like we will be able to decrypt it without more information. ## About ![[trust_issues_about.png]] The about page has some useful information: - It implies that website uses FastAPI, meaning that we can potentially browse `/docs`, `/redoc` or `/openapi.json` to view API endpoints. Doing so reveals that only `/openapi.json` is accessible and that we have already discovered all the endpoints. - It suggests that `timosbadlibraries` is being used for the cryptography. We will investigate this further in the next section. # Python ## Locating the Package Searching for `timosbadlibraries` on PyPI reveals [one package](https://pypi.org/project/timosbadlibraries). Whilst the associated GitHub repository appears to have been deleted, it is still possible to download the source files directly from PyPI. ## Reviewing the Code The main body of code can be found in `RSA.py`: ```python import numpy as np from Crypto.Util import number import hashlib import os class RSA: def __init__(self): self.private_key = np.empty([2], dtype='|S256') self.public_key = np.empty([2], dtype='|S256') def generate_key(self): while True: p = number.getPrime(1024) q = number.getPrime(1024) if p != q: break n = p * q phi = (p - 1) * (q - 1) e = 65537 if number.GCD(e, phi) != 1: raise ValueError("e is not coprime to phi(n)") d = number.inverse(e, phi) self.private_key[0] = n.to_bytes(256, "big") self.private_key[1] = d.to_bytes(256, "big") self.public_key[0] = n.to_bytes(256, "big") self.public_key[1] = e.to_bytes(256, "big") def encrypt(self, message): padded_message = self._oaep_pad(message.encode('utf-8'), hash_algo='sha256') return pow(int.from_bytes(padded_message, byteorder='big'), int.from_bytes(self.public_key[1], 'big'), int.from_bytes(self.public_key[0], 'big')) def decrypt(self, ciphertext): decrypted = pow(ciphertext, int.from_bytes(self.private_key[1], 'big'), int.from_bytes(self.private_key[0], 'big')) unpadded_message = self._oaep_unpad(decrypted.to_bytes(256, byteorder='big'), hash_algo='sha256') return unpadded_message.decode('utf-8') def _oaep_pad(self, message, hash_algo): ... # Ommited for brevity. def _oaep_unpad(self, ciphertext, hash_algo): ... # Ommited for brevity. def _mgf1(self, seed, mask_len, hash_algo): ... # Ommited for brevity. if __name__ == "__main__": # Example usage rsa = RSA() rsa.generate_key() message = "Hello, RSA!" encrypted = rsa.encrypt(message) print("Encrypted:", encrypted) decrypted = rsa.decrypt(encrypted) print("Decrypted:", decrypted) ``` The class implements RSA with OEAP padding. There don't seem to be any obvious implementation issues, but there are a couple of details that seem odd: - Why is `generate_key()` not called in the constructor? - Why is NumPy used to store the keys (it would be a lot easier to simply store then as Python integers)? The rest of the code can be found in `RSAServer.py`: ```python from .RSA import RSA class Server: def __init__(self): self.rsa = RSA() self.rsa.generate_key() self.last_log = b"" def get_public_key(self): return self.rsa.public_key def encrypt(self, message): return self.rsa.encrypt(message) def decrypt(self, message): return self.rsa.decrypt(message) def change_key(self, reason: str): self.rsa = RSA() self.last_log = reason.encode() self.rsa.generate_key() def get_log(self): return self.last_log ``` The `get_public_key()` and `change_key()` methods here look similar to the `/put/change_key` and `/get/public_key` API endpoints we saw earlier. With this code there are also some oddities, especially around the `change_key()` method's `reason` argument: - Why is it encoded to a byte array? - Why is the encoding done after `RSA` is initialised, but before `generate_key()` is called? - Why does it exist at all? ## NumPy's `empty()` and Memory Management As we mentioned before, the use of NumPy is bizarre and thus warrants further investigation. Reading the documentation for `np.empty()` we discover the following interesting note: > `empty`, unlike `zeros`, does not set the array values to zero, and may therefore be marginally faster. On the other hand, it requires the user to manually set all the values in the array, and should be used with caution. So, before `generate_key()` is called, the values of the key pairs will be whatever was in memory beforehand. Let's investigate this further by running a small experiment: ```python import numpy as np x = np.empty([2], dtype="|S256") y = np.empty([2], dtype="|S256") print(x) # [b'' b''] print(y) # [b'' b''] x[0] = b"AAAA" x[1] = b"BBBB" y[0] = b"CCCC" y[1] = b"DDDD" del x del y x = np.empty([2], dtype='|S256') y = np.empty([2], dtype='|S256') print(x) # [b'CCCC' b'DDDD'] print(y) # [b'AAAA' b'BBBB'] ``` The outputs of the first two `print`'s vary. Sometimes they are empty (as commented), sometimes they show snippets of text and sometimes they seem entirely random. This is what we'd expect: NumPy isn't initialising anything so we are just seeing what was in memory beforehand. The outputs from the second pair of `print`'s is more interesting. It appears that after `x` and `y` have been freed (with `del`) and reallocated (with `np.empty()`), their values have swapped around. This makes sense when you consider how memory management systems tend to work: - `del x` frees `x`'s object, placing the chunk of memory containing `[b'AAAA' b'BBBB']` at the front of a linked list containing other chunks with a similar size. - `del y` frees `y`'s object, placing the chunk of memory containing `[b'CCCC' b'DDDD']` at the front of the same linked list (so `[b'AAAA' b'BBBB']` is now second). - `x = np.empty(...)` removes a chunk of memory from the start of the linked list an allocates it to `x`'s object. This happens to be the memory containing `[b'CCCC' b'DDDD']`. - `y = np.empty(...)` removes a chunk of memory from the start of the linked list an allocates it to `y`'s object. This happens to be the memory containing `[b'AAAA' b'BBBB']`. Now let's try a similar experiment with `RSA` objects: ```python from .RSA import RSA rsa = RSA() rsa.generate_key() print(rsa.private_key[0][-4:].hex(), rsa.private_key[1][-4:].hex()) # 844f5b71 38177181 print(rsa.public_key[0][-4:].hex(), rsa.public_key[1][-4:].hex()) # 844f5b71 00010001 del rsa rsa = RSA() print(rsa.private_key[0][-4:].hex(), rsa.private_key[1][-4:].hex()) # 844f5b71 00010001 print(rsa.public_key[0][-4:].hex(), rsa.public_key[1][-4:].hex()) # 844f5b71 38177181 ``` We can see that after allocating an `RSA` object and generating a key, allocating another `RSA` object effectively causes the public and private key pairs to swap. Python uses an ordered dictionary to store an object's attributes, so they will be freed in the same order that they were allocated in. If we can cause this behaviour in the server, then we should be able to decrypt the flag. ## Crashing With `reason` Recall the `change_key()` method we reviewed earlier: ```python def change_key(self, reason: str): self.rsa = RSA() self.last_log = reason.encode() self.rsa.generate_key() ``` If we can cause the program to crash between when `RSA` is initialised and `generate_key()` is called, we will be able to achieve the key swapping behaviour. Luckily for us, `reason.encode()` is invoked between these two events. After some searching, we can discover that Unicode surrogates cause the exception we desire: ```python "\ud800".encode() # UnicodeEncodeError: 'utf-8' codec can't encode character '\ud800' in position 0: surrogates not allowed ``` # Putting It All Together We now have all the pieces we need to craft a solution: - We call `/put/change_key` with a standard `reason` to generate a key. - We call `/put/change_key` with a Unicode surrogate `reason` to swap the private and public key pairs. - We call `/get/public_key` to retrieve `e` (was previously `d`) and `n`. - We call `/get/secret?token=anonymous&user_id=2` to get the encrypted flag (`ct`). - We decrypt `ct` with `d` (was previously `e`) that is always `65537`. We can write a simple Python script to perform those steps: ```python import requests from .RSA import RSA BASE = "..." requests.put(f"{BASE}/put/change_key", json={"reason": ""}) requests.put(f"{BASE}/put/change_key", json={"reason": "\ud800"}) r = requests.get(f"{BASE}/get/public_key") e = int(r.json()["public_key"]["e"]) n = int(r.json()["public_key"]["n"]) r = requests.get(f"{BASE}/get/secret?token=anonymous&user_id=2") ct = int(r.json()["secret"]) d = 65537 rsa = RSA() rsa.private_key[0] = n.to_bytes(256, "big") rsa.private_key[1] = d.to_bytes(256, "big") rsa.public_key[0] = n.to_bytes(256, "big") rsa.public_key[1] = e.to_bytes(256, "big") print(rsa.decrypt(ct)) ``` Running the script gives us the flag: ```plain GCC{N3ver_TruST_a_L!Br4ry_Esp3c1alLy_tHouS3_Fr0m_T!mo} ```