Comment on page
Assembly Injection
Before getting started, you must decide whether you wish to implement your injection code and injected code in the same project or in two separate projects. Using a single project is easiest, but separate projects may be desirable if having the injection code in the injected assembly would lead to too many assemblies being pointlessly loaded into the target process.
For a single-project setup, your project file should look something like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Vezel.Ruptura.Hosting"
Version="x.y.z" />
<PackageReference Include="Vezel.Ruptura.Injection"
Version="x.y.z" />
</ItemGroup>
</Project>
(Replace
x.y.z
with the actual NuGet package version.)Your entry point can then be implemented using the
IInjectedProgram
interface:sealed class InjectedProgram : IInjectedProgram
{
public static async Task<int> RunAsync(InjectedProgramContext context, ReadOnlyMemory<string> args)
{
if (context.InjectorProcessId != null)
return 42;
using var target = TargetProcess.Open(int.Parse(args.Span[0]));
using var injector = new AssemblyInjector(
target, new AssemblyInjectorOptions(typeof(InjectedProgram).Assembly.Location));
await injector.InjectAssemblyAsync();
return await injector.WaitForCompletionAsync() == 42 ? 0 : 1;
}
}
(Note that, when using Vezel.Ruptura.Hosting, you should not implement a regular
Main
entry point; the IInjectedProgram
implementation will be used instead.)The above example injects itself into a given target process's ID. The injected code just returns the exit code
42
immediately, which the injection code verifies. In general, checking the InjectedProgramContext.InjectorProcessId
property for a non-null
value is the way to know if you are running in the target process.For a multi-project setup, the project file for the injector program should look like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Vezel.Ruptura.Injection"
Version="x.y.z" />
</ItemGroup>
</Project>
The injector entry point can be implemented normally:
static class Program
{
static async Task<int> Main(string[] args)
{
using var target = TargetProcess.Open(int.Parse(args[0]));
using var injector = new AssemblyInjector(target, new AssemblyInjectorOptions(args[1]));
await injector.InjectAssemblyAsync();
return await injector.WaitForCompletionAsync() == 42 ? 0 : 1;
}
}
The project file for the injected program should look like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFramework>net8.0</TargetFramework>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Vezel.Ruptura.Hosting"
Version="x.y.z" />
</ItemGroup>
</Project>
Note that the injected assembly must have
OutputType
set to Exe
too, since the .NET hosting APIs will look for an executable program entry point. Despite this, a native executable is not needed for the injected assembly, so UseAppHost
can safely be set to false
to cut down on build time and publish size.The entry point for the injected program is implemented with the
IInjectedProgram
interface:sealed class InjectedProgram : IInjectedProgram
{
public static Task<int> RunAsync(InjectedProgramContext context, ReadOnlyMemory<string> args)
{
return Task.FromResult(42);
}
}
This example does the same thing as the single-project example, but here, you pass the file name of the injected program to the injection program in addition to the target process's ID. There are ways that you can avoid that requirement, e.g. by referencing the injected program project from the injection program project and using
typeof(InjectedProgram).Assembly.Location
.In addition to injecting existing processes, you can also create new ones. This is mainly useful because it lets you start a process in a suspended state:
sealed class InjectedProgram : IInjectedProgram
{
public static async Task<int> RunAsync(InjectedProgramContext context, ReadOnlyMemory<string> args)
{
if (context.InjectorProcessId != null)
{
context.WakeUp();
return 42;
}
using var target = TargetProcess.Create(args.Span[0], string.Empty, null, suspended: true);
using var injector = new AssemblyInjector(
target, new AssemblyInjectorOptions(typeof(InjectedProgram).Assembly.Location));
await injector.InjectAssemblyAsync();
return await injector.WaitForCompletionAsync() == 42 ? 0 : 1;
}
}
The
InjectedProgramContext.WakeUp()
call resumes the process's main thread. Having control over when the main thread starts executing gives you a window of opportunity to perform setup work such as installing function hooks.It is worth noting that the target process's lifetime is completely unaffected by the lifetime of your injected program. Even if you return from your
IInjectedProgram.RunAsync()
implementation and you have no background work running, the target process will still continue running. If you need the target process to exit at the same time as your injected program, it is your responsibility to make that happen.Regardless of the above, the CoreCLR runtime will stay loaded in the process until it exits, due to limitations in the .NET hosting APIs. This means that you cannot inject into the same process more than once for the duration of its lifetime. If you need to reload managed code in the target process, you can use
AssemblyLoadContext
to do so. The McMaster.NETCore.Plugins library makes this particularly easy.Ruptura does not provide a mechanism for communication between the injector process and the injected process. A rudimentary way of passing arguments to the injected program exists in the form of the
AssemblyInjectorOptions.WithArguments()
method, however.One simple and type-safe way to achieve IPC between the two processes would be to combine either anonymous pipes (one-way) or named pipes (one-way or two-way) with the StreamJsonRpc library.
Last modified 23d ago