Antimalware Scan Interface

Chapter 9

  • Sometime ago, attackers relied heavily on in-built windows tools to execute malicious code. To help protect users against these novel "fileless malware" threats, Microsoft introduced the Antimalware Scan Interface (AMSI) with the release of Windows 10.

  • AMSI provides an interface that allows application developers to leverage antimalware providers registered on the system when determining if the data with which they are working is malicious.

  • Scripting languages offer a large number of advantages over compiled languages. They require less development time and overhead, bypass application allow-listing, can execute in memory, and are portable. They also provide the ability to use the features of frameworks such as .NET and, oftentimes, direct access to the Win32 API, which greatly extends the functionality of the scripting language.

Working Of AMSI

  • AMSI scans a target, then uses antimalware providers registered on the system to determine whether it is malicious. By default, it uses the antimalware provider Microsoft Defender IOfficeAntivirus (MpOav.dll), but third-party EDR vendors may also register their own providers.

  • AMSI is integrated into many Windows components, including modern versions of PowerShell, .NET, JavaScript, VBScript, Windows Script Host, Office VBA macros, and User Account Control. It is also integrated into Microsoft Exchange.

Implementation Of Powershell AMSI

  • Inside System.Management.Automation.dll, the DLL that provides the runtime for hosting PowerShell code, there exists a non-exported function called PerformSecurityChecks() that is responsible for scanning the supplied script block and determining whether it is malicious. This function is called by the command processor created by PowerShell as part of the execution pipeline just before compilation.

  • This function calls an internal utility, AmsiUtils.ScanContent(), passing in the script block or file to be scanned. This utility is a simple wrapper for another internal function, AmsiUtils.WinScanContent(), where all the real work takes place.

  • After checking the script block for the European Institute for Computer Antivirus Research (EICAR) test string, which all antiviruses must detect, WinScanContent’s first action is to create a new AMSI session via a call to amsi!AmsiOpenSession(). AMSI sessions correlate multiple scan requests.

  • Next, WinScanContent() calls amsi!AmsiScanBuffer(), the Win32 API function that will invoke the AMSI providers registered on the system and return the final determination regarding the script.

lock (s_amsiLockObject)
{
     --snip--
     if (s_amsiSession == IntPtr.Zero)
     {
          hr = AmsiNativeMethods.AmsiOpenSession(
                s_amsiContext,
                ref s_amsiSession);
 
           AmsiInitialized = true;
           if (!Utils.Succeeded(hr))
           {
                s_amsiInitFailed = true;
                return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
           }
     }

     --snip--

     AmsiNativeMethods.AMSI_RESULT result =
     AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_CLEAN;
     unsafe
     {
           fixed (char* buffer = content)
           {
                 var buffPtr = new IntPtr(buffer);
                 hr = AmsiNativeMethods.AmsiScanBuffer(
                        s_amsiContext,
                        buffPtr,
                        (uint)(content.Length * sizeof(char)),
                        sourceMetadata,
                        s_amsiSession,
                        ref result);
             }
      }

      if (!Utils.Succeeded(hr))
      {
            return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
      }

      return result;
}
  • The result of this call is returned to WinScanContent(). The WinScanContent() function can return:

    • AMSI_RESULT_NOT_DETECTED - A neutral result

    • AMSI_RESULT_CLEAN - A result indicating that the script block did not contain malware

    • AMSI_RESULT_DETECTED - A result indicating that the script block contained malware

  • If the AMSI_RESULT_DETECTED result is returned, a ParseException will be thrown, and execution of the script block will be halted.

if (amsiResult == AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED)
{
        var parseError = new ParseError(
                scriptExtent,
                "ScriptContainedMaliciousContent",
                ParserStrings.ScriptContainedMaliciousContent);
        throw new ParseException(new[] { parseError });
}

Understanding AMSI

  • When PowerShell starts, it first calls amsi!AmsiInitialize(). As its name suggests, this function is responsible for initializing the AMSI API. This initialization primarily centers on the creation of a COM class factory via a call to DllGetClassObject().

  • As an argument, it receives the class identifier correlating to amsi.dll, along with the interface identified for the IClassFactory, which enables a class of objects to be created. The interface pointer is then used to create an instance of the IAntimalware interface ({82d29c2e-f062-44e6-b5c9-3d9a2f24a2df}).

