diff --git a/WhiteMagic/Discovery/PatternScanner.cs b/WhiteMagic/Discovery/PatternScanner.cs new file mode 100644 index 0000000..a4316d0 --- /dev/null +++ b/WhiteMagic/Discovery/PatternScanner.cs @@ -0,0 +1,201 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace WhiteMagic.Discovery; + +/// +/// Scans process memory for a byte pattern with an optional wildcard mask. +/// +public static class PatternScanner +{ + /// + /// Scans a memory range for the first occurrence of a pattern with an optional wildcard mask. + /// + /// The memory accessor. + /// The byte pattern to search for. + /// + /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard". + /// If , all bytes are treated as 'x' (exact match). + /// + /// The starting address of the scan range. + /// The ending address (exclusive) of the scan range. + /// The address of the first match, or if not found. + /// + /// is empty, or length does not match + /// length, or contains invalid characters. + /// + /// Memory read fails with an unexpected error. + 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; + } + + /// + /// Scans a module's memory region (from its base address through its size) for a pattern. + /// + /// The memory accessor. + /// The byte pattern to search for. + /// + /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard". + /// If , all bytes are treated as 'x' (exact match). + /// + /// The module to scan. + /// The address of the first match, or if not found. + 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); + } + + /// + /// Scans multiple modules for a pattern, returning the first match found. + /// + /// The memory accessor. + /// The byte pattern to search for. + /// + /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard". + /// If , all bytes are treated as 'x' (exact match). + /// + /// The modules to scan, in order. + /// The address of the first match, or if not found. + public static IntPtr FindInModules( + MemoryBase memory, + byte[] pattern, + string? mask, + IEnumerable 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; + } + + /// + /// Searches a buffer for the first pattern match given a mask. + /// + 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; + } +} diff --git a/WhiteMagic/Discovery/PatternScannerCache.cs b/WhiteMagic/Discovery/PatternScannerCache.cs new file mode 100644 index 0000000..8843012 --- /dev/null +++ b/WhiteMagic/Discovery/PatternScannerCache.cs @@ -0,0 +1,157 @@ +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace WhiteMagic.Discovery; + +/// +/// Caches pattern scan results to avoid repeated scans of the same memory range. +/// +public sealed class PatternScannerCache +{ + private readonly ConcurrentDictionary _cache = new(); + private readonly MemoryBase _memory; + + /// + /// Creates a new cache for the given memory accessor. + /// + /// The memory accessor to scan. + public PatternScannerCache(MemoryBase memory) + { + ArgumentNullException.ThrowIfNull(memory); + _memory = memory; + } + + /// + /// Finds a pattern, returning a cached result if available. + /// + /// The byte pattern to search for. + /// + /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard". + /// If , all bytes are treated as 'x' (exact match). + /// + /// The starting address of the scan range. + /// The ending address (exclusive) of the scan range. + /// + /// The address of the first match from cache or memory, or if not found. + /// + 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; + } + + /// + /// Finds a pattern within a module, returning a cached result if available. + /// + /// The byte pattern to search for. + /// + /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard". + /// If , all bytes are treated as 'x' (exact match). + /// + /// The module to scan. + /// + /// The address of the first match from cache or memory, or if not found. + /// + 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); + } + + /// + /// Finds a pattern across multiple modules, returning a cached result if available. + /// + /// The byte pattern to search for. + /// + /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard". + /// If , all bytes are treated as 'x' (exact match). + /// + /// The modules to scan, in order. + /// + /// The address of the first match from cache or memory, or if not found. + /// + public IntPtr FindInModulesCached( + byte[] pattern, + string? mask, + IEnumerable 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; + } + + /// + /// Clears all cached scan results. + /// + public void Clear() + { + _cache.Clear(); + } + + /// + /// Cache key combining pattern, mask, and address range. + /// + private sealed record CacheKey( + byte[] Pattern, + string? Mask, + IntPtr Start, + IntPtr End, + IReadOnlyList? Modules = null) : IEquatable + { + // 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(); + } + } +} diff --git a/WhiteMagic/Discovery/PeHeaderParser.cs b/WhiteMagic/Discovery/PeHeaderParser.cs new file mode 100644 index 0000000..723ba70 --- /dev/null +++ b/WhiteMagic/Discovery/PeHeaderParser.cs @@ -0,0 +1,225 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Discovery; + +/// +/// Represents a section in a PE file. +/// +public readonly record struct PeSection +{ + /// + /// The 8-byte null-terminated section name (e.g., ".text", ".data"). + /// + public string Name { get; init; } + + /// + /// The virtual address of the section when loaded into memory (RVA). + /// + public IntPtr VirtualAddress { get; init; } + + /// + /// The size of the section in memory. + /// + public int VirtualSize { get; init; } +} + +/// +/// Parses PE headers to expose section information and entry points. +/// +public sealed class PeHeaderParser +{ + private readonly MemoryBase _memory; + private readonly IntPtr _baseAddress; + + /// + /// Creates a new PE header parser for the module at the specified base address. + /// + /// The memory accessor. + /// The base address of the module. + 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; + } + + /// + /// Gets the entry point RVA (Relative Virtual Address) of the PE file. + /// + /// The entry point RVA, or if unavailable. + /// Reading memory fails. + /// The PE headers are invalid. + 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)); + } + } + } + + /// + /// Enumerates all sections in the PE file. + /// + /// An enumerable of PE sections. + /// Reading memory fails. + /// The PE headers are invalid. + public IEnumerable 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 + }; + } + } + } + + /// + /// Parses the DOS header, PE signature, and optional header. + /// + 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); + } + + /// + /// Parses just the optional header (for entry point). + /// + private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeader() + { + return ParseOptionalHeaderAndSectionHeaders(); + } + + /// + /// Determines whether the PE file is PE32+ (64-bit) or PE32 (32-bit). + /// + 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; + } + + /// + /// Parses a null-terminated 8-byte section name. + /// + private static string ParseSectionName(byte[] sectionHeader) + { + // Name is first 8 bytes + var nameBytes = new Span(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]); + } +} diff --git a/WhiteMagic/Execution/InProcessInvoker.cs b/WhiteMagic/Execution/InProcessInvoker.cs new file mode 100644 index 0000000..bcbe31b --- /dev/null +++ b/WhiteMagic/Execution/InProcessInvoker.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.InteropServices; + +namespace WhiteMagic.Execution; + +/// +/// 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. +/// +/// +/// +/// 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. +/// +public sealed class InProcessInvoker +{ + private readonly MemoryBase _memory; + + /// Creates an invoker bound to the supplied memory reader. + public InProcessInvoker(MemoryBase memory) + { + _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + } + + /// + /// Creates a managed delegate of type that calls + /// the native function at . + /// + /// A delegate type whose signature matches the native function. + public TDelegate CreateFunction(IntPtr address) + where TDelegate : Delegate + { + if (address == IntPtr.Zero) + { + throw new ArgumentException( + "Function address cannot be zero.", nameof(address)); + } + + return Marshal.GetDelegateForFunctionPointer(address); + } + + /// + /// Reads the vtable pointer stored at the start of an object in memory. + /// + /// The address of the object instance. + /// The address of the vtable. + public IntPtr ReadVTable(IntPtr objectAddress) + { + return _memory.Read(objectAddress); + } + + /// + /// Reads a function pointer from a vtable by index. + /// + /// The address of the vtable. + /// The zero-based index of the method slot. + /// The address in the specified vtable slot. + public IntPtr ReadVTableFunction(IntPtr vTableAddress, int methodIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(methodIndex); + + int pointerSize = _memory.Is64Bit ? 8 : 4; + IntPtr slotAddress = vTableAddress + (methodIndex * pointerSize); + return _memory.Read(slotAddress); + } + + /// + /// Convenience helper that reads an object's vtable and returns the function + /// address at the requested method index. + /// + public IntPtr GetObjectVTableFunction(IntPtr objectAddress, int methodIndex) + { + IntPtr vTable = ReadVTable(objectAddress); + return ReadVTableFunction(vTable, methodIndex); + } +} diff --git a/WhiteMagic/Execution/MainThreadPump.cs b/WhiteMagic/Execution/MainThreadPump.cs new file mode 100644 index 0000000..d90fb13 --- /dev/null +++ b/WhiteMagic/Execution/MainThreadPump.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using WhiteMagic.Hooking; + +namespace WhiteMagic.Execution; + +/// +/// 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. +/// +/// +/// The pump assumes the frame function is parameterless and returns an . +/// This matches common per-frame functions such as D3D9 EndScene. +/// +public sealed class MainThreadPump : IDisposable +{ + private readonly DetourManager _detours; + private readonly IntPtr _frameAddress; + private readonly ConcurrentQueue _queue = new(); + + private Detour? _detour; + private bool _installed; + + /// + /// Creates a pump that will hook the frame function at . + /// + public MainThreadPump(DetourManager detours, IntPtr frameAddress) + { + _detours = detours; + _frameAddress = frameAddress; + } + + /// Returns after the frame hook has been applied. + public bool IsInstalled => _installed; + + /// Installs the frame-function detour. + public void Install() + { + if (_installed) + return; + + _detour = _detours.Create("MainThreadPump", _frameAddress, (FrameDelegate)PumpHook); + _detour.Apply(); + _installed = true; + } + + /// + /// Queues work to run on the hooked thread and blocks until it completes. + /// + public TResult Execute(Func work) + { + if (!_installed) + { + throw new InvalidOperationException( + "The main-thread pump is not installed. Call Install() first."); + } + + var tcs = new TaskCompletionSource(); + _queue.Enqueue(new WorkItem(() => work()!, tcs)); + + object? result = tcs.Task.GetAwaiter().GetResult(); + return (TResult)result!; + } + + /// + /// Queues work to run on the hooked thread and returns a . + /// + public Task ExecuteAsync(Func work) + { + if (!_installed) + { + throw new InvalidOperationException( + "The main-thread pump is not installed. Call Install() first."); + } + + var tcs = new TaskCompletionSource(); + object? Box() => work()!; + _queue.Enqueue(new WorkItem(Box, r => tcs.SetResult((TResult)r!), ex => tcs.SetException(ex))); + return tcs.Task; + } + + /// Removes the frame-function detour if it is installed. + 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? _setResult; + private readonly Action? _setException; + + public WorkItem(Func work, Action setResult, Action setException) + { + Work = work; + _setResult = setResult; + _setException = setException; + } + + public WorkItem(Func work, TaskCompletionSource tcs) + { + Work = work; + _setResult = r => tcs.SetResult(r); + _setException = ex => tcs.SetException(ex); + } + + public Func Work { get; } + + public void SetResult(object? result) + { + _setResult?.Invoke(result); + } + + public void SetException(Exception exception) + { + _setException?.Invoke(exception); + } + } +} diff --git a/WhiteMagic/Execution/RemoteThreadExecutor.cs b/WhiteMagic/Execution/RemoteThreadExecutor.cs new file mode 100644 index 0000000..0f897dd --- /dev/null +++ b/WhiteMagic/Execution/RemoteThreadExecutor.cs @@ -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; + +/// +/// Executes a function in the target process by creating a remote thread at a +/// calling-convention-aware stub. Waits for the thread to finish and returns the +/// typed exit value read from the thread's exit code. +/// +/// +/// This executor is safe only for thread-agnostic payloads. Calls that touch +/// single-threaded process state should use instead. +/// String arguments are encoded as null-terminated UTF-8 and allocated in the +/// remote process; struct arguments are serialized with the default interop marshaler +/// () and allocated with +/// bytes. All temporary remote allocations are released after the call, including on failure. +/// +public sealed class RemoteThreadExecutor +{ + private const uint WaitObject0 = 0x00000000; + private const uint WaitTimeout = 0x00000102; + private const uint WaitFailed = 0xFFFFFFFF; + + private const nuint AllocationGranularity = 0x10000; // 64 KB + private const int NearAllocationAttempts = 64; + + private readonly MemoryBase _reader; + private readonly StubAssembler _assembler; + + /// + /// Internal hook for tests that need to place the generated call stub inside an + /// already-allocated executable region (for example, immediately after the target + /// payload to keep the relative CALL within ±2 GiB). + /// + /// + /// When this delegate returns a non-zero pointer, the executor does not take + /// ownership of that memory and will not free it. + /// + internal Func? StubAllocator { get; set; } + + /// + /// Initializes a new for the process exposed by + /// . + /// + /// The memory reader that owns the target process handle. + public RemoteThreadExecutor(MemoryBase reader) + { + _reader = reader ?? throw new ArgumentNullException(nameof(reader)); + _assembler = new StubAssembler(); + } + + /// + /// Calls the function at in the target process using a + /// remote thread and returns its exit value cast to . + /// + /// The expected return type. + /// The target function address. + /// The calling convention (ignored on x64 targets). + /// Arguments to pass. Primitives, pointers and enums are packed + /// into pointer-sized slots. Strings and structs are allocated remotely and passed + /// by pointer. + /// The function's exit value converted to . + /// The process handle is not open or a + /// required native operation failed. + /// The remote thread did not complete in time. + public Task ExecuteAsync(IntPtr address, CallConvention convention, params object?[] args) + { + return Task.Run(() => Execute(address, convention, args)); + } + + /// + /// Synchronous variant of . + /// + public T Execute(IntPtr address, CallConvention convention, params object?[] args) + { + if (_reader.Handle.IsInvalid) + { + throw new InvalidOperationException( + "Cannot execute a remote function: the target process handle is not open."); + } + + if (address == IntPtr.Zero) + { + throw new ArgumentException( + "Target function address cannot be zero.", nameof(address)); + } + + int pointerSize = _reader.Is64Bit ? 8 : 4; + var allocations = new List(args.Length + 1); + IntPtr stubAddress = IntPtr.Zero; + SafeMemoryHandle? thread = null; + + try + { + nuint[] nativeArgs = MarshalArguments(args, pointerSize, allocations); + + // Compute the exact stub size with a dummy address close to the target; + // the emitted byte count does not depend on the stub's final address. + byte[] stubBytes = _assembler.BuildCallStub( + address, address, nativeArgs, pointerSize, convention); + + bool stubOwnedByExecutor = true; + if (StubAllocator != null) + { + stubAddress = StubAllocator(address, stubBytes.Length); + stubOwnedByExecutor = stubAddress != IntPtr.Zero; + } + + if (stubAddress == IntPtr.Zero) + { + stubAddress = AllocateExecutableMemory(_reader.Handle, address, stubBytes.Length); + stubOwnedByExecutor = true; + } + + if (stubAddress == IntPtr.Zero) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Failed to allocate remote stub memory: error {error}"); + } + + if (stubOwnedByExecutor) + { + allocations.Add(stubAddress); + } + + // Re-emit with the real stub address so the relative call lands correctly. + stubBytes = _assembler.BuildCallStub( + stubAddress, address, nativeArgs, pointerSize, convention); + + int written = _reader.WriteBytes(stubAddress, stubBytes); + if (written != stubBytes.Length) + { + throw new InvalidOperationException( + $"Failed to write the call stub to the remote process (wrote {written} of {stubBytes.Length} bytes)."); + } + + thread = NativeMethods.CreateRemoteThread( + _reader.Handle, + IntPtr.Zero, + 0, + stubAddress, + IntPtr.Zero, + ThreadCreationFlags.RunImmediately, + out _); + + if (thread.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"CreateRemoteThread failed: error {error}"); + } + + uint waitResult = NativeMethods.WaitForSingleObject(thread, uint.MaxValue); + if (waitResult == WaitFailed) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"WaitForSingleObject failed: error {error}"); + } + + if (waitResult == WaitTimeout) + { + throw new TimeoutException( + "The remote thread did not complete within the requested timeout."); + } + + if (waitResult != WaitObject0) + { + throw new InvalidOperationException( + $"Unexpected wait status: 0x{waitResult:X}"); + } + + if (!NativeMethods.GetExitCodeThread(thread, out uint exitCode)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"GetExitCodeThread failed: error {error}"); + } + + return ConvertExitCode(exitCode); + } + finally + { + // Dispose the thread handle explicitly so the safe handle releases it + // before any virtual memory is freed. + thread?.Dispose(); + + foreach (IntPtr alloc in allocations) + { + NativeMethods.VirtualFreeEx( + _reader.Handle, alloc, 0, MemoryFreeType.Release); + } + } + } + + /// + /// Converts the raw DWORD exit code into the requested return type. + /// + private static T ConvertExitCode(uint exitCode) + { + Type target = typeof(T); + + if (target == typeof(IntPtr) || target == typeof(nint)) + { + return (T)(object)(IntPtr)(nint)exitCode; + } + + if (target == typeof(UIntPtr) || target == typeof(nuint)) + { + return (T)(object)(UIntPtr)(nuint)exitCode; + } + + if (Nullable.GetUnderlyingType(target) is Type underlying) + { + return (T)Convert.ChangeType(exitCode, underlying, CultureInfo.InvariantCulture); + } + + return (T)Convert.ChangeType(exitCode, target, CultureInfo.InvariantCulture); + } + + /// + /// Marshals managed arguments into pointer-sized native argument slots. Allocates + /// remote memory for strings and structs and records each allocation in + /// . + /// + private nuint[] MarshalArguments(object?[] args, int pointerSize, List allocations) + { + var nativeArgs = new nuint[args.Length]; + + for (int i = 0; i < args.Length; i++) + { + object? arg = args[i]; + nativeArgs[i] = MarshalArgument(arg, pointerSize, allocations); + } + + return nativeArgs; + } + + /// + /// Marshals a single argument. Strings and structs become remote pointers; primitives, + /// enums and pointer values are packed directly. + /// + private nuint MarshalArgument(object? arg, int pointerSize, List allocations) + { + if (arg is null) + { + return 0; + } + + if (arg is string s) + { + return MarshalString(s, allocations); + } + + Type type = arg.GetType(); + if (IsPrimitiveOrPointer(type)) + { + return PackPrimitive(arg, pointerSize); + } + + if (type.IsValueType) + { + return MarshalStruct(arg, type, allocations); + } + + throw new ArgumentException( + $"Unsupported argument type: {type.FullName}. Only primitives, pointers, enums, strings and structs are supported."); + } + + /// + /// Allocates the UTF-8 encoding of a string in the target process and returns its + /// remote address. + /// + private nuint MarshalString(string value, List allocations) + { + byte[] bytes = Encoding.UTF8.GetBytes(value); + byte[] buffer = new byte[bytes.Length + 1]; + bytes.CopyTo(buffer, 0); + buffer[^1] = 0; + + IntPtr remote = NativeMethods.VirtualAllocEx( + _reader.Handle, + IntPtr.Zero, + buffer.Length, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ReadWrite); + + if (remote == IntPtr.Zero) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Failed to allocate remote string memory: error {error}"); + } + + int written = _reader.WriteBytes(remote, buffer); + if (written != buffer.Length) + { + throw new InvalidOperationException( + $"Failed to write string bytes to the remote process (wrote {written} of {buffer.Length} bytes)."); + } + + allocations.Add(remote); + return (nuint)(nint)remote; + } + + /// + /// Allocates unmanaged space for a struct in the target process, writes its bytes with + /// the default interop marshaler, and returns the remote address. + /// + private nuint MarshalStruct(object value, Type type, List allocations) + { + int size; + try + { + size = Marshal.SizeOf(type); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException( + $"Cannot marshal argument of type {type.FullName}: {ex.Message}", ex); + } + + byte[] buffer = new byte[size]; + GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false); + } + finally + { + pin.Free(); + } + + IntPtr remote = NativeMethods.VirtualAllocEx( + _reader.Handle, + IntPtr.Zero, + size, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ReadWrite); + + if (remote == IntPtr.Zero) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"Failed to allocate remote struct memory: error {error}"); + } + + int written = _reader.WriteBytes(remote, buffer); + if (written != size) + { + throw new InvalidOperationException( + $"Failed to write struct bytes to the remote process (wrote {written} of {size} bytes)."); + } + + allocations.Add(remote); + return (nuint)(nint)remote; + } + + /// + /// Determines whether a type can be passed directly as a pointer-sized value. + /// + private static bool IsPrimitiveOrPointer(Type type) + { + if (type == typeof(IntPtr) || type == typeof(UIntPtr) || + type == typeof(nint) || type == typeof(nuint)) + { + return true; + } + + TypeCode code = Type.GetTypeCode(type); + + switch (code) + { + case TypeCode.Boolean: + case TypeCode.Char: + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + case TypeCode.Int64: + case TypeCode.UInt64: + return true; + + case TypeCode.Object when type.IsEnum: + case TypeCode.Object when type == typeof(IntPtr) || type == typeof(UIntPtr): + return true; + + default: + return false; + } + } + + /// + /// Packs a primitive, enum or pointer value into a pointer-sized unsigned integer. + /// Values are truncated to the target pointer width so x86 arguments receive their + /// low 32 bits. + /// + private static nuint PackPrimitive(object value, int pointerSize) + { + Type type = value.GetType(); + ulong raw; + + if (type == typeof(IntPtr) || type == typeof(nint)) + { + raw = unchecked((ulong)(nint)value); + } + else if (type == typeof(UIntPtr) || type == typeof(nuint)) + { + raw = (ulong)(UIntPtr)value; + } + else if (type.IsEnum) + { + raw = Convert.ToUInt64(value); + } + else + { + raw = Convert.ToUInt64(value); + } + + if (pointerSize == 4) + { + raw &= uint.MaxValue; + } + + return unchecked((nuint)raw); + } + + /// + /// Attempts to allocate executable memory close to + /// so that the relative CALL instruction in the generated stub stays within its + /// ±2 GiB range. + /// + private static IntPtr AllocateExecutableMemory( + SafeMemoryHandle handle, + IntPtr preferredAddress, + nint size) + { + nuint preferred = (nuint)(nint)preferredAddress; + nuint mask = AllocationGranularity - (nuint)1; + nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask; + + for (int i = 0; i < NearAllocationAttempts; i++) + { + nuint candidate; + if (i == 0) + { + candidate = aligned; + } + else if ((i & 1) == 1) + { + candidate = aligned + (nuint)i * AllocationGranularity; + } + else + { + nuint offset = (nuint)i * AllocationGranularity; + if (offset > aligned) + { + continue; + } + + candidate = aligned - offset; + } + + IntPtr result = NativeMethods.VirtualAllocEx( + handle, + (IntPtr)(nint)candidate, + size, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ExecuteReadWrite); + + if (result != IntPtr.Zero) + { + return result; + } + } + + return NativeMethods.VirtualAllocEx( + handle, + IntPtr.Zero, + size, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ExecuteReadWrite); + } +} diff --git a/WhiteMagic/ExternalReader.cs b/WhiteMagic/ExternalReader.cs index 15a3834..74c3785 100644 --- a/WhiteMagic/ExternalReader.cs +++ b/WhiteMagic/ExternalReader.cs @@ -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; /// @@ -31,16 +34,27 @@ public sealed class ExternalReader : MemoryBase /// The target process. /// The access rights to request. Defaults to /// . - 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 /// public override SafeMemoryHandle Handle => _handle; + /// + public override bool Is64Bit => _is64Bit; + + /// + public override int ProcessId => _processId; + /// 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(); } } } diff --git a/WhiteMagic/Hooking/Detour.cs b/WhiteMagic/Hooking/Detour.cs new file mode 100644 index 0000000..0e4d708 --- /dev/null +++ b/WhiteMagic/Hooking/Detour.cs @@ -0,0 +1,250 @@ +using System; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Hooking; + +/// +/// A single reversible inline detour. Replaces the start of a native function +/// with a jump to a managed hook delegate, preserves the overwritten bytes in a +/// remote trampoline, and exposes the trampoline through . +/// +/// +/// Only supported in-process. The detour uses a 5-byte relative jmp on x86 +/// targets and a 14-byte RIP-relative absolute jmp on x64 targets. +/// +public sealed class Detour : IDisposable +{ + private readonly MemoryBase _memory; + + /// The unique name of this detour. + public string Name { get; } + + /// The target native function address. + public IntPtr Target { get; } + + /// The managed hook delegate that the detour invokes. + public Delegate Hook { get; } + + /// The bytes overwritten at . + public byte[] OverwrittenBytes { get; private set; } = Array.Empty(); + + /// + /// The allocated trampoline that executes the original prologue and then jumps + /// back into the original function. + /// + public IntPtr Trampoline { get; private set; } + + /// + /// A delegate wrapping with the same type as . + /// + public Delegate? Original { get; private set; } + + /// while the detour bytes are live at . + public bool IsApplied { get; private set; } + + internal Detour(MemoryBase memory, string name, IntPtr target, Delegate hook) + { + ArgumentNullException.ThrowIfNull(hook); + + _memory = memory; + Name = name; + Target = target; + Hook = hook; + } + + /// + /// Installs the detour after validating that the required overwrite covers whole + /// prologue instructions. + /// + /// The prologue cannot be safely spliced. + public void Apply() + { + if (IsApplied) + return; + + int pointerSize = _memory.Is64Bit ? 8 : 4; + int detourLength = pointerSize == 8 ? 14 : 5; + + 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; + } + } + + /// Restores the original bytes and releases the trampoline. + public void Remove() + { + if (!IsApplied) + return; + + if (OverwrittenBytes.Length > 0 && Target != IntPtr.Zero) + { + NativeMethods.VirtualProtectEx( + _memory.Handle, + Target, + OverwrittenBytes.Length, + MemoryProtectionType.ExecuteReadWrite, + out _); + + _memory.WriteBytes(Target, OverwrittenBytes); + } + + if (Trampoline != IntPtr.Zero) + { + NativeMethods.VirtualFreeEx( + _memory.Handle, + Trampoline, + 0, + MemoryFreeType.Release); + } + + Trampoline = IntPtr.Zero; + Original = null; + OverwrittenBytes = Array.Empty(); + IsApplied = false; + } + + /// + /// Invokes the original function through the trampoline. Pass the same arguments + /// that the native signature expects; the return value is boxed. + /// + public object? CallOriginal(params object?[] args) + { + if (Original is null) + { + throw new InvalidOperationException( + "The detour is not applied; there is no original trampoline to call."); + } + + return Original.DynamicInvoke(args); + } + + /// + public void Dispose() + { + Remove(); + } + + private static byte[] BuildRelativeJump(IntPtr source, IntPtr destination) + { + byte[] bytes = new byte[5]; + bytes[0] = 0xE9; + + long distance = (long)destination - ((long)source + 5); + if (distance < int.MinValue || distance > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(destination), + "Relative jump distance exceeds the 2 GiB range of an E8/E9 encoding."); + } + + uint rel = (uint)distance; + bytes[1] = (byte)rel; + bytes[2] = (byte)(rel >> 8); + bytes[3] = (byte)(rel >> 16); + bytes[4] = (byte)(rel >> 24); + return bytes; + } + + private static byte[] BuildAbsoluteJump(IntPtr destination) + { + // jmp [rip+0] followed by the absolute target address. + byte[] bytes = new byte[14]; + bytes[0] = 0xFF; + bytes[1] = 0x25; + bytes[2] = 0x00; + bytes[3] = 0x00; + bytes[4] = 0x00; + bytes[5] = 0x00; + + long addr = (long)destination; + bytes[6] = (byte)addr; + bytes[7] = (byte)(addr >> 8); + bytes[8] = (byte)(addr >> 16); + bytes[9] = (byte)(addr >> 24); + bytes[10] = (byte)(addr >> 32); + bytes[11] = (byte)(addr >> 40); + bytes[12] = (byte)(addr >> 48); + bytes[13] = (byte)(addr >> 56); + return bytes; + } +} diff --git a/WhiteMagic/Hooking/DetourManager.cs b/WhiteMagic/Hooking/DetourManager.cs new file mode 100644 index 0000000..7180b69 --- /dev/null +++ b/WhiteMagic/Hooking/DetourManager.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace WhiteMagic.Hooking; + +/// +/// Manages named inline detours against a . +/// 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. +/// +public sealed class DetourManager +{ + private readonly MemoryBase _memory; + private readonly Dictionary _detours = new(); + + /// Creates a detour manager bound to the supplied memory reader. + public DetourManager(MemoryBase memory) + { + _memory = memory; + } + + /// + /// Creates a new detour and registers it with the manager. + /// The delegate's type must match the native signature of + /// . + /// + public Detour Create(string name, IntPtr target, Delegate hook) + { + var detour = new Detour(_memory, name, target, hook); + _detours[name] = detour; + return detour; + } + + /// Looks up a detour by name. + public Detour? this[string name] + { + get + { + _detours.TryGetValue(name, out Detour? detour); + return detour; + } + } + + /// All detours registered in this manager. + public IEnumerable All => _detours.Values; + + /// Removes every applied detour, restoring original bytes. + public void RemoveAll() + { + foreach (Detour detour in _detours.Values) + { + detour.Remove(); + } + } +} diff --git a/WhiteMagic/Hooking/Patch.cs b/WhiteMagic/Hooking/Patch.cs new file mode 100644 index 0000000..19a6184 --- /dev/null +++ b/WhiteMagic/Hooking/Patch.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; + +namespace WhiteMagic.Hooking; + +/// +/// A single reversible byte patch. Captures the original bytes when applied, +/// restores them when removed, and reports its state by comparing live memory. +/// +public sealed class Patch : IDisposable +{ + private readonly MemoryBase _memory; + + /// The unique name of this patch. + public string Name { get; } + + /// The address the patch overwrites. + public IntPtr Address { get; } + + /// The bytes written by the patch. + public byte[] PatchBytes { get; } + + /// The bytes captured before the patch was applied. + public byte[]? OriginalBytes { get; private set; } + + /// + /// when the live bytes at match + /// . + /// + 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; + } + + /// Captures the original bytes and writes the patch bytes. + public void Apply() + { + if (IsApplied) + return; + + OriginalBytes = _memory.ReadBytes(Address, PatchBytes.Length); + _memory.WriteBytes(Address, PatchBytes); + } + + /// Restores the original bytes if they were captured. + public void Remove() + { + if (OriginalBytes is null) + return; + + _memory.WriteBytes(Address, OriginalBytes); + OriginalBytes = null; + } + + /// + public void Dispose() + { + Remove(); + } +} diff --git a/WhiteMagic/Hooking/PatchManager.cs b/WhiteMagic/Hooking/PatchManager.cs new file mode 100644 index 0000000..e23feec --- /dev/null +++ b/WhiteMagic/Hooking/PatchManager.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace WhiteMagic.Hooking; + +/// +/// Manages named, reversible byte patches against a . +/// Every patch records the bytes it replaced and can restore them later. +/// +public sealed class PatchManager +{ + private readonly MemoryBase _memory; + private readonly Dictionary _patches = new(); + + /// Creates a patch manager bound to the supplied memory reader. + public PatchManager(MemoryBase memory) + { + _memory = memory; + } + + /// Creates a new patch and registers it with the manager. + public Patch Create(string name, IntPtr address, byte[] patchBytes) + { + var patch = new Patch(_memory, name, address, patchBytes); + _patches[name] = patch; + return patch; + } + + /// Looks up a patch by name. + public Patch? this[string name] + { + get + { + _patches.TryGetValue(name, out Patch? patch); + return patch; + } + } + + /// All patches registered in this manager. + public IEnumerable All => _patches.Values; + + /// Removes every applied patch. + public void RestoreAll() + { + foreach (Patch patch in _patches.Values) + { + patch.Remove(); + } + } +} diff --git a/WhiteMagic/Hooking/PrologueDecoder.cs b/WhiteMagic/Hooking/PrologueDecoder.cs new file mode 100644 index 0000000..089ce94 --- /dev/null +++ b/WhiteMagic/Hooking/PrologueDecoder.cs @@ -0,0 +1,91 @@ +using System; + +namespace WhiteMagic.Hooking; + +/// +/// 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). +/// +/// +/// Covered shapes: +/// +/// push reg: 0x50-0x57 (1 byte), including REX-prefixed forms. +/// push ebp/rbp: 0x55 (1 byte). +/// mov edi, edi: 8B FF (2 bytes). +/// mov ebp/rbp, esp/rsp: 8B EC / 48 8B EC (2/3 bytes). +/// sub esp/rsp, imm8: 83 EC imm8 / 48 83 EC imm8 (3/4 bytes). +/// sub esp/rsp, imm32: 81 EC imm32 / 48 81 EC imm32 (6/7 bytes). +/// +/// +internal static class PrologueDecoder +{ + /// + /// Returns the length of the first instruction in + /// if it matches a covered shape; otherwise returns -1. + /// + public static int GetInstructionLength(ReadOnlySpan 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; + } + + /// + /// Walks prologue instructions until at least + /// have been covered, returning the total length of whole instructions that must + /// be preserved in the trampoline. + /// + /// An opcode is outside the covered set. + 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; + } +} diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs index 5079636..200ca04 100644 --- a/WhiteMagic/InProcessReader.cs +++ b/WhiteMagic/InProcessReader.cs @@ -23,6 +23,7 @@ public sealed class InProcessReader : MemoryBase { private readonly SafeMemoryHandle _handle; private readonly IntPtr _imageBase; + private readonly int _processId; private bool _disposed; /// @@ -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 /// public override SafeMemoryHandle Handle => _handle; + /// + public override bool Is64Bit => Environment.Is64BitProcess; + + /// + public override int ProcessId => _processId; + /// 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(); } } } diff --git a/WhiteMagic/Injection/CodeInjector.cs b/WhiteMagic/Injection/CodeInjector.cs new file mode 100644 index 0000000..dd62265 --- /dev/null +++ b/WhiteMagic/Injection/CodeInjector.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using WhiteMagic.Memory; +using WhiteMagic.Native; + +namespace WhiteMagic.Injection; + +/// +/// Injects raw machine code into a process's memory. +/// +public static class CodeInjector +{ + /// + /// Injects code at a specific address. + /// + /// The memory accessor. + /// The target address. + /// The machine code bytes to write. + /// The address the code was written to (same as ). + /// is empty. + /// Write fails. + 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; + } + + /// + /// Allocates executable memory and injects code into it. + /// + /// The memory accessor. + /// The machine code bytes to inject. + /// + /// The memory protection. Defaults to . + /// + /// + /// The base address of the allocated memory containing the code. + /// The caller is responsible for freeing this memory (e.g., via ). + /// + /// is empty. + /// Allocation or write fails. + 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; + } +} diff --git a/WhiteMagic/Injection/DllInjector.cs b/WhiteMagic/Injection/DllInjector.cs new file mode 100644 index 0000000..ae26145 --- /dev/null +++ b/WhiteMagic/Injection/DllInjector.cs @@ -0,0 +1,532 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using WhiteMagic.Native; + +namespace WhiteMagic.Injection; + +/// +/// Injects DLLs into an open target process by creating a remote thread or by +/// hijacking an existing thread. +/// +/// +/// +/// The DLL path is sent to LoadLibraryW, so it is encoded as a null-terminated +/// UTF-16 string in the target process. +/// +/// +/// 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. +/// +/// +public sealed class DllInjector +{ + private readonly MemoryBase _memory; + private readonly bool _currentIs64Bit; + + /// + /// Initializes a new for the target represented by + /// . + /// + /// A reader/writer for the target process. + public DllInjector(MemoryBase memory) + { + ArgumentNullException.ThrowIfNull(memory); + _memory = memory; + _currentIs64Bit = Environment.Is64BitProcess; + } + + /// + /// Gets the the injector is operating on. + /// + public MemoryBase Memory => _memory; + + /// + /// Injects a DLL into the target process by creating a remote thread that loads it. + /// + /// The path to the DLL. The file must exist. + /// The base address of the loaded module in the target process. + /// is null or empty. + /// does not exist. + /// The target bitness does not match the caller. + /// The remote load failed or timed out. + 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(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); + } + } + + /// + /// Injects a DLL by hijacking an existing thread in the target process. + /// + /// The operating-system identifier of the thread to hijack. + /// The path to the DLL. The file must exist. + /// The base address of the loaded module in the target process. + /// is not a positive value or + /// is null or empty. + /// does not exist. + /// The target bitness does not match the caller. + /// The hijack, load, or context restore failed. + 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(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(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(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(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(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 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 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)); + } +} diff --git a/WhiteMagic/Input/InputSimulator.cs b/WhiteMagic/Input/InputSimulator.cs new file mode 100644 index 0000000..667bf6d --- /dev/null +++ b/WhiteMagic/Input/InputSimulator.cs @@ -0,0 +1,73 @@ +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Input; + +/// +/// Mouse buttons supported by . +/// +public enum MouseButton +{ + /// The left mouse button. + Left, + + /// The right mouse button. + Right, +} + +/// +/// Simulates keyboard and mouse input directed at a target window via window messages. +/// +public sealed class InputSimulator +{ + /// + /// Sends a sequence of character messages to . + /// + /// if every character was posted successfully. + 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; + } + + /// + /// Sends a mouse click at client-area coordinates , + /// to . + /// + /// if the click was posted successfully. + 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)); + } +} diff --git a/WhiteMagic/Magic.cs b/WhiteMagic/Magic.cs new file mode 100644 index 0000000..c25a558 --- /dev/null +++ b/WhiteMagic/Magic.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using Process = System.Diagnostics.Process; +using WhiteMagic.Execution; +using WhiteMagic.Hooking; + +namespace WhiteMagic; + +/// +/// High-level entry point for a WhiteMagic session. Opens a process, exposes the +/// memory reader, execution tiers, hooking managers, and the +/// indexer. +/// +public sealed class Magic : IDisposable +{ + /// The underlying memory reader for this session. + public MemoryBase Memory { get; } + + /// Out-of-process execution via CreateRemoteThread. + public RemoteThreadExecutor RemoteThread { get; } + + /// Named byte-patch manager. + public PatchManager PatchManager => Memory.PatchManager; + + /// Inline-detour manager (in-process only). + public DetourManager DetourManager => Memory.DetourManager; + + private Magic(MemoryBase memory) + { + Memory = memory; + RemoteThread = new RemoteThreadExecutor(memory); + } + + /// Opens an external process for reading, writing, and execution. + public static Magic Open(System.Diagnostics.Process process) + { + return new Magic(new ExternalReader(process)); + } + + /// Creates an in-process session for the current process. + public static Magic OpenInProcess() + { + return new Magic(new InProcessReader()); + } + + /// + /// Creates a main-thread pump that hooks the per-frame function at + /// . + /// + public MainThreadPump CreateMainThreadPump(IntPtr frameAddress) + { + return new MainThreadPump(DetourManager, frameAddress); + } + + /// Returns a at . + public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address); + + /// + public void Dispose() + { + Memory.Dispose(); + } +} diff --git a/WhiteMagic/Memory/AllocatedMemory.cs b/WhiteMagic/Memory/AllocatedMemory.cs new file mode 100644 index 0000000..1d31ba8 --- /dev/null +++ b/WhiteMagic/Memory/AllocatedMemory.cs @@ -0,0 +1,180 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Memory; + +/// +/// Represents a chunk of remote memory subdivided into named regions. +/// +public sealed class AllocatedMemory : IDisposable +{ + private readonly MemoryBase _memory; + private readonly IntPtr _baseAddress; + private readonly int _size; + private readonly Dictionary _regions; + private bool _disposed; + + /// + /// Creates a new allocated memory chunk. + /// + /// The memory accessor. + /// The size of the allocation in bytes. + /// The initial memory protection. + /// Allocation fails. + public AllocatedMemory(MemoryBase memory, int size, MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite) + { + ArgumentNullException.ThrowIfNull(memory); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size); + + _memory = memory; + _size = size; + _regions = new Dictionary(); + + // 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})."); + } + } + + /// + /// Gets the base address of the allocated memory. + /// + public IntPtr BaseAddress => _baseAddress; + + /// + /// Gets the size of the allocation in bytes. + /// + public int Size => _size; + + /// + /// Adds a named region at a specific offset within the allocation. + /// + /// The unique name for the region. + /// The offset from the base address. + /// A region with this name already exists. + /// Offset is outside the allocation bounds. + 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; + } + + /// + /// Gets the absolute address of a named region. + /// + /// The region name. + /// The absolute address of the region. + /// No region with this name exists. + 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; + } + + /// + /// Reads a value of type from a named region. + /// + /// The value type. + /// The region name. + /// The value read from memory. + /// No region with this name exists. + public T Read(string name) where T : struct + { + ObjectDisposedException.ThrowIf(_disposed, this); + + IntPtr address = AddressOf(name); + return _memory.Read(address); + } + + /// + /// Writes a value of type to a named region. + /// + /// The value type. + /// The region name. + /// The value to write. + /// if all bytes were written. + /// No region with this name exists. + public bool Write(string name, T value) where T : struct + { + ObjectDisposedException.ThrowIf(_disposed, this); + + IntPtr address = AddressOf(name); + return _memory.Write(address, value); + } + + /// + /// Reads bytes from a named region. + /// + /// The region name. + /// The number of bytes to read. + /// The bytes read from memory. + /// No region with this name exists. + public byte[] ReadBytes(string name, int count) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + IntPtr address = AddressOf(name); + return _memory.ReadBytes(address, count); + } + + /// + /// Writes bytes to a named region. + /// + /// The region name. + /// The bytes to write. + /// The number of bytes written. + /// No region with this name exists. + public int WriteBytes(string name, ReadOnlySpan bytes) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + IntPtr address = AddressOf(name); + return _memory.WriteBytes(address, bytes); + } + + /// + /// Frees the allocated memory. + /// + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + + // Free using VirtualFreeEx + if (_baseAddress != IntPtr.Zero) + { + NativeMethods.VirtualFreeEx( + _memory.Handle, + _baseAddress, + 0, + MemoryFreeType.Release); + } + + _regions.Clear(); + } + } +} diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs index 90c9486..9e59822 100644 --- a/WhiteMagic/MemoryBase.cs +++ b/WhiteMagic/MemoryBase.cs @@ -1,3 +1,4 @@ +using WhiteMagic.Hooking; using WhiteMagic.Native; using System.Runtime.InteropServices; using System.Text; @@ -12,12 +13,31 @@ namespace WhiteMagic; /// public abstract class MemoryBase : IDisposable { + /// Creates the shared hooking managers for this memory instance. + protected MemoryBase() + { + PatchManager = new PatchManager(this); + DetourManager = new DetourManager(this); + } + /// The base address of the target process's main module. public abstract IntPtr ImageBase { get; } /// The native handle to the target process. public abstract SafeMemoryHandle Handle { get; } + /// if the target process is 64-bit. + public abstract bool Is64Bit { get; } + + /// The operating-system process identifier of the target process. + public abstract int ProcessId { get; } + + /// Named byte-patch manager; valid for in-process and external readers. + public PatchManager PatchManager { get; } + + /// Inline-detour manager; valid only when operating in-process. + public DetourManager DetourManager { get; } + // ── Raw byte IO ──────────────────────────────────────────────────────── /// Reads a sequence of bytes from the target address. @@ -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). - /// The address to read from. + /// The address to read from. For multi-byte encodings this must be + /// aligned to a code-unit boundary or the result is undefined. /// The text encoding. /// The maximum number of bytes to read. /// If , is relative /// to . + /// + /// 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. + /// 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(); + var accumulated = new System.Collections.Generic.List(); 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)); } /// Writes a null-terminated string to the target address. @@ -239,6 +273,8 @@ public abstract class MemoryBase : IDisposable /// 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; - } } diff --git a/WhiteMagic/Native/NativeEnums.cs b/WhiteMagic/Native/NativeEnums.cs index 47f15fd..1d690b7 100644 --- a/WhiteMagic/Native/NativeEnums.cs +++ b/WhiteMagic/Native/NativeEnums.cs @@ -33,6 +33,28 @@ public enum ProcessAccess : uint AllAccess = 0x001F0000 | Synchronize | 0xFFFF, } +/// +/// Access rights that open a thread object. +/// +[Flags] +public enum ThreadAccess : uint +{ + /// The right to terminate the thread with TerminateThread. + Terminate = 0x0001, + /// The right to suspend and resume the thread. + SuspendResume = 0x0002, + /// The right to read the thread context with GetThreadContext. + GetContext = 0x0008, + /// The right to set the thread context with SetThreadContext. + SetContext = 0x0010, + /// The right to query information from the thread. + QueryInformation = 0x0040, + /// The right to set information on the thread. + SetInformation = 0x0020, + /// All access rights for a thread object. + AllAccess = 0x001F0FFF, +} + /// /// Values that control how VirtualAllocEx allocates memory. /// diff --git a/WhiteMagic/Native/NativeMethods.cs b/WhiteMagic/Native/NativeMethods.cs index d6b3fe9..5160985 100644 --- a/WhiteMagic/Native/NativeMethods.cs +++ b/WhiteMagic/Native/NativeMethods.cs @@ -19,11 +19,32 @@ internal static partial class NativeMethods [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, int processId); + /// Opens an existing thread and returns a handle to it. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeMemoryHandle OpenThread( + ThreadAccess desiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + int threadId); + /// Closes an open object handle. [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool CloseHandle(IntPtr handle); + /// Determines whether the specified process is running under WOW64. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsWow64Process( + SafeMemoryHandle process, + [MarshalAs(UnmanagedType.Bool)] out bool wow64Process); + + /// Retrieves the termination status of the specified thread. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetExitCodeThread( + SafeMemoryHandle thread, + out uint exitCode); + // ── Memory ─────────────────────────────────────────────────────────────── /// Reads memory from a process. @@ -87,6 +108,22 @@ internal static partial class NativeMethods ThreadCreationFlags creationFlags, out uint threadId); + /// Suspends the specified thread. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint SuspendThread(SafeMemoryHandle thread); + + /// Resumes the specified thread. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint ResumeThread(SafeMemoryHandle thread); + + /// Returns the thread identifier of the specified thread. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint GetThreadId(SafeMemoryHandle thread); + + /// Returns the identifier of the calling thread. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint GetCurrentThreadId(); + /// Sets a 64-bit thread context (AMD64). [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); + } diff --git a/WhiteMagic/Native/SystemMethods.cs b/WhiteMagic/Native/SystemMethods.cs new file mode 100644 index 0000000..aec66d0 --- /dev/null +++ b/WhiteMagic/Native/SystemMethods.cs @@ -0,0 +1,178 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace WhiteMagic.Native; + +/// +/// 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 . +/// +internal static partial class NativeMethods +{ + // ── Natives used directly by public helpers ────────────────────────────── + + /// Queries information about the specified process. + [LibraryImport("ntdll.dll")] + internal static partial int NtQueryInformationProcess( + SafeMemoryHandle processHandle, + int processInformationClass, + ref ProcessBasicInformation processInformation, + uint processInformationLength, + out uint returnLength); + + /// Queries information about the specified thread. + [LibraryImport("ntdll.dll")] + internal static partial int NtQueryInformationThread( + SafeMemoryHandle threadHandle, + int threadInformationClass, + ref ThreadBasicInformation threadInformation, + uint threadInformationLength, + out uint returnLength); + + /// Enumerates all top-level windows on the screen. + [LibraryImport("user32.dll", SetLastError = true)] + internal static partial int EnumWindows( + nint lpEnumFunc, + IntPtr lParam); + + /// Retrieves the identifier of the thread that created the window and the process id of the window. + [LibraryImport("user32.dll", SetLastError = true)] + internal static partial uint GetWindowThreadProcessId( + IntPtr hWnd, + out uint lpdwProcessId); + + /// Retrieves the name of the class to which the specified window belongs. + [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial int GetClassNameW( + IntPtr hWnd, + [Out] char[] lpClassName, + int nMaxCount); + + /// Copies the text of the specified window's title bar into a buffer. + [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial int GetWindowTextW( + IntPtr hWnd, + [Out] char[] lpString, + int nMaxCount); + + /// Changes the text of the specified window's title bar. + [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetWindowTextW( + IntPtr hWnd, + string lpString); + + /// Changes the size, position, and Z order of a child, pop-up, or top-level window. + [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); + + /// Retrieves a handle to the foreground window. + [LibraryImport("user32.dll", SetLastError = true)] + internal static partial IntPtr GetForegroundWindow(); + + /// Brings the thread that created the specified window into the foreground and activates the window. + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetForegroundWindow(IntPtr hWnd); + + /// Flashes the specified window. + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool FlashWindowEx(ref FlashWindowInfo pwfi); + + /// Attaches or detaches the input processing mechanism of one thread to that of another thread. + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AttachThreadInput( + uint idAttach, + uint idAttachTo, + [MarshalAs(UnmanagedType.Bool)] bool fAttach); + + /// Places a message in the message queue associated with the thread that created the specified window. + [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; +} + +/// +/// Layout matches PROCESS_BASIC_INFORMATION (ProcessBasicInformation = 0). +/// +[StructLayout(LayoutKind.Sequential)] +internal struct ProcessBasicInformation +{ + public int ExitStatus; + public IntPtr PebBaseAddress; + public UIntPtr AffinityMask; + public int BasePriority; + public UIntPtr UniqueProcessId; + public UIntPtr InheritedFromUniqueProcessId; +} + +/// +/// Layout matches THREAD_BASIC_INFORMATION (ThreadBasicInformation = 0). +/// +[StructLayout(LayoutKind.Sequential)] +internal struct ThreadBasicInformation +{ + public int ExitStatus; + public IntPtr TebBaseAddress; + public ClientId ClientId; + public UIntPtr AffinityMask; + public int Priority; + public int BasePriority; +} + +/// +/// Layout matches CLIENT_ID. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct ClientId +{ + public IntPtr UniqueProcess; + public IntPtr UniqueThread; +} + +/// +/// Layout matches FLASHWINFO used by . +/// +[StructLayout(LayoutKind.Sequential)] +internal struct FlashWindowInfo +{ + public uint cbSize; + public IntPtr hwnd; + public uint dwFlags; + public uint uCount; + public uint dwTimeout; +} diff --git a/WhiteMagic/Process/ManagedPeb.cs b/WhiteMagic/Process/ManagedPeb.cs new file mode 100644 index 0000000..00a9f51 --- /dev/null +++ b/WhiteMagic/Process/ManagedPeb.cs @@ -0,0 +1,92 @@ +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.ProcessEnvironment; + +/// +/// Managed reader for a target process's Process Environment Block (PEB). +/// +public sealed class ManagedPeb +{ + private readonly MemoryBase _memory; + private readonly IntPtr _pebAddress; + + /// + /// Creates a PEB reader for the process associated with the specified memory facade. + /// + public ManagedPeb(MemoryBase memory) + { + _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + _pebAddress = QueryPebAddress(); + } + + /// Returns the native address of the PEB in the target process. + public IntPtr ReadPebAddress() => _pebAddress; + + /// Reads the BeingDebugged byte from the PEB. + public byte ReadBeingDebugged() + { + return _memory.Read(_pebAddress + 2); + } + + /// Reads the ImageBaseAddress pointer from the PEB. + public IntPtr ReadImageBaseAddress() + { + int offset = _memory.Is64Bit ? 0x10 : 0x08; + return ReadPointer(offset); + } + + /// Reads the PEB_LDR_DATA pointer from the PEB. + public IntPtr ReadLdrAddress() + { + int offset = _memory.Is64Bit ? 0x18 : 0x0C; + return ReadPointer(offset); + } + + /// + /// Determines whether the target process is running under WOW64. + /// + 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(), + 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(address); + return new IntPtr((long)raw); + } + + uint raw32 = _memory.Read(address); + return new IntPtr((int)raw32); + } +} diff --git a/WhiteMagic/RemotePointer.cs b/WhiteMagic/RemotePointer.cs new file mode 100644 index 0000000..fae0bf8 --- /dev/null +++ b/WhiteMagic/RemotePointer.cs @@ -0,0 +1,49 @@ +using System.Text; + +namespace WhiteMagic; + +/// +/// A pointer-relative view over a . Obtained through the +/// high-level facade indexer, it provides read/write/string operations with optional +/// offsets relative to a base address. +/// +public sealed class RemotePointer +{ + private readonly MemoryBase _memory; + + /// The base address of this view. + public IntPtr BaseAddress { get; } + + internal RemotePointer(MemoryBase memory, IntPtr baseAddress) + { + _memory = memory; + BaseAddress = baseAddress; + } + + /// Reads a value of type at BaseAddress + offset. + public T Read(nint offset = 0) where T : struct + { + return _memory.Read(BaseAddress + offset); + } + + /// Writes at BaseAddress + offset. + public bool Write(T value, nint offset = 0) where T : struct + { + return _memory.Write(BaseAddress + offset, value); + } + + /// Reads a null-terminated string at BaseAddress + offset. + public string ReadString(Encoding encoding, int maxLength = 512, nint offset = 0) + { + return _memory.ReadString(BaseAddress + offset, encoding ?? Encoding.UTF8, maxLength); + } + + /// Writes a null-terminated string at BaseAddress + offset. + public bool WriteString(string value, Encoding encoding, nint offset = 0) + { + return _memory.WriteString(BaseAddress + offset, value, encoding ?? Encoding.UTF8); + } + + /// Returns a new with the offset added. + public RemotePointer this[nint offset] => new RemotePointer(_memory, BaseAddress + offset); +} diff --git a/WhiteMagic/Thread/ManagedTeb.cs b/WhiteMagic/Thread/ManagedTeb.cs new file mode 100644 index 0000000..8d468e1 --- /dev/null +++ b/WhiteMagic/Thread/ManagedTeb.cs @@ -0,0 +1,98 @@ +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.ThreadEnvironment; + +/// +/// Managed reader for a target thread's Thread Environment Block (TEB). +/// +public sealed class ManagedTeb : IDisposable +{ + private readonly MemoryBase _memory; + private readonly SafeMemoryHandle _threadHandle; + private readonly IntPtr _tebAddress; + private bool _disposed; + + /// + /// Creates a TEB reader for the specified thread in the process associated + /// with the provided memory facade. + /// + 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(); + } + + /// Returns the native address of the TEB in the target process. + public IntPtr ReadTebAddress() => _tebAddress; + + /// Reads the stack base pointer stored in the TEB. + public IntPtr ReadStackBase() + { + int offset = _memory.Is64Bit ? 0x08 : 0x04; + return ReadPointer(offset); + } + + /// Reads the stack limit pointer stored in the TEB. + public IntPtr ReadStackLimit() + { + int offset = _memory.Is64Bit ? 0x10 : 0x08; + return ReadPointer(offset); + } + + /// + 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(), + 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(address); + return new IntPtr((long)raw); + } + + uint raw32 = _memory.Read(address); + return new IntPtr((int)raw32); + } +} diff --git a/WhiteMagic/Windows/RemoteWindow.cs b/WhiteMagic/Windows/RemoteWindow.cs new file mode 100644 index 0000000..7d8aad7 --- /dev/null +++ b/WhiteMagic/Windows/RemoteWindow.cs @@ -0,0 +1,147 @@ +using System.Runtime.InteropServices; +using System.Text; +using WhiteMagic.Native; + +namespace WhiteMagic.Windows; + +/// +/// Wrapper around a native window handle that supports querying and mutating +/// common window properties. +/// +public sealed class RemoteWindow +{ + private const int MaxTextLength = 512; + + /// Creates a wrapper for the specified window handle. + public RemoteWindow(IntPtr handle) + { + if (handle == IntPtr.Zero) + throw new ArgumentException("Window handle cannot be zero.", nameof(handle)); + + Handle = handle; + } + + /// The native window handle. + public IntPtr Handle { get; } + + /// The window class name. + public string ClassName => GetClassName(Handle); + + /// The current window text. + public string Text => GetWindowText(Handle); + + /// The process identifier that owns the window. + public uint ProcessId => GetWindowProcessId(Handle); + + /// Gets or sets the window title. + 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}."); + } + } + } + + /// if this window is currently the foreground window. + public bool IsActive => NativeMethods.GetForegroundWindow() == Handle; + + /// Moves and resizes the window. + public bool MoveResize(int x, int y, int width, int height) + { + return NativeMethods.SetWindowPos( + Handle, + NativeMethods.HwndTop, + x, + y, + width, + height, + NativeMethods.SwpShowWindow); + } + + /// Activates the window and brings it to the foreground. + 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); + } + } + + /// Flashes the window in the caption and taskbar button. + public bool Flash() + { + var info = new FlashWindowInfo + { + cbSize = (uint)Marshal.SizeOf(), + 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; + } +} diff --git a/WhiteMagic/Windows/WindowFactory.cs b/WhiteMagic/Windows/WindowFactory.cs new file mode 100644 index 0000000..372771e --- /dev/null +++ b/WhiteMagic/Windows/WindowFactory.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Windows; + +/// +/// Factory for enumerating and locating instances. +/// +public static class WindowFactory +{ + /// Enumerates all top-level windows. + public static unsafe IEnumerable GetWindows() + { + var handles = new List(); + GCHandle gch = GCHandle.Alloc(handles); + try + { + delegate* unmanaged[Stdcall] callback = &EnumWindowsCallback; + NativeMethods.EnumWindows((nint)callback, GCHandle.ToIntPtr(gch)); + } + finally + { + gch.Free(); + } + + return handles.Select(static h => new RemoteWindow(h)); + } + + /// Returns all top-level windows with the specified class name. + public static IEnumerable GetWindowsByClassName(string className) + { + if (className is null) + throw new ArgumentNullException(nameof(className)); + + return GetWindows().Where(w => w.ClassName.Equals(className, StringComparison.Ordinal)); + } + + /// Returns all top-level windows owned by the specified process. + public static IEnumerable GetWindowsByProcessId(int processId) + { + return GetWindows().Where(w => w.ProcessId == (uint)processId); + } + + /// Returns the first top-level window with the specified class name. + public static RemoteWindow? GetWindowByClassName(string className) + { + return GetWindowsByClassName(className).FirstOrDefault(); + } + + /// + /// Returns the main window of a process. When + /// is unavailable, falls back to the first enumerated window owned by the process. + /// + 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)GCHandle.FromIntPtr(lParam).Target!; + handles.Add(hWnd); + return 1; // Continue enumeration. + } +} diff --git a/WhiteMagicTest/Discovery/PatternScannerCacheTests.cs b/WhiteMagicTest/Discovery/PatternScannerCacheTests.cs new file mode 100644 index 0000000..8ebfdb9 --- /dev/null +++ b/WhiteMagicTest/Discovery/PatternScannerCacheTests.cs @@ -0,0 +1,201 @@ +using System.Runtime.InteropServices; +using WhiteMagic; +using WhiteMagic.Discovery; + +namespace WhiteMagicTest.Discovery; + +/// +/// Tests for . +/// +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().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); + } +} diff --git a/WhiteMagicTest/Discovery/PatternScannerTests.cs b/WhiteMagicTest/Discovery/PatternScannerTests.cs new file mode 100644 index 0000000..3c634a8 --- /dev/null +++ b/WhiteMagicTest/Discovery/PatternScannerTests.cs @@ -0,0 +1,188 @@ +using System.Runtime.InteropServices; +using WhiteMagic; +using WhiteMagic.Discovery; +using WhiteMagicTest; + +namespace WhiteMagicTest.Discovery; + +/// +/// Tests for . +/// +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(); + var ex = Assert.Throws(() => + 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(() => + 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(() => + 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); + } +} diff --git a/WhiteMagicTest/Discovery/PeHeaderParserTests.cs b/WhiteMagicTest/Discovery/PeHeaderParserTests.cs new file mode 100644 index 0000000..e7bd49a --- /dev/null +++ b/WhiteMagicTest/Discovery/PeHeaderParserTests.cs @@ -0,0 +1,156 @@ +using WhiteMagic; +using WhiteMagic.Discovery; + +namespace WhiteMagicTest.Discovery; + +/// +/// Tests for . +/// +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(() => + new PeHeaderParser(reader, IntPtr.Zero)); + + Assert.Contains("Base address cannot be zero", ex.Message); + } +} diff --git a/WhiteMagicTest/Execution/InProcessInvokerTests.cs b/WhiteMagicTest/Execution/InProcessInvokerTests.cs new file mode 100644 index 0000000..8bda667 --- /dev/null +++ b/WhiteMagicTest/Execution/InProcessInvokerTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Runtime.InteropServices; +using WhiteMagic; +using WhiteMagic.Execution; +using Xunit; + +namespace WhiteMagicTest.Execution; + +/// +/// Tests for operating in-process. +/// +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(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(() => invoker.CreateFunction(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(); + } + } +} diff --git a/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs b/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs new file mode 100644 index 0000000..4ff4427 --- /dev/null +++ b/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs @@ -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(() => + { + executor.Execute((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(() => + { + executor.Execute(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(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 bytes, bool isRelative = false) + => throw new NotSupportedException(); + + public override void Dispose() + { + } + } +} diff --git a/WhiteMagicTest/HighLevelTests.cs b/WhiteMagicTest/HighLevelTests.cs new file mode 100644 index 0000000..9035989 --- /dev/null +++ b/WhiteMagicTest/HighLevelTests.cs @@ -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; + +/// +/// Tests for the high-level facade () and . +/// +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(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(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(payload, CallConvention.Cdecl, 10, 32); + Assert.Equal(42, result); + } + finally + { + NativeMethods.VirtualFreeEx(magic.Memory.Handle, payload, 0, MemoryFreeType.Release); + } + } +} diff --git a/WhiteMagicTest/Hooking/HookingTests.cs b/WhiteMagicTest/Hooking/HookingTests.cs new file mode 100644 index 0000000..39c9af8 --- /dev/null +++ b/WhiteMagicTest/Hooking/HookingTests.cs @@ -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; + +/// +/// Tests for , and +/// operating in-process. +/// +public class HookingTests +{ + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int FrameFunc(); + + private const int FrameResult = 42; + + private static InProcessReader CreateReader() + { + return new InProcessReader(); + } + + /// + /// Allocates a tiny executable function whose prologue is made entirely of + /// covered instruction shapes, so detours apply cleanly in tests. + /// + 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(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(() => 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 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(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(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 bad = pump.ExecuteAsync(() => throw new InvalidOperationException("boom")); + Task good = pump.ExecuteAsync(() => 7); + + FrameFunc routed = Marshal.GetDelegateForFunctionPointer(targetPtr); + routed(); + + await Assert.ThrowsAsync(() => bad); + Assert.Equal(7, await good); + + pump.Dispose(); + } + finally + { + NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release); + } + } +} diff --git a/WhiteMagicTest/Injection/CodeInjectorTests.cs b/WhiteMagicTest/Injection/CodeInjectorTests.cs new file mode 100644 index 0000000..e301898 --- /dev/null +++ b/WhiteMagicTest/Injection/CodeInjectorTests.cs @@ -0,0 +1,214 @@ +using System.ComponentModel; +using WhiteMagic; +using WhiteMagic.Injection; +using WhiteMagic.Memory; +using WhiteMagic.Native; + +namespace WhiteMagicTest.Injection; + +/// +/// Tests for . +/// +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(); + + var ex = Assert.Throws(() => + 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(() => + 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(); + + var ex = Assert.Throws(() => + 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); + } +} diff --git a/WhiteMagicTest/Injection/DllInjectorTests.cs b/WhiteMagicTest/Injection/DllInjectorTests.cs new file mode 100644 index 0000000..5ae8b86 --- /dev/null +++ b/WhiteMagicTest/Injection/DllInjectorTests.cs @@ -0,0 +1,140 @@ +using System.Runtime.InteropServices; +using WhiteMagic; +using WhiteMagic.Injection; +using WhiteMagic.Native; + +namespace WhiteMagicTest.Injection; + +/// +/// Tests for . +/// +/// +/// Real injection tests exercise the current process, because it is always available +/// and the injected DLLs are ordinary system modules that are already loaded. +/// +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(() => 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( + () => 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)); + } + } + + /// + /// A minimal whose only job is to report a chosen bitness. + /// Reads and writes are not expected to be called by the rejection path. + /// + 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 bytes, bool isRelative = false) + => throw new NotSupportedException(); + } +} diff --git a/WhiteMagicTest/Input/InputSimulatorTests.cs b/WhiteMagicTest/Input/InputSimulatorTests.cs new file mode 100644 index 0000000..5307122 --- /dev/null +++ b/WhiteMagicTest/Input/InputSimulatorTests.cs @@ -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); + } +} diff --git a/WhiteMagicTest/Memory/AllocatedMemoryTests.cs b/WhiteMagicTest/Memory/AllocatedMemoryTests.cs new file mode 100644 index 0000000..ac3c759 --- /dev/null +++ b/WhiteMagicTest/Memory/AllocatedMemoryTests.cs @@ -0,0 +1,254 @@ +using System.ComponentModel; +using WhiteMagic; +using WhiteMagic.Memory; +using WhiteMagic.Native; + +namespace WhiteMagicTest.Memory; + +/// +/// Tests for . +/// +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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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("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("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(() => + 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("a")); + Assert.Equal(0x22222222, allocated.Read("b")); + Assert.Equal(0x33333333, allocated.Read("c")); + } + + [Fact] + public void Write_to_unknown_region_throws() + { + using var reader = CreateReader(); + + using var allocated = new AllocatedMemory(reader, 4096); + + var ex = Assert.Throws(() => + 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(() => + allocated.Read("unknown")); + + Assert.Contains("does not exist", ex.Message); + } +} diff --git a/WhiteMagicTest/MemoryHardeningTests.cs b/WhiteMagicTest/MemoryHardeningTests.cs index acf96c2..e8647a7 100644 --- a/WhiteMagicTest/MemoryHardeningTests.cs +++ b/WhiteMagicTest/MemoryHardeningTests.cs @@ -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) { diff --git a/WhiteMagicTest/ProcessEnvironment/ManagedPebTests.cs b/WhiteMagicTest/ProcessEnvironment/ManagedPebTests.cs new file mode 100644 index 0000000..69b801a --- /dev/null +++ b/WhiteMagicTest/ProcessEnvironment/ManagedPebTests.cs @@ -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()); + } +} diff --git a/WhiteMagicTest/StringReadWriteTests.cs b/WhiteMagicTest/StringReadWriteTests.cs index 6e0cf37..f1b493a 100644 --- a/WhiteMagicTest/StringReadWriteTests.cs +++ b/WhiteMagicTest/StringReadWriteTests.cs @@ -157,6 +157,61 @@ public class StringReadWriteTests } } + /// + /// 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. + /// + [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(); + } + } + + /// + /// 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. + /// + [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() { diff --git a/WhiteMagicTest/ThreadEnvironment/ManagedTebTests.cs b/WhiteMagicTest/ThreadEnvironment/ManagedTebTests.cs new file mode 100644 index 0000000..9fc9e09 --- /dev/null +++ b/WhiteMagicTest/ThreadEnvironment/ManagedTebTests.cs @@ -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()); + } +} diff --git a/WhiteMagicTest/Windows/WindowTests.cs b/WhiteMagicTest/Windows/WindowTests.cs new file mode 100644 index 0000000..da8cb55 --- /dev/null +++ b/WhiteMagicTest/Windows/WindowTests.cs @@ -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); + } +} diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md index 4d3aa53..8ea14ff 100644 --- a/openspec/changes/whitemagic-foundation/tasks.md +++ b/openspec/changes/whitemagic-foundation/tasks.md @@ -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` (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` (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` 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` 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