CRY - PRaNsomG

HTB Business CTF 2023

CONTEXT

1. Challenge Description

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.

2. Encrypted Files

There were 32 encrypted by ransomware.py:

$ stat --printf "%s\t%n\n" ./enc_files/*

  328	./enc_files/1_Marketing_Campaign_Report.enc
  328	./enc_files/2_Customer_Survey_Results.enc
  344	./enc_files/3_Marketing_Strategy.enc
  312	./enc_files/4_Sales_Forecast.enc
  360	./enc_files/5_Market_Research_Summary.enc
  344	./enc_files/6_Business_Growth_Strategy.enc
  312	./enc_files/7_Financial_Projections.enc
  312	./enc_files/8_Risk_Management_Plan.enc
  344	./enc_files/9_Business_Process_Improvement_Report.enc
  328	./enc_files/10_Business_Continuity_Plan.enc
  360	./enc_files/11_Employee_Performance_Review.enc
  344	./enc_files/12_Business_Plan.enc
  360	./enc_files/13_Profit_and_Loss_Statement.enc
  296	./enc_files/14_Expense_Tracker.enc
  312	./enc_files/15_Investment_Presentation.enc
  328	./enc_files/16_Business_Registration_Form.enc
  328	./enc_files/17_Budget_Spreadsheet.enc
  344	./enc_files/18_SWOT_Analysis.enc
  328	./enc_files/19_Employee_Handbook.enc
  328	./enc_files/20_Product_Catalog.enc
  312	./enc_files/21_Meeting_Minutes.enc
  360	./enc_files/22_Product_Demo_Video.enc
  136	./enc_files/23_Annual_Report.enc
  344	./enc_files/24_Training_Manual.enc
  328	./enc_files/25_Market_Analysis.enc
  360	./enc_files/26_Vendor_Contract.enc
  328	./enc_files/27_Financial_Statement.enc
  328	./enc_files/28_Corporate_Social_Responsibility_Policy.enc
  328	./enc_files/29_Competitive_Analysis.enc
  328	./enc_files/30_Sales_Report.enc
  328	./enc_files/31_Partnership_Agreement.enc
  328	./enc_files/32_Project_Proposal.enc

3. Ransomware

The python script to encrypt the files:

from Crypto.Util.number import long_to_bytes as l2b
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from random import getrandbits
import os, sys

class Encryptor:

    def __init__(self):
        self.out_dir = 'enc_files'
        self.counter = 0
        self.otp = os.urandom(2)
        self.initialize()

    def initialize(self):
        os.makedirs(f'./{self.out_dir}', exist_ok=True)

        self.secrets = []

        for _ in range(32):
            self.secrets.append(getrandbits(576))

        self.key = l2b(getrandbits(1680))[:16]
        self.iv = l2b(getrandbits(1680))[:16]

        self.cipher = AES.new(self.key, AES.MODE_CBC, self.iv)

    def _xor(self, a, b):
        return b''.join([bytes([a[i] ^ b[i % len(b)]]) for i in range(len(a))])

    def encrypt(self, target):
        for fname in os.listdir(target):
            with open(f'{target}/{fname}') as f:
                contents = f.read().rstrip().encode()

            enc_fname = f"{str(self.counter + 1)}_{fname.split('.')[0]}.enc"

            enc = self.cipher.encrypt(pad(contents, 16))
            enc += self._xor(l2b(self.secrets[self.counter]), self.otp)

            self.write(enc_fname, enc)
            self.counter += 1

    def write(self, filepath, data):
        with open(f'{self.out_dir}/{filepath}', 'wb') as f:
            f.write(data)

def main():
    encryptor = Encryptor()
    encryptor.encrypt(sys.argv[1])

if __name__ == "__main__":
    main()

PROGRAM FLOW

1. Initializations

A new directory, enc_files, is created where encrypted files will be saved. Also, a 2-byte OTP is initialized using os.urandom(2):

def __init__(self):
    self.out_dir = 'enc_files'
    self.counter = 0
    self.otp = os.urandom(2)
    self.initialize()

Then as part of initialize() function, thirty-two 576-bit integers are initialized as secrets using random.getrandbits():

self.secrets = []

for _ in range(32):
    self.secrets.append(getrandbits(576))

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:

self.key = l2b(getrandbits(1680))[:16]
self.iv = l2b(getrandbits(1680))[:16]

self.cipher = AES.new(self.key, AES.MODE_CBC, self.iv)

2. Encryption

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:

enc = self.cipher.encrypt(pad(contents, 16))
enc += self._xor(l2b(self.secrets[self.counter]), self.otp)

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):
    return b''.join([bytes([a[i] ^ b[i % len(b)]]) for i in range(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:

def getMTState(self):
    for y in range(48): self.rc.submit(0)      # 0 padding to compensate for the initial state
    for y in range(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:

def initializeAES(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)

2. Secrets

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:

def getSecrets(self, enc):
    for enc_data in enc:
        data = b"".join([bytes([enc_data[i] ^ self.otp[i % len(self.otp)]]) for i in range(len(enc_data))])
        secret = int(data.hex(), 16)
        self.secrets.append(secret)

directory = "./enc_files"

enc = list()
for i in fl:
    with open("%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 l2b
directory = "./enc_files"

fl = list()                                    # List initialization for all encrypted files
for i in os.listdir(directory):                 
    fl.append(i)
fl.sort(key=lambda x: 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 in range(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 AES
from Crypto.Util.number import long_to_bytes as l2b
from randcrack import RandCrack                # https://github.com/tna0y/Python-random-module-cracker
import random
import os

class Decryptor:
    def __init__(self, otp):
        self.key = None
        self.iv = None
        
        self.otp = otp
        self.secrets = list()
        self.rc = RandCrack()

    def getSecrets(self, enc):
        for enc_data in enc:
            data = b"".join([bytes([enc_data[i] ^ self.otp[i % len(self.otp)]]) for i in range(len(enc_data))])
            secret = int(data.hex(), 16)
            self.secrets.append(secret)

    def getMTState(self):
        for y in range(48): self.rc.submit(0)
        ctr = 0
        for y in range(32):
            x = self.secrets[y]
            while x > 0:
                self.rc.submit(x % (1 << 32))
                ctr += 1
                x = x >> 32
                
    def initializeAES(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)
        
    def decrypt(self, ct):
        pt = self.cipher.decrypt(ct)
        cipher = 0

        garbage = -ord(pt[-1:])
        return pt[:garbage]

def main():    
    directory = "./enc_files"

    fl = list()
    for i in os.listdir(directory):
        fl.append(i)
    fl.sort(key=lambda x: int(x.split("_")[0]))

    enc = list()
    for i in fl:
        with open("%s/%s" %(directory,i), 'rb') as enc_file:
            enc.append(enc_file.read()[-72:])

    for x in range(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:
            with open("%s/%s" % (directory,y), 'rb') as enc_file:
                ct = enc_file.read()[:-72]
                pt = attempt.decrypt(ct)
                if b"HTB{" in pt: 
                    print(pt.decode())
                    exit()
            
if __name__ == "__main__": main()

2. Flag

$ python3 decrypt.py

  HTB{v1t4l1um_h3r3_w3_c0m3___n0_r4ns0mw4r3_c4n_st0p_us}

Last updated