Files
whitemagic/WhiteMagic/Hooking/Detour.cs
T
kbeandClaude Opus 4.8 30d5a9a5ec Add optional Iced backend: arbitrary text assembly and full prologue validation
Section 8 of the whitemagic-foundation change.

- 8.1: reference Iced 1.21.0 behind IcedAssembler:IAssembler. The default
  StubAssembler path never touches Iced; only constructing IcedAssembler pulls
  it into a behavioral path.
- 8.2: IcedAssembler.Assemble bridges Intel-syntax text onto Iced's fluent
  Assembler by reflection (Iced ships no text parser). Registers, immediates and
  labels are supported with origin-relative encoding; memory operands throw
  NotSupportedException.
- 8.3: IcedAssembler.GetPrologueLength decodes arbitrary instructions via Iced's
  Decoder. DetourManager.PrologueLengthResolver (new delegate) defaults to the
  built-in PrologueDecoder and is swappable to the Iced resolver, threaded into
  each Detour. This lifts the "partial boundary safety" caveat on the hooking
  slice when Iced is opted in.

Also gitignore test-run TestResults artifacts.

Tests: 221 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:56:31 +02:00

292 lines
9.8 KiB
C#

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;
private readonly PrologueLengthResolver _prologueLength;
/// <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,
PrologueLengthResolver prologueLength)
{
ArgumentNullException.ThrowIfNull(hook);
ArgumentNullException.ThrowIfNull(prologueLength);
_memory = memory;
Name = name;
Target = target;
Hook = hook;
_prologueLength = prologueLength;
}
/// <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;
// The prologue decoder may need to see bytes past the minimum detour length
// to identify the whole instruction that crosses the splice point. Prefer a
// generous read, but if the target sits near an unmapped page boundary, read
// only up to that boundary so ReadProcessMemory does not fail entirely.
int preferredBuffer = detourLength + 16;
int pageSize = Environment.SystemPageSize;
int pageOffset = (int)(Target.ToInt64() & (pageSize - 1));
int bytesToPageBoundary = pageSize - pageOffset;
int readSize = Math.Min(preferredBuffer, bytesToPageBoundary);
byte[] prologue = _memory.ReadBytes(Target, readSize);
if (prologue.Length < detourLength)
{
throw new InvalidOperationException(
"Could not read enough bytes from the target function to install a detour.");
}
int preserveLength = _prologueLength(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,
// restoring the original protection regardless of success or failure.
if (!NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
preserveLength,
MemoryProtectionType.ExecuteReadWrite,
out MemoryProtectionType oldProtect))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"Failed to change target memory protection: error {error}");
}
try
{
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;
}
finally
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
preserveLength,
oldProtect,
out _);
}
}
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 MemoryProtectionType oldProtect);
try
{
_memory.WriteBytes(Target, OverwrittenBytes);
}
finally
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
OverwrittenBytes.Length,
oldProtect,
out _);
}
}
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;
}
}