using System; using System.Runtime.InteropServices; using WhiteMagic.Native; namespace WhiteMagic.Hooking; /// /// 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 . /// /// /// Only supported in-process. The detour uses a 5-byte relative jmp on x86 /// targets and a 14-byte RIP-relative absolute jmp on x64 targets. /// public sealed class Detour : IDisposable { private readonly MemoryBase _memory; /// The unique name of this detour. public string Name { get; } /// The target native function address. public IntPtr Target { get; } /// The managed hook delegate that the detour invokes. public Delegate Hook { get; } /// The bytes overwritten at . public byte[] OverwrittenBytes { get; private set; } = Array.Empty(); /// /// The allocated trampoline that executes the original prologue and then jumps /// back into the original function. /// public IntPtr Trampoline { get; private set; } /// /// A delegate wrapping with the same type as . /// public Delegate? Original { get; private set; } /// while the detour bytes are live at . 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; } /// /// Installs the detour after validating that the required overwrite covers whole /// prologue instructions. /// /// The prologue cannot be safely spliced. 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 = 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, // 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; } } /// Restores the original bytes and releases the trampoline. 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(); IsApplied = false; } /// /// Invokes the original function through the trampoline. Pass the same arguments /// that the native signature expects; the return value is boxed. /// 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); } /// 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; } }