Избавляемся от EXCEPTION_ACCESS_VIOLATION при отладке suspended-процессов на Windows

Дата публикации: 2019-10-06

Расскажу о решении одной загадочной проблемы, с которой я столкнулся при написании собственного отладчика для Windows.

Задача состояла в том, чтобы присоединить отладчик (разрабатываемый с помощью Windows Debugger API) к процессу, который был только что запущен с флагом CREATE_SUSPENDED.

Стандартным (и в общем-то единственным) подходом тут является вызов функции DebugActiveProcess. В соответствии с документацией, эта функция должна остановить все потоки в процессе, сгенерировать некоторое количество отладочных сообщений (которые затем будут нами получены при помощи WaitForDebugEvent), и продолжит выполнение процесса. Отдельно упоминается, что эта функция также сгенерирует событие EXCEPTION_DEBUG_EVENT.

Применительно к процессу в состоянии suspended это, увы, неправда.

  1. Потоки, находящиеся в состоянии suspended, не будут автоматически продолжены после завершения вызова DebugActiveProcess. Они останутся suspended. Да, это логично и я бы этого и ожидал, но документация этого не упоминает — а не помешало бы!
  2. Для процесса, запущенного с флагом CREATE_SUSPENDED, не будет сгенерировано событие EXCEPTION_DEBUG_EVENT. Это удивляет многих, кто занимался до этого написанием отладчика, но это правда — не будет и всё тут.

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

Но ведь это не должно быть проблемой, правда?

Неправда.

С некоторой вероятностью, зависящей от архитектуры процесса и версии ОС, это может привести к незамедлительному падению процесса. Я экспериментировал на 64-битной Windows 10 (1903) и выполнял тесты на 32-битных и 64-битных процессах. Для 64-битных процессов проблема не воспроизводилась, но 32-битные процессы падают примерно в 1.5% случаев. Похоже, что это относится к любым процессам: к нативным и к .NET, к графическим и консольным. В ответе на мой вопрос на Stack Overflow (к которому мы ещё вернёмся) пользователь @RbMm также утверждает, что на Windows XP это приводило к падению процесса всегда.

Вот какова последовательность событий при нормальной работе процесса:

Если же вам не повезло, то последовательность получаемых отладчиком событий будет выглядеть так:

После этого выполнение отлаживаемого процесса будет прекращено, он обречён.

Какое-то время провозившись с этой проблемой (мне-то нужен надёжно работающий отладчик!), я задал вопрос на Stack Overflow, на который удивительно быстро были оставлены комментарии, а затем и дан ответ пользователем @RbMm (который, похоже, один отвечает на все странные вопросы про Windows Debugger API — большое спасибо ему за это).

Итак, почему же это происходит? Из пояснений @RbMm и того, что я прочитал про отладчик, складывается примерно такая картина. Системный рантайм (возможно, CRT?) требуют от процесса, чтобы он всегда выполнял некоторую инициализацию. Инициализация выполняется первым потоком, который начинает вызывать функции системного рантайма, и в обычной ситуации всё в порядке, если подключать отладчик к уже запущенному процессу. Но в случае с процессом в состоянии suspended эти функции часто начинает первым вызывать именно поток отладчика, который был создан в процессе благодаря вызову функции DebugActiveProcess. А проводить эту инициализацию в любом потоке приложения, кроме главного, нежелательно — это и приводит к означенным проблемам.

Замечательно, а что делать? Единственная документированная для нас функция для отладки приложений имеет нежелательное поведение. Ну что ж, давайте тогда используем недокументированную функцию! Серьёзно, это на данный момент единственное надёжное решение проблемы.

В системной библиотеке ntdll есть недокументированные функции NtDebugActiveProcess и NtWaitForDebugEvent, которые отличаются от документированных DebugActiveProcess и WaitForDebugEvent довольно незначительно: во-первых, они получают контекст отладки не неявным образом, а требуют явного управления объектом DebugObjectHandle (тогда как официальный подход состоит в неявном поддержании контекста в thread-local storage), а во-вторых, NtDebugActiveProcess не создаёт лишних потоков в отлаживаемом приложении. То, что нужно! Не забудьте прилинковать к своему приложению библиотеку ntdll.lib: она нужна для использования этих функций.

Собрать работающий код из недокументированных функций было непросто: пришлось буквально по кусочкам сшивать сигнатуры функций, описания структур и enum'ов, но в итоге всё получилось, и проблему на итоговом коде я больше воспроизвести не могу.

Вот проект на GitHub, иллюстрирующий проблему и решение: в ветке simplified приведён проблемный код, а в ветке ntdebugactiveprocess — исправленная версия, использующая функцию NtDebugActiveProcess (код довольно грязноват, был быстро написан в качестве proof-of-concept).

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

$ Debug\NetRuntimeWaiter.exe > log.txt

Перенаправление в файл обязательно, т.к. иначе ведение лога работы программы (которых там немало!) начнёт занимать слишком большое время, и проблема будет воспроизводиться значительно реже.