Old version of the writeup: LINK
A couple of weeks ago, I played the preliminaries for Codegate CTF 2024 with about:blankets. We managed to qualify for the finals in Seoul! I was able to join for most of the 24 hours and it was really nice to come back to the CTF world after three/four really busy months.
crypto - Cogechan_Dating_Game
Challenge
- Author: BaaaaaaaaaaaaaaaaaaaaaaarkingDog
- Solves: 15
- Points: 306
Love Wins All
The challenge consists of multiple files. The most important ones are server.py
and load_and_save.py
. The first one handles the main game and allows you to act in the game. The second one, instead, contains all the main checks to load and save the game.
#server.py
def go(sock):
sock.settimeout(60) # No response for 60s then connection will be closed.
# trying to load a save file based on ID, PW first
ID_len = int.from_bytes(sock.recv(2), 'little')
ID = sock.recv(ID_len).decode()
PW_len = int.from_bytes(sock.recv(2), 'little')
PW = sock.recv(PW_len).decode()
status, character = load(ID, PW)
sock.send(status.to_bytes(1, 'little'))
if status == load_and_save.LOAD_SUCCESS:
sock.send(len(character.nickname).to_bytes(2, 'little') + character.nickname.encode())
sock.send(character.day.to_bytes(4, 'little'))
sock.send(character.stamina.to_bytes(4, 'little'))
sock.send(character.intelligence.to_bytes(4, 'little'))
sock.send(character.friendship.to_bytes(4, 'little'))
if status != load_and_save.LOAD_SUCCESS:
nickname_len = int.from_bytes(sock.recv(2), 'little')
character.nickname = sock.recv(nickname_len).decode('utf-8', 'ignore')
character.stamina = 100
while True:
com = int.from_bytes(sock.recv(1), 'little')
if com == 0: # Meaning that connection is closed
if DEBUG:
print("connection closed")
exit()
elif com == EAT_COMMAND:
rnd = random.randint(1, 4)
sock.send(rnd.to_bytes(1, 'little'))
character.stamina += rnd
character.day += 1
elif com == PWN_COMMAND:
rnd = random.randint(1, 4)
sock.send(rnd.to_bytes(1, 'little'))
if character.stamina >= 10:
character.stamina -= 10
character.intelligence += rnd
character.day += 1
elif com == SLEEP_COMMAND:
rnd = random.randint(1, 4)
sock.send(rnd.to_bytes(1, 'little'))
character.stamina += rnd
character.day += 1
pass
elif com == DATE_COMMAND:
rnd = random.randint(1, 4)
sock.send(rnd.to_bytes(1, 'little'))
if character.stamina >= 10 and character.intelligence.bit_length() >= character.friendship:
character.stamina -= 10
character.friendship += 1
character.day += 1
if character.friendship == 34:
flag = read_flag()
sock.send(len(flag).to_bytes(2, 'little') + flag.encode())
elif com == SAVE_COMMAND:
file_data_enc_len = int.from_bytes(sock.recv(2), 'little')
file_data_enc = sock.recv(file_data_enc_len)
tag = sock.recv(16)
status = load_and_save.save_game(ID, PW, character, file_data_enc, tag)
sock.send(status.to_bytes(1, 'little'))
#load_and_save.py
def decrypt_and_parse_save_data(key, nonce, save_data, tag):
cipher = AES.new(key, AES.MODE_GCM, nonce)
file_data = unpad(cipher.decrypt_and_verify(save_data, tag), 16)
idx = 0
nickname_len = int.from_bytes(file_data[idx:idx+2], 'little')
idx += 2
nickname = file_data[idx:idx+nickname_len].decode('utf-8', 'ignore')
idx += nickname_len
day = int.from_bytes(file_data[idx:idx+4], 'little')
idx += 4
stamina = int.from_bytes(file_data[idx:idx+4], 'little')
idx += 4
intelligence = int.from_bytes(file_data[idx:idx+4], 'little')
idx += 4
friendship = int.from_bytes(file_data[idx:idx+2], 'little')
character = Character.Character(nickname, day, stamina, intelligence, friendship)
return character
def id_pw_validity_check(ID, PW):
if len(ID) < 20 or len(PW) < 20:
return False
if len(set(ID)) < 20 or len(set(PW)) < 20:
return False
if ID == PW:
return False
return True
def load_game(ID, PW):
if not id_pw_validity_check(ID, PW):
return LOAD_FAIL, None
id_hash = hashlib.sha256(ID.encode()).digest()
pw_hash = hashlib.sha256(PW.encode()).digest()
nonce = id_hash[:12]
file_name = id_hash[16:24].hex()
key = pw_hash[:16]
# read save file
try:
with open(f'save/{file_name}', 'rb') as f:
raw_data = f.read()
file_data_enc = raw_data[:-16]
tag = raw_data[-16:]
except Exception as e:
return LOAD_FAIL, None
# parse it!
try:
character = decrypt_and_parse_save_data(key, nonce, file_data_enc, tag)
except Exception as e: # error during decryption
print("LOAD!!", e)
return LOAD_FAIL, None
return LOAD_SUCCESS, character
def save_game(ID, PW, character, save_data, tag):
if not id_pw_validity_check(ID, PW):
return SAVE_FAIL
id_hash = hashlib.sha256(ID.encode()).digest()
pw_hash = hashlib.sha256(PW.encode()).digest()
nonce = id_hash[:12]
file_name = id_hash[16:24].hex()
key = pw_hash[:16]
try:
character_parse = decrypt_and_parse_save_data(key, nonce, save_data, tag)
if character.day != character_parse.day or \
character.stamina != character_parse.stamina or \
character.intelligence != character_parse.intelligence or \
character.friendship != character_parse.friendship:
return SAVE_FAIL
if character.friendship >= 20: # Please do not save almost-cleared one
return SAVE_FAIL
except Exception as e: # error during decryption
print("SAVE!!", e)
return SAVE_FAIL
try:
with open(f'save/{file_name}', 'wb') as f:
f.write(save_data)
f.write(tag)
except:
return SAVE_FAIL, None
return SAVE_SUCCESS
In this challenge, our objective is to play a dating game. The win condition is to ask for a date while the attributes of our character are: friendliness$=33$, stamina$>=10$ and intellect>=$2^{33}$.
Reaching these attributes while playing honestly is not feasible, so we need to look at all the options that the server/game grants us in order to find a way to artificially increase our stats.
While connected to the server, playing a game, we are allowed to save and reload the game in a particular way.
First play and Save:
- When we connect, we specify a couple of $(ID,PWD)$ that the server uses to craft a $nonce$ and a $file name$, from $sha256(IV)$, and a $key$, from $sha256(PWD)$.
- Then, we can either play the game (by doing $Sleep$, $Pwn$, $Eat$ or $Date$) or request to $Save$.
- If we $Save$, the server asks us to send a couple ($ctx$,$tag$) to store as a save file for the ongoing game.
- To check if we have sent a valid save file, the server immediately takes the $n=nonce$ and $k=key$ from the current session and checks if:
- The couple $(ctx,tag)$ is a valid ciphertext in AES-GCM with the key $k$ and the nonce $n$.
- The attributes of the character contained in the save file, after decryption, match with the current attributes in the ongoing game.
- If all the checks are successful, it stores our $(ctx,tag)$ in the file $file name$.
Load
- When connecting another time, the server asks us $ID$ and $PWD$ and derives again $n=nonce$, $file name$ and $k=key$.
- If the server already contains a file $filename$, then it checks if the couple $(ctx,tag)$ that was stored in $filename$ is a valid ciphertext in AES-GCM with the key $k$ and the nonce $n$.
- If the check is successful, it reads the attributes of the character from the decryption of $ctx$ and uses them for the ongoing game.
Base attack idea
To cheat the system, we want to connect to the server with two different passwords $PWD_1$ and $PWD_2$ (and, therefore, with two different keys $k_1$ and $k_2$). In this way, the server will perform the saving checks under the key $k_1$ and the loading checks under the key $k_2$. This allows us to craft a special ciphertext $ctx$ that, when decrypted under the key $k_1$, will be seen as the weak start-game character that we are using before the save but, when decrypted under the key $k_2$, will be seen as a strong end-game character that is ready to ask for the final $Date$.
The “decrypt_and_parse_save_data” function
To understand how to craft such a ciphertext, we first need to understand how the server “reads” a save file. After decrypting it with $n$ and $k$, the plaintext gets translated to attributes following the following scheme:
- $nick_{len}$: The first two bytes are the length of the nickname.
- $nick$: The following $nick_{len}$ bytes are the nickname.
- $days$: The following 4 bytes are the days.
- $stam$: The following 4 bytes are the stamina.
- $inte$: The following 4 bytes are the intellect.
- $fr$: The following 2 bytes are the friendship.
A fresh new character, with no nickname, would have, as a save-file plaintext, the following 16-byte (1 AES block) representation:
$$P_1 = 00|0000|stam|0000|00,$$where $stam$ is the 4-bytes representation of the decimal $100$.
An end-game character, instead, needs to have as a save-file plaintext the following representation:
$$P_2=nick_{len}|nick|0000|STAM|INTE|FR,$$where $STAM$ is the 4-bytes representation of the decimal $2^{32}-1$ (any number greater than 50 would have worked, but hey let’s boost these stats!), $INTE$ is the 4-bytes representation of the decimal $2^{32}-1$ (this time, we need this value because we need to reach $2^{32}$ to have a successful $Date$) and $FR$ is the 2-bytes representation of the decimal $33$ (again, we need this exact value for the $Date$).
“Ambiguous” ciphertext: how to craft it and how to find the keys
This parsing is really important, because it allows us to encrypt two different sets of attributes in two different positions of the ciphertext just by modifying the value $nick_{len}$. In particular, we want to focus on a three-blocks ciphertext. The idea is to have a first block that contains the attributes that we need to pass the saving check and a third block that contains the attributes that we want after the load. (The second block will be a magical tool that we will be using later.)
First of all, we fix a $ID$. Any $ID$. We just need it for choosing the same file $file name$ during different connections. We won’t need to act on the $n=nonce$ of the AES_GCM in the following attack, so we just fix an $ID$ and we always use it when communicating with the server.
Now, we fix a key $k_1$ by just fixing any password $PWD_1$. Again, any $PWD_1$ is okay. We compute the first block $ctx_1$ of the ciphertext as the encryption of $P_1$ under the key $k_1$ and the nonce $n$. This allows us to pass the check (2.) during the save.
Finding the key $k_2$ will require a little bit more effort.
To trick the server, for the loading part, we need the same first ciphertext block $ctx_1$ to decrypt (under key $k_2$) to a plaintext that starts with the 2-bytes representation of the decimal 30. In this way, when reading the save file, the server will consider the remaining first block (14 bytes) and all the second block (16 bytes) as part of the nickname and will ignore them. After this, it will read the attributes from the decryption of the third block (14 bytes).
This means that we can fix the third block of the ciphertext as $ctx_3 \leftarrow Enc(k_2,P_3)$, with
$$ P_3= 0000|STAM|INTE|FR|PD, $$where $STAM$, $INTE$, $FR$ are as in $P_2$ and $PD$ is to get a valid padding for the block, therefore the 2-byte representation of the hex $02$ $02$.
An additional requirement for this ciphertext $ctx=(ctx_1|ctx_2|ctx_3)$ to be valid is that $Dec(k_1,ctx)$ satisfies the padding. So we also need to specify, for example, that the last byte of this decryption needs to be the byte representation of the decimal $1$.
Finding a key $k_2$ that satisfies both of these requirements can be done by just sampling values for $PWD_2$ again and again, and checking that the first two bytes of $Dec(k_2,ctx_1)==30_{10}$ and that the last byte of $Dec(k_1,ctx_3)==1_{10}$. This happens with probability around $2^{-24}$, we can brute force this.
aes_ctr_K1 = AES.new(key1, AES.MODE_CTR, nonce=nonce, initial_value=int(2))
ptx1=bytes.fromhex("00 00") + bytes.fromhex("00 00 00 00") + int(100).to_bytes(4, "little") + bytes.fromhex("00 00 00 00") + bytes.fromhex("00 00") # name length + days + STAMINA!!! + int + friendship
ctx1=aes_ctr_K1.encrypt(ptx1)
ptx3= bytes.fromhex("00 00 00 00") + bytes.fromhex("FF FF FF FF")+ bytes.fromhex("FF FF FF FF") + int(33).to_bytes(2, "little") + bytes.fromhex("02 02") #days + stamina + INT + friendship + PAD # NEED CHARACTER NAME LENGTH TO DECRYPT TO 2*AES.BLOCKLEN-2
alphabet="qu4DR0PhIWETYlKJGFSZXCVBNMmnbvcx"
for PW2 in tqdm(itertools.combinations(alphabet,20)):
# PW2 = "pHiquRWEKGFSXCBNMbvc"
PW2 = ''.join(PW2)
pw_hash = hashlib.sha256(PW2.encode()).digest()
key2 = pw_hash[:16]
aes_ctr_K1 = AES.new(key1, AES.MODE_CTR, nonce=nonce, initial_value=int(2+2))
aes_ctr_K2 = AES.new(key2, AES.MODE_CTR, nonce=nonce, initial_value=int(2))
ptx1_tmp = aes_ctr_K2.decrypt(ctx1)
aes_ctr_K2 = AES.new(key2, AES.MODE_CTR, nonce=nonce, initial_value=int(2+2))
ctx3_tmp = aes_ctr_K2.encrypt(ptx3)
if ptx1_tmp[:2]==int(30).to_bytes(2, "little") and aes_ctr_K1.decrypt(ctx3_tmp)[-1] == 1:
print(f"{PW2 = }")
# break
print("DONE")
print(f"{key2.hex() = }")
THE TAG
“We have the ciphertext and we have the keys! What else do we need?”. Unfortunately, the server also performs a verification check for the validity of the ciphertext by using the tag. This happens both when we save and when we load, so we need the same tag to verify on the same ciphertext in both schemes! (AES.GCM with key $k_1$ and AES.GCM with key $k_2$)
The tag$(k=key,n=nonce,ctx)$ is computed in the following way:
- Consider all the 16-bytes elements in the scheme (i.e. $ctx_1$,$ctx_2$,$ctx_3$) as elements of the Galois Field $GF(2^{128})$ (with representative polynimal $f = 1 + a + a^2 + a^7 + a^{128}$).
- Compute $H_k$ as $AES_k(0_{B})$. [$0_B$ is the 16-byte representation of $0$]
- Compute $LEFT_k$ as $AES_k(n|0001)$. [The argument is the content of the counter in GCM before the first encryption]
- Compute $LEN$ as the element of $GF(2^{128})$ corresponding to the block: 8-byte representation of $0$ | 8-byte representation of $8\cdot3\cdot16$.
- The tag is equal to $$(H_k\cdot(H_k\cdot(H_k\cdot ctx_1+ctx_2)+ctx_3)+LEN)+LEFT_k$$
def compute_tag(ctx,key,nonce):
IV = nonce + long_to_bytes(1,4)
EK=AES.new(key,AES.MODE_ECB)
LEFT = int_to_GF128(int.from_bytes(EK.encrypt(IV), "big"))
H = int_to_GF128(int.from_bytes(EK.encrypt(bytes(16)), 'big'))
l=len(ctx)//16
t=int_to_GF128(int(0))
for i in range(l):
t+=int_to_GF128(int.from_bytes(ctx[16*i:16*(i+1)], "big"))
t*=H
LEN= ((8 * 0) << 64) | (8 * l * 16)
t += int_to_GF128(LEN)
t *= H
t += LEFT
return GF128_to_int(t).to_bytes(16,"big")
Since we have already fixed $ctx_1$, $ctx_3$, $k_1$ and $k_2$, the only remaining variable is $ctx_2$. This means that we can impose equality between the two tags of the two schemes.
$$ tag(k_1,n,ctx_1|ctx_2|ctx_3)=tag(k_2,n,ctx_1|ctx_2|ctx_3) $$$$ tag_{k_1}(ctx_2)=tag_{k_2}(ctx_2) $$After expanding the computations, we obtain that
$$ ctx_2 = [ctx_1 \cdot (H_{k_1}^4 + H_{k_2}^4) + ctx_3 \cdot (H_{k_1}^2 +H_{k_2}^2) + $$$$+ LEN \cdot (H_{k_1} + H_{k_2}) + LEFT_{k_1} + LEFT_{k_2}]/(H_{k_1}^3 + H_{k_2}^3)$$R.<a> = PolynomialRing(GF(2))
f = 1 + a + a^2 + a^7 + a^128
F128.<b> = GF(2).extension(f)
z = int_to_GF128(3)
IV1 = nonce + long_to_bytes( 1 ,4)
EK1 = AES.new(key1, AES.MODE_ECB)
LEFT1 = EK1.encrypt(IV1)
H1 = EK1.encrypt( bytes(16) )
EK2 = AES.new(key2, AES.MODE_ECB)
LEFT2 = EK2.encrypt(IV1)
H2 = EK2.encrypt( bytes(16) )
endianness = "big"
H1 = int_to_GF128(int.from_bytes(H1, endianness))
LEFT1 = int_to_GF128(int.from_bytes(LEFT1, endianness))
H2 = int_to_GF128(int.from_bytes(H2, endianness))
LEFT2 = int_to_GF128(int.from_bytes(LEFT2, endianness))
LEN = int_to_GF128(int.from_bytes(bytes(8) + int(16*8*3).to_bytes(8, "big")))
ctx1_num = int_to_GF128(int.from_bytes(ctx1, endianness))
ctx3_num = int_to_GF128(int.from_bytes(ctx3, endianness))
ctx2_num = (ctx1_num*(H1**4 + H2**4) + ctx3_num*(H1**2 + H2**2) + LEN*(H1 + H2) + LEFT1 + LEFT2)/(H1**3 + H2**3)
ctx2 = GF128_to_int(ctx2_num).to_bytes(16, "big")
final_ctx = ctx1 + ctx2 + ctx3
Recap
The attack works as follows:
- We connect to the server for a new game with credentials $(ID,PWD_1)$.
- We immediately ask to save the game and send $(ctx,tag)$ to the server.
- The server here performs two checks
- Verifies $tag$ with $ctx$ for the scheme AES.GCM with key $k_1$. This check is successful because we computed the tag honestly.
- Decrypts $ctx$ with $k_1$ and checks if the parameters are the same as the ongoing game. This is confirmed because the parsing function only reads the decryption of the first block, which is exactly the desired $P_1$.
- The server saves $(ctx,tag)$. We disconnect.
- We now connect to the server with credentials $(ID,PWD_2)$.
- The server verifies $tag$ with $ctx$ for the scheme AES.GCM with key $k_2$. This check is successful because of how we crafted $ctx_2$.
- The server reads the character attributes from $Dec(k_2,ctx)$. From this it skips the first two blocks as the nickname and considers the third block, which is exactly equal to $P_3$.
- This game is almost won! We just need to perform a successful $Pwn$ and a $Date$ to capture the FLAG! We can easily do them honestly from the client.
Post Scriptum
After the end of the challenge, I discovered that this attack (in a slightly different version) was already known from a 2019 Crypto paper. The Salamander attack on AES-GCM is the technique we used when crafting the ambiguous ciphertext.