Резюме

Из-за природы среды выполнения компилируемого языка .NET, пользовательские вызовы асинхронных процедур (APC) обрабатываются при выходе из любой сборки .NET без ручного запуска состояния предупреждения из управляемого кода.

Кроме того, если пользователь порождает новый процесс и ставит APC в очередь в основной поток процесса, между очередью APC и основной подпрограммой процесса возникает состояние гонки, так что очередь APC обрабатывается первой.

Что такое пользовательские APC?

Асинхронные вызовы процедур (APC) - это способы, с помощью которых выполняющийся поток в приложении может выполнять задачу асинхронно из своих текущих операций. Каждый поток в приложении имеет очередь этих APC, которые выполняются в порядке очереди (FIFO), как только этот поток входит в состояние предупреждения. Поток переходит в состояние предупреждения, когда он вызывает SleepEx, SignalObjectAndWait, WaitForSingleObjectEx, WaitForMultipleObjectsEx, MsgWaitForMultipleObjectsEx или NtTestAlert. Ниже приведено изображение, демонстрирующее поток управления обработкой APC.

Проблема

Пока Мэтт Нельсон экспериментировал с этой техникой в ​​.NET, он понял, что очередь APC обрабатывается , хотя он не заставлял поток переходить в состояние предупреждения . Его код был примерно таким:

Читая суть, неясно, что заставляет поток перейти в состояние предупреждения. Чтобы еще больше усугубить путаницу, он также заметил, что при создании нового процесса и добавлении новой функции в очередь APC основного потока этого процесса очередь обрабатывается и запускает APC.

Его последнее наблюдение заключалось в том, что при постановке APC в очередь в основной поток уже запущенных процессов APC не запускался, как в примере выше. Это оставляет нам три вопроса:

  1. Что заставляет основной поток двоичных файлов .NET перейти в состояние предупреждения?
  2. Почему постановка APC в очередь только что созданного процесса немедленно обрабатывается?
  3. Почему постановка APC в очередь уже запущенного процесса не запускается, как описано выше?

Получение ответов с помощью логики и динамического тестирования

Чтобы начать сужать область, где могут происходить эти действия, предположим, что верно следующее утверждение: «Очереди APC не обрабатываются, пока они не перейдут в состояние предупреждения». Это не безосновательное предположение, поскольку оно взято непосредственно из документации Microsoft.

Если это предположение верно, то мы можем ответить на третью постановку задачи, сказав, что основной поток просто еще не вошел в состояние предупреждения.

Для второй постановки задачи, читаемой в документации QueueUserAPC, говорится, что Если приложение ставит APC в очередь до того, как поток начнет выполняться, поток начинает с вызова функции APC. В этом случае поток, который мы вытесняем, является основным потоком приложения. Мы можем проверить это динамически, введя режим ожидания перед постановкой APC в очередь в основной поток удаленного процесса. При искусственном сне, вызванном после запуска процесса, постановка APC в очередь не вызывает срабатывания функции. Это соответствует документации.

Учитывая все, что мы знаем о пользовательских APC до сих пор, остается вопрос: почему любая сборка .NET становится оповещаемой, если ни один из необходимых вызовов не выполняется из управляемого кода?

Сделанные выводы с помощью RTFM и динамического тестирования

Чтобы ответить на вопрос, «почему эта цепочка становится тревожной», мы должны сначала решить, когда она становится тревожной. Для этого я вставил первую суть сверху в Visual Studio и использовал отладчик, чтобы попытаться увидеть, когда срабатывает APC. Построчно просматривая программу и делая паузу после каждого выполнения, стало ясно, что программа становилась тревожной во время эпилога функции где-то в неуправляемом коде. Это было ясно, так как только после того, как моя программа попыталась выйти, она зависла на неопределенное время, выполняя шелл-код.

Чтобы получить более глубокое понимание, мне нужно выйти за рамки инструментов, которые предоставляет Visual Studio, и использовать WinDbg, чтобы выявить основную причину. Используя SOS Debugging, мы можем аккуратно отлаживать управляемые программы; в противном случае, когда приложение загружает среду CLR, отладчик будет запускать программу без пошагового выполнения каждой инструкции, что делает ее бесполезным занятием.

После установки Windows Driver Kit и включения отладки SOS загрузите сборку .NET, которая имеет вызов QueueUserAPC, нацеленный на основной поток двоичного файла в WinDbg, выбрав «Файл»> «Запустить исполняемый файл», как показано ниже.

После того как сборка .NET была приостановлена ​​отладчиком для первоначального выполнения, вы захотите прервать ее, когда .NET CLR загружается в процесс. После загрузки среды CLR вы можете включить отладку SOS для обхода сборки с помощью следующего набора команд:

// Break on CLR load
sxe ld:clr
// Continue until we hit the CLR load
g
// Enable SOS debugging and load symbols associated with .NET framework
.cordll -ve -u -l

Если символы загружены успешно, вы должны увидеть следующий результат:

CLRDLL: Loaded DLL C:\Windows\Microsoft.NET\Framework64\v4.0.30319\mscordacwks.dll
Automatically loaded SOS Extension
CLR DLL status: Loaded DLL C:\Windows\Microsoft.NET\Framework64\v4.0.30319\mscordacwks.dll

Затем мы устанавливаем прерывания для любой функции, которая может привести к переходу нашего потока в состояние предупреждения. Вы можете установить эти точки останова:

