# Write Up For Ransomware Vip Pro Number 1 Tung Tung - Security Bootcamp 2025 ![Chall Description](https://hackmd.io/_uploads/SJJNnHrsll.png) # Analyze compromised ESXI ![Esxi compromized](https://hackmd.io/_uploads/r1RUgwLoge.png) It can be seen that the attacker has compromised `root` account, accessed the ESXi server, enabled SSH, and disabled the `ExecInstalledOnly` feature to be able to run binaries. ![Machines](https://hackmd.io/_uploads/ByNoeD8sll.png) There are two machines in total on the ESXi, let's try to power them on to see if it works. ![Powering on 1](https://hackmd.io/_uploads/ryfG-PIile.png) ![Powering on 2](https://hackmd.io/_uploads/Skgl-PIieg.png) Unable to boot. Upon checking the datastores, we found that all of the virtual machine's files have been encrypted with the `.crt` extension. ![check datastore 1](https://hackmd.io/_uploads/rJKVWDIjex.png) ![check datastore 2](https://hackmd.io/_uploads/HyLvZPIjll.png) Continuing the search, we discovered two files in the datastore: a pcap file and an encrypted flag file. ![check datastore 3](https://hackmd.io/_uploads/SyAQmvIjge.png) ![check datastore 4](https://hackmd.io/_uploads/ryNWXDIjeg.png) Check the history to see what the attacker executed on the machine: ![image](https://hackmd.io/_uploads/Bk9kzP8oxg.png) The attacker has already disabled the history. To further investigate what binary was run, ESXi `8.0u3` supports logging non-installed files that have been executed by users. For details, refer to: https://knowledge.broadcom.com/external/article/344815/esxi-80-prevents-the-execution-of-nonins.html ![image](https://hackmd.io/_uploads/S1iUMv8igx.png) We'll download this `/bin/haltVms` file and inspect it: # Analyze binary ![image](https://hackmd.io/_uploads/rJwgdjUsgg.png) This is a statically linked and stripped ELF file. ## Detect it easy ![image](https://hackmd.io/_uploads/SkZNEPLolx.png) This ransomware file was written in the C language and compiled using GCC. Let's load this file into IDA and start the analysis: ## Func: start ![image](https://hackmd.io/_uploads/BJ1jBwIoel.png) Alternatively, for better readability, IDA supports loading libc signatures. Since we know this binary was compiled on Debian using GCC, we'll use the FLIRT signature `debian-libc6-dev-amd64.sig`. ![image](https://hackmd.io/_uploads/H18nDDUslx.png) ![image](https://hackmd.io/_uploads/Hkd8dwUjgg.png) Confirming that this is indeed the function that initializes and calls `main`. A preliminary analysis of the `start` function shows it's a standard C/C++ CRT start function. From this, we can see that `sub_407E10` is our main function. Let's analyze it: ## Main func: sub_407E10 ![image](https://hackmd.io/_uploads/BJiFjwUixl.png) Upon entering this function, the ransomware first attaches ptrace to itself. This is an anti-debugging technique because on Linux, each process can only have one ptrace attached at a time, which effectively disables GDB. ![image](https://hackmd.io/_uploads/B1rYwYUsgx.png) Next, the program runs the `sub_40C520` function in a separate thread. This indicates the program was likely written in C++, so let's try loading the signatures for libstdc++ and libgcc version 14.2.0-19 (the version obtained from Detect It Easy in the previous step). ## Creating .sig for libstdc++\_14.2.0-19 and libgcc\_14.2.0-19 > Use the `pelf`, `sigmake`, and `zipsig` binaries included with IDA PRO (in the `tools/flair` installation directory) to create `.sig` files for the libraries. Download the `.deb` files: ```bash apt download libgcc-14-dev=14.2.0-19 apt download libstdc++-14-dev=14.2.0-19 ``` Script to generate signatures from `.deb` files <details> <summary>Script extract `.sig` from `.deb`</summary> ```python import argparse import subprocess import os import glob import re PELF_PATH = "./flair/pelf" SIGMAKE_PATH = "./flair/sigmake" ZIPSIG_PATH = "./flair/zipsig" TAR_PATH = "/usr/bin/tar" AR_PATH = "/usr/bin/ar" def extract_a(deb_path: str) -> str: pkg_path = deb_path[:-4] os.makedirs(pkg_path, exist_ok=True) ar_files = subprocess.run([AR_PATH, 't', deb_path], capture_output=True).stdout data_tar_type = ar_files.decode().strip().split("\n")[2] data_tar = subprocess.run([AR_PATH, 'p', deb_path, data_tar_type], capture_output=True).stdout try: _ = subprocess.run([TAR_PATH, '-C', pkg_path, '--zstd', '-x', '--wildcards', "*.a"], input=data_tar, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except: print("No *.a files found") return pkg_path def a_to_pat(pkg_path: str, pat_name: str) -> str: a_path = glob.glob(os.path.join(pkg_path, "**/*.a"), recursive=True) pat_path = os.path.join(pkg_path, pat_name) _ = subprocess.run([PELF_PATH] + a_path + [pat_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return pat_path def _clean_exc(exc_name: str) -> None: with open(exc_name, 'r') as f: s = f.read() with open(exc_name, 'w') as f: cleaned_funcs: list[str] = [] s = re.sub(r';.+\s', '', s).strip() funcs_pairs = s.split(os.linesep * 2) for funcs_pair in funcs_pairs: funcs = funcs_pair.splitlines() start = 0 if len(funcs) > 1: #if only one collision, not add '+' cleaned_funcs.append('+' + funcs[0]) start += 1 funcs.append('') #double linesep cleaned_funcs.extend(funcs[start:]) s = os.linesep.join(cleaned_funcs) s = re.sub(r'\+\+', '+', s).strip() _ = f.write(s) def pat_to_sig(pat_path: str, sig_path: str) -> str: cmd = [SIGMAKE_PATH, "-N", pat_path, sig_path] exit_code = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode if exit_code != 0: while exit_code != 0: _clean_exc(sig_path[:-4] + ".exc") exit_code = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode # os.remove(sig_path[:-4] + ".exc") subprocess.run([ZIPSIG_PATH, sig_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return sig_path def main(): parser = argparse.ArgumentParser() parser.add_argument("-i", type=str, help="Path to .deb file", required=True) args = parser.parse_args() pkg = extract_a(args.i) pat_name = os.path.basename(args.i) + ".pat" pat_path = a_to_pat(pkg, pat_name) sig_path = os.path.join(pkg, os.path.basename(args.i) + ".sig") pat_to_sig(pat_path, sig_path) if __name__ == "__main__": main() ``` </details> Gen sig: ```bash python gen_sig -i libgcc-14-dev_14.2.0-19_amd64.deb python gen_sig.py -i libstdc++-14-dev_14.2.0-19_amd64.deb ``` Apply sig: ![image](https://hackmd.io/_uploads/ryKUqc8see.png) We can see clearer: ![image](https://hackmd.io/_uploads/ByhR958jeg.png) The program is running `sub_40C520` in the background (by creating and detaching a thread). ## sub_40C520 ![image](https://hackmd.io/_uploads/BJRKsqIsex.png) ![image](https://hackmd.io/_uploads/H1PciqLile.png) This is just a function that prints a rickroll :P ![image](https://hackmd.io/_uploads/Sy7Ajq8ogl.png) !SKIP ## Back to sub_407E10 ![image](https://hackmd.io/_uploads/Bkp-biIixl.png) We can see the program is performing an XOR operation on two byte strings: `AB2409E6C9B4E22138A7751BD81F5B13` and `AB103DD4E78DDB10169F432AF62D6222`. After putting this into CyberChef, we see that the result is an IP address: ![image](https://hackmd.io/_uploads/rk9wbjIilx.png) After decrypting the IP, the program loads it into a new `std::string` by calling `sub_40C450` to initialize the string: ![image](https://hackmd.io/_uploads/Hk3tSjIigl.png) And then it is passed into the `sub_40D690` function. ![image](https://hackmd.io/_uploads/r10wIs8slx.png) ## Func sub_40D690 ![image](https://hackmd.io/_uploads/ryUHvi8ilx.png) Upon entering, we see that this function calls initialization functions related to OpenSSL. Let's search for the "OpenSSL" string to see which version of the OpenSSL library the ransomware is using. From there, we can extract the FLIRT signature and load it into IDA for a cleaner disassembly: ![image](https://hackmd.io/_uploads/Sks5PjUslg.png) As we can see, this is the [OpenSSL 1.0.2u](https://openssl-library.org/source/old/1.0.2/) library; we will download and compile it to get the .a (static library) file. ![image](https://hackmd.io/_uploads/H1hHdsIigx.png) ## Creating .sig for OpenSSL 1.0.2u from .a file > Use the `pelf`, `sigmake`, and `zipsig` binaries included with IDA PRO (in the `tools/flair` installation directory) to create `.sig` files for the libraries. Modify the script used above to skip extracting the `.a` files from the `.deb` files. ``` diff gen_sig_before.py gen_sig.py 0a1 > 81c82,83 < pkg = extract_a(args.i) --- > # pkg = extract_a(args.i) > pkg = args.i 88c90 < main() --- > main() \ No newline at end of file ``` Copy the `.a` files into the directory, then run: ```bash python gen_sig.py -i openssl-libcrypto-1.0.2 python gen_sig.py -i openssl-libssl-1.0.2 ``` Load these two signature files into IDA. ![image](https://hackmd.io/_uploads/SyDuos8iex.png) ## Back to sub_40D690 ![image](https://hackmd.io/_uploads/SyUFsiLjxg.png) Ah isn't it beautiful like the day you didn't striped. Upon entering this function, the program calls OpenSSL's initialization functions. This is likely to set up a connection to the C2 server using the previously extracted IP, which is passed into this function (192.168.199.244). ![image](https://hackmd.io/_uploads/Hk4ZpjLogg.png) ![image](https://hackmd.io/_uploads/H1tNToUseg.png) It initializes a new SSL context and sets the `SSL_VERIFY_PEER` callback to the `authnone_validate` function, which always returns 1, thereby skipping SSL validation. After that, there is a series of strings that are XORed just like the IP above: ![image](https://hackmd.io/_uploads/rkEf0jUoxl.png) Decrypting everything, we get: ![image](https://hackmd.io/_uploads/BkOdCjLsge.png) ``` /etc/hosts /etc/passwd /etc/resolv.conf /bin/python3 /bin/esxcli.py hardware platform get ``` The three strings `/etc/hosts`, `/etc/passwd`, and `/etc/resolv.conf` are just decoys; after decryption, they are left unused. Only the "python3" string is executed via `execvp`, and its output is captured through two pipes and saved. This block is a C++ try-catch block and can be skipped. ![image](https://hackmd.io/_uploads/B1Q9NhUsex.png) ... ![image](https://hackmd.io/_uploads/rkrX5n8oxe.png) Pipe execute: ![image](https://hackmd.io/_uploads/Hk49Bh8jgg.png) Pipe get output: ![image](https://hackmd.io/_uploads/S1Aiqn8sll.png) After that, the output is Base64 encoded: ![image](https://hackmd.io/_uploads/rkTknn8sge.png) After that, a socket is created: ![image](https://hackmd.io/_uploads/HJOWLhUixe.png) Setup socket: ![image](https://hackmd.io/_uploads/rJJaL28sxx.png) 0xbb010002 -> - 02 -> AF_INET - 00 -> padding - 01bb -> 443 So, from the IP above and this Port -> C2 server: `192.168.199.244` ![image](https://hackmd.io/_uploads/rk1X_hLjee.png) It calls `connect(sock, ...)` and initializes an SSL context (using the low-level OpenSSL API). ![image](https://hackmd.io/_uploads/HkJDd2Uigl.png) We see an XORed string is passed into `SSL_set_cipher_list`, which decrypts to: `ECDH-ECDSA-AES128-GCM-SHA256` Then, it performs an `SSL_connect` and writes the length of the Base64 string, followed by the Base64 string itself, to that socket via `SSL_write`: ![image](https://hackmd.io/_uploads/SkIhn2Isee.png) And waits for a 4-byte response from the server via `SSL_read`. ![image](https://hackmd.io/_uploads/SkWdahIilx.png) If the 4-byte read is successful, it initializes the abcxyz... structs and continues to read the response from the server: ![image](https://hackmd.io/_uploads/HyWM0nLjll.png) Then, it checks if the response length is greater than 9 and if it contains the string `SBC NTLM:`. If not, it throws a `std::runtime_error`. ![image](https://hackmd.io/_uploads/H1ligpUjeg.png) Then, it strips the `SBC NTLM:` string and Base64 decodes the remainder (using the same code as above). ![image](https://hackmd.io/_uploads/B1O1baIsxx.png) After a successful decode, it deserializes the 56 bytes into a 56-byte data structure, and the function ends: ![image](https://hackmd.io/_uploads/rJ0ZVTIoee.png) ![image](https://hackmd.io/_uploads/B1-2Egujxe.png) ![image](https://hackmd.io/_uploads/H11xBeOsll.png) The challenge includes a pcap file. Next, we will analyze this file to see if it matches our findings from the reverse engineering process. ## Analyzing PCAP From the binary analysis, we obtained the C2 host: `192.168.199.244:443`. Using the filter `ip.addr == 192.168.199.244 && tcp.port == 443`. ![image](https://hackmd.io/_uploads/H12lHa8ogg.png) Let's check the Server Hello packet to see what TLS version the server supports: ![image](https://hackmd.io/_uploads/HyeLBa8jgl.png) ECDH_ECDSA_WITH_AES_128_GCM_SHA256, just like in the binary. This algorithm has been removed in newer versions of OpenSSL because if the private key used to sign the certificate is exposed, an attacker can decrypt all traffic. After discussing with an AI, it said the following elements are needed to decrypt the traffic: ![image](https://hackmd.io/_uploads/SkDkda8ilg.png) 1. Client Random: in the Client Hello packet 2. Server Random: in the Server Hello packet 3. Client Ephemeral Key: in the Client Key Exchange packet 4. Server static private key: d_server The pcap file already contains the first three pieces of information. After inspecting the packets in this pcap until my eyes glazed over, I found the following: ![image](https://hackmd.io/_uploads/rk4oO6Lige.png) This is most likely the `d_server`. Now, I'll ask the AI to continue writing the decryption script: ![image](https://hackmd.io/_uploads/rJUpdaIiee.png) <details> <summary>Script decrypt traffic</summary> ```python import hmac import hashlib from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization def p_hash(secret, seed, hash_algo, output_len): """ Implements the P_hash function from the TLS 1.2 spec (RFC 5246). """ output = b"" a = seed while len(output) < output_len: a = hmac.new(secret, a, hash_algo).digest() output += hmac.new(secret, a + seed, hash_algo).digest() return output[:output_len] def prf_sha256(secret, label, seed, output_len): """ Implements the TLS 1.2 PRF using HMAC-SHA256. """ return p_hash(secret, label + seed, hashlib.sha256, output_len) def calculate_master_secret(d_server, q_client_eph, client_random, server_random, curve): """ Calculates the premaster and master secret for a static ECDH handshake. :param d_server: The server's static private key (integer). :param q_client_eph: The client's ephemeral public key (bytes). :param client_random: The client random (bytes). :param server_random: The server random (bytes). :param curve: The elliptic curve object (e.g., ec.SECP256R1()). :return: The 48-byte master secret (bytes). """ try: # 1. Load the server's static private key server_private_key = ec.derive_private_key(d_server, curve) # 2. Load the client's ephemeral public key from the handshake client_public_key = ec.EllipticCurvePublicKey.from_encoded_point(curve, q_client_eph) # 3. Perform the ECDH key exchange to get the premaster secret premaster_secret = server_private_key.exchange(ec.ECDH(), client_public_key) print(f"[*] Calculated Premaster Secret: {premaster_secret.hex()}") # 4. Derive the master secret using the TLS 1.2 PRF master_secret = prf_sha256( premaster_secret, b"master secret", client_random + server_random, 48 # Master secret is always 48 bytes ) print(f"[*] Calculated Master Secret: {master_secret.hex()}") return master_secret except Exception as e: print(f"[!] An error occurred: {e}") return None if __name__ == '__main__': # --- 🕵️‍♀️ YOU NEED TO PROVIDE THESE VALUES --- # These must be extracted from your traffic capture (e.g., using Wireshark). # The server's static private key as an integer. # This is the secret you have obtained. SERVER_PRIVATE_KEY_HEX = 12345... # Replace with the actual private key integer # Client Random (from Client Hello), as a hex string CLIENT_RANDOM_HEX = "aabbcc..." # Server Random (from Server Hello), as a hex string SERVER_RANDOM_HEX = "ddeeff..." # Client's ephemeral public key (from Client Key Exchange message), as a hex string. # This is the uncompressed point, usually starting with '04'. CLIENT_PUBLIC_KEY_HEX = "04..." # The curve used for the key exchange (e.g., SECP256R1, SECP384R1) CURVE = ec.SECP256R1() # --- Calculation --- print("--- 🔑 Starting Master Secret Calculation ---") # Convert hex inputs to bytes cr_bytes = bytes.fromhex(CLIENT_RANDOM_HEX) sr_bytes = bytes.fromhex(SERVER_RANDOM_HEX) q_client_bytes = bytes.fromhex(CLIENT_PUBLIC_KEY_HEX) d_server_int = int(SERVER_PRIVATE_KEY_HEX, 16) master_secret = calculate_master_secret( d_server_int, q_client_bytes, cr_bytes, sr_bytes, CURVE ) if master_secret: # --- Wireshark Key Log Format --- # The format is: CLIENT_RANDOM <master_secret_hex> keylog_line = f"CLIENT_RANDOM {CLIENT_RANDOM_HEX} {master_secret.hex()}" print("\n--- ✅ Wireshark Key Log Line ---") print("Copy the line below into your SSL Keylog File:") print(keylog_line) ``` </details> 1. ![image](https://hackmd.io/_uploads/HyEVF6Usge.png) 2. ![image](https://hackmd.io/_uploads/HkDBt6Loel.png) 3. ![image](https://hackmd.io/_uploads/S1r8FpUiee.png) 4. ![image](https://hackmd.io/_uploads/H1_PYTUsel.png) Add it to the script and run it to get the key log. ![image](https://hackmd.io/_uploads/B1aBqp8sll.png) After importing it into Wireshark and following the TLS stream, we see that it matches the behavior analyzed in the binary: ![image](https://hackmd.io/_uploads/ryTAopUjgg.png) ![image](https://hackmd.io/_uploads/r1NS3TUjll.png) ![image](https://hackmd.io/_uploads/S1NvhpUigg.png) ![image](https://hackmd.io/_uploads/Bk1ohTLixe.png) ## Back to main function ### Parsing arguments After retrieving the data from the server, the main function proceeds to map it as follows: ![image](https://hackmd.io/_uploads/HJWFOx_iee.png) The first 32 bytes are mapped to a string. As for the remaining 24 bytes, looking in the disassembler, we see references to 6 variables, each 4 bytes long. Most likely, these are 6 values for some function: ![image](https://hackmd.io/_uploads/S1b10ediex.png) After calling this function, the program gets the key from the server and proceeds to parse the input `argv` as follows: First, `argv` and `argc` are stored here: ![image](https://hackmd.io/_uploads/SJi9FNFixg.png) Then, the program parses the arguments passed into it, and we observe it looping: ![image](https://hackmd.io/_uploads/HJPRYVYolg.png) ![image](https://hackmd.io/_uploads/ByVM94Koel.png) First, `argv` is cast to a `std::string`: ![image](https://hackmd.io/_uploads/B1X5jVKjgx.png) ![image](https://hackmd.io/_uploads/HyCTj4Kile.png) ![image](https://hackmd.io/_uploads/SJIfeSFsex.png) ![image](https://hackmd.io/_uploads/rJGNSrYslx.png) Then, we see the program parses the argument following "-d" into a `std::filesystem::absolute(std::filesystem::path(argv + 1))`, adds it to a `std::vector`, and then loops again. This implies the program accepts multiple "-d \<path>" arguments, with a format like: `./haltVms -d <path_1> -d <path_2> ...` ### Looping First (this is a guess): as it begins to loop through these directories, the program decrypts several more XORed strings, similar to the ones above: ![image](https://hackmd.io/_uploads/S157dSKjlx.png) Decrypting these strings, we get: ![image](https://hackmd.io/_uploads/rkF4qBKseg.png) Then, we see the program enters a `std::filesystem::recursive_directory_iterator` loop, which is initialized with the following values: `mov rdi, [rsp+6E8h+var_610]` - 1st arg: the 'this' pointer for this iterator `mov rsi, [rsp+6E8h+var_588]` - 2nd arg: the dir_path obtained above `mov edx, 2` - 3rd arg: `std::filesystem::directory_options::skip_permission_denied` `xor ecx, ecx` - 4th arg: a null pointer for std::error_code ![image](https://hackmd.io/_uploads/rkh5RrFigx.png) ![image](https://hackmd.io/_uploads/B1HJdSKjll.png) Extracting the file extension: ![image](https://hackmd.io/_uploads/S1TP1Utsgl.png) And compares it against the previously decrypted strings (the logic for how C++17 calls libraries is why it looks so messy): ![image](https://hackmd.io/_uploads/BkPhkIYogx.png) ![image](https://hackmd.io/_uploads/SkUpyLKixg.png) ![image](https://hackmd.io/_uploads/BJqPjS9iex.png) If the file's extension is in the list above, the program performs a calculation. First, we see it call a calculation function, `sub_40B640`, with the following arguments: ![image](https://hackmd.io/_uploads/ryNsiB5jeg.png) ![image](https://hackmd.io/_uploads/B1RlpSqoee.png) From this, we can see that the server sends a struct to the ransomware as follows: `char[32]`, `float[6]`, for a total of 56 bytes. This checks out. Let's continue analyzing what this function does with the 6 floats. First, it takes the second argument of this function and calls `sub_5FA260`: ![image](https://hackmd.io/_uploads/HkdBkU9ilx.png) This is a function that gets and returns the file size. With this `file_size`, the program calculates how many 64KiB (65536-byte) chunks the file will be divided into, as follows: ![image](https://hackmd.io/_uploads/Syx-nI9oee.png) Then, the program generates some byte string: ![image](https://hackmd.io/_uploads/S1PjFO9jee.png) I didn't know what it was doing, so I put it into Gemini and asked: ![image](https://hackmd.io/_uploads/HkcTFd9sge.png) ### Chunking file using Lorenz So, is it highly likely the attacker is using the Lorenz Chaotic System to generate random bytes for file chunking? The input to this function is a `float[6]` array, which Gemini analyzed as being the parameters for this Lorenz System. After generating the "states" of this Lorenz System, the values are stored in a `std::vector` container: ![image](https://hackmd.io/_uploads/ryv4q_9jge.png) ![image](https://hackmd.io/_uploads/Hyjaod5ile.png) I'm still unclear on how the attacker intends to use this Lorenz system for chunking, so I'll continue to analyze: ![image](https://hackmd.io/_uploads/HkCpwzt2eg.png) It seems the attacker intends to use the Lorenz system to generate a number of points equal to the number of chunks. This likely means that during encryption, the file will be processed by encrypting N random bytes and then skipping N bytes, with this randomness determined by the Lorenz system's output. However, from what I've read online, this Lorenz system only generates points (e.g., 0.3, 0.4, 0.7) and then sums them into a single value: 0.3 + 0.4 + 0.7 = 1.7, which is stored in `v19` (referencing `v10`, same as `v15`). After generation, it is stored in `v15` (which also references `v10`). So, the attacker probably normalizes it. Continuing the analysis: ![image](https://hackmd.io/_uploads/Hkw9lK5ixl.png) ![image](https://hackmd.io/_uploads/ByTDZtcilg.png) ![image](https://hackmd.io/_uploads/SJPsWYqoxg.png) ![image](https://hackmd.io/_uploads/rJ_KmFcjlx.png) ![image](https://hackmd.io/_uploads/BJ-4hY5jeg.png) ![image](https://hackmd.io/_uploads/ryNphKqjel.png) What the... :P So, in summary, the attacker chunks the file as follows: Get `file_size` -> Calculate the number of 64KB chunks -> Use 6 floats from the server as parameters for the Lorenz system -> Take the absolute value of the Lorenz results -> Sum these values -> Calculate the percentage distribution for the chunks -> Divide the `file_size` according to these percentages -> Adjust the last chunk with the remainder -> Done. Example: A 1 MiB (1,048,576 bytes) file: 1. Number of chunks: `ceil(1,048,576 / 65,536) = ceil(16.0) = 16`. 2. Example chaotic sequence: `[0.5, 1.2, 0.8, 0.9, 0.3, 1.5, 0.7, 0.6, 1.1, 0.4, 0.2, 0.9, 0.8, 0.6, 0.5, 0.7]` 3. Sum of the chaotic sequence: `11.2` 4. Convert the chaotic sequence into a percentage sequence: e.g., `arr[0] = 0.5 / 11.2 ~ 0.0446 -> [0.0446, 0.1071, ...]` 5. Calculate the chunk sizes: Multiply each percentage by the `file_size`, e.g., `0.0446 * 1,048,576 = 46,807 bytes`. 6. Calculate for all 16 chunks: `[46807, 112272, 74882, 83995, 28042, 140789, 65770, 56495, 103343, 37658, 18829, 83995, 74882, 56495, 46807, 65770]` 7. Difference between the sum of the chunks and the file_size: `1,048,576 - 1,048,587 = -11` 8. Adjust the last element: `65770 - 11 = 65,759` 9. The file is thus divided into the following chunks: `[46807, 112272, 74882, 83995, 28042, 140789, 65770, 56495, 103343, 37658, 18829, 83995, 74882, 56495, 46807, 65759]` After chunking the file, the program checks if the operation was successful by checking if the returned vector is `empty()`. If it is, the program moves to the next entry in the `recursive_directory_iterator`. ![image](https://hackmd.io/_uploads/S1eW7a_nee.png) ### Encrypt logic and Nonce generation Next, the program sets up a struct that references the 32-byte string from the 56 bytes received from the server. This is highly likely the key for an encryption algorithm (ChaCha20, more details below). ![image](https://hackmd.io/_uploads/rki6S6O3ll.png) Then, the program calculates a hash of the path string of the file being encrypted and stores it near where the struct above was initialized. (Since this is on the stack, it looks disjointed, but it's likely being saved into some struct; more details below). ![image](https://hackmd.io/_uploads/ry8YK6u3gg.png) Then the program open a new `fstream` to this file path: ![image](https://hackmd.io/_uploads/rykqop_2xl.png) The program then does the following: 1. It loops through the `chunk_sizes` that were output from the Lorenz file-chunking function. 2. If the current chunk's index is **even**, it proceeds; otherwise, it is **skipped**. 3. It reads the data for the current chunk. If the `chunk_size` is greater than 65,536, it's **clamped** to 65,536. This means it reads a maximum of **64 KiB** at a time, then continues to read and encrypt any remaining part of that chunk. 4. It encrypts this chunk with a **modified ChaCha20** algorithm: - The constants are **hardcoded** (not the standard "expand 32-byte k" string). - It performs **12 quarter-rounds** instead of the standard 20. - Within the quarter-round, the **rotation values are changed** from the standard `16, 12, 8, 7` to `12, 16, 8, 7`. 5. It writes the encrypted chunk back to the file. 6. This process is repeated until all chunks have been processed. ![encrypt_logic](https://hackmd.io/_uploads/SyNc-JK3lx.png) After it has finished encrypting all the even-indexed chunks, the program renames the file by appending the `.crt` extension to the end of its path: ![image](https://hackmd.io/_uploads/Hk5cRet2lx.png) And then, the program continues to loop through the rest of the `recursive_directory_iterator`, repeating the steps above until all files have been processed. ### Key and nonce According to RFC 7539, the ChaCha20 state is a 64-byte struct, as follows: ![image](https://hackmd.io/_uploads/HyIMwbFngg.png) Confirming this in the code: Loading the constants into 16 bytes: ![image](https://hackmd.io/_uploads/SkWr_Ztnee.png) `0x8F715FA9 0x1339CA60 0x09CBE098 0xD53D6F8B` The key is referenced through the variable `v371`, which was obtained from the server. ![image](https://hackmd.io/_uploads/HJbX3btnxg.png) Nonce and counter: ![image](https://hackmd.io/_uploads/r1GcAWYhge.png) ## Summarize So, the program will perform the following steps: 1. Runs the RickRoll in the background. 2. Sends the output of `/bin/python3 /bin/esxcli.py hardware platform get` to the server, encrypted with SSL using the `ECDH-ECDSA-AES128-GCM-SHA256` cipher suite. 3. Receives a 56-byte key from the server: - A 32-byte ChaCha20 key. - 24 bytes, interpreted as 6 floating-point numbers (4 bytes each), which serve as parameters for the Lorenz algorithm to generate random numbers for file chunking. 4. The program parses the input arguments (`-d /path/1 -d /path/2 .. -d /path/n`), which are the paths to the directories to be encrypted. 5. It checks the files within those directories. If a file has one of the following extensions \[`.vmdk`, `.vmx`, `.nvram`, `.vmxf`, `.iso`, `.vmsd`, `.vmsn`, `.vmss`, `.vmem`, `.vswp`, `.vmtm`, `.vmtx`], it performs these actions: 5.1. Chunks the file into random sizes based on the Lorenz algorithm as described above. 5.2. Encrypts the chunks at even indices (0, 2, 4, ...). 5.3. Appends the .crt extension to the filename. 6. Done # Decryptor The decryptor was written in C++ based on the analysis above (AI Generated from IDA Decompiled byte code 🤫): <details> <summary>Click to view decryptor.cpp</summary> ```c++ #include <cstring> #include <array> #include <cmath> #include <filesystem> #include <fstream> #include <iostream> #include <string> #include <vector> #include <ranges> namespace fs = std::filesystem; const std::string base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz" "0123456789+/"; struct ReceivedData { char chacha_key[32]; float values[6]; }; struct LorenzParams { float a; float r; float b; float x0; float y0; float z0; }; bool is_base64(unsigned char c); std::string base64_decode(std::string encoded_string); bool deserialize(std::string encoded_key, ReceivedData& received_data); std::vector<float> lorenz(std::size_t size, LorenzParams lorenz_params); std::vector<std::uintmax_t> chunk_file(fs::path file_path, LorenzParams lorenz_params); void decrypt(fs::path local_file_path, fs::path full_file_path_on_server, std::string chacha_key, LorenzParams lorenz_params); // 0x8F715FA9 0x1339CA60 0x09CBE098 0xD53D6F8B #define CONST_0 0x8F715FA9u #define CONST_1 0x1339CA60u #define CONST_2 0x09CBE098u #define CONST_3 0xD53D6F8Bu #define ENCODED_BASE64_KEY \ "TVhJR01VQk5JV0ZOUEhHSEVITVlTREdOU0tHQ09CRkJv/" \ "CtBasBAQrL5KkDmLL1ABPUIQCA4or0=" int main(int argc, char* argv[]) { std::string encoded_key = ENCODED_BASE64_KEY; ReceivedData received_data; if (!deserialize(encoded_key, received_data)) { std::cerr << "Failed to deserialize the provided key." << std::endl; return 1; } std::string chacha_key(received_data.chacha_key, 32); LorenzParams lorenz_params = { received_data.values[0], received_data.values[1], received_data.values[2], received_data.values[3], received_data.values[4], received_data.values[5]}; if (argc < 3) { std::cout << "Usage: " << argv[0] << " <local_file_path> <full_file_path_on_server_without_.crt>" << std::endl; return 1; } fs::path local_file_path = argv[1]; // full file path without .crt extension fs::path full_file_path_on_server = argv[2]; std::cout << "local_file_path: " << local_file_path << std::endl << "full_file_path_on_server: " << full_file_path_on_server << std::endl << "chacha_key: " << chacha_key << std::endl << "lorenz_params: " << lorenz_params.a << ", " << lorenz_params.r << ", " << lorenz_params.b << ", " << lorenz_params.x0 << ", " << lorenz_params.y0 << ", " << lorenz_params.z0 << std::endl << std::endl << "Starting decryption..." << std::endl; decrypt(local_file_path, full_file_path_on_server, chacha_key, lorenz_params); return 0; } class ChaCha20 { private: std::array<std::uint8_t, 48> _state{}; static constexpr void quarter_round(std::uint32_t& a, std::uint32_t& b, std::uint32_t& c, std::uint32_t& d) noexcept { a += b; d = std::rotl(d ^ a, 12); c += d; b = std::rotl(b ^ c, 16); a += b; d = std::rotl(d ^ a, 8); c += d; b = std::rotl(b ^ c, 7); } void chacha20_block(std::uint32_t block[16]) const noexcept { std::array<std::uint32_t, 12> state_words{}; std::memcpy(state_words.data(), _state.data(), state_words.size() * sizeof(std::uint32_t)); block[0] = CONST_0; block[1] = CONST_1; block[2] = CONST_2; block[3] = CONST_3; std::memcpy(block + 4, state_words.data(), state_words.size() * sizeof(std::uint32_t)); auto& x0 = block[0]; auto& x1 = block[1]; auto& x2 = block[2]; auto& x3 = block[3]; auto& x4 = block[4]; auto& x5 = block[5]; auto& x6 = block[6]; auto& x7 = block[7]; auto& x8 = block[8]; auto& x9 = block[9]; auto& x10 = block[10]; auto& x11 = block[11]; auto& x12 = block[12]; auto& x13 = block[13]; auto& x14 = block[14]; auto& x15 = block[15]; for (int round = 0; round < 12; ++round) { quarter_round(x0, x4, x8, x12); quarter_round(x1, x5, x9, x13); quarter_round(x2, x6, x10, x14); quarter_round(x3, x7, x11, x15); quarter_round(x0, x5, x10, x15); quarter_round(x1, x6, x11, x12); quarter_round(x2, x7, x8, x13); quarter_round(x3, x4, x9, x14); } x0 += CONST_0; x1 += CONST_1; x2 += CONST_2; x3 += CONST_3; x4 += state_words[0]; x5 += state_words[1]; x6 += state_words[2]; x7 += state_words[3]; x8 += state_words[4]; x9 += state_words[5]; x10 += state_words[6]; x11 += state_words[7]; x12 += state_words[8]; x13 += state_words[9]; x14 += state_words[10]; x15 += state_words[11]; } public: static constexpr std::size_t KEY_SIZE = 32; static constexpr std::size_t NONCE_SIZE = 12; static constexpr std::size_t BLOCK_SIZE = 64; ChaCha20() = default; void set_key(const std::array<std::uint8_t, KEY_SIZE>& key) noexcept { std::memcpy(_state.data(), key.data(), KEY_SIZE); } void set_nonce(std::span<const std::uint8_t, NONCE_SIZE> nonce) noexcept { std::memcpy(_state.data() + 36, nonce.data(), NONCE_SIZE); } void set_counter(std::uint32_t counter) noexcept { std::memcpy(_state.data() + 32, &counter, 4); } void encrypt(std::span<const std::uint8_t> input, std::span<std::uint8_t> output) noexcept { if (input.size() != output.size()) return; auto* in = input.data(); auto* out = output.data(); auto remaining = input.size(); auto* state_words = reinterpret_cast<std::uint32_t*>(_state.data()); alignas(16) std::array<std::uint32_t, 16> block; alignas(16) std::array<std::uint8_t, BLOCK_SIZE> keystream; while (remaining > 0) { chacha20_block(block.data()); std::memcpy(keystream.data(), block.data(), BLOCK_SIZE); ++state_words[8]; const auto chunk = std::min<std::size_t>(remaining, BLOCK_SIZE); std::size_t i = 0; for (; i + sizeof(std::uint64_t) <= chunk; i += sizeof(std::uint64_t)) { std::uint64_t lhs, rhs; std::memcpy(&lhs, in + i, sizeof(lhs)); std::memcpy(&rhs, keystream.data() + i, sizeof(rhs)); lhs ^= rhs; std::memcpy(out + i, &lhs, sizeof(lhs)); } for (; i < chunk; ++i) { out[i] = in[i] ^ keystream[i]; } in += chunk; out += chunk; remaining -= chunk; } } void encrypt(std::uint8_t* data, std::uint64_t size) noexcept { encrypt(std::span<std::uint8_t>(data, size), std::span<std::uint8_t>(data, size)); } }; bool is_base64(unsigned char c) { return (isalnum(c) || (c == '+') || (c == '/')); } std::string base64_decode(std::string encoded_string) { int in_len = encoded_string.size(); int i = 0; int j = 0; int in_ = 0; unsigned char char_array_4[4], char_array_3[3]; std::string ret; while (in_len-- && (encoded_string[in_] != '=') && is_base64(encoded_string[in_])) { char_array_4[i++] = encoded_string[in_]; in_++; if (i == 4) { for (i = 0; i < 4; i++) char_array_4[i] = base64_chars.find(char_array_4[i]); char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; for (i = 0; (i < 3); i++) ret += char_array_3[i]; i = 0; } } if (i) { for (j = i; j < 4; j++) char_array_4[j] = 0; for (j = 0; j < 4; j++) char_array_4[j] = base64_chars.find(char_array_4[j]); char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; for (j = 0; (j < i - 1); j++) ret += char_array_3[j]; } return ret; } bool deserialize(std::string encoded_key, ReceivedData& received_data) { std::string decoded_key = base64_decode(encoded_key); if (decoded_key.size() != sizeof(ReceivedData)) { return false; } memcpy(&received_data, decoded_key.data(), sizeof(ReceivedData)); return true; } std::vector<float> lorenz(std::size_t size, LorenzParams lorenz_params) { if (size == 0) { return std::vector<float>(); } std::vector<float> values(size); float state[3]; state[0] = lorenz_params.x0; state[1] = lorenz_params.y0; state[2] = lorenz_params.z0; float dt = 0.01f; values[0] = state[0] + state[1] + state[2]; for (std::size_t i = 1; i < size; ++i) { float x = state[0]; float y = state[1]; float z = state[2]; float dx = lorenz_params.a * (y - x); float dy = (lorenz_params.r - z) * x - y; float dz = x * y - lorenz_params.b * z; state[0] += dt * dx; state[1] += dt * dy; state[2] += dt * dz; values[i] = state[0] + state[1] + state[2]; } return values; } std::vector<std::uintmax_t> chunk_file(fs::path file_path, LorenzParams lorenz_params) { std::uintmax_t min_chunk_size = 64; std::uintmax_t max_chunk_size = 65536; std::uintmax_t max_chunks_num = 102400; const std::uintmax_t file_length = fs::file_size(file_path); if (file_length == 0) { return std::vector<std::uintmax_t>(); } double ratio = static_cast<double>(file_length) / max_chunk_size; std::uintmax_t requested_chunks = static_cast<std::uintmax_t>(std::ceil(ratio)); if (requested_chunks < 1) { requested_chunks = 1; } if (requested_chunks > max_chunks_num) { requested_chunks = max_chunks_num; } std::vector<float> chaotic_values = lorenz(requested_chunks, lorenz_params); for (std::size_t i = 0; i < chaotic_values.size(); ++i) { chaotic_values[i] = static_cast<float>(std::fabs(chaotic_values[i])); } std::vector<float> samples; samples.reserve(requested_chunks); for (std::size_t i = 0; i < requested_chunks && i < chaotic_values.size(); ++i) { samples.push_back(chaotic_values[i]); } const std::size_t actual_chunks = samples.size(); if (actual_chunks == 0) { return std::vector<std::uintmax_t>(); } float sum_values = 0.0f; for (std::size_t i = 0; i < actual_chunks; ++i) { sum_values += samples[i]; } std::vector<float> proportions; if (sum_values == 0.0f) { proportions.assign(actual_chunks, 1.0f / static_cast<float>(actual_chunks)); } else { proportions.reserve(actual_chunks); for (std::size_t i = 0; i < actual_chunks; ++i) { proportions.push_back(samples[i] / sum_values); } } std::vector<std::uintmax_t> chunk_sizes; chunk_sizes.reserve(actual_chunks); for (std::size_t i = 0; i < actual_chunks; ++i) { float fraction = proportions[i]; std::uintmax_t size_candidate = static_cast<std::uintmax_t>( std::floor(fraction * static_cast<float>(file_length))); if (size_candidate < min_chunk_size) { size_candidate = min_chunk_size; } chunk_sizes.push_back(size_candidate); } std::uintmax_t combined = 0; for (std::size_t i = 0; i < chunk_sizes.size(); ++i) { combined += chunk_sizes[i]; } if (combined != file_length && !chunk_sizes.empty()) { chunk_sizes[chunk_sizes.size() - 1] += file_length - combined; } return chunk_sizes; } void decrypt(fs::path local_file_path, fs::path full_file_path_on_server, std::string chacha_key, LorenzParams lorenz_params) { auto chunks = chunk_file(local_file_path, lorenz_params); if (chunks.empty()) { std::cerr << "No chunks to process." << std::endl; return; } ChaCha20 chacha20; std::hash<std::string> hasher; auto path_hash = hasher(full_file_path_on_server.string()); std::array<uint8_t, 12> nonce{}; std::memcpy(nonce.data(), &path_hash, std::min(sizeof(path_hash), nonce.size())); std::array<uint8_t, 32> key{}; std::memcpy(key.data(), chacha_key.data(), std::min<std::size_t>(key.size(), chacha_key.size())); chacha20.set_key(key); chacha20.set_nonce(nonce); std::fstream file(local_file_path, std::ios::in | std::ios::out | std::ios::binary); if (!file) { return; } constexpr size_t STREAM_BUFFER_SIZE = 64 * 1024; std::vector<uint8_t> stream_buffer(STREAM_BUFFER_SIZE); std::uintmax_t offset = 0; uint32_t counter = 0; for (size_t chunk_index = 0; chunk_index < chunks.size(); ++chunk_index) { const auto chunk_size = chunks[chunk_index]; if ((chunk_index & 1u) == 0u) { file.clear(); file.seekg(static_cast<std::streamoff>(offset), std::ios::beg); file.seekp(static_cast<std::streamoff>(offset), std::ios::beg); if (!file) { break; } chacha20.set_counter(counter++); std::uintmax_t remaining_chunk_size = chunk_size; while (remaining_chunk_size > 0) { const auto buffer_size = static_cast<std::size_t>( std::min<std::uintmax_t>(remaining_chunk_size, STREAM_BUFFER_SIZE)); file.read(reinterpret_cast<char*>(stream_buffer.data()), buffer_size); const auto bytes_read = file.gcount(); if (bytes_read <= 0) { break; } chacha20.encrypt(stream_buffer.data(), static_cast<std::size_t>(bytes_read)); const auto next_read_pos = file.tellg(); if (next_read_pos == std::streampos(-1)) { break; } file.seekp(next_read_pos - bytes_read); if (!file) { break; } file.write(reinterpret_cast<const char*>(stream_buffer.data()), bytes_read); if (!file) { break; } file.seekg(next_read_pos); if (!file) { break; } remaining_chunk_size -= static_cast<std::uintmax_t>(bytes_read); } } offset += chunk_size; } file.close(); std::cout << "Decryption completed." << std::endl; } ``` </details> Build: `g++ -std=c++26 decryptor.cpp -o decryptor` Test decrypt: ![image](https://hackmd.io/_uploads/B1EE1X53xe.png) Recovered! **In Ping**