[C#] Have some fun with .net core startup hooks

One feature of .net core 2.2 that didn’t catch my mind immediately is the startup hooks. Put simply, this is a way to register globally a method in an assembly that will be executed whenever a .net core application is started. This unlocks a whole range of scenarios, from injecting a profiler to tweaking a static context in a given environment.

How does it work? First, you need to create a new .net core assembly, and add a StartupHook class. Make sure that it’s outside of any namespace. That class must define a static Initialize method. That’s the method that will be called whenever a .net core application is started.

The following hook, for instance, will display “Hello world!” when an application is launched:

To register the hook, you need to declare a DOTNET_STARTUP_HOOKS environment variable, pointing to the assembly. Once you’ve done that, you’ll see the message displayed:

It’s really important to understand that the hook is executed inside of the application process. And a globally registered hook that runs inside of all .net core processes sounds like a good prank setup for unsuspecting coworkers.

The inverted console

What kind of prank? Let’s start with something simple: what about overriding the console stream to reverse the displayed text? First we write a custom TextWriter:

Then we assign it to the console in the startup hook:

Once the startup hook is registered, all .net core 2.2 applications will output inverted text in the console:

And the best part is that it even works for the “dotnet.exe” executable itself!

You can easily imagine the confusion that would come from it.

Overriding Array.Empty

Is there anything else we can do? I’ve been unsuccessful at replacing the value of string.Empty (probably because it’s declared as an intrinsic), but we can instead replace the value of Array.Empty<T>. For instance for Array.Empty<string>:

This sounds rather inoffensive, until you consider the case of methods with params argument. For instance, this method:

From a C# point of view, you can call it without any parameter (PrintArgs()). But from an IL perspective, the args parameter is just an ordinary array. The magic is done by the compiler, which automatically inserts an empty array, effectively rewriting the call to PrintArgs(Array.Empty<string>()). Therefore, with the startup hook registered, the method called without any parameter will actually display “Hello world!”.

The async state machine

Those are already nice ways to confuse coworkers, but I wanted to go even farther. That’s when I thought of replacing the default TaskScheduler. What could we do with it? What about… rewriting values at random in the async state machine? When a method uses async/await, it is converted to a state machine that stores among other things the local variables used by the method (to restore the context when the await continuation starts executing). If we manage to retrieve that state machine, we can therefore change the value of the locals between each await!

We start by declaring our custom task scheduler (and name it ThreadPoolTaskScheduler in case somebody would think of inspecting the callstack), and we use it to overwrite TaskScheduler.Default.

Note that we also always set s_asyncDebuggingEnabled to true to avoid having a different behavior when the debugger is attached, which would complicate our code. The task scheduler calls an empty MutateState method, then uses the threadpool to schedule the task execution. Now we need to implement that method.

How to retrieve the state machine? The first step is to retrieve the ContinuationWrapper. This is a structure that wraps the task action when s_asyncDebuggingEnabled is set to true. Depending on the type of task, we can find it either on task action or on the state:

From there, we retrieve the value of the _continuation field and check if it is an instance of AsyncStateMachineBox. If it is, then we can find the state machine in the StateMachine field:

What does an async state machine look like?

Two public fields are always there: <>1__state and <>t__builder. <>1__state is used to store the current execution step in the async method. We could use it for instance to rewind the execution of the method. <>t__builder contains the facilities used to await other methods (nested calls). There’s plenty of stuff we could do with it, but we’ll focus on the locals.

The locals are stored in the private fields. In this case, <>u__1 and <j>5__1. Those are the ones we want to play with:

What we do here is creating a new state machine, then copy the value of the old fields to the new ones. If the field is private and is an int, we replace it by a random value.

Now let’s make a simple program to test the hook:

And you’ll see that… it doesn’t work. Why? Because the TPL has been pretty well optimized. In a lot of places, the code checks the current scheduler, and completely bypasses it if it’s the default one to directly schedule the continuation on the threadpool. For instance, in the YieldAwaiter (used by Task.Yield).

How can we work around that? We absolutely need our custom task scheduler to be the default one, otherwise it won’t be used when calling Task.Run. But if the default task scheduler is assigned to a task, then we won’t be called back and we won’t be able to mutate the state. If we check the code of the YieldAwaiter above, we can see that it’s doing a simple reference comparison. So we can overwrite the scheduler of the task with a new instance of our custom scheduler to fool those checks:

Are we done? If we go back to our example, we can start debugging step by step:

i is 42, all good. One more step and…

Now go and enjoy the dumbfounded looks of your coworkers!

Note that this won’t work when using ConfigureAwait(false), because it directly enqueues the continuation to the threadpool and won’t even check the current task scheduler (why would it?). One way around that could be to override the task builder with a custom one, but the joke already went far enough as is 🙂

Of course, all those tricks could have unpredictable effects on the target applications, so make sure to closely supervise the prank and stop as soon as it could become dangerous.