Одна особенность .net core 2.2, которая не сразу бросилась мне в глаза, - это хуки запуска. Проще говоря, это способ глобальной регистрации метода в сборке, который будет выполняться всякий раз, когда запускается основное приложение .net. Это открывает целый ряд сценариев, от внедрения профилировщика до настройки статического контекста в данной среде.

Как это работает? Во-первых, вам нужно создать новую сборку ядра .net и добавить класс StartupHook. Убедитесь, что он не входит в какое-либо пространство имен. Этот класс должен определять статический метод инициализации. Это метод, который будет вызываться всякий раз, когда запускается основное приложение .net.

Например, следующий хук отобразит «Hello world!» при запуске приложения:

Чтобы зарегистрировать перехватчик, вам нужно объявить переменную среды DOTNET_STARTUP_HOOKS, указывающую на сборку. Как только вы это сделаете, вы увидите следующее сообщение:

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

Перевернутая консоль

Что за шалость? Давайте начнем с простого: как насчет переопределения консольного потока, чтобы перевернуть отображаемый текст? Сначала мы пишем кастом TextWriter:

Затем мы назначаем его консоли в хуке запуска:

После регистрации обработчика запуска все приложения .net core 2.2 будут выводить в консоль перевернутый текст:

И что самое приятное, он работает даже с самим исполняемым файлом «dotnet.exe»!

Вы легко можете представить себе путаницу, которая могла бы возникнуть из-за этого.

Переопределение Array.Empty

Что еще мы можем сделать? Мне не удалось заменить значение string.Empty (вероятно, потому, что оно объявлено как внутреннее), но вместо этого мы можем заменить значение Array.Empty<T>. Например, для Array.Empty<string>:

Это звучит довольно безобидно, пока вы не рассмотрите случай методов с аргументом params. Например, этот метод:

С точки зрения C # вы можете вызывать его без каких-либо параметров (PrintArgs()). Но с точки зрения IL параметр args - это просто обычный массив. Магия творится компилятором, который автоматически вставляет пустой массив, фактически переписывая вызов на PrintArgs(Array.Empty<string>()). Следовательно, с зарегистрированной обработкой запуска метод, вызываемый без каких-либо параметров, фактически отобразит «Hello world!».

Конечный автомат async

Это уже хороший способ запутать коллег, но я хотел пойти еще дальше. Именно тогда я подумал о замене используемого по умолчанию TaskScheduler. Что мы могли с этим сделать? А как насчет ... случайной перезаписи значений в асинхронном конечном автомате? Когда метод использует async / await, он преобразуется в конечный автомат, в котором, помимо прочего, хранятся локальные переменные, используемые методом (для восстановления контекста, когда начинается выполнение продолжения await). Если нам удастся получить этот конечный автомат, мы сможем изменить значение локальных переменных между каждым ожиданием!

Мы начинаем с объявления нашего настраиваемого планировщика задач (и называем его ThreadPoolTaskScheduler на тот случай, если кто-то подумает о проверке стека вызовов), и мы используем его для перезаписи TaskScheduler.Default.

Обратите внимание, что мы также всегда устанавливаем для s_asyncDebuggingEnabled значение true, чтобы избежать другого поведения при подключении отладчика, что могло бы усложнить наш код. Планировщик задач вызывает пустой метод MutateState, а затем использует пул потоков для планирования выполнения задачи. Теперь нам нужно реализовать этот метод.

Как получить конечный автомат? Первый шаг - получить ContinuationWrapper. Это структура, которая обертывает действие задачи, когда для s_asyncDebuggingEnabled установлено значение true. В зависимости от типа задачи мы можем найти ее либо по действию задачи, либо по состоянию:

Оттуда мы получаем значение поля _continuation и проверяем, является ли оно экземпляром AsyncStateMachineBox. Если это так, то мы можем найти конечный автомат в поле StateMachine:

Как выглядит асинхронный конечный автомат?

Всегда есть два общедоступных поля: <>1__state и <>t__builder. <>1__state используется для хранения текущего шага выполнения в асинхронном методе. Мы могли бы использовать его, например, для перемотки выполнения метода. <>t__builder содержит средства, используемые для ожидания других методов (вложенные вызовы). С этим можно было бы много чего сделать, но мы сосредоточимся на местных.

Местные жители хранятся в личных полях. В данном случае <>u__1 и <j>5__1. Это те, с которыми мы хотим поиграть:

Здесь мы создаем новый конечный автомат, а затем копируем значения старых полей в новые. Если поле является частным и имеет тип int, мы заменяем его случайным значением.

Теперь давайте напишем простую программу для проверки ловушки:

И вы увидите, что ... это не работает. Почему? Потому что TPL был довольно хорошо оптимизирован. Во многих местах код проверяет текущий планировщик и полностью обходит его, если он установлен по умолчанию для прямого планирования продолжения в пуле потоков. Например, в YieldAwaiter (используется Task.Yield).

Как мы можем это обойти? Нам абсолютно необходимо, чтобы наш настраиваемый планировщик задач использовался по умолчанию, иначе он не будет использоваться при вызове Task.Run. Но если для задачи назначен планировщик задач по умолчанию, то нас не перезвонят, и мы не сможем изменить состояние. Если мы проверим код YieldAwaiter выше, мы увидим, что он выполняет простое эталонное сравнение. Таким образом, мы можем перезаписать планировщик задачи новым экземпляром нашего настраиваемого планировщика, чтобы обмануть эти проверки:

Мы все? Если мы вернемся к нашему примеру, мы можем приступить к отладке шаг за шагом:

мне 42 года, все хорошо. Еще один шаг и…

А теперь иди и наслаждайся ошеломленным видом своих коллег!

Обратите внимание, что это не сработает при использовании ConfigureAwait(false), потому что оно напрямую ставит продолжение в пул потоков и даже не проверяет текущий планировщик задач (зачем это нужно?). Одним из способов решения этой проблемы может быть переопределение конструктора задач с помощью настраиваемого, но шутка уже зашла достаточно далеко

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