Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Broadly speaking, Ruptura's packages can be split into three different categories of functionality:
Vezel.Ruptura.Injection and Vezel.Ruptura.Hosting provide the ability to inject the CoreCLR runtime and managed assemblies into a target process.
Vezel.Ruptura.Memory provides function hooking, memory manipulation, and call tracing capabilities.
Vezel.Ruptura.System provides managed wrappers around operating system APIs and kernel objects.
You can pick and choose which of these packages you would like to use. For example, if you already have a working solution for injection, you may want to just use Vezel.Ruptura.Memory for its function hooking APIs, or Vezel.Ruptura.System for convenient Win32 API access. You could also use Vezel.Ruptura.Injection and Vezel.Ruptura.Hosting to inject your managed assembly, but choose to implement your own function hooking method.
Either way, simply use dotnet add package <name>
to add the relevant package(s) to your project and start writing code. No further configuration is required.
Ruptura provides a set of libraries that make it easy to inject a managed .NET assembly into arbitrary Windows (and Wine) processes for the purposes of function hooking and memory manipulation.
Ruptura injects a bundled native module into the target process, which then locates the .NET runtime in either framework-dependent or self-contained mode and initializes it, after which a user-specified managed assembly is executed - all without the user writing a single line of native code. Additionally, a library facilitating common function hooking and memory manipulation scenarios is available for use by the injected assembly.
Here are some of the Ruptura highlights:
Easy injection and hosting: Just point Ruptura to a target process and give it the name of a managed assembly to inject. All the complexity of injecting the CoreCLR runtime libraries and loading your assembly is handled for you.
Create or attach to processes: You can inject into existing processes or create them yourself. When creating a target process, it can be started in a suspended state, giving your injected assembly full control over when and how the target process starts running.
Native function hooks: Hooking APIs allow you to intercept calls to arbitrary native functions in a process. You can inspect or modify arguments and return values as needed, and it is up to you whether the original function is called at all.
Hook state access: A state object can be associated with a hook, giving you easy access to your managed application state from within a hook function, where it is ordinarily difficult to access non-global state.
Hook error prevention: A 'hook gate' prevents common programming errors (deadlocks, stack overflows, etc) by avoiding execution of hook functions when the Windows loader lock is held, or when the hook is already executing earlier in the call stack.
Call stack tracing: Call stack traces can be collected at any point, giving you insight into how the target process behaves. These traces contain highly detailed information both for managed and unmanaged stack frames.
Operating system interop: A set of convenient classes and functions for accessing Win32 APIs and kernel objects are provided, which Ruptura makes heavy use of for its own functionality.
Publishing modes: You can publish your Ruptura-based application in either framework-dependent or self-contained mode. There is also partial support for trimming.
All of these features make Ruptura a great choice for a wide variety of tasks such as closed-source modding, interoperability research, advanced diagnostics, security research, anti-tampering, etc.
There are some notable limitations and caveats to be aware of when using Ruptura:
Only modern versions of Windows 10 and 11 are supported due to certain native APIs used for function hooking and call tracing.
There is currently only support for injecting x64 processes. x86 support is in the works.
The CoreCLR runtime is never unloaded from the target process due to limitations in the hosting APIs for .NET 7+. This means that you cannot inject into the same process more than once for the duration of its lifetime.
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:
(Replace x.y.z
with the actual NuGet package version.)
Your entry point can then be implemented using the IInjectedProgram
interface:
(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:
The injector entry point can be implemented normally:
The project file for the injected program should look like this:
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:
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:
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.
The Vezel.Ruptura.Memory package provides an easy-to-use FunctionHook
class that takes care of some common requirements such as placing the trampoline near the target function, atomically toggling an installed hook, associating state with the hook function, and avoiding certain cases of deadlock or stack overflow.
Usage looks like this:
This example will print foobar
to the console. There is a lot going on here, so let us go over each part:
A PageCodeManager
instance is created to manage code allocations. This is used by FunctionHook
to allocate its internal trampoline near the target function.
FunctionHook.Create()
is called to hook SetThreadDescription
with the SetThreadDescriptionHook
method. The string object "bar"
is passed as the hook's associated state so that it can be accessed in the hook method.
A created hook is fully functional with all code emission and patching done, but calls will not actually be intercepted until the IsActive
property is set to true
, so that is then done.
The function pointer for the target function is invoked with some dummy arguments. Since the hook is active, the SetThreadDescriptionHook
method will be called.
SetThreadDescriptionHook
accesses the FunctionHook.Current
property and retrieves its State
property (i.e. the "bar"
string passed earlier). It concatenates it with the string passed in as lpThreadDescription
and prints the result ("foobar"
) to the console.
This example is fairly contrived, but you can hook almost any function, whether it comes from the operating system, a normal library, or an executable. Of course, you can also associate more useful state objects with the hook so that you can access your application's state.
It is important that the hook method matches the target function exactly in calling convention (cdecl
, stdcall
, etc), return type, and parameter types. Mismatches can result in unpredictable bugs and crashes. Also, managed exceptions thrown from within a hook method must be handled; native call frames cannot be correctly unwound by CoreCLR.
Internally, FunctionHook
uses a so-called hook gate as part of its trampoline to guard calls to the hook method. The most user-visible effects of this are:
The FunctionHook
instance for the most recent hook method in the call stack can be accessed through the Current
property.
Hook recursion does not invoke the hook method a second time, preventing common issues like deadlocks and stack overflows when calling arbitrary code in a hook.
Hooks are not invoked while the Windows loader lock is held since managed code is virtually impossible to run reliably when that is the case.
The API is used to retrieve a function pointer to the function.
The Vezel.Ruptura.System library provides convenient managed wrappers for native Win32 APIs and kernel objects. The focus is primarily on functionality that is not readily available in .NET, but there is still some overlap.
For example, you could enumerate all threads in a process like so:
Instances of classes derived from KernelObject
(such as the SnapshotObject
and ThreadObject
instances in the above code) are thin wrappers around SafeHandle
objects, with a few niceties on top, such as a settable IsInheritable
property and equality operators based on CompareObjectHandles
. KernelObject
inherits from CriticalFinalizerObject
, so like SafeHandle
, you can rely on KernelObject
instances being usable in finalizers. On top of that, they expose a bunch of relevant Win32 APIs for the particular object type.
Some kernel objects are waitable. This is the case for ProcessObject
and ThreadObject
, for example. They derive from SynchronizationObject
(which derives from KernelObject
). You could wait for a thread to exit like this:
(WaitResult.Alerted
can only occur if alertable
is set to true
. Also, WaitResult.Abandoned
is only relevant when waiting on a mutex.)
When trying to understand closed-source binaries or collecting diagnostic data, it can be useful to capture a detailed call stack trace at any given point - especially from a function hook. The Vezel.Ruptura.Memory package provides the CallTrace
API to do exactly that. For example:
This will print something like:
Of course, the captured CallTrace
object has plenty of details that you can inspect programmatically. For example, you could print a bit more information about the RIP
, RSP
, and RBP
registers in each CallFrame
:
You will now get:
CallTrace
tries very hard internally to fill in as much information as it can. For managed frames, information is pulled from CoreCLR internals, while for unmanaged frames, the DbgHelp library will consult export names, symbol files, etc. These are implemented in the ManagedCallFrameSymbolicator
and NativeCallFrameSymbolicator
singleton classes, respectively, and both derive from the CallFrameSymbolicator
class.
There is a CallTrace.Capture()
overload that allows you to specify the CallFrameSymbolicator
instances you would like to use in a call trace instead of or in addition to the aforementioned two. This allows you to implement your own symbolicators that can pull on whatever data you would like. For instance, you could symbolicate based on signature matching, or based on a symbol table manually constructed from reverse engineering.
For NativeCallFrameSymbolicator
, it is worth noting that the DbgHelp library shipped with Windows does not have symbol server support (symsrv.dll
). That is why the call traces above had rather poor, export-based symbolication for coreclr.dll
and related libraries.
If you obtain a standalone version of the DbgHelp library consisting of dbghelp.dll
and symsrv.dll
, you can simply drop them into your application directory and Ruptura will pick them up. Setting the _NT_SYMBOL_PATH
environment variable to https://msdl.microsoft.com/download/symbols
or similar will then enable symsrv.dll
to actually download Microsoft symbol files. Doing that, you will get a much better call trace: