
Основанная на памяти техника обхода, которая делает шеллкод невидимым от начала до конца процесса.
Техника уклонения на основе памяти, которая делает шелл-код невидимым от начала до завершения процесса.
Я хотел поделиться этой POC самовнедрения шелл-кода, чтобы продемонстрировать некоторые концепции обхода AV/EDR, которые могут быть полезны для Red Teaming. Всего несколько недель назад я придумал пользовательскую технику уклонения в памяти, которую назвал ShellGhost. Эта техника возникла из необходимости иметь код, который выполняет «невидимый» шелл-код от начала до конца процесса.
ShellGhost полагается на Vectored Exception Handling в сочетании с программными точками останова, чтобы циклически останавливать выполнение потока, заменять выполненную точку останова на RC4-зашифрованную инструкцию шелл-кода, расшифровывать инструкцию и возобновлять выполнение после восстановления защиты памяти на RX. Когда возникает последующее EXCEPTION_BREAKPOINT, обработчик исключений заменяет предыдущую инструкцию шелл-кода новой точкой останова, так что выделение никогда не раскроет полный шелл-код в незашифрованном состоянии. Это происходит внутри закрытой страницы памяти, которая изначально помечена как READ/WRITE. Наличие выделения RW PRV не будет считаться «индикатором компрометации» сканерами памяти, такими как PE-Sieve и Moneta. Когда выделение становится RX и страница сканируется, будут найдены только точки останова. Это происходит в то время, когда шелл-код фактически выполняется. Следующее изображение показывает, что обратная оболочка работает, но Moneta не находит IOC (кроме того, что двоичный файл не подписан).

Попытка сканировать процесс с помощью Pe-Sieve дает еще лучший результат:

Отображение шелл-кода — это основная функциональность ShellGhost. Эта тактика позволяет потоку периодически выполнять инструкции, никогда не раскрывая весь шелл-код в памяти. Это возможно, потому что позиция каждой отдельной инструкции шелл-кода, которую выполняет поток, соответствует позиции определенной точки останова внутри выделенной страницы памяти. ShellGhost определяет эту позицию, вычисляя относительный виртуальный адрес (RVA) от RIP потока до базового адреса выделенной страницы памяти и добавляет его к базовому адресу зашифрованного шелл-кода / зашифрованных инструкций. Количество заменяемых точек останова не всегда одинаковое, а варьируется в зависимости от количества опкодов, необходимых для правильной генерации и интерпретации каждой инструкции (QUOTA). Например, инструкция 'POP RBP' равна '5D', что означает, что будет заменена только одна точка останова. Напротив, инструкция 'JMP RAX' требует опкодов 'FF E0', поэтому будут заменены две точки останова. По этой причине я создал следующую структуру данных C.
typedef struct CRYPT_BYTES_QUOTA {
DWORD RVA; // смещение к зашифрованной инструкции
DWORD quota; // количество опкодов, образующих инструкцию
} CRYPT_BYTES_QUOTA, * PCRYPT_BYTES_QUOTA;
Точки останова не заменяются сразу на соответствующие инструкции. Это связано с тем, что инструкции должны пройти процедуру расшифровки перед выполнением. Здесь в дело вступает DWORD quota. ShellGhost полагается на теперь уже популярную 'SystemFunction032' для выполнения расшифровки RC4. В отличие от XOR, RC4 не является одно-байтовой схемой шифрования. Это означает, что шелл-код не может быть зашифрован и расшифрован весь сразу. Это также еще одна причина, по которой каждая инструкция обрабатывается отдельно. После замены точек останова длина буфера, необходимая для SystemFunction032, будет равна 'квоте инструкции', которая опять же представляет собой количество опкодов, из которых состоит конкретная инструкция. Так, например, рассмотрим следующий фрагмент.
CRYPT_BYTES_QUOTA instruction[200];
instruction[5].quota = 2
USTRING buf = { 0 }; // будет содержать буфер для расшифровки и его длину
USTRING key = { 0 }; // будет содержать ключ RC4 и длину
buf.Length = 2 // длина буфера или длина инструкции для расшифровки
Мы знаем, что инструкция шелл-кода номер 5 состоит из 2 опкодов, поэтому длина буфера 2 будет передана в SystemFunction032. Это важно, потому что попытка расшифровать весь шелл-код одним вызовом SystemFunction032 полностью его испортит.
Шелл-код необходимо отобразить с помощью ShellGhost_mapping.py перед компиляцией. Скрипт извлекает каждую отдельную инструкцию и обрабатывает ее как маленький независимый шелл-код. Инструкции шифруются одна за другой и выводятся в формате C все вместе как unsigned char. Результат может быть жестко закодирован внутри кода C. Ниже приведен пример того, как выглядят зашифрованные инструкции шелл-кода MSF для calc.exe.