Breakpoint 4 hit
amsi!AmsiInitialize+0x1a9:
00007ff9`5ea733e9 ff15899d0000 call qword ptr [amsi!_guard_dispatch_icall_fptr ] --snip--

0:011> dt OLE32!IID @r8
    {82d29c2e-f062-44e6-b5c9-3d9a2f24a2df}
        +0x000 Data1 : 0x82d29c2e
        +0x004 Data2 : 0xf062
        +0x006 Data3 : 0x44e6
        +0x008 Data4 : [8] "???"
        
0:011> dt @rax
ATL::CComClassFactory::CreateInstance
  • Rather than an explicit call to some functions, you’ll occasionally find references to _guard_dispatch_icall_fptr(). This is a component of Control Flow Guard (CFG), an anti-exploit technology that attempts to prevent indirect calls, such as in the event of return-oriented programming.

  • This call then leads into amsi!AmsiComCreateProviders <IAntiMalwareProvider>.

0:011> kc
 # Call Site
00 amsi!AmsiComCreateProviders<IAntimalwareProvider>
01 amsi!CamsiAntimalware::FinalConstruct
02 amsi!ATL::CcomCreator<ATL::CcomObject<CamsiAntimalware> >::CreateInstance
03 amsi!ATL::CcomClassFactory::CreateInstance
04 amsi!AmsiInitialize
--snip--
  • The first major action is a call to amsi!CGuidEnum::StartEnum(). This function receives the string "Software\Microsoft\AMSI\Providers", which it passes into a call to RegOpenKey() and then RegQueryInfoKeyW() in order to get the number of subkeys.

  • Then, amsi!CGuidEnum::NextGuid() iterates through the subkeys and converts the class identifiers of registered AMSI providers from strings to UUIDs. After enumerating all the required class identifiers, it passes execution to amsi!AmsiComSecureLoadInProcServer(), where the InProcServer32 value corresponding to the AMSI provider is queried via RegGetValueW().

0:011> u @rip L1
amsi!AmsiComSecureLoadInProcServer+0x18c:
00007ff9`5ea75590 48ff1589790000 call qword ptr [amsi!_imp_RegGetValueW]

0:011> du @rdx
00000057`2067eaa0 "Software\Classes\CLSID\{2781761E"
00000057`2067eae0 "-28E0-4109-99FE-B9D127C57AFE}\In"
00000057`2067eb20 "procServer32"
  • Next, amsi!CheckTrustLevel() is called to check the value of the registry key SOFTWARE\Microsoft\ AMSI FeatureBits. This key contains a DWORD, which can be either 1 (the default) or 2 to disable or enable Authenticode signing checks for providers. If Authenticode signing checks are enabled, the path listed in the InProcServer32 registry key is verified. Following a successful check, the path is passed into LoadLibraryW() to load the AMSI provider DLL.

0:011> u @rip L1
amsi!AmsiComSecureLoadInProcServer+0x297:
00007ff9`5ea7569b 48ff15fe770000 call qword ptr [amsi!_imp_LoadLibraryExW]

0:011> du @rcx
00000057`2067e892 "C:\ProgramData\Microsoft\Windows"
00000057`2067e8d2 " Defender\Platform\4.18.2111.5-0"
00000057`2067e912 "\MpOav.dll"
  • If the provider DLL loads successfully, its DllRegisterServer() function is called to tell it to create registry entries for all COM classes supported by the provider. This cycle repeats calls to amsi!CGuidEnum::NextGuid() until all providers are loaded.

0:011> dt OLE32!IID @rdx
 {82d29c2e-f062-44e6-b5c9-3d9a2f24a2df}
 +0x000 Data1 : 0x82d29c2e
 +0x004 Data2 : 0xf062
 +0x006 Data3 : 0x44e6
 +0x008 Data4 : [8] "???"
 
0:011> u @rip L1
amsi!ATL::CComCreator<ATL::CComObject<CAmsiAntimalware> >::CreateInstance+0x10d:
00007ff8`0b7475bd ff15b55b0000 call qword ptr [amsi!_guard_dispatch_icall_fptr]

