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
+201
View File
@@ -0,0 +1,201 @@
using System.ComponentModel;
using System.Diagnostics;
namespace WhiteMagic.Discovery;
/// <summary>
/// Scans process memory for a byte pattern with an optional wildcard mask.
/// </summary>
public static class PatternScanner
{
/// <summary>
/// Scans a memory range for the first occurrence of a pattern with an optional wildcard mask.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="start">The starting address of the scan range.</param>
/// <param name="end">The ending address (exclusive) of the scan range.</param>
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="pattern"/> is empty, or <paramref name="mask"/> length does not match
/// <paramref name="pattern"/> length, or <paramref name="mask"/> contains invalid characters.
/// </exception>
/// <exception cref="Win32Exception">Memory read fails with an unexpected error.</exception>
public static IntPtr Find(
MemoryBase memory,
byte[] pattern,
string? mask,
IntPtr start,
IntPtr end)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentNullException.ThrowIfNull(pattern);
if (pattern.Length == 0)
throw new ArgumentException("Pattern cannot be empty.", nameof(pattern));
// Validate and normalize mask
if (mask is not null)
{
if (mask.Length != pattern.Length)
throw new ArgumentException(
$"Mask length ({mask.Length}) must match pattern length ({pattern.Length}).",
nameof(mask));
foreach (char c in mask)
{
if (c != 'x' && c != '?')
throw new ArgumentException(
$"Mask may contain only 'x' (match) or '?' (wildcard); found '{c}'.",
nameof(mask));
}
}
// Null mask means treat all bytes as 'x' (exact match)
mask ??= new string('x', pattern.Length);
// Scan range in reasonable chunks (64 KB to avoid massive single reads)
const int chunkSize = 64 * 1024;
int patternLen = pattern.Length;
long rangeSize = (long)end - (long)start;
if (rangeSize <= 0)
return IntPtr.Zero;
// For small ranges, read all at once
if (rangeSize <= chunkSize)
{
byte[] buffer = memory.ReadBytes(start, (int)rangeSize);
return FindInBuffer(buffer, pattern, mask, start);
}
// For larger ranges, scan in chunks
long remaining = rangeSize;
IntPtr current = start;
while (remaining > 0)
{
int toRead = (int)Math.Min(chunkSize, remaining);
byte[] chunk = memory.ReadBytes(current, toRead);
// Empty read means we hit an unmapped region or read failure
if (chunk.Length == 0)
{
// Skip past this unreadable region
current += toRead;
remaining -= toRead;
continue;
}
// Search in this chunk
IntPtr found = FindInBuffer(chunk, pattern, mask, current);
if (found != IntPtr.Zero)
return found;
// Move to next chunk, leaving room for pattern that might straddle boundary
// We advance by (chunkSize - patternLen + 1) to ensure we don't miss matches
int advance = toRead - patternLen + 1;
if (advance <= 0)
advance = toRead;
current += advance;
remaining -= advance;
}
return IntPtr.Zero;
}
/// <summary>
/// Scans a module's memory region (from its base address through its size) for a pattern.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="module">The module to scan.</param>
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
public static IntPtr FindInModule(
MemoryBase memory,
byte[] pattern,
string? mask,
ProcessModule module)
{
ArgumentNullException.ThrowIfNull(module);
IntPtr start = module.BaseAddress;
IntPtr end = start + module.ModuleMemorySize;
return Find(memory, pattern, mask, start, end);
}
/// <summary>
/// Scans multiple modules for a pattern, returning the first match found.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="modules">The modules to scan, in order.</param>
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
public static IntPtr FindInModules(
MemoryBase memory,
byte[] pattern,
string? mask,
IEnumerable<ProcessModule> modules)
{
ArgumentNullException.ThrowIfNull(modules);
foreach (var module in modules)
{
IntPtr found = FindInModule(memory, pattern, mask, module);
if (found != IntPtr.Zero)
return found;
}
return IntPtr.Zero;
}
/// <summary>
/// Searches a buffer for the first pattern match given a mask.
/// </summary>
private static IntPtr FindInBuffer(
byte[] buffer,
byte[] pattern,
string mask,
IntPtr bufferBase)
{
if (buffer.Length < pattern.Length)
return IntPtr.Zero;
int patternLen = pattern.Length;
int maxOffset = buffer.Length - patternLen;
for (int offset = 0; offset <= maxOffset; offset++)
{
bool match = true;
for (int i = 0; i < patternLen; i++)
{
// Only compare if mask says 'x' (exact match required)
if (mask[i] == 'x' && buffer[offset + i] != pattern[i])
{
match = false;
break;
}
}
if (match)
return bufferBase + offset;
}
return IntPtr.Zero;
}
}
+157
View File
@@ -0,0 +1,157 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace WhiteMagic.Discovery;
/// <summary>
/// Caches pattern scan results to avoid repeated scans of the same memory range.
/// </summary>
public sealed class PatternScannerCache
{
private readonly ConcurrentDictionary<CacheKey, IntPtr> _cache = new();
private readonly MemoryBase _memory;
/// <summary>
/// Creates a new cache for the given memory accessor.
/// </summary>
/// <param name="memory">The memory accessor to scan.</param>
public PatternScannerCache(MemoryBase memory)
{
ArgumentNullException.ThrowIfNull(memory);
_memory = memory;
}
/// <summary>
/// Finds a pattern, returning a cached result if available.
/// </summary>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="start">The starting address of the scan range.</param>
/// <param name="end">The ending address (exclusive) of the scan range.</param>
/// <returns>
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
/// </returns>
public IntPtr FindCached(
byte[] pattern,
string? mask,
IntPtr start,
IntPtr end)
{
var key = new CacheKey(pattern, mask, start, end);
// Try to get from cache first
if (_cache.TryGetValue(key, out IntPtr cached))
return cached;
// Not in cache, perform the scan
IntPtr found = PatternScanner.Find(_memory, pattern, mask, start, end);
// Cache the result (even if Zero)
_cache[key] = found;
return found;
}
/// <summary>
/// Finds a pattern within a module, returning a cached result if available.
/// </summary>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="module">The module to scan.</param>
/// <returns>
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
/// </returns>
public IntPtr FindInModuleCached(
byte[] pattern,
string? mask,
ProcessModule module)
{
ArgumentNullException.ThrowIfNull(module);
IntPtr start = module.BaseAddress;
IntPtr end = start + module.ModuleMemorySize;
return FindCached(pattern, mask, start, end);
}
/// <summary>
/// Finds a pattern across multiple modules, returning a cached result if available.
/// </summary>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="modules">The modules to scan, in order.</param>
/// <returns>
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
/// </returns>
public IntPtr FindInModulesCached(
byte[] pattern,
string? mask,
IEnumerable<ProcessModule> modules)
{
// For multiple modules, we use a combined key (all modules hashed together)
// This is less granular but still useful for repeated queries
var moduleList = modules.ToList();
var key = new CacheKey(pattern, mask, IntPtr.Zero, IntPtr.Zero, Modules: moduleList);
if (_cache.TryGetValue(key, out IntPtr cached))
return cached;
IntPtr found = PatternScanner.FindInModules(_memory, pattern, mask, moduleList);
_cache[key] = found;
return found;
}
/// <summary>
/// Clears all cached scan results.
/// </summary>
public void Clear()
{
_cache.Clear();
}
/// <summary>
/// Cache key combining pattern, mask, and address range.
/// </summary>
private sealed record CacheKey(
byte[] Pattern,
string? Mask,
IntPtr Start,
IntPtr End,
IReadOnlyList<ProcessModule>? Modules = null) : IEquatable<CacheKey>
{
// Override GetHashCode to hash the contents, not references
public override int GetHashCode()
{
var hash = new HashCode();
// Hash pattern bytes
foreach (byte b in Pattern)
hash.Add(b);
// Hash mask
hash.Add(Mask?.GetHashCode() ?? 0);
// Hash address range
hash.Add(Start.GetHashCode());
hash.Add(End.GetHashCode());
// Hash modules if present (by base address)
if (Modules is not null)
{
foreach (var m in Modules)
hash.Add(m.BaseAddress.GetHashCode());
}
return hash.ToHashCode();
}
}
}
+225
View File
@@ -0,0 +1,225 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Discovery;
/// <summary>
/// Represents a section in a PE file.
/// </summary>
public readonly record struct PeSection
{
/// <summary>
/// The 8-byte null-terminated section name (e.g., ".text", ".data").
/// </summary>
public string Name { get; init; }
/// <summary>
/// The virtual address of the section when loaded into memory (RVA).
/// </summary>
public IntPtr VirtualAddress { get; init; }
/// <summary>
/// The size of the section in memory.
/// </summary>
public int VirtualSize { get; init; }
}
/// <summary>
/// Parses PE headers to expose section information and entry points.
/// </summary>
public sealed class PeHeaderParser
{
private readonly MemoryBase _memory;
private readonly IntPtr _baseAddress;
/// <summary>
/// Creates a new PE header parser for the module at the specified base address.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="baseAddress">The base address of the module.</param>
public PeHeaderParser(MemoryBase memory, IntPtr baseAddress)
{
ArgumentNullException.ThrowIfNull(memory);
if (baseAddress == IntPtr.Zero)
throw new ArgumentException("Base address cannot be zero.", nameof(baseAddress));
_memory = memory;
_baseAddress = baseAddress;
}
/// <summary>
/// Gets the entry point RVA (Relative Virtual Address) of the PE file.
/// </summary>
/// <returns>The entry point RVA, or <see cref="IntPtr.Zero"/> if unavailable.</returns>
/// <exception cref="Win32Exception">Reading memory fails.</exception>
/// <exception cref="InvalidDataException">The PE headers are invalid.</exception>
public IntPtr EntryPoint
{
get
{
// Read and parse PE headers
var (optionalHeader, _) = ParseOptionalHeader();
if (optionalHeader is null)
return IntPtr.Zero;
// Entry point is at different offsets for PE32 vs PE32+
bool isPe32Plus = IsPe32Plus();
if (isPe32Plus)
{
// PE32+: AddressOfEntryPoint is at offset 16 in OPTIONAL_HEADER (64-bit)
return (IntPtr)BitConverter.ToUInt32(
optionalHeader.AsSpan(16, 4));
}
else
{
// PE32: AddressOfEntryPoint is at offset 16 in OPTIONAL_HEADER (32-bit)
return (IntPtr)BitConverter.ToUInt32(
optionalHeader.AsSpan(16, 4));
}
}
}
/// <summary>
/// Enumerates all sections in the PE file.
/// </summary>
/// <returns>An enumerable of PE sections.</returns>
/// <exception cref="Win32Exception">Reading memory fails.</exception>
/// <exception cref="InvalidDataException">The PE headers are invalid.</exception>
public IEnumerable<PeSection> Sections
{
get
{
var (optionalHeader, sectionHeaders) = ParseOptionalHeaderAndSectionHeaders();
if (sectionHeaders is null || sectionHeaders.Length == 0)
yield break;
foreach (var sectionHeader in sectionHeaders)
{
// Parse section name (8-byte, null-terminated)
string name = ParseSectionName(sectionHeader);
// VirtualAddress and VirtualSize
uint virtualAddress = BitConverter.ToUInt32(sectionHeader, 12);
uint virtualSize = BitConverter.ToUInt32(sectionHeader, 8);
yield return new PeSection
{
Name = name,
VirtualAddress = (IntPtr)virtualAddress,
VirtualSize = (int)virtualSize
};
}
}
}
/// <summary>
/// Parses the DOS header, PE signature, and optional header.
/// </summary>
private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeaderAndSectionHeaders()
{
// Read DOS header (first 64 bytes)
byte[] dosHeader = _memory.ReadBytes(_baseAddress, 64);
if (dosHeader.Length < 64)
throw new InvalidDataException("Failed to read DOS header.");
// Verify DOS signature "MZ"
if (dosHeader[0] != 0x4D || dosHeader[1] != 0x5A)
throw new InvalidDataException("Invalid DOS signature (not a PE file).");
// PE header offset is at 0x3C in DOS header
int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
if (peOffset < 0 || peOffset > 0x1000) // Sanity check
throw new InvalidDataException($"Invalid PE offset: {peOffset}");
// Read PE signature (4 bytes: "PE\0\0")
IntPtr peSigAddr = _baseAddress + peOffset;
byte[] peSignature = _memory.ReadBytes(peSigAddr, 4);
if (peSignature.Length < 4)
throw new InvalidDataException("Failed to read PE signature.");
if (peSignature[0] != 0x50 || peSignature[1] != 0x45 ||
peSignature[2] != 0x00 || peSignature[3] != 0x00)
throw new InvalidDataException("Invalid PE signature.");
// COFF header follows PE signature (20 bytes)
IntPtr coffAddr = peSigAddr + 4;
byte[] coffHeader = _memory.ReadBytes(coffAddr, 20);
if (coffHeader.Length < 20)
throw new InvalidDataException("Failed to read COFF header.");
// SizeOfOptionalHeader is at offset 16 in COFF header
ushort sizeOfOptionalHeader = BitConverter.ToUInt16(coffHeader, 16);
// NumberOfSections is at offset 2 in COFF header
ushort numberOfSections = BitConverter.ToUInt16(coffHeader, 2);
if (numberOfSections == 0 || numberOfSections > 96)
return (null, null); // No sections or unreasonable number
// Optional header follows COFF header
IntPtr optAddr = coffAddr + 20;
byte[] optionalHeader = _memory.ReadBytes(optAddr, sizeOfOptionalHeader);
if (optionalHeader.Length < sizeOfOptionalHeader)
throw new InvalidDataException("Failed to read optional header.");
// Section headers follow optional header
IntPtr sectionAddr = optAddr + sizeOfOptionalHeader;
int sectionHeaderSize = 40; // IMAGE_SECTION_HEADER is 40 bytes
byte[][] sectionHeaders = new byte[numberOfSections][];
for (int i = 0; i < numberOfSections; i++)
{
byte[] section = _memory.ReadBytes(sectionAddr + (i * sectionHeaderSize), sectionHeaderSize);
if (section.Length < sectionHeaderSize)
throw new InvalidDataException($"Failed to read section header {i}.");
sectionHeaders[i] = section;
}
return (optionalHeader, sectionHeaders);
}
/// <summary>
/// Parses just the optional header (for entry point).
/// </summary>
private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeader()
{
return ParseOptionalHeaderAndSectionHeaders();
}
/// <summary>
/// Determines whether the PE file is PE32+ (64-bit) or PE32 (32-bit).
/// </summary>
private bool IsPe32Plus()
{
var (optionalHeader, _) = ParseOptionalHeaderAndSectionHeaders();
if (optionalHeader is null || optionalHeader.Length < 2)
throw new InvalidDataException("Optional header too short.");
// Magic is at offset 0 in optional header
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit)
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
return magic == 0x20b;
}
/// <summary>
/// Parses a null-terminated 8-byte section name.
/// </summary>
private static string ParseSectionName(byte[] sectionHeader)
{
// Name is first 8 bytes
var nameBytes = new Span<byte>(sectionHeader, 0, 8);
// Find null terminator
int len = 0;
for (; len < 8; len++)
{
if (nameBytes[len] == 0)
break;
}
return System.Text.Encoding.ASCII.GetString(nameBytes[..len]);
}
}
+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);
}
}
+24 -4
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using Process = System.Diagnostics.Process;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
@@ -13,6 +14,8 @@ public sealed class ExternalReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
private readonly bool _is64Bit;
private readonly int _processId;
private bool _disposed;
/// <summary>
@@ -31,16 +34,27 @@ public sealed class ExternalReader : MemoryBase
/// <param name="process">The target process.</param>
/// <param name="desiredAccess">The access rights to request. Defaults to
/// <see cref="DefaultAccess"/>.</param>
public ExternalReader(Process process, ProcessAccess desiredAccess = DefaultAccess)
public ExternalReader(System.Diagnostics.Process process, ProcessAccess desiredAccess = DefaultAccess)
{
_handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id);
_processId = process.Id;
_handle = NativeMethods.OpenProcess(desiredAccess, false, _processId);
if (_handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"OpenProcess failed for PID {process.Id}: error {error}");
$"OpenProcess failed for PID {_processId}: error {error}");
}
// Derive target bitness. A 64-bit host sees a 32-bit target as WOW64.
// A 32-bit host can only open 32-bit targets. If the API fails, fall
// back to the current process bitness (self-open path).
if (!NativeMethods.IsWow64Process(_handle, out bool wow64))
{
wow64 = false;
}
_is64Bit = Environment.Is64BitProcess && !wow64;
// Process.MainModule throws Win32Exception for a bitness-mismatched or protected
// target. A missing image base must not sink the whole reader — callers can still
// use absolute addresses when ImageBase is unknown.
@@ -60,6 +74,12 @@ public sealed class ExternalReader : MemoryBase
/// <inheritdoc />
public override SafeMemoryHandle Handle => _handle;
/// <inheritdoc />
public override bool Is64Bit => _is64Bit;
/// <inheritdoc />
public override int ProcessId => _processId;
/// <inheritdoc />
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
@@ -84,7 +104,7 @@ public sealed class ExternalReader : MemoryBase
if (!_disposed)
{
_disposed = true;
_handle.Dispose();
base.Dispose();
}
}
}
+250
View File
@@ -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;
}
}
+55
View File
@@ -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();
}
}
}
+74
View File
@@ -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();
}
}
+49
View File
@@ -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();
}
}
}
+91
View File
@@ -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;
}
}
+11 -2
View File
@@ -23,6 +23,7 @@ public sealed class InProcessReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
private readonly int _processId;
private bool _disposed;
/// <summary>
@@ -31,8 +32,10 @@ public sealed class InProcessReader : MemoryBase
public InProcessReader()
{
Process current = Process.GetCurrentProcess();
_processId = current.Id;
_handle = NativeMethods.OpenProcess(
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation,
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
| ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize,
false,
current.Id);
if (_handle.IsInvalid)
@@ -60,6 +63,12 @@ public sealed class InProcessReader : MemoryBase
/// <inheritdoc />
public override SafeMemoryHandle Handle => _handle;
/// <inheritdoc />
public override bool Is64Bit => Environment.Is64BitProcess;
/// <inheritdoc />
public override int ProcessId => _processId;
/// <inheritdoc />
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
@@ -84,7 +93,7 @@ public sealed class InProcessReader : MemoryBase
if (!_disposed)
{
_disposed = true;
_handle.Dispose();
base.Dispose();
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagic.Injection;
/// <summary>
/// Injects raw machine code into a process's memory.
/// </summary>
public static class CodeInjector
{
/// <summary>
/// Injects code at a specific address.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="address">The target address.</param>
/// <param name="code">The machine code bytes to write.</param>
/// <returns>The address the code was written to (same as <paramref name="address"/>).</returns>
/// <exception cref="ArgumentException"><paramref name="code"/> is empty.</exception>
/// <exception cref="Win32Exception">Write fails.</exception>
public static IntPtr InjectAtAddress(MemoryBase memory, IntPtr address, byte[] code)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentNullException.ThrowIfNull(code);
if (code.Length == 0)
throw new ArgumentException("Code cannot be empty.", nameof(code));
if (address == IntPtr.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
// Write the code to the target address
int written = memory.WriteBytes(address, code);
if (written != code.Length)
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error,
$"WriteProcessMemory failed at {address} (wrote {written} of {code.Length} bytes).");
}
return address;
}
/// <summary>
/// Allocates executable memory and injects code into it.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="code">The machine code bytes to inject.</param>
/// <param name="protection">
/// The memory protection. Defaults to <see cref="MemoryProtectionType.ExecuteReadWrite"/>.
/// </param>
/// <returns>
/// The base address of the allocated memory containing the code.
/// The caller is responsible for freeing this memory (e.g., via <see cref="AllocatedMemory.Dispose"/>).
/// </returns>
/// <exception cref="ArgumentException"><paramref name="code"/> is empty.</exception>
/// <exception cref="Win32Exception">Allocation or write fails.</exception>
public static AllocatedMemory Inject(
MemoryBase memory,
byte[] code,
MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentNullException.ThrowIfNull(code);
if (code.Length == 0)
throw new ArgumentException("Code cannot be empty.", nameof(code));
// Allocate memory with the specified protection
var allocated = new AllocatedMemory(memory, code.Length, protection);
// Write the code to the allocated memory
int written = memory.WriteBytes(allocated.BaseAddress, code);
if (written != code.Length)
{
int error = Marshal.GetLastPInvokeError();
allocated.Dispose();
throw new Win32Exception(error,
$"WriteProcessMemory failed (wrote {written} of {code.Length} bytes).");
}
return allocated;
}
}
+532
View File
@@ -0,0 +1,532 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using WhiteMagic.Native;
namespace WhiteMagic.Injection;
/// <summary>
/// Injects DLLs into an open target process by creating a remote thread or by
/// hijacking an existing thread.
/// </summary>
/// <remarks>
/// <para>
/// The DLL path is sent to <c>LoadLibraryW</c>, so it is encoded as a null-terminated
/// UTF-16 string in the target process.
/// </para>
/// <para>
/// Injection requires the target process to have the same bitness as the current
/// process, because the emitted x86/x64 stubs and the captured thread context must
/// match the target architecture.
/// </para>
/// </remarks>
public sealed class DllInjector
{
private readonly MemoryBase _memory;
private readonly bool _currentIs64Bit;
/// <summary>
/// Initializes a new <see cref="DllInjector"/> for the target represented by
/// <paramref name="memory"/>.
/// </summary>
/// <param name="memory">A reader/writer for the target process.</param>
public DllInjector(MemoryBase memory)
{
ArgumentNullException.ThrowIfNull(memory);
_memory = memory;
_currentIs64Bit = Environment.Is64BitProcess;
}
/// <summary>
/// Gets the <see cref="MemoryBase"/> the injector is operating on.
/// </summary>
public MemoryBase Memory => _memory;
/// <summary>
/// Injects a DLL into the target process by creating a remote thread that loads it.
/// </summary>
/// <param name="dllPath">The path to the DLL. The file must exist.</param>
/// <returns>The base address of the loaded module in the target process.</returns>
/// <exception cref="ArgumentException"><paramref name="dllPath"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException"><paramref name="dllPath"/> does not exist.</exception>
/// <exception cref="InvalidOperationException">The target bitness does not match the caller.</exception>
/// <exception cref="InvalidOperationException">The remote load failed or timed out.</exception>
public IntPtr InjectWithRemoteThread(string dllPath)
{
ValidateAndCheckBitness(dllPath);
IntPtr loadLibrary = ResolveLoadLibraryW();
byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
int pointerSize = _currentIs64Bit ? 8 : 4;
int stubSize = _currentIs64Bit ? 39 : 18;
int pathOffset = Align(stubSize, pointerSize);
int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
int totalSize = resultOffset + pointerSize + 4096;
IntPtr remoteBase = NativeMethods.VirtualAllocEx(
_memory.Handle,
IntPtr.Zero,
totalSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (remoteBase == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
}
try
{
IntPtr pathAddress = remoteBase + pathOffset;
IntPtr resultAddress = remoteBase + resultOffset;
if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
throw new InvalidOperationException("Failed to write the DLL path into the target process.");
byte[] stub = _currentIs64Bit
? BuildRemoteThreadStubX64(pathAddress, resultAddress, loadLibrary)
: BuildRemoteThreadStubX86(pathAddress, resultAddress, loadLibrary);
if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
throw new InvalidOperationException("Failed to write the remote thread stub.");
using SafeMemoryHandle thread = NativeMethods.CreateRemoteThread(
_memory.Handle,
IntPtr.Zero,
0,
remoteBase,
IntPtr.Zero,
ThreadCreationFlags.RunImmediately,
out _);
if (thread.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"CreateRemoteThread failed (error {error}).");
}
const uint timeoutMs = 30000;
uint wait = NativeMethods.WaitForSingleObject(thread, timeoutMs);
if (wait == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"WaitForSingleObject failed (error {error}).");
}
if (wait == 0x00000102)
throw new InvalidOperationException("Remote thread timed out while loading the DLL.");
IntPtr result = _memory.Read<IntPtr>(resultAddress);
if (result == IntPtr.Zero)
throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
return result;
}
finally
{
// The DLL is already loaded; the temporary stub, path and result slot can be released.
NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
}
}
/// <summary>
/// Injects a DLL by hijacking an existing thread in the target process.
/// </summary>
/// <param name="threadId">The operating-system identifier of the thread to hijack.</param>
/// <param name="dllPath">The path to the DLL. The file must exist.</param>
/// <returns>The base address of the loaded module in the target process.</returns>
/// <exception cref="ArgumentException"><paramref name="threadId"/> is not a positive value or
/// <paramref name="dllPath"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException"><paramref name="dllPath"/> does not exist.</exception>
/// <exception cref="InvalidOperationException">The target bitness does not match the caller.</exception>
/// <exception cref="InvalidOperationException">The hijack, load, or context restore failed.</exception>
public IntPtr InjectWithThreadHijack(int threadId, string dllPath)
{
if (threadId <= 0)
throw new ArgumentException("Thread ID must be a positive value.", nameof(threadId));
ValidateAndCheckBitness(dllPath);
IntPtr loadLibrary = ResolveLoadLibraryW();
byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
int pointerSize = _currentIs64Bit ? 8 : 4;
int stubSize = _currentIs64Bit ? 40 : 19;
int pathOffset = Align(stubSize, pointerSize);
int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
int totalSize = resultOffset + pointerSize + 4096;
IntPtr remoteBase = NativeMethods.VirtualAllocEx(
_memory.Handle,
IntPtr.Zero,
totalSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (remoteBase == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
}
try
{
IntPtr pathAddress = remoteBase + pathOffset;
IntPtr resultAddress = remoteBase + resultOffset;
if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
throw new InvalidOperationException("Failed to write the DLL path into the target process.");
byte[] stub = _currentIs64Bit
? BuildHijackStubX64(pathAddress, resultAddress, loadLibrary)
: BuildHijackStubX86(pathAddress, resultAddress, loadLibrary);
if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
throw new InvalidOperationException("Failed to write the hijack stub.");
nint stackTop = (nint)(remoteBase + totalSize);
stackTop = AlignDown(stackTop, pointerSize);
if (_currentIs64Bit)
stackTop = AlignDown(stackTop, 16);
using SafeMemoryHandle thread = NativeMethods.OpenThread(
ThreadAccess.SuspendResume | ThreadAccess.GetContext | ThreadAccess.SetContext | ThreadAccess.QueryInformation,
false,
threadId);
if (thread.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed (error {error}).");
}
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed (error {error}).");
}
try
{
IntPtr result;
if (_currentIs64Bit)
{
var originalContext = new Context64 { ContextFlags = ContextFlags.Amd64Full };
if (!NativeMethods.GetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed (error {error}).");
}
var redirectContext = originalContext;
redirectContext.Rip = (ulong)(nint)remoteBase;
redirectContext.Rsp = (ulong)stackTop;
if (!NativeMethods.SetThreadContext(thread, ref redirectContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread failed (error {error}).");
}
result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
}
if (!NativeMethods.SetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext restore failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
}
}
else
{
var originalContext = new Context32 { ContextFlags = ContextFlags.X86Full };
if (!NativeMethods.Wow64GetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Wow64GetThreadContext failed (error {error}).");
}
var redirectContext = originalContext;
redirectContext.Eip = (uint)(nint)remoteBase;
redirectContext.Esp = (uint)(nint)stackTop;
if (!NativeMethods.Wow64SetThreadContext(thread, ref redirectContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Wow64SetThreadContext failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread failed (error {error}).");
}
result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
}
if (!NativeMethods.Wow64SetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Wow64SetThreadContext restore failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
}
}
if (result == IntPtr.Zero)
throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
return result;
}
catch
{
// Best effort: resume the thread if we left it suspended.
_ = NativeMethods.ResumeThread(thread);
throw;
}
}
finally
{
NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
}
}
private void ValidateAndCheckBitness(string dllPath)
{
if (string.IsNullOrWhiteSpace(dllPath))
throw new ArgumentException("DLL path cannot be null or empty.", nameof(dllPath));
if (!File.Exists(dllPath))
throw new FileNotFoundException("The specified DLL was not found.", dllPath);
if (_memory.Is64Bit != _currentIs64Bit)
{
throw new InvalidOperationException(
"The target process bitness does not match the current process bitness.");
}
}
private static IntPtr ResolveLoadLibraryW()
{
// kernel32.dll is loaded at the same base address in every process at a given
// bitness, so resolving the export in the current process gives the correct
// target address for the remote process.
IntPtr kernel32 = NativeMethods.LoadLibrary("kernel32.dll");
if (kernel32 == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Unable to obtain a handle to kernel32.dll (error {error}).");
}
IntPtr loadLibrary = NativeMethods.GetProcAddress(kernel32, "LoadLibraryW");
if (loadLibrary == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Unable to resolve LoadLibraryW (error {error}).");
}
return loadLibrary;
}
private IntPtr WaitForResult(IntPtr resultAddress, TimeSpan timeout)
{
Stopwatch watch = Stopwatch.StartNew();
while (watch.Elapsed < timeout)
{
IntPtr value = _memory.Read<IntPtr>(resultAddress);
if (value != IntPtr.Zero)
return value;
Thread.Sleep(5);
}
return IntPtr.Zero;
}
private static byte[] BuildRemoteThreadStubX86(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
{
var buffer = new List<byte>(18);
// push pathAddress
buffer.Add(0x68);
EmitU32(buffer, (uint)(nint)pathAddress);
// mov ecx, LoadLibraryW
buffer.Add(0xB9);
EmitU32(buffer, (uint)(nint)loadLibrary);
// call ecx
buffer.Add(0xFF);
buffer.Add(0xD1);
// mov [resultAddress], eax
buffer.Add(0xA3);
EmitU32(buffer, (uint)(nint)resultAddress);
// ret
buffer.Add(0xC3);
return buffer.ToArray();
}
private static byte[] BuildRemoteThreadStubX64(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
{
var buffer = new List<byte>(39);
// mov rcx, pathAddress
buffer.Add(0x48);
buffer.Add(0xB9);
EmitU64(buffer, (ulong)(nint)pathAddress);
// mov rax, LoadLibraryW
buffer.Add(0x48);
buffer.Add(0xB8);
EmitU64(buffer, (ulong)(nint)loadLibrary);
// call rax
buffer.Add(0xFF);
buffer.Add(0xD0);
// mov rdx, rax
buffer.Add(0x48);
buffer.Add(0x89);
buffer.Add(0xC2);
// mov rax, resultAddress
buffer.Add(0x48);
buffer.Add(0xB8);
EmitU64(buffer, (ulong)(nint)resultAddress);
// mov [rax], rdx
buffer.Add(0x48);
buffer.Add(0x89);
buffer.Add(0x10);
// ret
buffer.Add(0xC3);
return buffer.ToArray();
}
private static byte[] BuildHijackStubX86(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
{
var buffer = new List<byte>(19);
// push pathAddress
buffer.Add(0x68);
EmitU32(buffer, (uint)(nint)pathAddress);
// mov ecx, LoadLibraryW
buffer.Add(0xB9);
EmitU32(buffer, (uint)(nint)loadLibrary);
// call ecx
buffer.Add(0xFF);
buffer.Add(0xD1);
// mov [resultAddress], eax
buffer.Add(0xA3);
EmitU32(buffer, (uint)(nint)resultAddress);
// jmp $ (infinite loop so the main injector can suspend and restore context)
buffer.Add(0xEB);
buffer.Add(0xFE);
return buffer.ToArray();
}
private static byte[] BuildHijackStubX64(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
{
var buffer = new List<byte>(40);
// mov rcx, pathAddress
buffer.Add(0x48);
buffer.Add(0xB9);
EmitU64(buffer, (ulong)(nint)pathAddress);
// mov rax, LoadLibraryW
buffer.Add(0x48);
buffer.Add(0xB8);
EmitU64(buffer, (ulong)(nint)loadLibrary);
// call rax
buffer.Add(0xFF);
buffer.Add(0xD0);
// mov rdx, rax
buffer.Add(0x48);
buffer.Add(0x89);
buffer.Add(0xC2);
// mov rax, resultAddress
buffer.Add(0x48);
buffer.Add(0xB8);
EmitU64(buffer, (ulong)(nint)resultAddress);
// mov [rax], rdx
buffer.Add(0x48);
buffer.Add(0x89);
buffer.Add(0x10);
// jmp $ (infinite loop)
buffer.Add(0xEB);
buffer.Add(0xFE);
return buffer.ToArray();
}
private static void EmitU32(List<byte> buffer, uint value)
{
buffer.Add((byte)value);
buffer.Add((byte)(value >> 8));
buffer.Add((byte)(value >> 16));
buffer.Add((byte)(value >> 24));
}
private static void EmitU64(List<byte> buffer, ulong value)
{
EmitU32(buffer, (uint)value);
EmitU32(buffer, (uint)(value >> 32));
}
private static int Align(int value, int alignment)
{
return (value + alignment - 1) / alignment * alignment;
}
private static nint AlignDown(nint value, int alignment)
{
return (nint)((nuint)value & ~((nuint)alignment - 1));
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Input;
/// <summary>
/// Mouse buttons supported by <see cref="InputSimulator.SendMouseClick"/>.
/// </summary>
public enum MouseButton
{
/// <summary>The left mouse button.</summary>
Left,
/// <summary>The right mouse button.</summary>
Right,
}
/// <summary>
/// Simulates keyboard and mouse input directed at a target window via window messages.
/// </summary>
public sealed class InputSimulator
{
/// <summary>
/// Sends a sequence of character messages to <paramref name="hWnd"/>.
/// </summary>
/// <returns><see langword="true"/> if every character was posted successfully.</returns>
public bool SendKeys(IntPtr hWnd, string text)
{
if (text is null)
throw new ArgumentNullException(nameof(text));
if (hWnd == IntPtr.Zero)
return false;
foreach (char c in text)
{
if (!NativeMethods.PostMessageW(hWnd, NativeMethods.WmChar, (nuint)c, 0))
return false;
}
return true;
}
/// <summary>
/// Sends a mouse click at client-area coordinates <paramref name="x"/>,
/// <paramref name="y"/> to <paramref name="hWnd"/>.
/// </summary>
/// <returns><see langword="true"/> if the click was posted successfully.</returns>
public bool SendMouseClick(IntPtr hWnd, int x, int y, MouseButton button)
{
if (hWnd == IntPtr.Zero)
return false;
nint lParam = MakeLong(x, y);
(uint down, uint up) = button switch
{
MouseButton.Left => (NativeMethods.WmLButtonDown, NativeMethods.WmLButtonUp),
MouseButton.Right => (NativeMethods.WmRButtonDown, NativeMethods.WmRButtonUp),
_ => throw new ArgumentOutOfRangeException(nameof(button)),
};
if (!NativeMethods.PostMessageW(hWnd, down, 0, lParam))
return false;
return NativeMethods.PostMessageW(hWnd, up, 0, lParam);
}
private static nint MakeLong(int low, int high)
{
return (nint)((uint)low | ((uint)high << 16));
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Diagnostics;
using Process = System.Diagnostics.Process;
using WhiteMagic.Execution;
using WhiteMagic.Hooking;
namespace WhiteMagic;
/// <summary>
/// High-level entry point for a WhiteMagic session. Opens a process, exposes the
/// memory reader, execution tiers, hooking managers, and the <see cref="RemotePointer"/>
/// indexer.
/// </summary>
public sealed class Magic : IDisposable
{
/// <summary>The underlying memory reader for this session.</summary>
public MemoryBase Memory { get; }
/// <summary>Out-of-process execution via <c>CreateRemoteThread</c>.</summary>
public RemoteThreadExecutor RemoteThread { get; }
/// <summary>Named byte-patch manager.</summary>
public PatchManager PatchManager => Memory.PatchManager;
/// <summary>Inline-detour manager (in-process only).</summary>
public DetourManager DetourManager => Memory.DetourManager;
private Magic(MemoryBase memory)
{
Memory = memory;
RemoteThread = new RemoteThreadExecutor(memory);
}
/// <summary>Opens an external process for reading, writing, and execution.</summary>
public static Magic Open(System.Diagnostics.Process process)
{
return new Magic(new ExternalReader(process));
}
/// <summary>Creates an in-process session for the current process.</summary>
public static Magic OpenInProcess()
{
return new Magic(new InProcessReader());
}
/// <summary>
/// Creates a main-thread pump that hooks the per-frame function at
/// <paramref name="frameAddress"/>.
/// </summary>
public MainThreadPump CreateMainThreadPump(IntPtr frameAddress)
{
return new MainThreadPump(DetourManager, frameAddress);
}
/// <summary>Returns a <see cref="RemotePointer"/> at <paramref name="address"/>.</summary>
public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
/// <inheritdoc />
public void Dispose()
{
Memory.Dispose();
}
}
+180
View File
@@ -0,0 +1,180 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Memory;
/// <summary>
/// Represents a chunk of remote memory subdivided into named regions.
/// </summary>
public sealed class AllocatedMemory : IDisposable
{
private readonly MemoryBase _memory;
private readonly IntPtr _baseAddress;
private readonly int _size;
private readonly Dictionary<string, int> _regions;
private bool _disposed;
/// <summary>
/// Creates a new allocated memory chunk.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="size">The size of the allocation in bytes.</param>
/// <param name="protection">The initial memory protection.</param>
/// <exception cref="Win32Exception">Allocation fails.</exception>
public AllocatedMemory(MemoryBase memory, int size, MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size);
_memory = memory;
_size = size;
_regions = new Dictionary<string, int>();
// Allocate using VirtualAllocEx
_baseAddress = NativeMethods.VirtualAllocEx(
memory.Handle,
IntPtr.Zero,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
protection);
if (_baseAddress == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error, $"VirtualAllocEx failed (size={size}).");
}
}
/// <summary>
/// Gets the base address of the allocated memory.
/// </summary>
public IntPtr BaseAddress => _baseAddress;
/// <summary>
/// Gets the size of the allocation in bytes.
/// </summary>
public int Size => _size;
/// <summary>
/// Adds a named region at a specific offset within the allocation.
/// </summary>
/// <param name="name">The unique name for the region.</param>
/// <param name="offset">The offset from the base address.</param>
/// <exception cref="ArgumentException">A region with this name already exists.</exception>
/// <exception cref="ArgumentOutOfRangeException">Offset is outside the allocation bounds.</exception>
public void AddRegion(string name, int offset)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(name);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(offset, _size);
if (_regions.ContainsKey(name))
throw new ArgumentException($"Region '{name}' already exists.", nameof(name));
_regions[name] = offset;
}
/// <summary>
/// Gets the absolute address of a named region.
/// </summary>
/// <param name="name">The region name.</param>
/// <returns>The absolute address of the region.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public IntPtr AddressOf(string name)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(name);
if (!_regions.TryGetValue(name, out int offset))
throw new ArgumentException($"Region '{name}' does not exist.", nameof(name));
return _baseAddress + offset;
}
/// <summary>
/// Reads a value of type <typeparamref name="T"/> from a named region.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="name">The region name.</param>
/// <returns>The value read from memory.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public T Read<T>(string name) where T : struct
{
ObjectDisposedException.ThrowIf(_disposed, this);
IntPtr address = AddressOf(name);
return _memory.Read<T>(address);
}
/// <summary>
/// Writes a value of type <typeparamref name="T"/> to a named region.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="name">The region name.</param>
/// <param name="value">The value to write.</param>
/// <returns><see langword="true"/> if all bytes were written.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public bool Write<T>(string name, T value) where T : struct
{
ObjectDisposedException.ThrowIf(_disposed, this);
IntPtr address = AddressOf(name);
return _memory.Write(address, value);
}
/// <summary>
/// Reads bytes from a named region.
/// </summary>
/// <param name="name">The region name.</param>
/// <param name="count">The number of bytes to read.</param>
/// <returns>The bytes read from memory.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public byte[] ReadBytes(string name, int count)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IntPtr address = AddressOf(name);
return _memory.ReadBytes(address, count);
}
/// <summary>
/// Writes bytes to a named region.
/// </summary>
/// <param name="name">The region name.</param>
/// <param name="bytes">The bytes to write.</param>
/// <returns>The number of bytes written.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public int WriteBytes(string name, ReadOnlySpan<byte> bytes)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IntPtr address = AddressOf(name);
return _memory.WriteBytes(address, bytes);
}
/// <summary>
/// Frees the allocated memory.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
// Free using VirtualFreeEx
if (_baseAddress != IntPtr.Zero)
{
NativeMethods.VirtualFreeEx(
_memory.Handle,
_baseAddress,
0,
MemoryFreeType.Release);
}
_regions.Clear();
}
}
}
+62 -46
View File
@@ -1,3 +1,4 @@
using WhiteMagic.Hooking;
using WhiteMagic.Native;
using System.Runtime.InteropServices;
using System.Text;
@@ -12,12 +13,31 @@ namespace WhiteMagic;
/// </summary>
public abstract class MemoryBase : IDisposable
{
/// <summary>Creates the shared hooking managers for this memory instance.</summary>
protected MemoryBase()
{
PatchManager = new PatchManager(this);
DetourManager = new DetourManager(this);
}
/// <summary>The base address of the target process's main module.</summary>
public abstract IntPtr ImageBase { get; }
/// <summary>The native handle to the target process.</summary>
public abstract SafeMemoryHandle Handle { get; }
/// <summary><see langword="true"/> if the target process is 64-bit.</summary>
public abstract bool Is64Bit { get; }
/// <summary>The operating-system process identifier of the target process.</summary>
public abstract int ProcessId { get; }
/// <summary>Named byte-patch manager; valid for in-process and external readers.</summary>
public PatchManager PatchManager { get; }
/// <summary>Inline-detour manager; valid only when operating in-process.</summary>
public DetourManager DetourManager { get; }
// ── Raw byte IO ────────────────────────────────────────────────────────
/// <summary>Reads a sequence of bytes from the target address.</summary>
@@ -153,11 +173,17 @@ public abstract class MemoryBase : IDisposable
/// chunks. Stops at the null terminator, the maximum length, or the first page boundary
/// that fails to read (avoids an atomic failure when a 512-byte window crosses an unmapped
/// region).</summary>
/// <param name="address">The address to read from.</param>
/// <param name="address">The address to read from. For multi-byte encodings this must be
/// aligned to a code-unit boundary or the result is undefined.</param>
/// <param name="encoding">The text encoding.</param>
/// <param name="maxLength">The maximum number of bytes to read.</param>
/// <param name="relative">If <see langword="true"/>, <paramref name="address"/> is relative
/// to <see cref="ImageBase"/>.</param>
/// <remarks>
/// The scan is aligned to the encoding's code-unit width (1 byte for UTF-8/ASCII, 2 bytes
/// for UTF-16, 4 bytes for UTF-32). The trailing bytes of each chunk are merged with the
/// next chunk so a null terminator that straddles the chunk boundary is not missed.
/// </remarks>
public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
{
if (relative)
@@ -166,46 +192,54 @@ public abstract class MemoryBase : IDisposable
// The encoded null terminator. For ASCII/UTF-8 this is a single 0x00 byte;
// for UTF-16 it is two zero bytes (0x00 0x00); for UTF-32 it is four.
byte[] nullTerminator = encoding.GetBytes("\0");
int nullLen = nullTerminator.Length;
const int chunkSize = 64;
int remaining = maxLength;
var accumulated = new System.Collections.Generic.List<byte[]>();
var accumulated = new System.Collections.Generic.List<byte>();
while (remaining > 0)
{
int take = Math.Min(chunkSize, remaining);
byte[] chunk = ReadBytes(address, take);
byte[] chunk = ReadBytes(address + accumulated.Count, take);
if (chunk.Length == 0)
break;
int nullPos = IndexOfPattern(chunk, nullTerminator);
if (nullPos >= 0)
int previousLen = accumulated.Count;
accumulated.AddRange(chunk);
// Search the newly extended buffer at code-unit-aligned positions. A terminator
// can start as far back as (nullLen - 1) bytes before the new bytes, so start
// the search just before the previous end, rounded up to the next code-unit.
int firstAligned = ((previousLen - nullLen + 1 + nullLen - 1) / nullLen) * nullLen;
firstAligned = Math.Max(0, firstAligned);
int limit = accumulated.Count - nullLen;
for (int i = firstAligned; i <= limit; i += nullLen)
{
if (nullPos > 0)
accumulated.Add(chunk[..nullPos]);
break;
bool match = true;
for (int j = 0; j < nullLen; j++)
{
if (accumulated[i + j] != nullTerminator[j])
{
match = false;
break;
}
}
if (match)
{
accumulated.RemoveRange(i, accumulated.Count - i);
remaining = 0;
break;
}
}
accumulated.Add(chunk);
// Advance by the bytes actually read, not the amount requested: a partial
// read (chunk.Length < take) must not skip the unread tail of the window.
address += chunk.Length;
remaining -= chunk.Length;
if (remaining > 0)
remaining -= chunk.Length;
}
int totalLength = 0;
foreach (byte[] part in accumulated)
totalLength += part.Length;
byte[] combined = new byte[totalLength];
int offset = 0;
foreach (byte[] part in accumulated)
{
part.CopyTo(combined, offset);
offset += part.Length;
}
return encoding.GetString(combined);
return encoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(accumulated));
}
/// <summary>Writes a null-terminated string to the target address.</summary>
@@ -239,6 +273,8 @@ public abstract class MemoryBase : IDisposable
/// <inheritdoc />
public virtual void Dispose()
{
DetourManager.RemoveAll();
PatchManager.RestoreAll();
Handle?.Dispose();
}
@@ -278,24 +314,4 @@ public abstract class MemoryBase : IDisposable
bytes.CopyTo(destination);
}
private static int IndexOfPattern(byte[] data, byte[] pattern)
{
int lastStart = data.Length - pattern.Length;
int stride = Math.Max(1, pattern.Length);
for (int i = 0; i <= lastStart; i += stride)
{
bool match = true;
for (int j = 0; j < pattern.Length; j++)
{
if (data[i + j] != pattern[j])
{
match = false;
break;
}
}
if (match)
return i;
}
return -1;
}
}
+22
View File
@@ -33,6 +33,28 @@ public enum ProcessAccess : uint
AllAccess = 0x001F0000 | Synchronize | 0xFFFF,
}
/// <summary>
/// Access rights that open a thread object.
/// </summary>
[Flags]
public enum ThreadAccess : uint
{
/// <summary>The right to terminate the thread with TerminateThread.</summary>
Terminate = 0x0001,
/// <summary>The right to suspend and resume the thread.</summary>
SuspendResume = 0x0002,
/// <summary>The right to read the thread context with GetThreadContext.</summary>
GetContext = 0x0008,
/// <summary>The right to set the thread context with SetThreadContext.</summary>
SetContext = 0x0010,
/// <summary>The right to query information from the thread.</summary>
QueryInformation = 0x0040,
/// <summary>The right to set information on the thread.</summary>
SetInformation = 0x0020,
/// <summary>All access rights for a thread object.</summary>
AllAccess = 0x001F0FFF,
}
/// <summary>
/// Values that control how VirtualAllocEx allocates memory.
/// </summary>
+38
View File
@@ -19,11 +19,32 @@ internal static partial class NativeMethods
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
int processId);
/// <summary>Opens an existing thread and returns a handle to it.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle OpenThread(
ThreadAccess desiredAccess,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
int threadId);
/// <summary>Closes an open object handle.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(IntPtr handle);
/// <summary>Determines whether the specified process is running under WOW64.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool IsWow64Process(
SafeMemoryHandle process,
[MarshalAs(UnmanagedType.Bool)] out bool wow64Process);
/// <summary>Retrieves the termination status of the specified thread.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GetExitCodeThread(
SafeMemoryHandle thread,
out uint exitCode);
// ── Memory ───────────────────────────────────────────────────────────────
/// <summary>Reads memory from a process.</summary>
@@ -87,6 +108,22 @@ internal static partial class NativeMethods
ThreadCreationFlags creationFlags,
out uint threadId);
/// <summary>Suspends the specified thread.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint SuspendThread(SafeMemoryHandle thread);
/// <summary>Resumes the specified thread.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint ResumeThread(SafeMemoryHandle thread);
/// <summary>Returns the thread identifier of the specified thread.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint GetThreadId(SafeMemoryHandle thread);
/// <summary>Returns the identifier of the calling thread.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint GetCurrentThreadId();
/// <summary>Sets a 64-bit thread context (AMD64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
@@ -134,4 +171,5 @@ internal static partial class NativeMethods
internal static partial uint WaitForSingleObject(
SafeMemoryHandle handle,
uint milliseconds);
}
+178
View File
@@ -0,0 +1,178 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace WhiteMagic.Native;
/// <summary>
/// P/Invoke declarations for kernel32/ntdll/user32 APIs used by the high-level
/// PEB, TEB, windowing, and input helpers. These live in a separate partial file so
/// they can evolve independently of <see cref="NativeMethods"/>.
/// </summary>
internal static partial class NativeMethods
{
// ── Natives used directly by public helpers ──────────────────────────────
/// <summary>Queries information about the specified process.</summary>
[LibraryImport("ntdll.dll")]
internal static partial int NtQueryInformationProcess(
SafeMemoryHandle processHandle,
int processInformationClass,
ref ProcessBasicInformation processInformation,
uint processInformationLength,
out uint returnLength);
/// <summary>Queries information about the specified thread.</summary>
[LibraryImport("ntdll.dll")]
internal static partial int NtQueryInformationThread(
SafeMemoryHandle threadHandle,
int threadInformationClass,
ref ThreadBasicInformation threadInformation,
uint threadInformationLength,
out uint returnLength);
/// <summary>Enumerates all top-level windows on the screen.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial int EnumWindows(
nint lpEnumFunc,
IntPtr lParam);
/// <summary>Retrieves the identifier of the thread that created the window and the process id of the window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial uint GetWindowThreadProcessId(
IntPtr hWnd,
out uint lpdwProcessId);
/// <summary>Retrieves the name of the class to which the specified window belongs.</summary>
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial int GetClassNameW(
IntPtr hWnd,
[Out] char[] lpClassName,
int nMaxCount);
/// <summary>Copies the text of the specified window's title bar into a buffer.</summary>
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial int GetWindowTextW(
IntPtr hWnd,
[Out] char[] lpString,
int nMaxCount);
/// <summary>Changes the text of the specified window's title bar.</summary>
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowTextW(
IntPtr hWnd,
string lpString);
/// <summary>Changes the size, position, and Z order of a child, pop-up, or top-level window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int x,
int y,
int cx,
int cy,
uint uFlags);
/// <summary>Retrieves a handle to the foreground window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial IntPtr GetForegroundWindow();
/// <summary>Brings the thread that created the specified window into the foreground and activates the window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetForegroundWindow(IntPtr hWnd);
/// <summary>Flashes the specified window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool FlashWindowEx(ref FlashWindowInfo pwfi);
/// <summary>Attaches or detaches the input processing mechanism of one thread to that of another thread.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool AttachThreadInput(
uint idAttach,
uint idAttachTo,
[MarshalAs(UnmanagedType.Bool)] bool fAttach);
/// <summary>Places a message in the message queue associated with the thread that created the specified window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool PostMessageW(
IntPtr hWnd,
uint msg,
nuint wParam,
nint lParam);
// ── Window / input constants ───────────────────────────────────────────────
internal const uint WmChar = 0x0102;
internal const uint WmLButtonDown = 0x0201;
internal const uint WmLButtonUp = 0x0202;
internal const uint WmRButtonDown = 0x0204;
internal const uint WmRButtonUp = 0x0205;
internal static readonly IntPtr HwndTop = IntPtr.Zero;
internal const uint SwpShowWindow = 0x0040;
internal const uint SwpNoActivate = 0x0010;
internal const uint FlashwAll = 0x00000003;
internal const uint FlashwCaption = 0x00000001;
internal const uint FlashwTray = 0x00000002;
internal const uint FlashwTimer = 0x00000004;
internal const uint FlashwTimerNoFg = 0x0000000C;
}
/// <summary>
/// Layout matches <c>PROCESS_BASIC_INFORMATION</c> (ProcessBasicInformation = 0).
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ProcessBasicInformation
{
public int ExitStatus;
public IntPtr PebBaseAddress;
public UIntPtr AffinityMask;
public int BasePriority;
public UIntPtr UniqueProcessId;
public UIntPtr InheritedFromUniqueProcessId;
}
/// <summary>
/// Layout matches <c>THREAD_BASIC_INFORMATION</c> (ThreadBasicInformation = 0).
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ThreadBasicInformation
{
public int ExitStatus;
public IntPtr TebBaseAddress;
public ClientId ClientId;
public UIntPtr AffinityMask;
public int Priority;
public int BasePriority;
}
/// <summary>
/// Layout matches <c>CLIENT_ID</c>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ClientId
{
public IntPtr UniqueProcess;
public IntPtr UniqueThread;
}
/// <summary>
/// Layout matches <c>FLASHWINFO</c> used by <see cref="NativeMethods.FlashWindowEx"/>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct FlashWindowInfo
{
public uint cbSize;
public IntPtr hwnd;
public uint dwFlags;
public uint uCount;
public uint dwTimeout;
}
+92
View File
@@ -0,0 +1,92 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.ProcessEnvironment;
/// <summary>
/// Managed reader for a target process's Process Environment Block (PEB).
/// </summary>
public sealed class ManagedPeb
{
private readonly MemoryBase _memory;
private readonly IntPtr _pebAddress;
/// <summary>
/// Creates a PEB reader for the process associated with the specified memory facade.
/// </summary>
public ManagedPeb(MemoryBase memory)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_pebAddress = QueryPebAddress();
}
/// <summary>Returns the native address of the PEB in the target process.</summary>
public IntPtr ReadPebAddress() => _pebAddress;
/// <summary>Reads the BeingDebugged byte from the PEB.</summary>
public byte ReadBeingDebugged()
{
return _memory.Read<byte>(_pebAddress + 2);
}
/// <summary>Reads the ImageBaseAddress pointer from the PEB.</summary>
public IntPtr ReadImageBaseAddress()
{
int offset = _memory.Is64Bit ? 0x10 : 0x08;
return ReadPointer(offset);
}
/// <summary>Reads the PEB_LDR_DATA pointer from the PEB.</summary>
public IntPtr ReadLdrAddress()
{
int offset = _memory.Is64Bit ? 0x18 : 0x0C;
return ReadPointer(offset);
}
/// <summary>
/// Determines whether the target process is running under WOW64.
/// </summary>
public bool ReadIsWow64Process()
{
if (!NativeMethods.IsWow64Process(_memory.Handle, out bool wow64))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"IsWow64Process failed with error {error}.");
}
return wow64;
}
private IntPtr QueryPebAddress()
{
var info = new ProcessBasicInformation();
int status = NativeMethods.NtQueryInformationProcess(
_memory.Handle,
0,
ref info,
(uint)Marshal.SizeOf<ProcessBasicInformation>(),
out _);
if (status < 0 || info.PebBaseAddress == IntPtr.Zero)
{
throw new InvalidOperationException(
$"NtQueryInformationProcess failed to retrieve the PEB (NTSTATUS {status:X8}).");
}
return info.PebBaseAddress;
}
private IntPtr ReadPointer(int offset)
{
IntPtr address = _pebAddress + offset;
if (_memory.Is64Bit)
{
ulong raw = _memory.Read<ulong>(address);
return new IntPtr((long)raw);
}
uint raw32 = _memory.Read<uint>(address);
return new IntPtr((int)raw32);
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text;
namespace WhiteMagic;
/// <summary>
/// A pointer-relative view over a <see cref="MemoryBase"/>. Obtained through the
/// high-level facade indexer, it provides read/write/string operations with optional
/// offsets relative to a base address.
/// </summary>
public sealed class RemotePointer
{
private readonly MemoryBase _memory;
/// <summary>The base address of this view.</summary>
public IntPtr BaseAddress { get; }
internal RemotePointer(MemoryBase memory, IntPtr baseAddress)
{
_memory = memory;
BaseAddress = baseAddress;
}
/// <summary>Reads a value of type <typeparamref name="T"/> at <c>BaseAddress + offset</c>.</summary>
public T Read<T>(nint offset = 0) where T : struct
{
return _memory.Read<T>(BaseAddress + offset);
}
/// <summary>Writes <paramref name="value"/> at <c>BaseAddress + offset</c>.</summary>
public bool Write<T>(T value, nint offset = 0) where T : struct
{
return _memory.Write(BaseAddress + offset, value);
}
/// <summary>Reads a null-terminated string at <c>BaseAddress + offset</c>.</summary>
public string ReadString(Encoding encoding, int maxLength = 512, nint offset = 0)
{
return _memory.ReadString(BaseAddress + offset, encoding ?? Encoding.UTF8, maxLength);
}
/// <summary>Writes a null-terminated string at <c>BaseAddress + offset</c>.</summary>
public bool WriteString(string value, Encoding encoding, nint offset = 0)
{
return _memory.WriteString(BaseAddress + offset, value, encoding ?? Encoding.UTF8);
}
/// <summary>Returns a new <see cref="RemotePointer"/> with the offset added.</summary>
public RemotePointer this[nint offset] => new RemotePointer(_memory, BaseAddress + offset);
}
+98
View File
@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.ThreadEnvironment;
/// <summary>
/// Managed reader for a target thread's Thread Environment Block (TEB).
/// </summary>
public sealed class ManagedTeb : IDisposable
{
private readonly MemoryBase _memory;
private readonly SafeMemoryHandle _threadHandle;
private readonly IntPtr _tebAddress;
private bool _disposed;
/// <summary>
/// Creates a TEB reader for the specified thread in the process associated
/// with the provided memory facade.
/// </summary>
public ManagedTeb(MemoryBase memory, int threadId)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_threadHandle = NativeMethods.OpenThread(
ThreadAccess.QueryInformation,
false,
threadId);
if (_threadHandle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"OpenThread failed for thread {threadId}: error {error}.");
}
_tebAddress = QueryTebAddress();
}
/// <summary>Returns the native address of the TEB in the target process.</summary>
public IntPtr ReadTebAddress() => _tebAddress;
/// <summary>Reads the stack base pointer stored in the TEB.</summary>
public IntPtr ReadStackBase()
{
int offset = _memory.Is64Bit ? 0x08 : 0x04;
return ReadPointer(offset);
}
/// <summary>Reads the stack limit pointer stored in the TEB.</summary>
public IntPtr ReadStackLimit()
{
int offset = _memory.Is64Bit ? 0x10 : 0x08;
return ReadPointer(offset);
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_threadHandle.Dispose();
}
}
private IntPtr QueryTebAddress()
{
var info = new ThreadBasicInformation();
int status = NativeMethods.NtQueryInformationThread(
_threadHandle,
0,
ref info,
(uint)Marshal.SizeOf<ThreadBasicInformation>(),
out _);
if (status < 0 || info.TebBaseAddress == IntPtr.Zero)
{
throw new InvalidOperationException(
$"NtQueryInformationThread failed to retrieve the TEB (NTSTATUS {status:X8}).");
}
return info.TebBaseAddress;
}
private IntPtr ReadPointer(int offset)
{
IntPtr address = _tebAddress + offset;
if (_memory.Is64Bit)
{
ulong raw = _memory.Read<ulong>(address);
return new IntPtr((long)raw);
}
uint raw32 = _memory.Read<uint>(address);
return new IntPtr((int)raw32);
}
}
+147
View File
@@ -0,0 +1,147 @@
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic.Native;
namespace WhiteMagic.Windows;
/// <summary>
/// Wrapper around a native window handle that supports querying and mutating
/// common window properties.
/// </summary>
public sealed class RemoteWindow
{
private const int MaxTextLength = 512;
/// <summary>Creates a wrapper for the specified window handle.</summary>
public RemoteWindow(IntPtr handle)
{
if (handle == IntPtr.Zero)
throw new ArgumentException("Window handle cannot be zero.", nameof(handle));
Handle = handle;
}
/// <summary>The native window handle.</summary>
public IntPtr Handle { get; }
/// <summary>The window class name.</summary>
public string ClassName => GetClassName(Handle);
/// <summary>The current window text.</summary>
public string Text => GetWindowText(Handle);
/// <summary>The process identifier that owns the window.</summary>
public uint ProcessId => GetWindowProcessId(Handle);
/// <summary>Gets or sets the window title.</summary>
public string Title
{
get => GetWindowText(Handle);
set
{
if (value is null)
throw new ArgumentNullException(nameof(value));
if (!NativeMethods.SetWindowTextW(Handle, value))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"SetWindowText failed for window {Handle} with error {error}.");
}
}
}
/// <summary><see langword="true"/> if this window is currently the foreground window.</summary>
public bool IsActive => NativeMethods.GetForegroundWindow() == Handle;
/// <summary>Moves and resizes the window.</summary>
public bool MoveResize(int x, int y, int width, int height)
{
return NativeMethods.SetWindowPos(
Handle,
NativeMethods.HwndTop,
x,
y,
width,
height,
NativeMethods.SwpShowWindow);
}
/// <summary>Activates the window and brings it to the foreground.</summary>
public bool Activate()
{
IntPtr foreground = NativeMethods.GetForegroundWindow();
uint targetThread = NativeMethods.GetWindowThreadProcessId(Handle, out _);
uint foregroundThread = NativeMethods.GetWindowThreadProcessId(foreground, out _);
if (targetThread == 0)
return false;
if (targetThread == foregroundThread)
return NativeMethods.SetForegroundWindow(Handle);
if (!NativeMethods.AttachThreadInput(foregroundThread, targetThread, true))
return false;
try
{
return NativeMethods.SetForegroundWindow(Handle);
}
finally
{
NativeMethods.AttachThreadInput(foregroundThread, targetThread, false);
}
}
/// <summary>Flashes the window in the caption and taskbar button.</summary>
public bool Flash()
{
var info = new FlashWindowInfo
{
cbSize = (uint)Marshal.SizeOf<FlashWindowInfo>(),
hwnd = Handle,
dwFlags = NativeMethods.FlashwAll,
uCount = 3,
dwTimeout = 0,
};
return NativeMethods.FlashWindowEx(ref info);
}
public override string ToString()
{
var sb = new StringBuilder();
sb.Append("RemoteWindow(");
sb.Append(Handle.ToString("X"));
sb.Append(", ");
sb.Append(ClassName);
sb.Append(")");
return sb.ToString();
}
private static string GetClassName(IntPtr handle)
{
var buffer = new char[256];
int length = NativeMethods.GetClassNameW(handle, buffer, buffer.Length);
if (length <= 0)
return string.Empty;
return new string(buffer, 0, length);
}
private static string GetWindowText(IntPtr handle)
{
var buffer = new char[MaxTextLength];
int length = NativeMethods.GetWindowTextW(handle, buffer, buffer.Length);
if (length <= 0)
return string.Empty;
return new string(buffer, 0, length);
}
private static uint GetWindowProcessId(IntPtr handle)
{
NativeMethods.GetWindowThreadProcessId(handle, out uint processId);
return processId;
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
using WhiteMagic.Native;
namespace WhiteMagic.Windows;
/// <summary>
/// Factory for enumerating and locating <see cref="RemoteWindow"/> instances.
/// </summary>
public static class WindowFactory
{
/// <summary>Enumerates all top-level windows.</summary>
public static unsafe IEnumerable<RemoteWindow> GetWindows()
{
var handles = new List<IntPtr>();
GCHandle gch = GCHandle.Alloc(handles);
try
{
delegate* unmanaged[Stdcall]<IntPtr, IntPtr, int> callback = &EnumWindowsCallback;
NativeMethods.EnumWindows((nint)callback, GCHandle.ToIntPtr(gch));
}
finally
{
gch.Free();
}
return handles.Select(static h => new RemoteWindow(h));
}
/// <summary>Returns all top-level windows with the specified class name.</summary>
public static IEnumerable<RemoteWindow> GetWindowsByClassName(string className)
{
if (className is null)
throw new ArgumentNullException(nameof(className));
return GetWindows().Where(w => w.ClassName.Equals(className, StringComparison.Ordinal));
}
/// <summary>Returns all top-level windows owned by the specified process.</summary>
public static IEnumerable<RemoteWindow> GetWindowsByProcessId(int processId)
{
return GetWindows().Where(w => w.ProcessId == (uint)processId);
}
/// <summary>Returns the first top-level window with the specified class name.</summary>
public static RemoteWindow? GetWindowByClassName(string className)
{
return GetWindowsByClassName(className).FirstOrDefault();
}
/// <summary>
/// Returns the main window of a process. When <see cref="Process.MainWindowHandle"/>
/// is unavailable, falls back to the first enumerated window owned by the process.
/// </summary>
public static RemoteWindow? GetMainWindow(System.Diagnostics.Process process)
{
if (process is null)
throw new ArgumentNullException(nameof(process));
if (process.MainWindowHandle != IntPtr.Zero)
return new RemoteWindow(process.MainWindowHandle);
return GetWindowsByProcessId(process.Id).FirstOrDefault();
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
private static int EnumWindowsCallback(IntPtr hWnd, IntPtr lParam)
{
var handles = (List<IntPtr>)GCHandle.FromIntPtr(lParam).Target!;
handles.Add(hWnd);
return 1; // Continue enumeration.
}
}