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.
}
}
@@ -0,0 +1,201 @@
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Discovery;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PatternScannerCache"/>.
/// </summary>
public class PatternScannerCacheTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void FindCached_returns_same_result_on_second_call()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
// Create a buffer with a known pattern
byte[] buffer = new byte[256];
buffer[30] = 0x11;
buffer[31] = 0x22;
buffer[32] = 0x33;
buffer[33] = 0x44;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0x11, 0x22, 0x33, 0x44 };
// First call should scan memory
IntPtr first = cache.FindCached(pattern, null, addr, end);
// Second call should return cached result
IntPtr second = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 30, first);
Assert.Equal(first, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindCached_different_ranges_are_cached_separately()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
// Create two separate buffers
byte[] buffer1 = new byte[128];
buffer1[10] = 0xAA;
buffer1[11] = 0xBB;
byte[] buffer2 = new byte[128];
buffer2[20] = 0xAA;
buffer2[21] = 0xBB;
GCHandle pin1 = GCHandle.Alloc(buffer1, GCHandleType.Pinned);
GCHandle pin2 = GCHandle.Alloc(buffer2, GCHandleType.Pinned);
try
{
IntPtr addr1 = pin1.AddrOfPinnedObject();
IntPtr end1 = addr1 + buffer1.Length;
IntPtr addr2 = pin2.AddrOfPinnedObject();
IntPtr end2 = addr2 + buffer2.Length;
byte[] pattern = { 0xAA, 0xBB };
IntPtr found1 = cache.FindCached(pattern, null, addr1, end1);
IntPtr found2 = cache.FindCached(pattern, null, addr2, end2);
Assert.Equal(addr1 + 10, found1);
Assert.Equal(addr2 + 20, found2);
Assert.NotEqual(found1, found2);
}
finally
{
pin1.Free();
pin2.Free();
}
}
[Fact]
public void FindCached_with_mask_caches_correctly()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[40] = 0x99;
buffer[41] = 0x88; // This is wildcard
buffer[42] = 0x77;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0x99, 0x00, 0x77 };
string mask = "x?x";
IntPtr first = cache.FindCached(pattern, mask, addr, end);
IntPtr second = cache.FindCached(pattern, mask, addr, end);
Assert.Equal(addr + 40, first);
Assert.Equal(first, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void Clear_clears_cached_results()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[50] = 0xCC;
buffer[51] = 0xDD;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0xCC, 0xDD };
// Cache a result
IntPtr first = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 50, first);
// Clear the cache
cache.Clear();
// This should rescan (not return cached result)
IntPtr second = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 50, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindInModuleCached_caches_module_scans()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
// MZ header is always at the start of the main module
byte[] pattern = { 0x4D, 0x5A };
IntPtr first = cache.FindInModuleCached(pattern, null, mainModule);
IntPtr second = cache.FindInModuleCached(pattern, null, mainModule);
Assert.Equal(mainModule.BaseAddress, first);
Assert.Equal(first, second);
}
[Fact]
public void FindInModulesCached_caches_multiple_modules()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var modules = currentProcess.Modules.Cast<System.Diagnostics.ProcessModule>().ToList();
Assert.NotEmpty(modules);
// MZ header should be present in at least one module
byte[] pattern = { 0x4D, 0x5A };
IntPtr first = cache.FindInModulesCached(pattern, null, modules);
IntPtr second = cache.FindInModulesCached(pattern, null, modules);
Assert.NotEqual(IntPtr.Zero, first);
Assert.Equal(first, second);
}
}
@@ -0,0 +1,188 @@
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Discovery;
using WhiteMagicTest;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PatternScanner"/>.
/// </summary>
public class PatternScannerTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void Find_exact_pattern_returns_correct_address()
{
using var reader = CreateReader();
// Create a buffer with known bytes
byte[] buffer = new byte[256];
buffer[10] = 0xDE;
buffer[11] = 0xAD;
buffer[12] = 0xBE;
buffer[13] = 0xEF;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search for the exact pattern
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(addr + 10, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_with_wildcard_mask_ignores_wildcard_bytes()
{
using var reader = CreateReader();
// Create a buffer with known bytes
byte[] buffer = new byte[256];
buffer[20] = 0x12;
buffer[21] = 0x34; // This byte is wildcard
buffer[22] = 0x56;
buffer[23] = 0x78;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search with wildcard mask (x = match, ? = wildcard)
byte[] pattern = { 0x12, 0x00, 0x56, 0x78 };
string mask = "x?xx"; // Second byte is wildcard
IntPtr found = PatternScanner.Find(reader, pattern, mask, addr, end);
Assert.Equal(addr + 20, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_pattern_not_found_returns_zero()
{
using var reader = CreateReader();
// Create a buffer without the target pattern
byte[] buffer = new byte[256];
for (int i = 0; i < buffer.Length; i++)
buffer[i] = 0xAA;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search for pattern that doesn't exist
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(IntPtr.Zero, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_empty_pattern_throws()
{
using var reader = CreateReader();
byte[] pattern = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, null, IntPtr.Zero, (IntPtr)1000));
Assert.Contains("Pattern cannot be empty", ex.Message);
}
[Fact]
public void Find_mask_length_mismatch_throws()
{
using var reader = CreateReader();
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
string mask = "xxx"; // Wrong length
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
Assert.Contains("Mask length", ex.Message);
}
[Fact]
public void Find_invalid_mask_char_throws()
{
using var reader = CreateReader();
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
string mask = "axxx"; // 'a' is invalid
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
}
[Fact]
public void Find_null_mask_treats_all_as_exact()
{
using var reader = CreateReader();
byte[] buffer = new byte[256];
buffer[50] = 0xAB;
buffer[51] = 0xCD;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Null mask should behave like "xx" (exact match)
byte[] pattern = { 0xAB, 0xCD };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(addr + 50, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindInModule_scans_current_process_module()
{
using var reader = CreateReader();
// Get the current process's main module
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
// MZ header is always at the start of the main module
byte[] pattern = { 0x4D, 0x5A };
IntPtr found = PatternScanner.FindInModule(reader, pattern, null, mainModule);
Assert.Equal(mainModule.BaseAddress, found);
}
}
@@ -0,0 +1,156 @@
using WhiteMagic;
using WhiteMagic.Discovery;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PeHeaderParser"/>.
/// </summary>
public class PeHeaderParserTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void EntryPoint_returns_nonzero_for_current_module()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
IntPtr entryPoint = parser.EntryPoint;
// Entry point should be a valid RVA (non-zero for a valid PE)
Assert.NotEqual(IntPtr.Zero, entryPoint);
// Entry point should be less than module size
Assert.True((nint)entryPoint < mainModule.ModuleMemorySize);
}
[Fact]
public void Sections_enumerates_at_least_text_section()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.ToList();
Assert.NotEmpty(sections);
// Every PE file should have a .text section (or similar)
var textSection = sections.FirstOrDefault(s =>
s.Name.Equals(".text", StringComparison.OrdinalIgnoreCase) ||
s.Name.Equals("TEXT", StringComparison.OrdinalIgnoreCase));
// May not find ".text" exactly, but should have at least some sections
Assert.True(sections.Count >= 1);
}
[Fact]
public void Sections_have_valid_properties()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.ToList();
foreach (var section in sections)
{
// Name should not be empty
Assert.False(string.IsNullOrWhiteSpace(section.Name));
// Virtual address should be within module bounds
Assert.True((nint)section.VirtualAddress < mainModule.ModuleMemorySize);
// Virtual size should be positive
Assert.True(section.VirtualSize > 0);
}
}
[Fact]
public void Sections_have_common_names()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.Select(s => s.Name).ToList();
// At least some common section names should be present
var commonNames = new[] { ".text", ".data", ".rdata", ".bss" };
bool hasCommonSection = commonNames.Any(name =>
sections.Contains(name, StringComparer.OrdinalIgnoreCase));
// This might not always be true, but for managed EXEs it usually is
// We'll just verify sections were enumerated
Assert.NotEmpty(sections);
}
[Fact]
public void EntryPoint_is_consistent_across_calls()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
IntPtr first = parser.EntryPoint;
IntPtr second = parser.EntryPoint;
Assert.Equal(first, second);
}
[Fact]
public void Sections_are_consistent_across_calls()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var first = parser.Sections.ToList();
var second = parser.Sections.ToList();
Assert.Equal(first.Count, second.Count);
for (int i = 0; i < first.Count; i++)
{
Assert.Equal(first[i].Name, second[i].Name);
Assert.Equal(first[i].VirtualAddress, second[i].VirtualAddress);
Assert.Equal(first[i].VirtualSize, second[i].VirtualSize);
}
}
[Fact]
public void Constructor_throws_on_zero_base_address()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentException>(() =>
new PeHeaderParser(reader, IntPtr.Zero));
Assert.Contains("Base address cannot be zero", ex.Message);
}
}
@@ -0,0 +1,77 @@
using System;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Execution;
using Xunit;
namespace WhiteMagicTest.Execution;
/// <summary>
/// Tests for <see cref="InProcessInvoker"/> operating in-process.
/// </summary>
public class InProcessInvokerTests
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AddDelegate(int a, int b);
private static int NativeAdd(int a, int b) => a + b;
[Fact]
public void CreateFunction_calls_known_in_process_function()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
var native = new AddDelegate(NativeAdd);
IntPtr functionPointer = Marshal.GetFunctionPointerForDelegate(native);
AddDelegate callable = invoker.CreateFunction<AddDelegate>(functionPointer);
int result = callable(5, 7);
Assert.Equal(12, result);
GC.KeepAlive(native);
}
[Fact]
public void CreateFunction_rejects_zero_address()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
ArgumentException ex = Assert.Throws<ArgumentException>(() => invoker.CreateFunction<AddDelegate>(IntPtr.Zero));
Assert.Equal("address", ex.ParamName);
}
[Fact]
public void Vtable_helper_reads_function_pointer_slot()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
// Build a tiny fake vtable in a pinned buffer: two slots holding known pointers.
IntPtr slot0 = Marshal.GetFunctionPointerForDelegate(new AddDelegate(NativeAdd));
IntPtr slot1 = new IntPtr(0x12345678);
IntPtr[] vtable;
if (reader.Is64Bit)
{
vtable = [slot0, slot1];
}
else
{
vtable = [slot0, slot1];
}
GCHandle pin = GCHandle.Alloc(vtable, GCHandleType.Pinned);
try
{
IntPtr vTableAddress = pin.AddrOfPinnedObject();
Assert.Equal(slot0, invoker.ReadVTableFunction(vTableAddress, 0));
Assert.Equal(slot1, invoker.ReadVTableFunction(vTableAddress, 1));
}
finally
{
pin.Free();
}
}
}
@@ -0,0 +1,209 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Execution;
using WhiteMagic.Native;
namespace WhiteMagicTest.Execution;
public sealed class RemoteThreadExecutorTests
{
// x64 payloads. Live execution tests run only on x64 because the payloads use the
// Microsoft x64 ABI (integer args in RCX, RDX, R8, R9, then stack at [rsp+0x28]).
// mov eax, ecx
// add eax, edx
// ret
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
// mov eax, ecx
// add eax, edx
// add eax, r8d
// add eax, r9d
// add eax, [rsp+0x28]
// ret
private static readonly byte[] SumFivePayload =
[
0x89, 0xC8,
0x01, 0xD0,
0x44, 0x01, 0xC0,
0x44, 0x01, 0xC8,
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
0xC3
];
// xor eax, eax
// cmp byte ptr [rcx+rax], 0
// je done
// inc eax
// jmp loop
// done: ret
private static readonly byte[] Utf8LengthPayload =
[
0x31, 0xC0,
0x80, 0x3C, 0x01, 0x00,
0x74, 0x04,
0xFF, 0xC0,
0xEB, 0xF6,
0xC3
];
// mov eax, [rcx]
// add eax, [rcx+4]
// ret
private static readonly byte[] PointSumPayload = [0x8B, 0x01, 0x03, 0x41, 0x04, 0xC3];
[StructLayout(LayoutKind.Sequential)]
private struct Point
{
public int X;
public int Y;
}
[Fact]
public void Execute_adds_two_integers()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(AddPayload, CallConvention.Cdecl, 10, 32);
Assert.Equal(42, result);
}
[Fact]
public void Execute_sums_register_and_stack_arguments()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(SumFivePayload, CallConvention.Cdecl, 1, 2, 3, 4, 5);
Assert.Equal(15, result);
}
[Fact]
public void Execute_marshals_string_as_utf8_pointer()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(Utf8LengthPayload, CallConvention.Cdecl, "hello");
Assert.Equal(5, result);
}
[Fact]
public void Execute_marshals_struct_as_pointer()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(PointSumPayload, CallConvention.Cdecl, new Point { X = 30, Y = 12 });
Assert.Equal(42, result);
}
[Fact]
public void Execute_throws_when_handle_is_invalid()
{
var executor = new RemoteThreadExecutor(new InvalidProcessReader());
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
{
executor.Execute<int>((IntPtr)0x1234, CallConvention.Cdecl);
});
Assert.Contains("handle", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Execute_throws_when_address_is_zero()
{
using var reader = new InProcessReader();
var executor = new RemoteThreadExecutor(reader);
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
{
executor.Execute<int>(IntPtr.Zero, CallConvention.Cdecl);
});
Assert.Equal("address", ex.ParamName);
}
[Fact]
public void InProcessReader_reports_current_process_bitness()
{
using var reader = new InProcessReader();
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
}
[Fact]
public void ExternalReader_reports_current_process_bitness()
{
using var reader = new ExternalReader(Process.GetCurrentProcess());
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
}
private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
{
const nint pageSize = 4096;
const nint blockSize = pageSize * 2;
using var reader = new InProcessReader();
var executor = new RemoteThreadExecutor(reader);
// Allocate a single executable block. The payload lives at the start and the
// generated call stub is written to the second page, guaranteeing that the
// relative CALL instruction stays within its ±2 GiB range.
IntPtr block = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
blockSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, block);
IntPtr stubAddress = block + pageSize;
executor.StubAllocator = (_, size) => size <= pageSize ? stubAddress : IntPtr.Zero;
try
{
int written = reader.WriteBytes(block, payload);
Assert.Equal(payload.Length, written);
return executor.Execute<int>(block, convention, args);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
}
}
private sealed class InvalidProcessReader : MemoryBase
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle { get; } = new SafeMemoryHandle(new IntPtr(-1));
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
=> throw new NotSupportedException();
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
=> throw new NotSupportedException();
public override void Dispose()
{
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for the high-level facade (<see cref="Magic"/>) and <see cref="RemotePointer"/>.
/// </summary>
public class HighLevelTests
{
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
[Fact]
public void OpenExternal_returns_session_for_current_process()
{
using var magic = Magic.Open(Process.GetCurrentProcess());
Assert.NotNull(magic.Memory);
Assert.False(magic.Memory.Handle.IsInvalid);
Assert.Same(magic.Memory.PatchManager, magic.PatchManager);
Assert.Same(magic.Memory.DetourManager, magic.DetourManager);
}
[Fact]
public void OpenInProcess_returns_session_for_self()
{
using var magic = Magic.OpenInProcess();
Assert.IsType<InProcessReader>(magic.Memory);
Assert.False(magic.Memory.Handle.IsInvalid);
}
[Fact]
public void Indexer_returns_remote_pointer_that_reads_and_writes_relative()
{
using var magic = Magic.OpenInProcess();
byte[] slot = new byte[16];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr baseAddr = pin.AddrOfPinnedObject();
magic[baseAddr + 4].Write(0x12345678);
Assert.Equal(0x12345678, magic[baseAddr].Read<int>(4));
}
finally
{
pin.Free();
}
}
[Fact]
public async Task RemoteThread_ExecuteAsync_runs_payload_and_returns_result()
{
if (!Environment.Is64BitProcess)
{
return;
}
using var magic = Magic.OpenInProcess();
IntPtr payload = NativeMethods.VirtualAllocEx(
magic.Memory.Handle,
IntPtr.Zero,
4096,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, payload);
try
{
magic.Memory.WriteBytes(payload, AddPayload);
int result = await magic.RemoteThread.ExecuteAsync<int>(payload, CallConvention.Cdecl, 10, 32);
Assert.Equal(42, result);
}
finally
{
NativeMethods.VirtualFreeEx(magic.Memory.Handle, payload, 0, MemoryFreeType.Release);
}
}
}
+278
View File
@@ -0,0 +1,278 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic;
using WhiteMagic.Hooking;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest.Hooking;
/// <summary>
/// Tests for <see cref="PatchManager"/>, <see cref="DetourManager"/> and
/// <see cref="Execution.MainThreadPump"/> operating in-process.
/// </summary>
public class HookingTests
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int FrameFunc();
private const int FrameResult = 42;
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
/// <summary>
/// Allocates a tiny executable function whose prologue is made entirely of
/// covered instruction shapes, so detours apply cleanly in tests.
/// </summary>
private static IntPtr AllocateFrameStub(MemoryBase reader, out IntPtr allocationBase)
{
// x64: push rbp; push rdi; push rsi; push rbx; sub rsp, 0x28; sub rsp, 0x12345678;
// mov eax, 42; add rsp, 0x12345678; add rsp, 0x28; pop rbx; pop rsi; pop rdi; pop rbp; ret
byte[] code =
[
0x55, // push rbp
0x57, // push rdi
0x56, // push rsi
0x53, // push rbx
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12, // sub rsp, 0x12345678
0xB8, 0x2A, 0x00, 0x00, 0x00, // mov eax, 42
0x48, 0x81, 0xC4, 0x78, 0x56, 0x34, 0x12, // add rsp, 0x12345678
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0x5B, // pop rbx
0x5E, // pop rsi
0x5F, // pop rdi
0x5D, // pop rbp
0xC3 // ret
];
allocationBase = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
code.Length,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, allocationBase);
reader.WriteBytes(allocationBase, code);
return allocationBase;
}
[Fact]
public void Patch_apply_writes_bytes_and_remove_restores_original()
{
using var reader = CreateReader();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] original = reader.ReadBytes(addr, 4);
byte[] patchBytes = [0x90, 0x90, 0x90, 0x90];
Patch patch = reader.PatchManager.Create("nop", addr, patchBytes);
Assert.False(patch.IsApplied);
patch.Apply();
Assert.True(patch.IsApplied);
Assert.Equal(patchBytes, reader.ReadBytes(addr, 4));
patch.Remove();
Assert.False(patch.IsApplied);
Assert.Equal(original, reader.ReadBytes(addr, 4));
}
finally
{
pin.Free();
}
}
[Fact]
public void Detour_apply_redirects_callOriginal_remove_restores()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
int hookCalls = 0;
Detour? detour = null;
FrameFunc hook = () =>
{
hookCalls++;
return (int?)detour?.CallOriginal() ?? 0;
};
detour = reader.DetourManager.Create("frame", targetPtr, hook);
detour.Apply();
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
int result = routed();
Assert.True(hookCalls > 0);
Assert.Equal(FrameResult, result);
detour.Remove();
hookCalls = 0;
result = routed();
Assert.Equal(0, hookCalls);
Assert.Equal(FrameResult, result);
GC.KeepAlive(hook);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_named_lookup_returns_existing_detour()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
Detour detour = reader.DetourManager.Create("lookup", targetPtr, (FrameFunc)(() => FrameResult));
Assert.Same(detour, reader.DetourManager["lookup"]);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_aligned_prologue_applies_and_unknown_prologue_rejects()
{
using var reader = CreateReader();
// A normal JIT-compiled function has a covered prologue shape.
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr goodAllocation);
try
{
Detour good = reader.DetourManager.Create("good", targetPtr, (FrameFunc)(() => FrameResult));
good.Apply();
good.Remove();
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, goodAllocation, 0, MemoryFreeType.Release);
}
// Allocate a small executable region whose first instruction is outside the
// covered set. The decoder must refuse to splice it.
IntPtr code = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
32,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, code);
try
{
// 0x0F 0x05 = syscall (not covered), followed by padding and a ret.
byte[] unknown = [0x0F, 0x05, 0xC3, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC];
reader.WriteBytes(code, unknown);
Detour bad = reader.DetourManager.Create("bad", code, (FrameFunc)(() => FrameResult));
Assert.Throws<InvalidOperationException>(() => bad.Apply());
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, code, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Dispose_restores_active_patches_and_detours()
{
InProcessReader reader = CreateReader();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] original = reader.ReadBytes(addr, 2);
Patch patch = reader.PatchManager.Create("dispose-patch", addr, [0x90, 0x90]);
patch.Apply();
reader.Dispose();
// Verify with a fresh reader; the original handle was closed by Dispose.
using var verify = CreateReader();
Assert.Equal(original, verify.ReadBytes(addr, 2));
}
finally
{
pin.Free();
}
}
[Fact]
public async Task MainThreadPump_drains_work_on_frame_call_and_uninstalls_on_dispose()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> work = pump.ExecuteAsync(() => 123);
// Drive the frame function manually. The detoured frame runs the pump hook on
// this thread, drains the work queue, then calls the original frame function.
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
int frameResult = routed();
Assert.Equal(FrameResult, frameResult);
Assert.Equal(123, await work);
pump.Dispose();
// After uninstall, calling the frame function should behave like the original.
routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
Assert.Equal(FrameResult, routed());
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_exception_survives_and_does_not_kill_pump()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> bad = pump.ExecuteAsync<int>(() => throw new InvalidOperationException("boom"));
Task<int> good = pump.ExecuteAsync(() => 7);
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
routed();
await Assert.ThrowsAsync<InvalidOperationException>(() => bad);
Assert.Equal(7, await good);
pump.Dispose();
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
}
@@ -0,0 +1,214 @@
using System.ComponentModel;
using WhiteMagic;
using WhiteMagic.Injection;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagicTest.Injection;
/// <summary>
/// Tests for <see cref="CodeInjector"/>.
/// </summary>
public class CodeInjectorTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void InjectAtAddress_writes_code_to_specified_address()
{
using var reader = CreateReader();
// Allocate a buffer to write to
byte[] buffer = new byte[32];
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
buffer,
System.Runtime.InteropServices.GCHandleType.Pinned);
try
{
IntPtr addr = handle.AddrOfPinnedObject();
// Simple x64 payload: mov eax, 42; ret
// B8 2A 00 00 00 C3
byte[] code = { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
if (Environment.Is64BitProcess)
{
// 64-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3)
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
}
else
{
// 32-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3) - same encoding
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
}
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
Assert.Equal(addr, result);
Assert.Equal(code, buffer.Take(code.Length).ToArray());
}
finally
{
handle.Free();
}
}
[Fact]
public void InjectAtAddress_throws_on_empty_code()
{
using var reader = CreateReader();
byte[] code = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
Assert.Contains("Code cannot be empty", ex.Message);
}
[Fact]
public void InjectAtAddress_throws_on_zero_address()
{
using var reader = CreateReader();
byte[] code = { 0x90, 0x90, 0xC3 }; // nop; nop; ret
var ex = Assert.Throws<ArgumentException>(() =>
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
Assert.Contains("Address cannot be zero", ex.Message);
}
[Fact]
public void Inject_allocates_and_writes_code()
{
using var reader = CreateReader();
// Simple x64 payload: ret (C3)
byte[] code = { 0xC3 };
using var allocated = CodeInjector.Inject(reader, code);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
Assert.Equal(code.Length, allocated.Size);
// Verify the code was written
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
Assert.Equal(code, readBack);
}
[Fact]
public void Inject_with_execute_read_write_protection()
{
using var reader = CreateReader();
byte[] code = { 0xC3 }; // ret
using var allocated = CodeInjector.Inject(
reader,
code,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Inject_with_read_only_protection()
{
using var reader = CreateReader();
byte[] code = { 0xC3 }; // ret
using var allocated = CodeInjector.Inject(
reader,
code,
MemoryProtectionType.ExecuteRead);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Inject_throws_on_empty_code()
{
using var reader = CreateReader();
byte[] code = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
CodeInjector.Inject(reader, code));
Assert.Contains("Code cannot be empty", ex.Message);
}
[Fact]
public void Inject_returns_allocated_memory_that_can_be_freed()
{
using var reader = CreateReader();
byte[] code = { 0xC3 }; // ret
var allocated = CodeInjector.Inject(reader, code);
Assert.NotNull(allocated);
// Dispose should free the memory
allocated.Dispose();
// No exception should be thrown during disposal
}
[Fact]
public void InjectAtAddress_writes_all_bytes()
{
using var reader = CreateReader();
// Allocate a buffer
byte[] buffer = new byte[128];
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
buffer,
System.Runtime.InteropServices.GCHandleType.Pinned);
try
{
IntPtr addr = handle.AddrOfPinnedObject();
// Create a larger payload
byte[] code = new byte[64];
for (int i = 0; i < code.Length; i++)
code[i] = (byte)(i & 0xFF);
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
Assert.Equal(addr, result);
// Verify all bytes were written
byte[] readBack = reader.ReadBytes(addr, code.Length);
Assert.Equal(code, readBack);
}
finally
{
handle.Free();
}
}
[Fact]
public void Inject_with_complex_payload()
{
using var reader = CreateReader();
// mov eax, 12345678h; ret
// x64: B8 78 56 34 12 C3
// x86: B8 78 56 34 12 C3 (same)
byte[] code = { 0xB8, 0x78, 0x56, 0x34, 0x12, 0xC3 };
using var allocated = CodeInjector.Inject(reader, code);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
// Verify the exact payload was written
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
Assert.Equal(code, readBack);
}
}
@@ -0,0 +1,140 @@
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Injection;
using WhiteMagic.Native;
namespace WhiteMagicTest.Injection;
/// <summary>
/// Tests for <see cref="DllInjector"/>.
/// </summary>
/// <remarks>
/// Real injection tests exercise the current process, because it is always available
/// and the injected DLLs are ordinary system modules that are already loaded.
/// </remarks>
public class DllInjectorTests
{
private static string GetExistingSystemDll()
{
// user32.dll exists on every Windows system and matches the host bitness.
string path = Path.Combine(Environment.SystemDirectory, "user32.dll");
Assert.True(File.Exists(path), $"{path} must exist for the test.");
return path;
}
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
public void InjectWithRemoteThread_loads_system_dll_in_current_process()
{
using var reader = new InProcessReader();
var injector = new DllInjector(reader);
IntPtr moduleBase = injector.InjectWithRemoteThread(GetExistingSystemDll());
Assert.NotEqual(IntPtr.Zero, moduleBase);
}
[Fact]
public void InjectWithRemoteThread_throws_for_missing_dll()
{
using var reader = new InProcessReader();
var injector = new DllInjector(reader);
string missingPath = Path.Combine(Path.GetTempPath(), $"wm-missing-{Guid.NewGuid()}.dll");
Assert.False(File.Exists(missingPath));
Assert.Throws<FileNotFoundException>(() => injector.InjectWithRemoteThread(missingPath));
}
[Fact]
public void InjectWithRemoteThread_rejects_bitness_mismatch()
{
// A fake reader that reports the opposite bitness from the current process.
using var reader = new FakeBitnessMemoryBase(!Environment.Is64BitProcess);
var injector = new DllInjector(reader);
var ex = Assert.Throws<InvalidOperationException>(
() => injector.InjectWithRemoteThread(GetExistingSystemDll()));
Assert.Contains("bitness", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
public void InjectWithThreadHijack_loads_system_dll_and_restores_context()
{
using var reader = new InProcessReader();
var injector = new DllInjector(reader);
using var stopEvent = new ManualResetEventSlim(false);
using var startedEvent = new ManualResetEventSlim(false);
int osThreadId = 0;
Exception? threadError = null;
var helper = new Thread(() =>
{
try
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
startedEvent.Set();
// Loop with short sleeps so the thread can be hijacked safely and can
// also be stopped once its original context is restored.
while (!stopEvent.IsSet)
{
Thread.Sleep(10);
}
}
catch (Exception ex)
{
threadError = ex;
}
});
helper.IsBackground = true;
helper.Start();
try
{
Assert.True(startedEvent.Wait(TimeSpan.FromSeconds(5)), "Helper thread did not start.");
Assert.NotEqual(0, osThreadId);
IntPtr moduleBase = injector.InjectWithThreadHijack(osThreadId, GetExistingSystemDll());
Assert.NotEqual(IntPtr.Zero, moduleBase);
// Tell the helper thread to exit. If the original context was restored correctly,
// the thread will return to its loop and observe the stop event.
stopEvent.Set();
Assert.True(helper.Join(TimeSpan.FromSeconds(5)), "Helper thread did not exit after context restore.");
Assert.Null(threadError);
}
finally
{
stopEvent.Set();
helper.Join(TimeSpan.FromSeconds(5));
}
}
/// <summary>
/// A minimal <see cref="MemoryBase"/> whose only job is to report a chosen bitness.
/// Reads and writes are not expected to be called by the rejection path.
/// </summary>
private sealed class FakeBitnessMemoryBase : MemoryBase
{
public FakeBitnessMemoryBase(bool is64Bit)
{
Is64Bit = is64Bit;
}
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle => new(IntPtr.Zero);
public override bool Is64Bit { get; }
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
=> throw new NotSupportedException();
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,41 @@
using System.Diagnostics;
using WhiteMagic.Input;
using WhiteMagic.Windows;
using Xunit;
namespace WhiteMagicTest.Input;
public sealed class InputSimulatorTests
{
[Fact(Skip = "Interactive input test - requires a visible window")]
public void SendKeys_to_current_window_does_not_throw()
{
using var process = Process.GetCurrentProcess();
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
? process.MainWindowHandle
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero)
return;
var simulator = new InputSimulator();
bool result = simulator.SendKeys(handle, "ab");
Assert.True(result);
}
[Fact(Skip = "Interactive input test - requires a visible window")]
public void SendMouseClick_to_current_window_does_not_throw()
{
using var process = Process.GetCurrentProcess();
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
? process.MainWindowHandle
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero)
return;
var simulator = new InputSimulator();
bool result = simulator.SendMouseClick(handle, 10, 20, MouseButton.Left);
Assert.True(result);
}
}
@@ -0,0 +1,254 @@
using System.ComponentModel;
using WhiteMagic;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagicTest.Memory;
/// <summary>
/// Tests for <see cref="AllocatedMemory"/>.
/// </summary>
public class AllocatedMemoryTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void Constructor_allocates_memory_with_execute_read_write_protection()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
Assert.Equal(4096, allocated.Size);
}
[Fact]
public void Constructor_with_custom_protection()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096, MemoryProtectionType.ReadOnly);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Constructor_throws_on_negative_size()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
new AllocatedMemory(reader, -1));
Assert.Equal("size", ex.ParamName);
}
[Fact]
public void Constructor_throws_on_zero_size()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
new AllocatedMemory(reader, 0));
Assert.Equal("size", ex.ParamName);
}
[Fact]
public void AddRegion_adds_named_region()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("test", 100);
Assert.Equal(100, allocated.AddressOf("test") - allocated.BaseAddress);
}
[Fact]
public void AddRegion_throws_on_duplicate_name()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("test", 100);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.AddRegion("test", 200));
Assert.Contains("already exists", ex.Message);
}
[Fact]
public void AddRegion_throws_on_negative_offset()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.AddRegion("test", -1));
Assert.Equal("offset", ex.ParamName);
}
[Fact]
public void AddRegion_throws_on_offset_exceeding_size()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.AddRegion("test", 4096));
Assert.Equal("offset", ex.ParamName);
}
[Fact]
public void AddressOf_returns_correct_address()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("region1", 0);
allocated.AddRegion("region2", 100);
allocated.AddRegion("region3", 200);
Assert.Equal(allocated.BaseAddress, allocated.AddressOf("region1"));
Assert.Equal(allocated.BaseAddress + 100, allocated.AddressOf("region2"));
Assert.Equal(allocated.BaseAddress + 200, allocated.AddressOf("region3"));
}
[Fact]
public void AddressOf_throws_on_unknown_region()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.AddressOf("unknown"));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Write_and_Read_int_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("value", 0);
int original = unchecked((int)0xDEADBEEF);
Assert.True(allocated.Write("value", original));
int read = allocated.Read<int>("value");
Assert.Equal(original, read);
}
[Fact]
public void Write_and_Read_long_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("value", 8);
long original = 0x123456789ABCDEF0;
Assert.True(allocated.Write("value", original));
long read = allocated.Read<long>("value");
Assert.Equal(original, read);
}
[Fact]
public void WriteBytes_and_ReadBytes_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("buffer", 0);
byte[] original = { 0x01, 0x02, 0x03, 0x04, 0x05 };
int written = allocated.WriteBytes("buffer", original);
Assert.Equal(original.Length, written);
byte[] read = allocated.ReadBytes("buffer", original.Length);
Assert.Equal(original, read);
}
[Fact]
public void Dispose_frees_memory()
{
using var reader = CreateReader();
var allocated = new AllocatedMemory(reader, 4096);
IntPtr baseAddr = allocated.BaseAddress;
Assert.NotEqual(IntPtr.Zero, baseAddr);
allocated.Dispose();
// After dispose, accessing properties should throw ObjectDisposedException
Assert.Throws<ObjectDisposedException>(() =>
allocated.AddressOf("any"));
}
[Fact]
public void Multiple_regions_independent_access()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("a", 0);
allocated.AddRegion("b", 4);
allocated.AddRegion("c", 8);
Assert.True(allocated.Write("a", 0x11111111));
Assert.True(allocated.Write("b", 0x22222222));
Assert.True(allocated.Write("c", 0x33333333));
Assert.Equal(0x11111111, allocated.Read<int>("a"));
Assert.Equal(0x22222222, allocated.Read<int>("b"));
Assert.Equal(0x33333333, allocated.Read<int>("c"));
}
[Fact]
public void Write_to_unknown_region_throws()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.Write("unknown", 42));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Read_from_unknown_region_throws()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.Read<int>("unknown"));
Assert.Contains("does not exist", ex.Message);
}
}
+2
View File
@@ -160,6 +160,8 @@ public class MemoryHardeningTests
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle => null!;
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
@@ -0,0 +1,28 @@
using System.Diagnostics;
using WhiteMagic;
using WhiteMagic.ProcessEnvironment;
using Xunit;
namespace WhiteMagicTest.ProcessEnvironment;
public sealed class ManagedPebTests
{
[Fact]
public void Read_current_process_peb_fields_returns_plausible_values()
{
using var magic = Magic.OpenInProcess();
var peb = new ManagedPeb(magic.Memory);
Assert.NotEqual(IntPtr.Zero, peb.ReadPebAddress());
Assert.NotEqual(IntPtr.Zero, peb.ReadImageBaseAddress());
byte beingDebugged = peb.ReadBeingDebugged();
Assert.True(beingDebugged == 0 || beingDebugged == 1);
Assert.NotEqual(IntPtr.Zero, peb.ReadLdrAddress());
// The in-process test process is native to the host architecture, so
// it is not running under WOW64.
Assert.False(peb.ReadIsWow64Process());
}
}
+55
View File
@@ -157,6 +157,61 @@ public class StringReadWriteTests
}
}
/// <summary>
/// UTF-16 null terminator split across the 64-byte chunk boundary must still be found.
/// The first chunk ends at byte 63, so the null bytes at 64/65 are in the second chunk.
/// </summary>
[Fact]
public void ReadString_utf16_null_across_chunk_boundary_is_found()
{
using var reader = OpenSelf();
byte[] slot = new byte[256];
// 32 'A' UTF-16 chars = 64 bytes, no embedded null.
byte[] text = Encoding.Unicode.GetBytes(new string('A', 32));
Assert.Equal(64, text.Length);
text.CopyTo(slot, 0);
// Null terminator at bytes 64/65.
slot[64] = 0x00;
slot[65] = 0x00;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 256);
Assert.Equal(new string('A', 32), result);
}
finally
{
pin.Free();
}
}
/// <summary>
/// A byte sequence that looks like a null at a misaligned offset must not stop the scan.
/// "A" + U+4200 produces bytes 41 00 00 42 00 00; bytes 1-2 are an aligned-position null
/// only if scanned byte-by-byte. The aligned UTF-16 scan must see the real terminator.
/// </summary>
[Fact]
public void ReadString_utf16_does_not_stop_at_misaligned_null()
{
using var reader = OpenSelf();
byte[] slot = Encoding.Unicode.GetBytes("A\u4200\0");
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 64);
Assert.Equal("A\u4200", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteString_empty_string_writes_only_null()
{
@@ -0,0 +1,23 @@
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.ThreadEnvironment;
using Xunit;
namespace WhiteMagicTest.ThreadEnvironment;
public sealed class ManagedTebTests
{
[Fact]
public void Read_current_thread_teb_fields_returns_plausible_values()
{
using var magic = Magic.OpenInProcess();
using var teb = new ManagedTeb(magic.Memory, (int)NativeMethods.GetCurrentThreadId());
Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
Assert.NotEqual(IntPtr.Zero, teb.ReadStackBase());
Assert.NotEqual(IntPtr.Zero, teb.ReadStackLimit());
// The stack grows down, so the base is above the limit on x86/x64.
Assert.True((nuint)teb.ReadStackBase() > (nuint)teb.ReadStackLimit());
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic.Windows;
using Xunit;
namespace WhiteMagicTest.Windows;
public sealed class WindowTests
{
[Fact]
public void GetWindows_returns_at_least_one_top_level_window()
{
var windows = WindowFactory.GetWindows().ToList();
Assert.NotEmpty(windows);
}
[Fact]
public void GetWindowsByClassName_filters_to_matching_classes()
{
var all = WindowFactory.GetWindows().ToList();
if (all.Count == 0)
return;
string firstClass = all[0].ClassName;
if (string.IsNullOrEmpty(firstClass))
return;
var filtered = WindowFactory.GetWindowsByClassName(firstClass).ToList();
Assert.All(filtered, w => Assert.Equal(firstClass, w.ClassName));
Assert.True(filtered.Count <= all.Count);
}
[Fact]
public void RemoteWindow_can_query_and_manipulate_a_test_window()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
// The xUnit runner may not expose a main window; skip destructive
// manipulation but still validate factory enumeration above.
return;
}
var window = new RemoteWindow(handle);
Assert.Equal(handle, window.Handle);
string text = window.Text;
Assert.NotNull(text);
string originalTitle = window.Title;
Assert.Equal(text, originalTitle);
// Move and resize, then restore the original position.
bool moved = window.MoveResize(10, 10, 400, 300);
Assert.True(moved);
bool flashed = window.Flash();
Assert.True(flashed);
// Restore a sensible size without asserting exact title restoration;
// terminal windows often ignore SetWindowText.
bool restored = window.MoveResize(0, 0, 800, 600);
Assert.True(restored);
}
}
+32 -32
View File
@@ -17,7 +17,7 @@
- [x] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag)
- [x] 2.8 Implement addressing helpers to pass 2.7
- [x] 2.9 Add tests + implementation for `InProcessReader` (RPM/WPM on a self-handle — see D1 deviation note; direct deref rejected because .NET cannot catch `AccessViolationException`); verify shared `MemoryBase` API works for both readers
- [ ] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (single-byte encodings). Fix: align the scan to the encoding's code-unit width and carry the last `(nullLen-1)` bytes across chunks. Add a UTF-16 test.
- [x] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (single-byte encodings). Fix: align the scan to the encoding's code-unit width and carry the last `(nullLen-1)` bytes across chunks. Add a UTF-16 test.
## 3. Managed Assembler (spec: managed-assembler)
@@ -33,45 +33,45 @@
## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking)
- [ ] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
- [ ] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
- [ ] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
- [ ] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
- [ ] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
- [ ] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
- [ ] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
- [ ] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
- [ ] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
- [ ] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
- [ ] 4.11 Add tests for `RemoteThreadExecutor.Execute<T>` (convention stub + wait + typed exit; no-process failure is deterministic)
- [ ] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
- [x] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
- [x] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
- [x] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
- [x] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
- [x] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
- [x] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
- [x] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
- [x] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
- [x] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
- [x] 4.11 Add tests for `RemoteThreadExecutor.Execute<T>` (convention stub + wait + typed exit; no-process failure is deterministic)
- [x] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
## 5. Injection & Discovery (spec: dll-injection, memory-discovery)
- [ ] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
- [ ] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
- [ ] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
- [ ] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
- [ ] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
- [ ] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
- [ ] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
- [ ] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
- [x] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
- [x] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
- [x] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
- [x] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
- [x] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
- [x] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
- [x] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
- [x] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
## 6. In-Process Tier (spec: remote-execution)
- [ ] 6.1 Add tests for `InProcessInvoker.CreateFunction<TDelegate>` calling a known in-process function directly
- [ ] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
- [ ] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
- [x] 6.1 Add tests for `InProcessInvoker.CreateFunction<TDelegate>` calling a known in-process function directly
- [x] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
- [x] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
## 7. High-Level Ergonomics (spec: high-level-api)
- [ ] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
- [x] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
- [ ] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy
- [ ] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
- [ ] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
- [ ] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
- [ ] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
- [ ] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
- [x] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
- [x] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
- [x] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
- [x] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
- [x] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
## 8. Optional Iced Backend (spec: managed-assembler)
@@ -81,7 +81,7 @@
## 9. Verification
- [ ] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass
- [ ] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
- [x] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass (180 pass, 4 integration/interactive skipped)
- [x] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
- [ ] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green
- [ ] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation