using System.Globalization; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using WhiteMagic.Assembly; using WhiteMagic.Native; namespace WhiteMagic.Execution; /// /// 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. /// /// /// This executor is safe only for thread-agnostic payloads. Calls that touch /// single-threaded process state should use instead. /// String arguments are encoded as null-terminated UTF-8 and allocated in the /// remote process; struct arguments are serialized with the default interop marshaler /// () and allocated with /// bytes. All temporary remote allocations are released after the call, including on failure. /// 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; /// /// 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). /// /// /// When this delegate returns a non-zero pointer, the executor does not take /// ownership of that memory and will not free it. /// internal Func? StubAllocator { get; set; } /// /// Initializes a new for the process exposed by /// . /// /// The memory reader that owns the target process handle. public RemoteThreadExecutor(MemoryBase reader) { _reader = reader ?? throw new ArgumentNullException(nameof(reader)); _assembler = new StubAssembler(); } /// /// Calls the function at in the target process using a /// remote thread and returns its exit value cast to . /// /// The expected return type. /// The target function address. /// The calling convention (ignored on x64 targets). /// Arguments to pass. Primitives, pointers and enums are packed /// into pointer-sized slots. Strings and structs are allocated remotely and passed /// by pointer. /// The function's exit value converted to . /// The process handle is not open or a /// required native operation failed. /// The remote thread did not complete in time. public Task ExecuteAsync(IntPtr address, CallConvention convention, params object?[] args) { return Task.Run(() => Execute(address, convention, args)); } /// /// Synchronous variant of . /// public T Execute(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(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(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); } } } /// /// Converts the raw DWORD exit code into the requested return type. /// private static T ConvertExitCode(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); } /// /// Marshals managed arguments into pointer-sized native argument slots. Allocates /// remote memory for strings and structs and records each allocation in /// . /// private nuint[] MarshalArguments(object?[] args, int pointerSize, List 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; } /// /// Marshals a single argument. Strings and structs become remote pointers; primitives, /// enums and pointer values are packed directly. /// private nuint MarshalArgument(object? arg, int pointerSize, List 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."); } /// /// Allocates the UTF-8 encoding of a string in the target process and returns its /// remote address. /// private nuint MarshalString(string value, List 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; } /// /// Allocates unmanaged space for a struct in the target process, writes its bytes with /// the default interop marshaler, and returns the remote address. /// private nuint MarshalStruct(object value, Type type, List 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; } /// /// Determines whether a type can be passed directly as a pointer-sized value. /// 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; } } /// /// 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. /// 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); } /// /// Attempts to allocate executable memory close to /// so that the relative CALL instruction in the generated stub stays within its /// ±2 GiB range. /// 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); } }