Этот шелл-код содержит 98 инструкций, поэтому объявляется 98 структур CRYPT_BYTES_QUOTA. При выполнении кода эти структуры должны быть заполнены соответствующими RVA и QUOTA инструкций. Параметр '-1' указывает скрипту отображения вывести фрагмент кода, который это делает.

Шелл-коды Metasploit x64 обычно содержат строковые параметры winapi, хранящиеся между инструкциями. Иными словами, шелл-код MSF x64, вызывающий Winexec, не помещает в стек последовательность байтов с нулевым байтом в конце, чтобы получить первый строковый параметр. Вместо этого регистр RCX (первый параметр) является указателем внутри самого шелл-кода, как показано на следующем изображении.

Это означает, что точки останова, позиция которых относится к строке, никогда не будут разрешены, потому что RIP никогда не коснется этой позиции. По сути, этот код разрешает фактические инструкции шелл-кода, через которые проходит RIP, а не параметры, которые никогда не будут выполняться как инструкции. Чтобы это исправить, я заметил, что шелл-коды MSF всегда сохраняют указатель на вызываемый winapi в регистре RAX, а затем выполняют переход на сам регистр. Поэтому, когда ShellGhost VEH обнаруживает, что разрешенная точка останова — это 'JMP RAX', а регистр RCX содержит указатель на позицию внутри шелл-кода, он пытается также разрешить то, на что указывает RCX. Впоследствии выполнение не возвращается в выделенную память. Скорее, RAX (адрес winapi) копируется в RIP, и выполнение потока возобновляется с winapi, таким образом переопределяя 'JMP RAX' и сохраняя выделенную память RW. Это необходимо для обратных оболочек, вызывающих WaitForSingleObject, что приведет к засыпанию потока после 'JMP RAX', оставляя память RX на все время жизни оболочки. Следующий фрагмент кода содержит два условия, которые должны быть выполнены, чтобы ShellGhost скорректировал регистр RCX, когда он содержит строку параметра winapi, и позволил шелл-коду MSF правильно выполнить вызов функции (в примере — WinExec).
<snip>
if (*(PWORD)exceptionData->ContextRecord->Rip == 0xe0ff) // если RIP равен 'JMP RAX'
<snip>
if ((contextRecord->Rcx >= (DWORD_PTR)allocation_base) && (contextRecord->Rcx <= ((DWORD_PTR)allocation_base + sizeof(sh)))) // если RCX находится внутри выделения
<snip>
RDX, R8 и R9 (второй, третий и четвертый параметры) пока не охвачены.
ShellcodeFluctuation — это очень похожая концепция уклонения в памяти. Как и в ней, выделенная память здесь «флуктуирует» от RW к RX. В отличие от нее, ShellGhost вводит следующие улучшения:
Однако ShellGhost далек от совершенной техники. Он все еще страдает от самого большого недостатка всех этих техник, а именно необходимости иметь частную исполняемую память в какой-то момент выполнения. Более продвинутые техники, такие как Foliage, уже нашли способ обойти это. Кроме того, выделение памяти, полностью заполненное программными точками останова, может быть обнаружено правилом YARA. Следующее изображение показывает, что Moneta правильно обнаруживает IOC для выделения RX PRV.

Когда дело доходит до обхода решения EDR, сканирование памяти — лишь часть общей картины. Полное отсутствие IOC не обязательно означает, что двоичный файл, использующий эту технику, окажется эффективным против конкретного EDR. Насколько я могу судить, я сталкивался с ситуациями, когда решение даже не позволяет запустить двоичный файл так, как вы это делаете. Другая сторона медали заключается в том, что IOC не всегда являются точными индикаторами, и некоторые из них могут оказаться ложными срабатываниями. С учетом вышесказанного, это всего лишь сырая техника и вдохновение, которое, надеюсь, читатель оценит. Red Teamer знает, что так же, как и компоненты EDR, уклонение от памяти — лишь один из компонентов движка.
Компиляция требует отключения инкрементальной компоновки. В этом проекте VS все параметры компилятора/компоновщика уже установлены.