Following the mole's dismissal, the nation suffered from an onslaught of relentless phishing campaigns. With little time to spare during this chaotic and tense period, warnings and safeguards for staff members were inadequate. A few individuals fell victim to the phishing attempts, leading to the encryption of sensitive documents by ransomware. You were assigned the mission of reverse engineering the ransomware and ultimately recovering the classified files, restoring order and safeguarding the nation's sensitive information.
Afterwhich, using the same random.getrandbits() function, the key and iv were generated as 1680-bit integers then converted from long integer to a byte array. The key and iv were then used to initialize encryption via AES-CBC:
Each of the files were encrypted iteratively through counter. And using one of the 32 secrets generated (taken via counter as an index), additional data is appended to the encrypted file:
The additional data is just the secret used with each byte XORed to either of the two bytes of the OTP. The OTP byte used for the operation is determined whether the current byte of secrets has an even or odd index:
def_xor(self,a,b):returnb''.join([bytes([a[i] ^ b[i %len(b)]]) for i inrange(len(a))])
It is important to note that the length of OTP is 2 bytes and the length of a secret will always be 72 bytes so the contents of the encrypted file will always be like this:
<AES ENCRYPTED DATA> + <72-bytes of secret^otp>
The encrypted file will then be saved as:
<counter+1>_<filename without extension>.enc
DECRYPTION
1. Mersenne Twister
The Python random library uses Mersenne Twister for its Pseudorandom Number Generator (PRNG). It is, for the most part, statistically random and has a very long period of (2^19937) - 1; however, it was determined to be not cryptographically secure. The main reason for this is that when it is initialized, its initial state consists of 624 32-bit integers which when all bits are used up, it's twisted to create a new state of 624 32-bit integers.
This can be abused when a certain number of integers or bits has been observed from the PRNG and the initial state can be derived. Once the initial state has been recovered, future generations could be predicted. Without going through the trouble of describing the math, this particular Python library was used -- RandCrack.
In the case of this challenge, a fresh state is used to generate 32 576-bit integers. This could also be translated as having 576 32-bit integers already taken from the initial state. The number of values already available seems to already be significant; however, making use of the library mentioned earlier requires 624 32-bit integers so the beginning was just padded with 0s and see if it will work:
defgetMTState(self):for y inrange(48): self.rc.submit(0)# 0 padding to compensate for the initial statefor y inrange(32):# Iterating through each secret x = self.secrets[y]while x >0:# Until all the bits of the secret has been passed self.rc.submit(x % (1<<32))# Passing 32 bits of the current secret x = x >>32# Removing the 32 bits passed
The next values taken from the PRNG were the key and iv meaning that it should be predictable once the initial state has been recovered. And if the prediction will work as expected, the following should initialize AES-CBC with the correct key and iv:
Given that the OTP is known, it is possible to derive the secrets from the appended data in each encrypted file. The function just needs to be reversed since the XOR operation is associative meaning:
enc_data = secret ^ OTP
secret = enc_data ^ OTP
With that in mind, after extracting the last 72-bytes from each encrypted file, it could just be used to replace secret in the _xor() function from ransomware.py:
defgetSecrets(self,enc):for enc_data in enc: data =b"".join([bytes([enc_data[i] ^ self.otp[i %len(self.otp)]]) for i inrange(len(enc_data))]) secret =int(data.hex(), 16) self.secrets.append(secret)directory ="./enc_files"enc =list()for i in fl:withopen("%s/%s"%(directory,i), 'rb')as enc_file: enc.append(enc_file.read()[-72:])attempt.getSecrets(enc)
3. File Sorting
This is just an extra tidbit. Since the AES cipher was only initialized once in ransomware.py, it is impossible to decrypt the files one by one but instead should be done in order of encryption.
When reading files from a directory using os.listdir(), files are sorted as a string instead of the prefix of each file which is an integer so in order to work around this, this method was used:
from Crypto.Util.number import long_to_bytes as l2bdirectory ="./enc_files"fl =list()# List initialization for all encrypted filesfor i in os.listdir(directory): fl.append(i)fl.sort(key=lambdax: int(x.split("_")[0]))# Sort the list based on the integer prefix
4. OTP Bruteforce
Now that everything is set, the last thing that needs to be figured out is the OTP. There is no sure way (that I know of) to find out definitely what it is besides bruteforcing. Since it only consists of 2 bytes meaning there are only 2^16 (65536) possible values for the OTP.
For every value of OTP there is, a new set of secrets will be derived which will then be used to identify the initial state of the PRNG as well as initialize AES-CBC for decryption:
for x inrange(0, 16**4): otp =bytes.fromhex(format(x, "04x")) attempt =Decryptor(otp) attempt.getSecrets(enc) attempt.getMTState() attempt.initializeAES()
SOLUTION
1. Python Script
from Crypto.Cipher import AESfrom Crypto.Util.number import long_to_bytes as l2bfrom randcrack import RandCrack # https://github.com/tna0y/Python-random-module-crackerimport randomimport osclassDecryptor:def__init__(self,otp): self.key =None self.iv =None self.otp = otp self.secrets =list() self.rc =RandCrack()defgetSecrets(self,enc):for enc_data in enc: data =b"".join([bytes([enc_data[i] ^ self.otp[i %len(self.otp)]]) for i inrange(len(enc_data))]) secret =int(data.hex(), 16) self.secrets.append(secret)defgetMTState(self):for y inrange(48): self.rc.submit(0) ctr =0for y inrange(32): x = self.secrets[y]while x >0: self.rc.submit(x % (1<<32)) ctr +=1 x = x >>32definitializeAES(self): self.key =l2b(self.rc.predict_getrandbits(1680))[:16] self.iv =l2b(self.rc.predict_getrandbits(1680))[:16] self.cipher = AES.new(self.key, AES.MODE_CBC, self.iv)defdecrypt(self,ct): pt = self.cipher.decrypt(ct) cipher =0 garbage =-ord(pt[-1:])return pt[:garbage]defmain(): directory ="./enc_files" fl =list()for i in os.listdir(directory): fl.append(i) fl.sort(key=lambdax: int(x.split("_")[0])) enc =list()for i in fl:withopen("%s/%s"%(directory,i), 'rb')as enc_file: enc.append(enc_file.read()[-72:])for x inrange(0, 16**4): otp =bytes.fromhex(format(x, "04x"))#otp = b'i\xc0' # Actual OTP attempt =Decryptor(otp) attempt.getSecrets(enc) attempt.getMTState() attempt.initializeAES()for y in fl:withopen("%s/%s"% (directory,y), 'rb')as enc_file: ct = enc_file.read()[:-72] pt = attempt.decrypt(ct)ifb"HTB{"in pt:print(pt.decode())exit()if__name__=="__main__":main()