--- tags: Writeup --- # PBCTF2021 This CTF marked the start of my journey on preparing myself to convert my role from Software Engineer to Security Engineer for the next 4 years. On this CTF, I managed to solve 2 Crypto Challenge - Alkoloid Stream (134 pts) - GoodHash (218 pts) Today, I will explain my solution on solving the GoodHash. ## GoodHash (218 pts) > I think I made a good hash function based on AES. Could you test this? > `nc good-hash.chal.perfect.blue 1337` > Author: rbtree > file: `main.py` ### Source Code ``` #!/usr/bin/env python3 from Crypto.Cipher import AES from Crypto.Util.number import * from flag import flag import json import os import string ACCEPTABLE = string.ascii_letters + string.digits + string.punctuation + " " class GoodHash: def __init__(self, v=b""): self.key = b"goodhashGOODHASH" self.buf = v def update(self, v): self.buf += v def digest(self): cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.buf) enc, tag = cipher.encrypt_and_digest(b"\0" * 32) return enc + tag def hexdigest(self): return self.digest().hex() if __name__ == "__main__": token = json.dumps({"token": os.urandom(16).hex(), "admin": False}) token_hash = GoodHash(token.encode()).hexdigest() print(f"Body: {token}") print(f"Hash: {token_hash}") inp = input("> ") if len(inp) > 64 or any(v not in ACCEPTABLE for v in inp): print("Invalid input :(") exit(0) inp_hash = GoodHash(inp.encode()).hexdigest() if token_hash == inp_hash: try: token = json.loads(inp) if token["admin"] == True: print("Wow, how did you find a collision?") print(f"Here's the flag: {flag}") else: print("Nice try.") print("Now you need to set the admin value to True") except: print("Invalid input :(") else: print("Invalid input :(") ``` ### Analysis #### General key points I really love this challenge, because I learn a lot of new things during taking my time to solve this challenge. After I read the source code, some key notes that we could infer: - It use AES-GCM - It give us the encryption key `goodhashGOODHASH` - It require us to find a hash collision with some constraints: - The message should be in json format - The message should only contains characters from the `ACCEPTABLE` variable - The message will need to contain `"admin": true` if we want to get the flag - Our input is actually being used as the AES-GCM nonce, not as the plaintext. - If you read the source code, the plaintext message always `\0*32` - Check `digest` method After reading many articles in the internet about AES-GCM, below is the encryption scheme. ![](https://i.imgur.com/S4Eebgb.png) Further analysis: - If you notice, because our plaintext is always 0, we can safely ignore the plaintext and just focus on the nonce (because if you see the image `pt1^Ek(Counter 1) = Ek(Counter 1)` because of null plaintext) - Turns out, the recommended nonce length for AES-GCM is 96 bits (12 bytes), so that the first block of encryption will be `iv||counter`, where iv is 12 bytes and counter is 4 bytes. - If the nonce length is not 96 bit (whether shorter or longer), the nonce will be hashed by using the GHASH algorithm. - **Reading the source code, we know that the targeted input (`'{"token":"xxx", "admin": false}'`) and our input length will always be longer than 12 bytes, which mean our nonce will be always hashed by GHASH method.** **(This is important note)** #### GHASH Explained For detailed info, you can read [wiki](https://en.wikipedia.org/wiki/Galois/Counter_Mode), but I'll try to explain it. Defined as GHASH(H,A,C), it requires 3 inputs: H: The secret key, calculated by Ek('\0'*16) A: Associated data C: Message that we want to authenticate ``` GHASH Pseudocode: ---------------------------------------------- x = bytes(16) # Initialize x with 16 bytes of 0 padded_a = pad(a) # Append extra '\x00' until the size is divisible by 16 padded_c = pad(c) # Append extra '\x00' until the size is divisible by 16 for i in range(0,len(padded_a),16): x = xor(x, padded_a[i:i+16])•H for i in range(0,len(padded_c),16): x = xor(x, padded_c[i:i+16])•H # Notes: # conv is illustration function that will convert integer to 16 bytes representation # Example: # 1 -> b'\x00\x00\x00\x00\x00\x00\x00\x01' # 8 -> b'\x00\x00\x00\x00\x00\x00\x00\x08' # 11 -> b'\x00\x00\x00\x00\x00\x00\x00\x0b' return xor(x, conv(len(padded_a)+len(padded_c)))•H ``` Notes: The multiplication of xor(x1,x2)•H is happen on $GF(2^{128})$ defined by polynomial $$Poly=x^{128}+x^7+x^2+x+1$$ #### Vulnerability So, if you read the source code, you can see that: - Our input is limited to 64 - Target hash is created from nonce `{"token: "xxxxxxxxxxxxxxxx", "admin": false}`, which length is 61. I've explained above that if the nonce is not equal to 92 bit, by default AES-GCM will GHASH the nonce. Let's try to simulate it: - Let say that the nonce is `{"token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "admin": false}`. Length is 61 - AES-GCM will call `GHASH(H,bytes(),nonce)` (without any associated data) - GHASH will pad the nonce and split it per 16 bytes. ``` block_1 = '{"token": "xxxxx' block_2 = 'xxxxxxxxxxxxxxxx' block_3 = 'xxxxxxxxxxx", "a' block_4 = 'dmin": false}\x00\x00\x00' ``` - GHASH will initialize x with 16 bytes of `\x00`. `final_x` will be used as the AES-GCM first block input (substitute of `iv||counter`) ``` x0 = '\x00'*16 x1 = xor(x0, block_1)•H x2 = xor(x1, block_2)•H x3 = xor(x2, block_3)•H x4 = xor(x3, block_4)•H final_x = xor(x4, ('\x00'*15 + '\x3d'))•H # \x3d is nonce length representation in bytes, \x3d == 61 ``` - After this, the AES-GCM will start and give the encrypted+tag The vulnerability of this code is we know the secret key `H`. Because we know the secret, we can do preimage attack. Consider the below equation, which is how GHASH works: $$x_i=(x_{i−1}⊕A_i)⋅H$$ where A is the input that we control. If we know state $x_i$, and we want the next state $x_{i+1}$ equals to let say $P$ $$𝑃=x_{𝑖+1}=(x_𝑖⊕A_{𝑖+1})⋅𝐻$$ We can get the correct $A_{i+1}$ by multiplying it with $H^{-1}$. $$A_{i+1}=(P⋅H^{-1})⊕x_i$$ #### Attack Idea So, based on that preimage attack, we can craft a valid nonce which hash collides with the given hash, yet contains of `"admin": true` Connect to the server, we got this: ``` Body: {"token": "c6ffded97492efee4eca42ac218a7088", "admin": false} Hash: e7f0421f828d7a7ae902cb2dfbd3a3fa00e1953b0ebd647acc121c352b208156e51c5823199fb39e7de53e4f4c43ece2 ``` First, we need to calculate the GHASH of the `body`. By doing the previous simulation, we will have: - `target_x1` (hash of block1, `{"token": "c6ffd`) - `target_x2` (hash of block2, `ed97492efee4eca4`) - `target_x3` (hash of block3, `2ac218a7088", "a`) - `target_x4` (hash of block4, `dmin": false}\x00\x00\x00`) - `target_finalx` (hash of nonce length) After calculating each block hash, we can start crafting our payload: - Set `block_1` with `'{"admin":true, "'` (len = 16). So: - `new_block1='{"admin":true, "'` - `new_x1=xor(x0, block_1)•H` - Set `new_block2` with random string length of 16, which all the characters are allowed. Example, the random string is `aaaabbbbccccdddd`, so: - `new_block2='aaaabbbbccccdddd'` - `new_x2=xor(x1, block_2)•H` - Apply preimage attack to the `new_block3`, so that the `new_block3` hash value will be the same as `target_x3`. - Calculate the correct value by do: - `new_block3=xor((target_x3⋅H^-1), new_x2)` - And then, for the `new_block4`, reuse the given `block4`, which is `dmin": false}\x00\x00\x00` - We can do this because we apply preimage attack on the third block, which mean the value of `new_x3` is equals to `target_x3`, which mean the value of `new_finalx` will be equals to `target_finalx`, and because it is the same **hash collision will happen!!** Important note during crafting the payload: - We need to brute force the `new_block2` string until the correct value of `new_block3` consist of the allowed characters. ### Solution So, here is my full solution. ```python3 from Crypto.Util.number import * from Crypto.Cipher import AES from multiprocessing import Process import random acceptable = string.ascii_letters + string.digits + '!#$%&()*+,-.:;<=>?@[]^_`|~ ' key = b'goodhashGOODHASH' def tobin(x, n): x = Integer(x) nbits = x.nbits() assert nbits <= n return [0] * (n - nbits) + x.bits()[::-1] def frombin(v): return int("".join(map(str, v)), 2 ) X = GF(2).polynomial_ring().gen() poly = X**128 + X**7 + X**2 + X**1 + 1 F = GF(2**128, name='a', modulus=poly) def toF(x): # Little endian, so need bit reverse x = frombin(tobin(x, 128)[::-1]) return F.fetch_int(x) def fromF(x): # Little endian, so need bit reverse x = x.integer_representation() x = frombin(tobin(x, 128)[::-1]) return x def field_mult(a, b): return fromF(toF(a) * toF(b)) def rand_str(): global acceptable n = 16 return ''.join(random.choice(acceptable) for _ in range(n)).encode() # Calculate H and H^-1 ex = False Ek = AES.new(key, AES.MODE_ECB) hkey = Ek.encrypt(b'\x00'*(16)) h = bytes_to_long(hkey) inverse_h = fromF(toF(h)^-1) target_json = b'{"token": "a617eb720dd41c256119c8b8e64d50d1", "admin": false}' padded_target_json = target_json + b'\x00'*((16-len(target_json)%16)%16) target_x = [0] # initialize with 0 for i in range(0, len(padded_target_json), 16): block = padded_target_json[i:i+16] x = field_mult(target_x[-1]^^bytes_to_long(block), h) target_x.append(x) def solve(): global target_x global h global inverse_h global target_json # Craft payload new_x = [0] new_block1 = b'{"admin":true, "' new_x.append(field_mult(new_x[-1]^^bytes_to_long(new_block1), h)) while True: new_block2 = rand_str() new_x2 = field_mult(new_x[-1]^^bytes_to_long(new_block2), h) new_block3 = long_to_bytes(field_mult(target_x[3], inverse_h)^^new_x2) # print new_block1+new_block2+new_block3+target_json[48:], target_x[3] == field_mult(new_x2^^bytes_to_long(new_block3), h) if not any(v not in acceptable.encode() for v in new_block3): payload = new_block1+new_block2+new_block3+target_json[48:] print('[+] Payload:', payload) exit() def finds(): global ex while True: if ex: break solve() if __name__ == '__main__': proc = [] for _ in range(6): p = Process(target=finds) p.start() proc.append(p) input() ex = True for p in proc: p.join() ``` Running the code will give you this ![](https://i.imgur.com/YUXDmvl.png) Putting one of the found payload will give you the flag ![](https://i.imgur.com/iFpncht.png) `Flag: pbctf{GHASH_is_short_for_GoodHash_:joy:}` ### References - [StackExchange question about preimage attack on GHASH](https://crypto.stackexchange.com/questions/32574/why-does-a-fixed-key-not-make-macs-like-cbc-mac-or-gmac-into-secure-hash-functio) - [AES-GCM Implementation](https://jhafranco.com/2013/05/31/aes-gcm-implementation-in-python/) - [Wikipedia about AES-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode) - [MSLC Writeup about GCM (which I reuse some of their codes. Thanks)](http://mslc.ctf.su/wp/boston-key-party-ctf-2016-gcm-crypto-9pts-2/) ## Social Media Follow me on [Twitter](https://twitter.com/chovid99)