0:011> t
amsi!ATL::CComObject<CAmsiAntimalware>::QueryInterface:
00007ff8`0b747a20 4d8bc8 mov r9,r8
  • After AmsiInitialize() returns, AMSI is ready to go. Before PowerShell begins evaluating a script block, it calls AmsiOpenSession(). As mentioned previously, this function allows AMSI to correlate multiple scans. When this function completes, it returns a HAMSISESSION to the caller, and the caller can choose to pass this value to all subsequent calls to AMSI within the current session.

  • When PowerShell’s AMSI instrumentation receives a script block and an AMSI session has been opened, it calls AmsiScanBuffer() with the script block passed as input. The function’s primary responsibility is to check the validity of the parameters passed to it. This includes checks for content in the input buffer and the presence of a valid HAMSICONTEXT handle with a tag of AMSI.

HRESULT AmsiScanBuffer(
         [in] HAMSICONTEXT amsiContext,
         [in] PVOID buffer,
         [in] ULONG length,
         [in] LPCWSTR contentName,
         [in, optional] HAMSISESSION amsiSession,
         [out] AMSI_RESULT *result
);
  • If these checks pass, AMSI invokes amsi!CAmsiAntimalware::Scan().

0:023> kc
 # Call Site
00 amsi!CAmsiAntimalware::Scan
01 amsi!AmsiScanBuffer
02 System_Management_Automation_ni
--snip--
  • This method contains a while loop that iterates over every registered AMSI provider (the count of which is stored at R14 + 0x1c0). In this loop, it calls the IAntimalwareProvider::Scan() function, which the EDR vendor can implement however they wish.

HRESULT Scan(
    [in] IAmsiStream *stream,
    [out] AMSI_RESULT *result
);
  • If the script block was found to contain malicious content, PowerShell throws a ScriptContainedMaliciousContent exception and prevents its execution.

Implementing A Custom AMSI Provider

  • At their core, AMSI providers are nothing more than COM servers, or DLLs loaded into a host process that expose a function required by the caller: in this case, IAntimalwareProvider. This function extends the IUnknown interface by adding three additional methods: CloseSession closes the AMSI session via its HAMSISESSION handle, DisplayName displays the name of the AMSI provider, and Scan scans an IAmsiStream of content and returns an AMSI_RESULT.

  • In C++, a basic class declaration that overrides IAntimalwareProvider’s methods may look like:

class AmsiProvider :
    public RuntimeClass<RuntimeClassFlags<ClassicCom>,
    IAntimalwareProvider,
    FtmBase>
{
public:
    IFACEMETHOD(Scan)(
        IAmsiStream *stream,
        AMSI_RESULT *result
    ) override;

    IFACEMETHOD_(void, CloseSession)(
        ULONGLONG session
        ) override;

    IFACEMETHOD(DisplayName)(
         LPWSTR *displayName
        ) override;
};
  • Above code makes use of the Windows Runtime C++ Template Library, which reduces the amount of code used to create COM components. The CloseSession() and DisplayName() methods are simply overridden with our own functions to close the AMSI session and return the AMSI provider, respectively. The Scan() function receives the buffer to be scanned as part of an IAmsiStream, which exposes two methods, GetAttribute() and Read().

MIDL_INTERFACE("3e47f2e5-81d4-4d3b-897f-545096770373")
IAmsiStream : public IUnknown
{
public:
     virtual HRESULT STDMETHODCALLTYPE GetAttribute(
          /* [in] */ AMSI_ATTRIBUTE attribute,
          /* [range][in] */ ULONG dataSize,
          /* [length_is][size_is][out] */ unsigned char *data,
          /* [out] */ ULONG *retData) = 0;
     virtual HRESULT STDMETHODCALLTYPE Read(
           /* [in] */ ULONGLONG position,
           /* [range][in] */ ULONG size,
           /* [length_is][size_is][out] */ unsigned char *buffer,
           /* [out] */ ULONG *readSize) = 0;
};
  • The GetAttribute() retrieves metadata about the contents to be scanned. Developers request these attributes by passing an AMSI_ATTRIBUTE value that indicates what information they would like to retrieve, along with an appropriately sized buffer.

typedef enum AMSI_ATTRIBUTE {
  AMSI_ATTRIBUTE_APP_NAME,
  AMSI_ATTRIBUTE_CONTENT_NAME,
  AMSI_ATTRIBUTE_CONTENT_SIZE,
  AMSI_ATTRIBUTE_CONTENT_ADDRESS,
  AMSI_ATTRIBUTE_SESSION,
  AMSI_ATTRIBUTE_REDIRECT_CHAIN_SIZE,
  AMSI_ATTRIBUTE_REDIRECT_CHAIN_ADDRESS,
  AMSI_ATTRIBUTE_ALL_SIZE,
  AMSI_ATTRIBUTE_ALL_ADDRESS,
  AMSI_ATTRIBUTE_QUIET
} ;
  • Microsoft documents only the first five:

    • AMSI_ATTRIBUTE_APP_NAME is a string containing the name, version, or GUID of the calling application

    • AMSI_ATTRIBUTE_CONTENT_NAME is a string containing the filename, URL, script ID, or equivalent identifier of the content to be scanned

    • AMSI_ATTRIBUTE_CONTENT_SIZE is a ULONGLONG containing the size of the data to be scanned

    • AMSI_ATTRIBUTE_CONTENT_ADDRESS is the memory address of the content, if it has been fully loaded into memory

    • AMSI_ATTRIBUTE_SESSION contains a pointer to the next portion of the content to be scanned or NULL if the content is self-contained.

HRESULT AmsiProvider::Scan(IAmsiStream* stream, AMSI_RESULT* result)
{
    HRESULT hr = E_FAIL;
    ULONG ulBufferSize = 0;
    ULONG ulAttributeSize = 0;
    PBYTE pszAppName = nullptr;

    hr = stream->GetAttribute(
        AMSI_ATTRIBUTE_APP_NAME,
        0,
        nullptr,
        &ulBufferSize);

    if (hr != E_NOT_SUFFICIENT_BUFFER)
    {
        return hr;
    }

    pszAppName = (PBYTE)HeapAlloc(
        GetProcessHeap(),
        0,
        ulBufferSize);

    if (!pszAppName)
    {
        return E_OUTOFMEMORY;
    }

    hr = stream->GetAttribute(
        AMSI_ATTRIBUTE_APP_NAME,
        ulBufferSize,
        pszAppName,
        &ulAttributeSize);

    if (hr != ERROR_SUCCESS || ulAttributeSize > ulBufferSize)
    {
        HeapFree(
            GetProcessHeap(),
            0,
            pszAppName);

        return hr;
    }

    --snip--
}
  • If AMSI_ATTRIBUTE_CONTENT_ADDRESS returns a memory address, we know that the content to be scanned has been fully loaded into memory, so we can interact with it directly. Most often, the data is provided as a stream, in which case we use the Read() method to retrieve the contents of the buffer one chunk at a time.

HRESULT Read(
     [in] ULONGLONG position,
     [in] ULONG size,
     [out] unsigned char *buffer,
     [out] ULONG *readSize
);
  • What the provider does with these chunks of data is completely up to the developer. They could scan each chunk, read the full stream, and hash its contents, or simply log details about it. The only rule is that, when the Scan() method returns, it must pass an HRESULT and an AMSI_RESULT to the caller.

Evading AMSI

String Obfuscation

  • One of the earliest evasions for AMSI involved simple string obfuscation. If an attacker could determine which part of a script block was being flagged as malicious, they could often get around the detection by splitting, encoding, or otherwise obscuring the string.

AMSI Patching

  • By patching critical values or functions inside amsi.dll, attackers can prevent AMSI from functioning inside their process. This evasion technique is extremely potent and has been the go-to choice for many red teams since around 2016.

  • When it comes to patching, attackers commonly target AmsiScanBuffer(), the function responsible for passing buffer contents to the providers. The steps are:

    1. Retrieve the address of AmsiScanBuffer() within the amsi.dll currently loaded into the process.

    2. Use kernel32!VirtualProtect() to change the memory protections to read-write, which allows the attacker to place the patch.

    3. Copy the patch into the entry point of the AmsiScanBuffer() function.

    4. Use kernel32!VirtualProtect() once again to revert the memory protection back to read-execute.

  • The patch itself takes advantage of the fact that, internally, AmsiScan Buffer() returns E_INVALIDARG if its initial checks fail. These checks include attempts to validate the address of the buffer to be scanned. After this patch, when AmsiScanBuffer() is executed, it will immediately return this error code because the actual instruction that made up the original function has been overwritten.

Using Hardware Breakpoints

  • The technique is quite clever. First, the attacker obtains a function pointer to amsi!AmsiScanBuffer() either from the loaded amsi.dll or by forcing it to load into the process through a call to LoadLibrary(). Next, they register a vectored exception handler via kernel32!AddVectoredExceptionHandler(). This handler allows developers to register a function that monitors and manages all exceptions in the application. Finally, they set a hardware breakpoint on the address of AmsiScanBuffer() by modifying the current thread’s debug registers (DR0, DR6, and DR7).

  • When the attacker executes their .NET code inline, the system will eventually call AmsiScanBuffer(), triggering the hardware breakpoint and invoking the vectored exception handler. This function takes the current thread context and updates the registers to match the values set when AMSI doesn’t detect malicious content, namely a return value of 0 (S-OK) in RAX and a result of 0 (AMSI_RESULT_CLEAN) in RSP+48.

  • Additionally, it pulls the return address from the stack (RSP) and points the instruction pointer (RIP) back to the caller of the AmsiScanBuffer() function. Next, it walks the stack pointer back to its position from before the call to AmsiScanBuffer(), clears the hardware breakpoint, and returns the EXCEPTION_CONTINUE_EXECUTION code. Execution resumes at the point at which the breakpoint occurred.

Last updated