from Crypto.Util.number import bytes_to_long, long_to_bytes from Crypto.Util.strxor import strxor from Crypto.Cipher import AES import os from secrets import KEY, FLAG import random
ACCOUNT_NAME_CHARS = set([chr(i) for i inrange(ord('a'), ord('z')+1)] + [chr(i) for i inrange(ord('A'), ord('Z')+1)]) FLAG_COST = random.randint(10**13, 10**14-1)
defblockify(text: str, block_size: int): return [text[i:i+block_size] for i inrange(0, len(text), block_size)]
defgetKey(self): save = f"{self.__name}:{self.__balance}".encode() blocks = blockify(save, AES.block_size) pblocks = pad(blocks, b'\x00', AES.block_size) cipher = AES.new(KEY, AES.MODE_ECB) ct = [] for i, b inenumerate(pblocks): if i == 0: tmp = strxor(b, self.__iv) ct.append(cipher.encrypt(tmp)) else: tmp = strxor(strxor(ct[i-1], pblocks[i-1]), b) ct.append(cipher.encrypt(tmp)) ct_str = f"{self.__iv.hex()}:{(b''.join(ct)).hex()}" return ct_str
defload(key: str): key_split = key.split(':') iv = bytes.fromhex(key_split[0]) ct = bytes.fromhex(key_split[1]) cipher = AES.new(KEY, AES.MODE_ECB) pt = blockify(cipher.decrypt(ct), AES.block_size) ct = blockify(ct, AES.block_size) for i, p inenumerate(pt): if i == 0: pt[i] = strxor(p, iv) else: pt[i] = strxor(strxor(ct[i-1], pt[i-1]), p) pt = b''.join(pt) pt_split = pt.split(b':') try: name = pt_split[0].decode() except Exception: name = "ERROR" balance = int(pt_split[1].strip(b'\x00').decode()) return Account(iv, name, balance)
defaccountLogin(): print("\nPlease provide your account details.") account = input("> ").strip() account = Account.load(account) print(f"\nWelcome {account.getName()}!") whileTrue: print("What would you like to do?") print("0 -> View balance") print(f"1 -> Buy flag ({FLAG_COST} acorns)") print("2 -> Save") opt = int(input("> ").strip()) if opt == 0: print(f"Balance: {account.getBalance()} acorns\n") elif opt == 1: if account.getBalance() < FLAG_COST: print("Insufficient balance.\n") else: print(f"Flag: {FLAG}\n") account.setBalance(account.getBalance()-FLAG_COST) elif opt == 2: print(f"Save key: {account.getKey()}\n") break
defaccountNew(): print("\nWhat would you like the account to be named?") account_name = input("> ").strip() dif = set(account_name).difference(ACCOUNT_NAME_CHARS) iflen(dif) != 0: print(f"Invalid character(s) {dif} in name, only letters allowed!") print("Returning to main menu...\n") return account_iv = os.urandom(16) account = Account(account_iv, account_name, 0) print(f"Wecome to Squirrel Treasury {account.getName()}") print(f"Here is your account key: {account.getKey()}\n")
In short, this program emulates a login page with its own custom cipher for the account key. Notably, the account key controls the balance of acorns a user has. Our goal for this challenge is to modify the number of acorns in a user’s balance.
The custom cipher implemented is very similar to AES CBC mode, but with one key difference. Let’s take a look at the load() function, i.e. the decryption function:
defload(key: str): key_split = key.split(':') iv = bytes.fromhex(key_split[0]) ct = bytes.fromhex(key_split[1]) cipher = AES.new(KEY, AES.MODE_ECB) pt = blockify(cipher.decrypt(ct), AES.block_size) ct = blockify(ct, AES.block_size) for i, p inenumerate(pt): if i == 0: pt[i] = strxor(p, iv) else: pt[i] = strxor(strxor(ct[i-1], pt[i-1]), p) pt = b''.join(pt) pt_split = pt.split(b':') try: name = pt_split[0].decode() except Exception: name = "ERROR" balance = int(pt_split[1].strip(b'\x00').decode()) return Account(iv, name, balance)
If you take a look at this section:
1 2 3 4
if i == 0: pt[i] = strxor(p, iv) else: pt[i] = strxor(strxor(ct[i-1], pt[i-1]), p)
You’ll notice that the plaintext is decrypted by XORing the current plaintext block with the previous ciphertext block and the previous plaintext block. This is slightly different from AES CBC mode, in which the plaintext is decrypted by XORing the current plaintext block with the previous ciphertext block. See this site for a visual of how AES CBC decryption works.
A common attack on AES CBC is to modify the IV in order to change the result of the first plaintext block. This is because we know that the IV will not be changed, unlike the rest of the blocks which are decrypted via AES ECB mode, and so we know exactly how it would affect the first plaintext block. For instance, consider the following modification to the IV and how it would affect the first plaintext block:
$$pt_0 = AES.decrypt(ct) \oplus IV$$ Where pt_0 is the original plaintext, ct is the ciphertext, and the IV is the initialization vector. Let’s modify the IV: $$IV_{forged} = IV \oplus pt_0 \oplus b”injection string”$$ $$pt_1 = AES.decrypt(ct) \oplus IV_{forged} = AES.decrypt(ct) \oplus IV \oplus pt_0 \oplus b”injection string”$$ We can substitute in the first equation. $$pt_1 = pt_0 \oplus pt_0 \oplus b”injection string”$$ $$pt_1 = b”injection string”$$
Therefore, we have successfully modified the first plaintext block to become “injection string” instead of the original plaintext!
That’s how it would work for AES CBC, but what about this custom cipher…?
Well, although this cipher is different, you might notice that the decryption of the very first block is the same as in AES CBC.
1 2
if i == 0: pt[i] = strxor(p, iv)
Thus, the exact same exploit should work.
*Note that we can simply enter no name to get the balance to be entirely contained within the first plaintext block. Actually, the way I solved during the contest was to send 16 bytes for the name and get the balance entirely contained within the second plaintext block. It turns out the same exploit works for both methods, since the first plaintext block is XORed with the second plaintext block. I am guessing that the solution explained above is the unintended, and that my original solution of modifying the second plaintext block was the intended.
Here’s the solve script, which essentially overwrites the balance to be 99999999999999:
from pwn import * from Crypto.Util.strxor import strxor from binascii import *
p = remote('treasury.squ1rrel-ctf-codelab.kctf.cloud', 1337)
p.sendlineafter(b'\n> ', b'1') # the below commented line does the same exploit but for modifying the second plaintext block # p.sendlineafter(b'> ', b'a'*16) p.sendlineafter(b'>', b'') p.recvuntil(b': ') key = p.recvline().decode('ascii')[:-1] # key = input()
iv = unhexlify(key.split(':')[0]) iv = strxor(iv, b'\x00'*2 + b'9'*14) key_mod = hexlify(iv) + b':' + key.split(':')[1].encode() # print(key_mod)