I had the pleasure of providing a challenge for the third iteration of the Glacier 2024 event in the crypto category.
It is a symmetric encryption challenge that is split into an easier first stage and a more challenging second stage, which revolves around symmetric cryptanalysis to recover a round key of AES.
Challenge
The challenge provides two files:
challenge.py (where the main challenge is located)
aes.py (a python implementation of individual AES operations which was taken from boppreh)
The number of rounds is either 22 for normal users or 24 for premium users (which will be explained below)
The last round of AES also performs the mix columns operation, which means that all rounds of AES are the same
The mode of operation is ECB mode with a maximum block size of 3 (meaning you can only encrypt three blocks of AES per encryption call).
Before encrypting a block, the message gets padded to be a multiple of 16, and after decryption, the message is unpaded.
Inside the challenge.py a key is securely generated
1
key=os.urandom(16)
and you are allowed to perform three different operations (all using the same key):
Encryption: You can choose to encrypt a chosen message m using AES with 22 rounds. The only restrictions on the message m are that it can’t be m != "premium" and the message |m| length can’t exceed 3*16 bytes (meaning three blocks of encryption).
$$
Enc22_{k}(m)
$$
1
2
3
ifpt==PREMIUM_USER.decode():print("Not so fast my friend.")return
Premium encryption: You can choose to encrypt a chosen message $m$ using AES with 24 rounds. However, to access this feature, you need to know the encrypted value of Enc22_k(premium) using the normal 22-round encryption. Here, you also have the same length restrictions of 3*16 bytes.
$$
Enc24_{k}(m)
$$
1
2
3
ifdecrypt_msg(ct,key,False)!=PREMIUM_USER.decode():print("Sorry, you are not a premium user.")return
Guess Key: to win the challenge and get the flag, you must correctly guess the key generated at the start.
Importantly, you are only allowed to perform 4 actions (and one of them needs to be the key guess). If you have not solved the challenge, now is a great time to think about how you could approach this particular problem.
I discussed all the building blocks you need in order to solve the challenge.
Solution
In the following section, we will look at the intended solution.
Judging from the discussion on Discord and other online forums, there were no unintended ways of solving this challenge.
To solve the challenge, we need to recover the key used for the AES encryption.
Here, it is essential to know that if we can recover any round key of AES, we can calculate the master key that was used.
We will exploit the fact that we can gain access to 22 and 24-round AES encryption where, importantly, the last round is the same, and by doing some cryptanalysis, we will be able to recover the secret key used.
Getting the premium encryption
The first step of unlocking the premium option is quite trivial with AES in ECB mode, as the check is quite brittle. The easiest way to circumvent this check is to provide a padded string of the premium user.
1
pad(premium,16)# b'premium\t\t\t\t\t\t\t\t\t'
We use this string as an input for the normal encryption.
This string will parse the check as it does not match just b"premium" but will successfully authenticate us as a premium user since, during the decryption, the padding is removed.
$$
Enc22_{k}(\text{b’premium\t\t\t\t\t\t\t\t\t’}) = t
$$
Recovering a round key
Now that we can access the premium round feature, we can access the 24-round AES encryption. Here is where we can exploit the fact that the last round of the AES implementation is not different from all other rounds. The key insight is that the output of the 22-round encryption becomes the intermediate value before the last two rounds of the 24-round encryption.
We can transform the output of the Enc22 by applying one round of AES:
$$Enc22_{k}(pt)=ct$$
SubBytes
ShiftRows
MixColumns
which leaves us with only one round of AES.
Now, we are tasked with the easier challenge of recovering the AES key for one full round of AES.
With this, we can perform a known plaintext attack on 1 round AES (with two known plaintexts, we can have up to 3 blocks of AES per call).
In practice, this means that we will first use our remaining two operations to:
Encrypt two blocks of AES using the ENC22 function with a fixed input
$$Enc22_{k}(pt)=[ct_1, ct_2]$$
Encrypt two blocks of AES using the ENC24 function with the same input
$$Enc24_{k}(pt)=[ct’_1, ct’_2]$$
The attack requires two known plaintext/ciphertext pairs and has a time complexity of 2^16 operations.
It is taken from Low Data Complexity Attacks on AES by Bouillaguet et.al.
We can recover the key by using the following steps:
We work backward from the 24-round ciphertexts by applying the inverse MixColumns MC^(-1)(ct'_i), ShiftRows SR^(-1)(ct'_i) until we reach the SubBytes operation.
For each byte position we guess the value of the key and check if this value would even be possible given the Sbox opeartion:
Once we have all potential candidates for the round key, we can reverse the key schedule to obtain the master key and verify our guess by trial encryption. Bing checling
The correct key will produce the same ciphertexts as the server.
This approach would give us 2^16 suggestions.
There is also a more efficient version of this attack that can be done with a complexity of 2^12.
One of the keys will give the correct result, which will be the proper key, and we will have to solve the challenge.
#!/usr/bin/env python3# -*- coding: utf-8 -*-# This exploit template was generated via:# $ pwn template --host localhost --port 1337frompwnimport*importreimportaesfromaeskeyscheduleimportreverse_key_schedule,key_scheduleimportitertools# Set up pwntools for the correct architecture# Just set TERM_PROGRAM in your ~/.profile!# context.update(terminal='CHANGEME')exe=(args.EXEor'challenge')host=args.HOSTor'localhost'port=int(args.PORTor1337)# Find flag by exact match or format# log.success(find_flag(io.recvall()))deffind_flag(output):ifnotisinstance(output,str):output=output.decode(errors="ignore")# Match real flagifreal_flaginoutput:returnreal_flag# Match fake flagiffake_flaginoutput:returnfake_flag# Match possible local flagwithopen("/flag.txt","r")aslocal:locl_flag=local.readline().strip()iflocl_flaginoutput:returnlocl_flag# Match regexp flagr=find_flag_fmt(output)ifrisnotNone:returnr# Definitely no flag foundreturnNone# Find flag by format# log.success(find_flag_fmt(io.recvall()))ffmt=re.compile(r"gctf{.*}")deffind_flag_fmt(output):ifnotisinstance(output,str):output=output.decode(errors="ignore")m=ffmt.search(output)ifmisNone:returnNonereturnm.group(0)defstart_local(argv=[],*a,**kw):'''Execute the target binary locally'''returnprocess([exe]+argv,*a,**kw)defstart_remote(argv=[],*a,**kw):'''Connect to the process on the remote host'''io=connect(host,port)returniodefstart(argv=[],*a,**kw):'''Start the exploit against the target.'''ifargs.LOCAL:returnstart_local(argv,*a,**kw)else:returnstart_remote(argv,*a,**kw)defexpand_key(key:bytes,rounds:int)->list[list[int]]:round_keys=[[key[i:i+4]foriinrange(0,len(key),4)]]whilelen(round_keys)<rounds+1:base_key=b"".join(round_keys[-1])round_keys+=aes.expand_key(base_key,10)[1:]round_keys=[b"".join(k)forkinround_keys]round_keys=[aes.bytes2matrix(k)forkinround_keys]returnround_keys[:rounds+1]defencrypt_block(pt:bytes,key:bytes,rounds:int)->bytes:iflen(pt)!=16orlen(key)!=16:exit(1)subkeys=expand_key(key,rounds)block=aes.bytes2matrix(pt)aes.add_round_key(block,subkeys[0])foriinrange(1,rounds+1):aes.sub_bytes(block)aes.shift_rows(block)aes.mix_columns(block)aes.add_round_key(block,subkeys[i])returnaes.matrix2bytes(block)PREMIUM_USER=b"premium"#===========================================================# EXPLOIT GOES HERE#===========================================================fromCrypto.Util.Paddingimportpaddefsend_encrypted(io,option,pt,code:bytes=None):io.sendlineafter(b"Enter option (1: Encrypt, 2: Premium Encrypt, 3: Guess Key):",option)ifoption=="2":print(code)io.sendlineafter(b"Enter ciphertext:",code)io.sendlineafter(b"Enter plaintext: ",pt)ct=bytes.fromhex(io.recvline().strip().decode()[11:])ct1,ct2=ct[:16],ct[16:32]returnct1,ct2print("Exploiting...")io=start()NORMAL_ROUND=22PREMIUM_ROUNDS=24key_size=16# The the admin codecode,_=send_encrypted(io,"1",pad(PREMIUM_USER,16)+b"a")print(code)assertlen(code)==16code=code.hex()pt1=b"a"*16pt2=b"~"*16new_pt1,new_pt2=send_encrypted(io,"1",pt1+pt2+b"a")new_ct1,new_ct2=send_encrypted(io,"2",pt1+pt2+b"a",code)# Solve:new_pt1=aes.bytes2matrix(new_pt1)aes.sub_bytes(new_pt1)aes.shift_rows(new_pt1)aes.mix_columns(new_pt1)new_pt1=aes.matrix2bytes(new_pt1)new_pt2=aes.bytes2matrix(new_pt2)aes.sub_bytes(new_pt2)aes.shift_rows(new_pt2)aes.mix_columns(new_pt2)new_pt2=aes.matrix2bytes(new_pt2)ct1_prime=aes.bytes2matrix(new_ct1)aes.inv_mix_columns(ct1_prime)aes.inv_shift_rows(ct1_prime)ct1_prime=aes.matrix2bytes(ct1_prime)ct2_prime=aes.bytes2matrix(new_ct2)aes.inv_mix_columns(ct2_prime)aes.inv_shift_rows(ct2_prime)ct2_prime=aes.matrix2bytes(ct2_prime)possible_key24:list[list[int]]=[[]for_inrange(key_size)]forbitinrange(key_size):forsub_k_24_guessinrange(256):sub_k_25_guess_1=ct1_prime[bit]^aes.s_box[new_pt1[bit]^sub_k_24_guess]sub_k_25_guess_2=ct2_prime[bit]^aes.s_box[new_pt2[bit]^sub_k_24_guess]ifsub_k_25_guess_1==sub_k_25_guess_2:# possible_key1[bit].append(k_1_guess_1)possible_key24[bit].append(sub_k_24_guess)print(possible_key24)deftest_key(pt1,pt2,ct1,ct2,key)->bool:ct_test=encrypt_block(pt1,key,PREMIUM_ROUNDS)ifct_test==ct1:returnTruect_test=encrypt_block(pt2,key,PREMIUM_ROUNDS)ifct_test==ct2:returnTruereturnFalseall_possible_keys=list(itertools.product(*possible_key24))all_possible_keys=[bytes(k)forkinall_possible_keys]print(len(all_possible_keys))defget_master_key(key,rounds):offset=rounds%10k=reverse_key_schedule(key,offset)key_rev=rounds//10for_inrange(key_rev):k=reverse_key_schedule(k,10)returnkforkinall_possible_keys:# Reverse key schedulemaster_key=get_master_key(k,NORMAL_ROUND+1)# k = reverse_key_schedule(k, 10)iftest_key(pt1,pt2,new_ct1,new_ct2,master_key):print(master_key)io.sendlineafter(b"Enter option","3")io.sendlineafter(b"Enter key (hex): ",master_key.hex())data1=io.recvall()print(data1)log.success(find_flag_fmt(data1))exit(0)assertFalse# Key not found