bp kernel32!SleepEx
bp kernel32!SignalObjectAndWait
bp kernel32!WaitForSingleObject
bp kernel32!WaitForSingleObjectEx
bp kernel32!WaitForMultipleObjects
bp kernel32!WaitForMultipleObjectsEx
bp user32!MsgWaitForMultipleObjectsEx
bp ntdll!NtTestAlert

После установки точек останова продолжайте выполнение сборки. Есть определенно лучшие методы сужения того, какие функции заставляют поток становиться тревожным, но в целом моя методология была такой:

  1. Запустите сборку и подсчитайте количество заданных вами точек останова, прежде чем вы попадете в свой шелл-код. Назовите этот номер N.
  2. В N-й точке останова «Выйдите» из вызова функции и сориентируйтесь. Обратите внимание на функцию, которая вызвала функцию предупреждения. Если вам не удалось получить больше контекста, попробуйте еще раз, остановившись в точке останова N-1 и выйдите.
  3. Продолжайте выходить и переходить по функциям, пока не дойдете до незакрепленного дерева вызовов.

После того, как вы составили нечеткое дерево вызовов, вы можете разбить каждую функцию в дереве вызовов и пошагово выполнять их пошагово. В зависимости от того, где вы решите прерваться и выйти, у вас может быть множество различных деревьев вызовов с разными уровнями детализации. Поскольку я знал, что этот код должен быть вызван из CLR, я сузил круг поиска, включив только функции CLR и исключив любые вызовы KERNEL32 или NTDLL. Используя свой собственный предписанный фильтр, я придумал следующее неаккуратное дерево вызовов:

clr!EEShutDown
clr!EEShutDownHelper
clr!FinalizerThread::FinalizerThreadWatchDog
clr!FinalizerThread::FinalizerThreadWatchDogHelper
clr!CLREventBase::Wait
clr!Thread::DoAppropriateWait
clr!Thread::DoAppropriateWaitWorker
clr!WaitForMultipleObjectsEx_SO_TOLERANT

Основываясь исключительно на приведенном выше дереве вызовов, вы можете видеть, что функция с именем EEShutDown отвечает за инициирование нескольких событий, связанных с разрывом .NET CLR. Он должен выполнять эту работу каким-то параллельным образом и ждать, пока все потоки завершатся корректно, с помощью WaitForMultipleObjectsEx. Кроме того, используя ту же методологию, описанную выше, я смог создать грубый поток управления тем, как исполняемые файлы .NET загружаются и запускаются через CLR.

Источник поиска: погружение в Core CLR

Хотя в проекте, который я создал специально, использовалась .NET Framework, а не .NET Core, я решил, что Core будет достаточно близок к .NET Framework для наших целей. Этот шаг веры не является строго необходимым для понимания, но действительно значительно облегчает работу, если мы принимаем вольный перевод между ними.

Погружаясь в проект coreclr, мы начинаем с функции EEShutDown. Просматривая, мы находим интересный комментарий, в котором говорится, что EEStartup отвечает за создание доменов приложений по умолчанию и общих доменов, а также за загрузку основных типов, таких как System.Object. EEShutDown просто выполняет обратную операцию.

Следуя потоку кода, мы видим, что EEShutdown вызывает EEShutDownHelper, который отвечает за большую часть процесса завершения работы. Следуя за потоком приложения, мы видим, что во время FinalizerThread :: RaiseForShutdownEvents, полезный комментарий гласит, что «это ожидание должно быть предупреждено для обработки случаев, когда требуется контекст текущего потока», прежде чем приступить к вызову серии функций ожидания, которые все в конечном итоге вызывают WaitForSingleObjectEx.

Читая исходники и комментарии Core, становится ясно, как наконец называется наш APC. Через EEStartup создается экземпляр AppDomain по умолчанию. В этом домене приложений живет наш управляемый код. Этот управляемый код получает текущий поток и помещает в него APC. После завершения программы вызывается EEShutDown, который инициирует процесс разрыва для доменов приложений и других выполняющихся потоков. Чтобы синхронизировать этот процесс, эти потоки получают возможность обмениваться информацией друг с другом. В процессе эти вызовы ожидания запускают наш APC, и мы успешно выполнили наш APC, не вызывая ни один из вызовов предупреждающих функций.

Заключение

То, что начиналось как вопиющее противоречие с документацией Microsoft, привело к нескольким личным открытиям в отношении тонкостей QueueUserAPC и .NET. В качестве метода внедрения процесса, если вы участвуете в гонке основного потока удаленного процесса, вы можете перейти к APC без необходимости переводить поток в состояние предупреждения. Кроме того, если вы используете управляемые исполняемые файлы .NET, скомпилированные с помощью .NET Framework или .NET Core, исполняемый поток по умолчанию всегда будет предупреждать из-за способа обработки выгрузки домена приложения при выходе из процесса.

Ценность этого метода как оскорбительного оператора заключается в том, что вы узурпируете выполнение обычного кода QueueUserAPC, заставляя компилятор добавлять за вас вызовы с предупреждениями. Например, можно представить себе использование запланированной задачи, которая запускает двоичный файл .NET, подписанный компанией. Используя триггеры событий WMI, вы можете создать постоянство для создания этих задач и внедрить APC в среду выполнения .NET, и при выходе из этой запланированной задачи будет запущено сохранение. Как защитник, следует знать, как вредоносные программы могут влиять на выполнение кода сборок .NET, не придерживаясь обычных путей кода.

Ссылки