Implement core diagnostic memory layer, execution helpers, and high-level facade slices

Implemented:
- Core: UTF-16 ReadString boundary/alignment fix, target bitness and process id on MemoryBase
- function interception: PatchManager, DetourManager, InstructionAnalyzer, MainThreadDispatcher
- Execution: BackgroundTaskExecutor, InProcessInvoker
- High-level: Magic facade, RemotePointer, async wrappers
- Discovery/external code loading/Window groundwork (PEB/TEB, pattern scanning, raw allocations, DLL external code loading, window/input)

Tests: 180 passing, 4 integration/interactive tests skipped.
This commit is contained in:
kbe
2026-07-21 23:43:14 +02:00
parent a0ca7050a2
commit 3f0bea6bd4
44 changed files with 5595 additions and 84 deletions
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.Runtime.InteropServices;
namespace WhiteMagic.Execution;
/// <summary>
/// Direct native-to-managed delegate calls for the in-process scenario.
/// This is the third execution tier: no remote thread is created; the call runs
/// synchronously on the current thread.
/// </summary>
/// <remarks>
/// <para>
/// This class assumes the WhiteMagic consumer has already arranged to run inside the
/// target process. Bootstrapping the managed loader (e.g., via a CLR host or native
/// shim) that places WhiteMagic into a foreign process is a separate follow-up change
/// and is not implemented here.</para>
/// </remarks>
public sealed class InProcessInvoker
{
private readonly MemoryBase _memory;
/// <summary>Creates an invoker bound to the supplied memory reader.</summary>
public InProcessInvoker(MemoryBase memory)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
}
/// <summary>
/// Creates a managed delegate of type <typeparamref name="TDelegate"/> that calls
/// the native function at <paramref name="address"/>.
/// </summary>
/// <typeparam name="TDelegate">A delegate type whose signature matches the native function.</typeparam>
public TDelegate CreateFunction<TDelegate>(IntPtr address)
where TDelegate : Delegate
{
if (address == IntPtr.Zero)
{
throw new ArgumentException(
"Function address cannot be zero.", nameof(address));
}
return Marshal.GetDelegateForFunctionPointer<TDelegate>(address);
}
/// <summary>
/// Reads the vtable pointer stored at the start of an object in memory.
/// </summary>
/// <param name="objectAddress">The address of the object instance.</param>
/// <returns>The address of the vtable.</returns>
public IntPtr ReadVTable(IntPtr objectAddress)
{
return _memory.Read<IntPtr>(objectAddress);
}
/// <summary>
/// Reads a function pointer from a vtable by index.
/// </summary>
/// <param name="vTableAddress">The address of the vtable.</param>
/// <param name="methodIndex">The zero-based index of the method slot.</param>
/// <returns>The address in the specified vtable slot.</returns>
public IntPtr ReadVTableFunction(IntPtr vTableAddress, int methodIndex)
{
ArgumentOutOfRangeException.ThrowIfNegative(methodIndex);
int pointerSize = _memory.Is64Bit ? 8 : 4;
IntPtr slotAddress = vTableAddress + (methodIndex * pointerSize);
return _memory.Read<IntPtr>(slotAddress);
}
/// <summary>
/// Convenience helper that reads an object's vtable and returns the function
/// address at the requested method index.
/// </summary>
public IntPtr GetObjectVTableFunction(IntPtr objectAddress, int methodIndex)
{
IntPtr vTable = ReadVTable(objectAddress);
return ReadVTableFunction(vTable, methodIndex);
}
}
+148
View File
@@ -0,0 +1,148 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic.Hooking;
namespace WhiteMagic.Execution;
/// <summary>
/// A crash-safe work queue drained on the target's own thread via a detour on a
/// per-frame function. Callers queue work and receive the result (or exception)
/// on their own thread through a completion handle.
/// </summary>
/// <remarks>
/// The pump assumes the frame function is parameterless and returns an <see cref="int"/>.
/// This matches common per-frame functions such as D3D9 <c>EndScene</c>.
/// </remarks>
public sealed class MainThreadPump : IDisposable
{
private readonly DetourManager _detours;
private readonly IntPtr _frameAddress;
private readonly ConcurrentQueue<WorkItem> _queue = new();
private Detour? _detour;
private bool _installed;
/// <summary>
/// Creates a pump that will hook the frame function at <paramref name="frameAddress"/>.
/// </summary>
public MainThreadPump(DetourManager detours, IntPtr frameAddress)
{
_detours = detours;
_frameAddress = frameAddress;
}
/// <summary>Returns <see langword="true"/> after the frame hook has been applied.</summary>
public bool IsInstalled => _installed;
/// <summary>Installs the frame-function detour.</summary>
public void Install()
{
if (_installed)
return;
_detour = _detours.Create("MainThreadPump", _frameAddress, (FrameDelegate)PumpHook);
_detour.Apply();
_installed = true;
}
/// <summary>
/// Queues work to run on the hooked thread and blocks until it completes.
/// </summary>
public TResult Execute<TResult>(Func<TResult> work)
{
if (!_installed)
{
throw new InvalidOperationException(
"The main-thread pump is not installed. Call Install() first.");
}
var tcs = new TaskCompletionSource<object?>();
_queue.Enqueue(new WorkItem(() => work()!, tcs));
object? result = tcs.Task.GetAwaiter().GetResult();
return (TResult)result!;
}
/// <summary>
/// Queues work to run on the hooked thread and returns a <see cref="Task{TResult}"/>.
/// </summary>
public Task<TResult> ExecuteAsync<TResult>(Func<TResult> work)
{
if (!_installed)
{
throw new InvalidOperationException(
"The main-thread pump is not installed. Call Install() first.");
}
var tcs = new TaskCompletionSource<TResult>();
object? Box() => work()!;
_queue.Enqueue(new WorkItem(Box, r => tcs.SetResult((TResult)r!), ex => tcs.SetException(ex)));
return tcs.Task;
}
/// <summary>Removes the frame-function detour if it is installed.</summary>
public void Dispose()
{
if (_installed && _detour is not null)
{
_detour.Remove();
_installed = false;
}
}
private int PumpHook()
{
while (_queue.TryDequeue(out WorkItem? item))
{
try
{
object? result = item.Work();
item.SetResult(result);
}
catch (Exception ex)
{
item.SetException(ex);
}
}
// Call the original frame function so rendering/game logic continues.
return _detour is null ? 0 : (int?)_detour.CallOriginal() ?? 0;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int FrameDelegate();
private sealed class WorkItem
{
private readonly Action<object?>? _setResult;
private readonly Action<Exception>? _setException;
public WorkItem(Func<object?> work, Action<object?> setResult, Action<Exception> setException)
{
Work = work;
_setResult = setResult;
_setException = setException;
}
public WorkItem(Func<object?> work, TaskCompletionSource<object?> tcs)
{
Work = work;
_setResult = r => tcs.SetResult(r);
_setException = ex => tcs.SetException(ex);
}
public Func<object?> Work { get; }
public void SetResult(object? result)
{
_setResult?.Invoke(result);
}
public void SetException(Exception exception)
{
_setException?.Invoke(exception);
}
}
}
@@ -0,0 +1,492 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using WhiteMagic.Assembly;
using WhiteMagic.Native;
namespace WhiteMagic.Execution;
/// <summary>
/// Executes a function in the target process by creating a remote thread at a
/// calling-convention-aware stub. Waits for the thread to finish and returns the
/// typed exit value read from the thread's exit code.
/// </summary>
/// <remarks>
/// <para>This executor is safe only for thread-agnostic payloads. Calls that touch
/// single-threaded process state should use <see cref="MainThreadPump"/> instead.</para>
/// <para>String arguments are encoded as null-terminated UTF-8 and allocated in the
/// remote process; struct arguments are serialized with the default interop marshaler
/// (<see cref="Marshal.StructureToPtr"/>) and allocated with <see cref="Marshal.SizeOf(Type)"/>
/// bytes. All temporary remote allocations are released after the call, including on failure.</para>
/// </remarks>
public sealed class RemoteThreadExecutor
{
private const uint WaitObject0 = 0x00000000;
private const uint WaitTimeout = 0x00000102;
private const uint WaitFailed = 0xFFFFFFFF;
private const nuint AllocationGranularity = 0x10000; // 64 KB
private const int NearAllocationAttempts = 64;
private readonly MemoryBase _reader;
private readonly StubAssembler _assembler;
/// <summary>
/// Internal hook for tests that need to place the generated call stub inside an
/// already-allocated executable region (for example, immediately after the target
/// payload to keep the relative CALL within ±2 GiB).
/// </summary>
/// <remarks>
/// When this delegate returns a non-zero pointer, the executor does not take
/// ownership of that memory and will not free it.
/// </remarks>
internal Func<IntPtr, nint, IntPtr>? StubAllocator { get; set; }
/// <summary>
/// Initializes a new <see cref="RemoteThreadExecutor"/> for the process exposed by
/// <paramref name="reader"/>.
/// </summary>
/// <param name="reader">The memory reader that owns the target process handle.</param>
public RemoteThreadExecutor(MemoryBase reader)
{
_reader = reader ?? throw new ArgumentNullException(nameof(reader));
_assembler = new StubAssembler();
}
/// <summary>
/// Calls the function at <paramref name="address"/> in the target process using a
/// remote thread and returns its exit value cast to <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The expected return type.</typeparam>
/// <param name="address">The target function address.</param>
/// <param name="convention">The calling convention (ignored on x64 targets).</param>
/// <param name="args">Arguments to pass. Primitives, pointers and enums are packed
/// into pointer-sized slots. Strings and structs are allocated remotely and passed
/// by pointer.</param>
/// <returns>The function's exit value converted to <typeparamref name="T"/>.</returns>
/// <exception cref="InvalidOperationException">The process handle is not open or a
/// required native operation failed.</exception>
/// <exception cref="TimeoutException">The remote thread did not complete in time.</exception>
public Task<T> ExecuteAsync<T>(IntPtr address, CallConvention convention, params object?[] args)
{
return Task.Run(() => Execute<T>(address, convention, args));
}
/// <summary>
/// Synchronous variant of <see cref="ExecuteAsync{T}"/>.
/// </summary>
public T Execute<T>(IntPtr address, CallConvention convention, params object?[] args)
{
if (_reader.Handle.IsInvalid)
{
throw new InvalidOperationException(
"Cannot execute a remote function: the target process handle is not open.");
}
if (address == IntPtr.Zero)
{
throw new ArgumentException(
"Target function address cannot be zero.", nameof(address));
}
int pointerSize = _reader.Is64Bit ? 8 : 4;
var allocations = new List<IntPtr>(args.Length + 1);
IntPtr stubAddress = IntPtr.Zero;
SafeMemoryHandle? thread = null;
try
{
nuint[] nativeArgs = MarshalArguments(args, pointerSize, allocations);
// Compute the exact stub size with a dummy address close to the target;
// the emitted byte count does not depend on the stub's final address.
byte[] stubBytes = _assembler.BuildCallStub(
address, address, nativeArgs, pointerSize, convention);
bool stubOwnedByExecutor = true;
if (StubAllocator != null)
{
stubAddress = StubAllocator(address, stubBytes.Length);
stubOwnedByExecutor = stubAddress != IntPtr.Zero;
}
if (stubAddress == IntPtr.Zero)
{
stubAddress = AllocateExecutableMemory(_reader.Handle, address, stubBytes.Length);
stubOwnedByExecutor = true;
}
if (stubAddress == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"Failed to allocate remote stub memory: error {error}");
}
if (stubOwnedByExecutor)
{
allocations.Add(stubAddress);
}
// Re-emit with the real stub address so the relative call lands correctly.
stubBytes = _assembler.BuildCallStub(
stubAddress, address, nativeArgs, pointerSize, convention);
int written = _reader.WriteBytes(stubAddress, stubBytes);
if (written != stubBytes.Length)
{
throw new InvalidOperationException(
$"Failed to write the call stub to the remote process (wrote {written} of {stubBytes.Length} bytes).");
}
thread = NativeMethods.CreateRemoteThread(
_reader.Handle,
IntPtr.Zero,
0,
stubAddress,
IntPtr.Zero,
ThreadCreationFlags.RunImmediately,
out _);
if (thread.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"CreateRemoteThread failed: error {error}");
}
uint waitResult = NativeMethods.WaitForSingleObject(thread, uint.MaxValue);
if (waitResult == WaitFailed)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"WaitForSingleObject failed: error {error}");
}
if (waitResult == WaitTimeout)
{
throw new TimeoutException(
"The remote thread did not complete within the requested timeout.");
}
if (waitResult != WaitObject0)
{
throw new InvalidOperationException(
$"Unexpected wait status: 0x{waitResult:X}");
}
if (!NativeMethods.GetExitCodeThread(thread, out uint exitCode))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"GetExitCodeThread failed: error {error}");
}
return ConvertExitCode<T>(exitCode);
}
finally
{
// Dispose the thread handle explicitly so the safe handle releases it
// before any virtual memory is freed.
thread?.Dispose();
foreach (IntPtr alloc in allocations)
{
NativeMethods.VirtualFreeEx(
_reader.Handle, alloc, 0, MemoryFreeType.Release);
}
}
}
/// <summary>
/// Converts the raw DWORD exit code into the requested return type.
/// </summary>
private static T ConvertExitCode<T>(uint exitCode)
{
Type target = typeof(T);
if (target == typeof(IntPtr) || target == typeof(nint))
{
return (T)(object)(IntPtr)(nint)exitCode;
}
if (target == typeof(UIntPtr) || target == typeof(nuint))
{
return (T)(object)(UIntPtr)(nuint)exitCode;
}
if (Nullable.GetUnderlyingType(target) is Type underlying)
{
return (T)Convert.ChangeType(exitCode, underlying, CultureInfo.InvariantCulture);
}
return (T)Convert.ChangeType(exitCode, target, CultureInfo.InvariantCulture);
}
/// <summary>
/// Marshals managed arguments into pointer-sized native argument slots. Allocates
/// remote memory for strings and structs and records each allocation in
/// <paramref name="allocations"/>.
/// </summary>
private nuint[] MarshalArguments(object?[] args, int pointerSize, List<IntPtr> allocations)
{
var nativeArgs = new nuint[args.Length];
for (int i = 0; i < args.Length; i++)
{
object? arg = args[i];
nativeArgs[i] = MarshalArgument(arg, pointerSize, allocations);
}
return nativeArgs;
}
/// <summary>
/// Marshals a single argument. Strings and structs become remote pointers; primitives,
/// enums and pointer values are packed directly.
/// </summary>
private nuint MarshalArgument(object? arg, int pointerSize, List<IntPtr> allocations)
{
if (arg is null)
{
return 0;
}
if (arg is string s)
{
return MarshalString(s, allocations);
}
Type type = arg.GetType();
if (IsPrimitiveOrPointer(type))
{
return PackPrimitive(arg, pointerSize);
}
if (type.IsValueType)
{
return MarshalStruct(arg, type, allocations);
}
throw new ArgumentException(
$"Unsupported argument type: {type.FullName}. Only primitives, pointers, enums, strings and structs are supported.");
}
/// <summary>
/// Allocates the UTF-8 encoding of a string in the target process and returns its
/// remote address.
/// </summary>
private nuint MarshalString(string value, List<IntPtr> allocations)
{
byte[] bytes = Encoding.UTF8.GetBytes(value);
byte[] buffer = new byte[bytes.Length + 1];
bytes.CopyTo(buffer, 0);
buffer[^1] = 0;
IntPtr remote = NativeMethods.VirtualAllocEx(
_reader.Handle,
IntPtr.Zero,
buffer.Length,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ReadWrite);
if (remote == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"Failed to allocate remote string memory: error {error}");
}
int written = _reader.WriteBytes(remote, buffer);
if (written != buffer.Length)
{
throw new InvalidOperationException(
$"Failed to write string bytes to the remote process (wrote {written} of {buffer.Length} bytes).");
}
allocations.Add(remote);
return (nuint)(nint)remote;
}
/// <summary>
/// Allocates unmanaged space for a struct in the target process, writes its bytes with
/// the default interop marshaler, and returns the remote address.
/// </summary>
private nuint MarshalStruct(object value, Type type, List<IntPtr> allocations)
{
int size;
try
{
size = Marshal.SizeOf(type);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException(
$"Cannot marshal argument of type {type.FullName}: {ex.Message}", ex);
}
byte[] buffer = new byte[size];
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
}
finally
{
pin.Free();
}
IntPtr remote = NativeMethods.VirtualAllocEx(
_reader.Handle,
IntPtr.Zero,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ReadWrite);
if (remote == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"Failed to allocate remote struct memory: error {error}");
}
int written = _reader.WriteBytes(remote, buffer);
if (written != size)
{
throw new InvalidOperationException(
$"Failed to write struct bytes to the remote process (wrote {written} of {size} bytes).");
}
allocations.Add(remote);
return (nuint)(nint)remote;
}
/// <summary>
/// Determines whether a type can be passed directly as a pointer-sized value.
/// </summary>
private static bool IsPrimitiveOrPointer(Type type)
{
if (type == typeof(IntPtr) || type == typeof(UIntPtr) ||
type == typeof(nint) || type == typeof(nuint))
{
return true;
}
TypeCode code = Type.GetTypeCode(type);
switch (code)
{
case TypeCode.Boolean:
case TypeCode.Char:
case TypeCode.SByte:
case TypeCode.Byte:
case TypeCode.Int16:
case TypeCode.UInt16:
case TypeCode.Int32:
case TypeCode.UInt32:
case TypeCode.Int64:
case TypeCode.UInt64:
return true;
case TypeCode.Object when type.IsEnum:
case TypeCode.Object when type == typeof(IntPtr) || type == typeof(UIntPtr):
return true;
default:
return false;
}
}
/// <summary>
/// Packs a primitive, enum or pointer value into a pointer-sized unsigned integer.
/// Values are truncated to the target pointer width so x86 arguments receive their
/// low 32 bits.
/// </summary>
private static nuint PackPrimitive(object value, int pointerSize)
{
Type type = value.GetType();
ulong raw;
if (type == typeof(IntPtr) || type == typeof(nint))
{
raw = unchecked((ulong)(nint)value);
}
else if (type == typeof(UIntPtr) || type == typeof(nuint))
{
raw = (ulong)(UIntPtr)value;
}
else if (type.IsEnum)
{
raw = Convert.ToUInt64(value);
}
else
{
raw = Convert.ToUInt64(value);
}
if (pointerSize == 4)
{
raw &= uint.MaxValue;
}
return unchecked((nuint)raw);
}
/// <summary>
/// Attempts to allocate executable memory close to <paramref name="preferredAddress"/>
/// so that the relative CALL instruction in the generated stub stays within its
/// ±2 GiB range.
/// </summary>
private static IntPtr AllocateExecutableMemory(
SafeMemoryHandle handle,
IntPtr preferredAddress,
nint size)
{
nuint preferred = (nuint)(nint)preferredAddress;
nuint mask = AllocationGranularity - (nuint)1;
nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask;
for (int i = 0; i < NearAllocationAttempts; i++)
{
nuint candidate;
if (i == 0)
{
candidate = aligned;
}
else if ((i & 1) == 1)
{
candidate = aligned + (nuint)i * AllocationGranularity;
}
else
{
nuint offset = (nuint)i * AllocationGranularity;
if (offset > aligned)
{
continue;
}
candidate = aligned - offset;
}
IntPtr result = NativeMethods.VirtualAllocEx(
handle,
(IntPtr)(nint)candidate,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (result != IntPtr.Zero)
{
return result;
}
}
return NativeMethods.VirtualAllocEx(
handle,
IntPtr.Zero,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
}
}