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:
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// A single reversible inline detour. Replaces the start of a native function
|
||||
/// with a jump to a managed hook delegate, preserves the overwritten bytes in a
|
||||
/// remote trampoline, and exposes the trampoline through <see cref="CallOriginal"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only supported in-process. The detour uses a 5-byte relative <c>jmp</c> on x86
|
||||
/// targets and a 14-byte RIP-relative absolute <c>jmp</c> on x64 targets.
|
||||
/// </remarks>
|
||||
public sealed class Detour : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
|
||||
/// <summary>The unique name of this detour.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The target native function address.</summary>
|
||||
public IntPtr Target { get; }
|
||||
|
||||
/// <summary>The managed hook delegate that the detour invokes.</summary>
|
||||
public Delegate Hook { get; }
|
||||
|
||||
/// <summary>The bytes overwritten at <see cref="Target"/>.</summary>
|
||||
public byte[] OverwrittenBytes { get; private set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// The allocated trampoline that executes the original prologue and then jumps
|
||||
/// back into the original function.
|
||||
/// </summary>
|
||||
public IntPtr Trampoline { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// A delegate wrapping <see cref="Trampoline"/> with the same type as <see cref="Hook"/>.
|
||||
/// </summary>
|
||||
public Delegate? Original { get; private set; }
|
||||
|
||||
/// <summary><see langword="true"/> while the detour bytes are live at <see cref="Target"/>.</summary>
|
||||
public bool IsApplied { get; private set; }
|
||||
|
||||
internal Detour(MemoryBase memory, string name, IntPtr target, Delegate hook)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hook);
|
||||
|
||||
_memory = memory;
|
||||
Name = name;
|
||||
Target = target;
|
||||
Hook = hook;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the detour after validating that the required overwrite covers whole
|
||||
/// prologue instructions.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The prologue cannot be safely spliced.</exception>
|
||||
public void Apply()
|
||||
{
|
||||
if (IsApplied)
|
||||
return;
|
||||
|
||||
int pointerSize = _memory.Is64Bit ? 8 : 4;
|
||||
int detourLength = pointerSize == 8 ? 14 : 5;
|
||||
|
||||
byte[] prologue = _memory.ReadBytes(Target, detourLength + 16);
|
||||
if (prologue.Length < detourLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not read enough bytes from the target function to install a detour.");
|
||||
}
|
||||
|
||||
int preserveLength = PrologueDecoder.GetWholeInstructionLength(prologue, detourLength, _memory.Is64Bit);
|
||||
OverwrittenBytes = new byte[preserveLength];
|
||||
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
|
||||
|
||||
IntPtr hookAddress = Marshal.GetFunctionPointerForDelegate(Hook);
|
||||
byte[] hookJump = pointerSize == 8
|
||||
? BuildAbsoluteJump(hookAddress)
|
||||
: BuildRelativeJump(Target, hookAddress);
|
||||
|
||||
// Allocate and build the trampoline before touching the target.
|
||||
int returnJumpSize = pointerSize == 8 ? 14 : 5;
|
||||
int trampolineSize = preserveLength + returnJumpSize;
|
||||
IntPtr trampoline = NativeMethods.VirtualAllocEx(
|
||||
_memory.Handle,
|
||||
IntPtr.Zero,
|
||||
trampolineSize,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
if (trampoline == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to allocate detour trampoline: error {error}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var trampolineBytes = new byte[trampolineSize];
|
||||
OverwrittenBytes.CopyTo(trampolineBytes, 0);
|
||||
|
||||
byte[] returnJump = pointerSize == 8
|
||||
? BuildAbsoluteJump(Target + preserveLength)
|
||||
: BuildRelativeJump(trampoline + preserveLength, Target + preserveLength);
|
||||
|
||||
returnJump.CopyTo(trampolineBytes, preserveLength);
|
||||
|
||||
int written = _memory.WriteBytes(trampoline, trampolineBytes);
|
||||
if (written != trampolineSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Failed to write the detour trampoline into the target process.");
|
||||
}
|
||||
|
||||
// Make the target page writable if necessary, then write the detour jump.
|
||||
if (!NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Target,
|
||||
preserveLength,
|
||||
MemoryProtectionType.ExecuteReadWrite,
|
||||
out _))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to change target memory protection: error {error}");
|
||||
}
|
||||
|
||||
written = _memory.WriteBytes(Target, hookJump);
|
||||
if (written != hookJump.Length)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to write detour jump to target.");
|
||||
}
|
||||
|
||||
Trampoline = trampoline;
|
||||
Original = Marshal.GetDelegateForFunctionPointer(Trampoline, Hook.GetType());
|
||||
IsApplied = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(
|
||||
_memory.Handle,
|
||||
trampoline,
|
||||
0,
|
||||
MemoryFreeType.Release);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the original bytes and releases the trampoline.</summary>
|
||||
public void Remove()
|
||||
{
|
||||
if (!IsApplied)
|
||||
return;
|
||||
|
||||
if (OverwrittenBytes.Length > 0 && Target != IntPtr.Zero)
|
||||
{
|
||||
NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Target,
|
||||
OverwrittenBytes.Length,
|
||||
MemoryProtectionType.ExecuteReadWrite,
|
||||
out _);
|
||||
|
||||
_memory.WriteBytes(Target, OverwrittenBytes);
|
||||
}
|
||||
|
||||
if (Trampoline != IntPtr.Zero)
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(
|
||||
_memory.Handle,
|
||||
Trampoline,
|
||||
0,
|
||||
MemoryFreeType.Release);
|
||||
}
|
||||
|
||||
Trampoline = IntPtr.Zero;
|
||||
Original = null;
|
||||
OverwrittenBytes = Array.Empty<byte>();
|
||||
IsApplied = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the original function through the trampoline. Pass the same arguments
|
||||
/// that the native signature expects; the return value is boxed.
|
||||
/// </summary>
|
||||
public object? CallOriginal(params object?[] args)
|
||||
{
|
||||
if (Original is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The detour is not applied; there is no original trampoline to call.");
|
||||
}
|
||||
|
||||
return Original.DynamicInvoke(args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Remove();
|
||||
}
|
||||
|
||||
private static byte[] BuildRelativeJump(IntPtr source, IntPtr destination)
|
||||
{
|
||||
byte[] bytes = new byte[5];
|
||||
bytes[0] = 0xE9;
|
||||
|
||||
long distance = (long)destination - ((long)source + 5);
|
||||
if (distance < int.MinValue || distance > int.MaxValue)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(destination),
|
||||
"Relative jump distance exceeds the 2 GiB range of an E8/E9 encoding.");
|
||||
}
|
||||
|
||||
uint rel = (uint)distance;
|
||||
bytes[1] = (byte)rel;
|
||||
bytes[2] = (byte)(rel >> 8);
|
||||
bytes[3] = (byte)(rel >> 16);
|
||||
bytes[4] = (byte)(rel >> 24);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] BuildAbsoluteJump(IntPtr destination)
|
||||
{
|
||||
// jmp [rip+0] followed by the absolute target address.
|
||||
byte[] bytes = new byte[14];
|
||||
bytes[0] = 0xFF;
|
||||
bytes[1] = 0x25;
|
||||
bytes[2] = 0x00;
|
||||
bytes[3] = 0x00;
|
||||
bytes[4] = 0x00;
|
||||
bytes[5] = 0x00;
|
||||
|
||||
long addr = (long)destination;
|
||||
bytes[6] = (byte)addr;
|
||||
bytes[7] = (byte)(addr >> 8);
|
||||
bytes[8] = (byte)(addr >> 16);
|
||||
bytes[9] = (byte)(addr >> 24);
|
||||
bytes[10] = (byte)(addr >> 32);
|
||||
bytes[11] = (byte)(addr >> 40);
|
||||
bytes[12] = (byte)(addr >> 48);
|
||||
bytes[13] = (byte)(addr >> 56);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Manages named inline detours against a <see cref="MemoryBase"/>.
|
||||
/// Detours only work when operating in-process; applying a detour to an
|
||||
/// external target will fail because the hook delegate lives in the host process.
|
||||
/// </summary>
|
||||
public sealed class DetourManager
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly Dictionary<string, Detour> _detours = new();
|
||||
|
||||
/// <summary>Creates a detour manager bound to the supplied memory reader.</summary>
|
||||
public DetourManager(MemoryBase memory)
|
||||
{
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new detour and registers it with the manager.
|
||||
/// The <paramref name="hook"/> delegate's type must match the native signature of
|
||||
/// <paramref name="target"/>.
|
||||
/// </summary>
|
||||
public Detour Create(string name, IntPtr target, Delegate hook)
|
||||
{
|
||||
var detour = new Detour(_memory, name, target, hook);
|
||||
_detours[name] = detour;
|
||||
return detour;
|
||||
}
|
||||
|
||||
/// <summary>Looks up a detour by name.</summary>
|
||||
public Detour? this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
_detours.TryGetValue(name, out Detour? detour);
|
||||
return detour;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All detours registered in this manager.</summary>
|
||||
public IEnumerable<Detour> All => _detours.Values;
|
||||
|
||||
/// <summary>Removes every applied detour, restoring original bytes.</summary>
|
||||
public void RemoveAll()
|
||||
{
|
||||
foreach (Detour detour in _detours.Values)
|
||||
{
|
||||
detour.Remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// A single reversible byte patch. Captures the original bytes when applied,
|
||||
/// restores them when removed, and reports its state by comparing live memory.
|
||||
/// </summary>
|
||||
public sealed class Patch : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
|
||||
/// <summary>The unique name of this patch.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The address the patch overwrites.</summary>
|
||||
public IntPtr Address { get; }
|
||||
|
||||
/// <summary>The bytes written by the patch.</summary>
|
||||
public byte[] PatchBytes { get; }
|
||||
|
||||
/// <summary>The bytes captured before the patch was applied.</summary>
|
||||
public byte[]? OriginalBytes { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when the live bytes at <see cref="Address"/> match
|
||||
/// <see cref="PatchBytes"/>.
|
||||
/// </summary>
|
||||
public bool IsApplied
|
||||
{
|
||||
get
|
||||
{
|
||||
byte[] current = _memory.ReadBytes(Address, PatchBytes.Length);
|
||||
return current.SequenceEqual(PatchBytes);
|
||||
}
|
||||
}
|
||||
|
||||
internal Patch(MemoryBase memory, string name, IntPtr address, byte[] patchBytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(patchBytes);
|
||||
|
||||
_memory = memory;
|
||||
Name = name;
|
||||
Address = address;
|
||||
PatchBytes = patchBytes;
|
||||
}
|
||||
|
||||
/// <summary>Captures the original bytes and writes the patch bytes.</summary>
|
||||
public void Apply()
|
||||
{
|
||||
if (IsApplied)
|
||||
return;
|
||||
|
||||
OriginalBytes = _memory.ReadBytes(Address, PatchBytes.Length);
|
||||
_memory.WriteBytes(Address, PatchBytes);
|
||||
}
|
||||
|
||||
/// <summary>Restores the original bytes if they were captured.</summary>
|
||||
public void Remove()
|
||||
{
|
||||
if (OriginalBytes is null)
|
||||
return;
|
||||
|
||||
_memory.WriteBytes(Address, OriginalBytes);
|
||||
OriginalBytes = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Manages named, reversible byte patches against a <see cref="MemoryBase"/>.
|
||||
/// Every patch records the bytes it replaced and can restore them later.
|
||||
/// </summary>
|
||||
public sealed class PatchManager
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly Dictionary<string, Patch> _patches = new();
|
||||
|
||||
/// <summary>Creates a patch manager bound to the supplied memory reader.</summary>
|
||||
public PatchManager(MemoryBase memory)
|
||||
{
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new patch and registers it with the manager.</summary>
|
||||
public Patch Create(string name, IntPtr address, byte[] patchBytes)
|
||||
{
|
||||
var patch = new Patch(_memory, name, address, patchBytes);
|
||||
_patches[name] = patch;
|
||||
return patch;
|
||||
}
|
||||
|
||||
/// <summary>Looks up a patch by name.</summary>
|
||||
public Patch? this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
_patches.TryGetValue(name, out Patch? patch);
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All patches registered in this manager.</summary>
|
||||
public IEnumerable<Patch> All => _patches.Values;
|
||||
|
||||
/// <summary>Removes every applied patch.</summary>
|
||||
public void RestoreAll()
|
||||
{
|
||||
foreach (Patch patch in _patches.Values)
|
||||
{
|
||||
patch.Remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
|
||||
/// The set is intentionally small: any opcode outside the covered set is rejected
|
||||
/// rather than guessed. Full arbitrary-prologue validation is provided by the
|
||||
/// optional Iced backend (Phase 8).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Covered shapes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>push reg</c>: 0x50-0x57 (1 byte), including REX-prefixed forms.</item>
|
||||
/// <item><c>push ebp/rbp</c>: 0x55 (1 byte).</item>
|
||||
/// <item><c>mov edi, edi</c>: 8B FF (2 bytes).</item>
|
||||
/// <item><c>mov ebp/rbp, esp/rsp</c>: 8B EC / 48 8B EC (2/3 bytes).</item>
|
||||
/// <item><c>sub esp/rsp, imm8</c>: 83 EC imm8 / 48 83 EC imm8 (3/4 bytes).</item>
|
||||
/// <item><c>sub esp/rsp, imm32</c>: 81 EC imm32 / 48 81 EC imm32 (6/7 bytes).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class PrologueDecoder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the length of the first instruction in <paramref name="bytes"/>
|
||||
/// if it matches a covered shape; otherwise returns -1.
|
||||
/// </summary>
|
||||
public static int GetInstructionLength(ReadOnlySpan<byte> bytes, bool is64Bit)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
return 0;
|
||||
|
||||
int i = 0;
|
||||
if (is64Bit && bytes[i] >= 0x40 && bytes[i] <= 0x4F)
|
||||
{
|
||||
// REX prefix.
|
||||
i++;
|
||||
if (bytes.Length <= i)
|
||||
return -1;
|
||||
}
|
||||
|
||||
byte op = bytes[i];
|
||||
|
||||
// push reg / push rbp.
|
||||
if ((op & 0xF8) == 0x50 || op == 0x55)
|
||||
return i + 1;
|
||||
|
||||
// mov r32/64, r/m32/64. Recognize only the specific forms listed above.
|
||||
if (op == 0x8B && bytes.Length > i + 1)
|
||||
{
|
||||
byte modrm = bytes[i + 1];
|
||||
if (modrm == 0xFF || modrm == 0xEC)
|
||||
return i + 2;
|
||||
}
|
||||
|
||||
// sub r/m32/64, imm8.
|
||||
if (op == 0x83 && bytes.Length > i + 2)
|
||||
return i + 3;
|
||||
|
||||
// sub r/m32/64, imm32.
|
||||
if (op == 0x81 && bytes.Length > i + 5)
|
||||
return i + 6;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks prologue instructions until at least <paramref name="requiredBytes"/>
|
||||
/// have been covered, returning the total length of whole instructions that must
|
||||
/// be preserved in the trampoline.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">An opcode is outside the covered set.</exception>
|
||||
public static int GetWholeInstructionLength(byte[] prologue, int requiredBytes, bool is64Bit)
|
||||
{
|
||||
int total = 0;
|
||||
while (total < requiredBytes)
|
||||
{
|
||||
int len = GetInstructionLength(prologue.AsSpan(total), is64Bit);
|
||||
if (len <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The target prologue contains an instruction outside the covered opcode set. " +
|
||||
"Install the optional Iced backend for full instruction-boundary validation.");
|
||||
}
|
||||
|
||||
total += len;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user