From 302da5f3c2e1842e6c1e13ae33b70a82072a5dbe Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 17:01:41 +0200 Subject: [PATCH 1/9] task 1.4: add NativeMethods.cs LibraryImport P/Invoke surface Add LibraryImport-based P/Invoke declarations for all required Win32 APIs: OpenProcess, CloseHandle, ReadProcessMemory, WriteProcessMemory, VirtualAllocEx, VirtualProtectEx, VirtualFreeEx, CreateRemoteThread, GetThreadContext, SetThreadContext, Wow64GetThreadContext, Wow64SetThreadContext, LoadLibrary, GetProcAddress, and WaitForSingleObject. Add InternalsVisibleTo WhiteMagicTest so the test project can call internal NativeMethods. Tests (NativeSurfaceTests): 7/7 passing. --- WhiteMagic/Native/NativeEnums.cs | 136 ++++++++++++ WhiteMagic/Native/NativeMethods.cs | 136 ++++++++++++ WhiteMagic/Native/NativeStructures.cs | 207 ++++++++++++++++++ WhiteMagic/Native/SafeMemoryHandle.cs | 34 +++ WhiteMagic/WhiteMagic.csproj | 4 + WhiteMagicTest/Native/NativeSurfaceTests.cs | 112 ++++++++++ .../changes/whitemagic-foundation/tasks.md | 2 +- 7 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 WhiteMagic/Native/NativeEnums.cs create mode 100644 WhiteMagic/Native/NativeMethods.cs create mode 100644 WhiteMagic/Native/NativeStructures.cs create mode 100644 WhiteMagic/Native/SafeMemoryHandle.cs create mode 100644 WhiteMagicTest/Native/NativeSurfaceTests.cs diff --git a/WhiteMagic/Native/NativeEnums.cs b/WhiteMagic/Native/NativeEnums.cs new file mode 100644 index 0000000..47f15fd --- /dev/null +++ b/WhiteMagic/Native/NativeEnums.cs @@ -0,0 +1,136 @@ +namespace WhiteMagic.Native; + +/// +/// Access rights that open a process object. +/// +[Flags] +public enum ProcessAccess : uint +{ + /// The right to terminate the process with TerminateProcess. + Terminate = 0x0001, + /// The right to create a thread in the process. + CreateThread = 0x0002, + /// The right to operate on the address space of the process. + VmOperation = 0x0008, + /// The right to read memory with ReadProcessMemory. + VmRead = 0x0010, + /// The right to write memory with WriteProcessMemory. + VmWrite = 0x0020, + /// The right to duplicate a handle with DuplicateHandle. + DupHandle = 0x0040, + /// The right to set information about the process. + SetInformation = 0x0200, + /// The right to read information about the process, such as the exit code. + QueryInformation = 0x0400, + /// The right to suspend or resume the process. + SuspendResume = 0x0800, + /// The right to read a limited set of information about the process. + QueryLimitedInformation = 0x1000, + /// The right to use the process object for synchronization. + Synchronize = 0x00100000, + + /// All access rights for a process object. + AllAccess = 0x001F0000 | Synchronize | 0xFFFF, +} + +/// +/// Values that control how VirtualAllocEx allocates memory. +/// +[Flags] +public enum MemoryAllocationType : uint +{ + /// Commit physical storage for the reserved pages. The pages start as zero. + Commit = 0x00001000, + /// Reserve a range of address space without physical storage. + Reserve = 0x00002000, + /// Reset the data in the range to indicate that it is no longer of interest. + Reset = 0x00080000, + /// Allocate memory at the highest possible address. + TopDown = 0x00100000, +} + +/// +/// Values that protect a block of memory. +/// +[Flags] +public enum MemoryProtectionType : uint +{ + /// No access to the committed pages. + NoAccess = 0x01, + /// Read access to the committed pages. + ReadOnly = 0x02, + /// Read and write access to the committed pages. + ReadWrite = 0x04, + /// Copy-on-write access to the committed pages. + WriteCopy = 0x08, + /// Execute access to the committed pages. + Execute = 0x10, + /// Execute and read access to the committed pages. + ExecuteRead = 0x20, + /// Execute, read, and write access to the committed pages. + ExecuteReadWrite = 0x40, + /// Execute and copy-on-write access to the committed pages. + ExecuteWriteCopy = 0x80, + /// The pages in the range become guard pages. + Guard = 0x100, + /// The system does not cache the committed pages. + NoCache = 0x200, + /// The system uses write-combined access for the pages. + WriteCombine = 0x400, +} + +/// +/// Values that control how VirtualFreeEx frees memory. +/// +[Flags] +public enum MemoryFreeType : uint +{ + /// Decommit the committed pages. The address range stays reserved. + Decommit = 0x4000, + /// Release the range of pages. The size must be zero. + Release = 0x8000, +} + +/// +/// Values that set the initial state of a new thread. +/// +[Flags] +public enum ThreadCreationFlags : uint +{ + /// The thread runs immediately after creation. + RunImmediately = 0, + /// The thread starts in a suspended state. Call ResumeThread to start it. + CreateSuspended = 0x00000004, + /// The stack-size parameter sets the reserve size of the stack. + StackSizeParamIsAReservation = 0x00010000, +} + +/// +/// Flags that select the registers that the thread-context functions read or write. +/// There are separate constants for 32-bit (x86/WOW64) and 64-bit (AMD64) contexts. +/// +public static class ContextFlags +{ + /// Architecture identifier for x86 contexts. + public const uint X86 = 0x00010000; + /// Architecture identifier for AMD64 contexts. + public const uint Amd64 = 0x00100000; + + /// x86: SS:SP, CS:IP, FLAGS, and BP. + public const uint X86Control = X86 | 0x01; + /// x86: AX, BX, CX, DX, SI, and DI. + public const uint X86Integer = X86 | 0x02; + /// x86: DS, ES, FS, and GS. + public const uint X86Segments = X86 | 0x04; + /// x86: control, integer, and segment registers. + public const uint X86Full = X86Control | X86Integer | X86Segments; + + /// AMD64: SegSs, Rsp, SegCs, Rip, and EFlags. + public const uint Amd64Control = Amd64 | 0x01; + /// AMD64: Rax, Rcx, Rdx, Rbx, Rbp, Rsi, Rdi, and R8 to R15. + public const uint Amd64Integer = Amd64 | 0x02; + /// AMD64: SegDs, SegEs, SegFs, and SegGs. + public const uint Amd64Segments = Amd64 | 0x04; + /// AMD64: control, integer, and segment registers. + public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments; +} diff --git a/WhiteMagic/Native/NativeMethods.cs b/WhiteMagic/Native/NativeMethods.cs new file mode 100644 index 0000000..bdc07e2 --- /dev/null +++ b/WhiteMagic/Native/NativeMethods.cs @@ -0,0 +1,136 @@ +using System.Runtime.InteropServices; + +namespace WhiteMagic.Native; + +/// +/// P/Invoke declarations for the Win32 process, memory, thread, and module +/// APIs that WhiteMagic uses. Every declaration uses +/// (source-generated interop). SetLastError is enabled on all calls that the +/// Win32 API documents as setting a thread-local last-error value. +/// +internal static partial class NativeMethods +{ + // ── Process ────────────────────────────────────────────────────────────── + + /// Opens an existing process and returns a handle to it. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeMemoryHandle OpenProcess( + ProcessAccess desiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + int processId); + + /// Closes an open object handle. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CloseHandle(IntPtr handle); + + // ── Memory ─────────────────────────────────────────────────────────────── + + /// Reads memory from a process. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ReadProcessMemory( + SafeMemoryHandle process, + IntPtr baseAddress, + Span buffer, + int size, + out nint bytesRead); + + /// Writes memory to a process. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool WriteProcessMemory( + SafeMemoryHandle process, + IntPtr baseAddress, + ReadOnlySpan buffer, + int size, + out nint bytesWritten); + + /// Reserves or commits a region of memory in a process. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial IntPtr VirtualAllocEx( + SafeMemoryHandle process, + IntPtr address, + nint size, + MemoryAllocationType allocationType, + MemoryProtectionType protect); + + /// Changes the protection on a committed region of memory. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool VirtualProtectEx( + SafeMemoryHandle process, + IntPtr address, + nint size, + MemoryProtectionType newProtect, + out MemoryProtectionType oldProtect); + + /// Releases or decommits a region of memory in a process. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool VirtualFreeEx( + SafeMemoryHandle process, + IntPtr address, + nint size, + MemoryFreeType freeType); + + // ── Threading ──────────────────────────────────────────────────────────── + + /// Creates a thread that runs in the virtual address space of a process. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeMemoryHandle CreateRemoteThread( + SafeMemoryHandle process, + IntPtr threadAttributes, + nint stackSize, + IntPtr startAddress, + IntPtr parameter, + ThreadCreationFlags creationFlags, + out int threadId); + + /// Sets a 64-bit thread context (AMD64). + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetThreadContext( + SafeMemoryHandle thread, + ref Context64 context); + + /// Gets a 64-bit thread context (AMD64). + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetThreadContext( + SafeMemoryHandle thread, + ref Context64 context); + + /// Sets a 32-bit (WOW64) thread context. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool Wow64SetThreadContext( + SafeMemoryHandle thread, + ref Context32 context); + + /// Gets a 32-bit (WOW64) thread context. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool Wow64GetThreadContext( + SafeMemoryHandle thread, + ref Context32 context); + + // ── Modules ────────────────────────────────────────────────────────────── + + /// Loads a module into the calling process. + [LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "LoadLibraryW")] + internal static partial IntPtr LoadLibrary( + [MarshalAs(UnmanagedType.LPWStr)] string lpFileName); + + /// Returns the address of a function or variable from a loaded module. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial IntPtr GetProcAddress( + IntPtr hModule, + [MarshalAs(UnmanagedType.LPStr)] string lpProcName); + + /// Waits until a thread exits and retrieves its exit code. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial int WaitForSingleObject( + SafeMemoryHandle handle, + uint milliseconds); +} diff --git a/WhiteMagic/Native/NativeStructures.cs b/WhiteMagic/Native/NativeStructures.cs new file mode 100644 index 0000000..5fef352 --- /dev/null +++ b/WhiteMagic/Native/NativeStructures.cs @@ -0,0 +1,207 @@ +using System.Runtime.InteropServices; + +namespace WhiteMagic.Native; + +/// +/// The x87 and MMX state inside a 32-bit thread context. +/// +[StructLayout(LayoutKind.Sequential)] +public unsafe struct FloatingSaveArea32 +{ + /// The x87 FPU control word. + public uint ControlWord; + /// The x87 FPU status word. + public uint StatusWord; + /// The x87 FPU tag word. + public uint TagWord; + /// The offset of the instruction that caused the last FPU exception. + public uint ErrorOffset; + /// The selector of the instruction that caused the last FPU exception. + public uint ErrorSelector; + /// The offset of the operand that caused the last FPU exception. + public uint DataOffset; + /// The selector of the operand that caused the last FPU exception. + public uint DataSelector; + /// The 80-byte register area. + public fixed byte RegisterArea[80]; + /// The CR0 numeric-processor-extension state. + public uint Cr0NpxState; +} + +/// +/// A 32-bit (x86/WOW64) thread context. Use it with +/// Wow64GetThreadContext and Wow64SetThreadContext to inspect a 32-bit thread. +/// The total size is 716 bytes. +/// +[StructLayout(LayoutKind.Sequential)] +public unsafe struct Context32 +{ + /// Selects which parts of the context are valid. See . + public uint ContextFlags; + + /// Debug register 0. + public uint Dr0; + /// Debug register 1. + public uint Dr1; + /// Debug register 2. + public uint Dr2; + /// Debug register 3. + public uint Dr3; + /// Debug register 6. + public uint Dr6; + /// Debug register 7. + public uint Dr7; + + /// The floating-point state. + public FloatingSaveArea32 FloatSave; + + /// The GS segment. + public uint SegGs; + /// The FS segment. + public uint SegFs; + /// The ES segment. + public uint SegEs; + /// The DS segment. + public uint SegDs; + + /// The EDI register. + public uint Edi; + /// The ESI register. + public uint Esi; + /// The EBX register. + public uint Ebx; + /// The EDX register. + public uint Edx; + /// The ECX register. + public uint Ecx; + /// The EAX register. + public uint Eax; + + /// The base (frame) pointer. + public uint Ebp; + /// The instruction pointer. + public uint Eip; + /// The CS segment. + public uint SegCs; + /// The flags register. + public uint EFlags; + /// The stack pointer. + public uint Esp; + /// The SS segment. + public uint SegSs; + + /// The extended (processor-specific) registers. The size is 512 bytes. + public fixed byte ExtendedRegisters[512]; +} + +/// +/// A 64-bit (AMD64) thread context. Use it with the native +/// GetThreadContext and SetThreadContext from a 64-bit process. +/// The structure needs 16-byte alignment. The total size is 1232 bytes. +/// +[StructLayout(LayoutKind.Sequential, Pack = 16)] +public unsafe struct Context64 +{ + /// Home storage for a register parameter. + public ulong P1Home; + /// Home storage for a register parameter. + public ulong P2Home; + /// Home storage for a register parameter. + public ulong P3Home; + /// Home storage for a register parameter. + public ulong P4Home; + /// Home storage for a register parameter. + public ulong P5Home; + /// Home storage for a register parameter. + public ulong P6Home; + + /// Selects which parts of the context are valid. See . + public uint ContextFlags; + /// The MXCSR register. + public uint MxCsr; + + /// The CS segment. + public ushort SegCs; + /// The DS segment. + public ushort SegDs; + /// The ES segment. + public ushort SegEs; + /// The FS segment. + public ushort SegFs; + /// The GS segment. + public ushort SegGs; + /// The SS segment. + public ushort SegSs; + + /// The flags register. + public uint EFlags; + + /// Debug register 0. + public ulong Dr0; + /// Debug register 1. + public ulong Dr1; + /// Debug register 2. + public ulong Dr2; + /// Debug register 3. + public ulong Dr3; + /// Debug register 6. + public ulong Dr6; + /// Debug register 7. + public ulong Dr7; + + /// The RAX register. + public ulong Rax; + /// The RCX register. + public ulong Rcx; + /// The RDX register. + public ulong Rdx; + /// The RBX register. + public ulong Rbx; + /// The stack pointer. + public ulong Rsp; + /// The base (frame) pointer. + public ulong Rbp; + /// The RSI register. + public ulong Rsi; + /// The RDI register. + public ulong Rdi; + /// The R8 register. + public ulong R8; + /// The R9 register. + public ulong R9; + /// The R10 register. + public ulong R10; + /// The R11 register. + public ulong R11; + /// The R12 register. + public ulong R12; + /// The R13 register. + public ulong R13; + /// The R14 register. + public ulong R14; + /// The R15 register. + public ulong R15; + + /// The instruction pointer. + public ulong Rip; + + /// The XMM save area. The size is 512 bytes. + public fixed byte FltSave[512]; + + /// The vector registers (26 entries of 16 bytes, stored as 52 entries of 8 bytes). + public fixed ulong VectorRegister[52]; + + /// The vector control register. + public ulong VectorControl; + + /// The debug-control MSR. + public ulong DebugControl; + /// The target RIP of the last branch. + public ulong LastBranchToRip; + /// The source RIP of the last branch. + public ulong LastBranchFromRip; + /// The target RIP of the last exception. + public ulong LastExceptionToRip; + /// The source RIP of the last exception. + public ulong LastExceptionFromRip; +} diff --git a/WhiteMagic/Native/SafeMemoryHandle.cs b/WhiteMagic/Native/SafeMemoryHandle.cs new file mode 100644 index 0000000..000c79f --- /dev/null +++ b/WhiteMagic/Native/SafeMemoryHandle.cs @@ -0,0 +1,34 @@ +using Microsoft.Win32.SafeHandles; + +namespace WhiteMagic.Native; + +/// +/// A Win32 handle (process, thread, or snapshot) with a managed lifetime. +/// The handle closes with CloseHandle, even after an exception or a thread abort. +/// +/// The pattern comes from MemorySharp's SafeMemoryHandle. +public sealed class SafeMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + /// + /// Makes an empty handle. The interop marshaller uses this constructor for a + /// handle that a system call returns (for example, ). + /// + public SafeMemoryHandle() : base(true) + { + } + + /// + /// Wraps a raw handle and takes ownership of the handle. + /// + /// The handle to own. + public SafeMemoryHandle(IntPtr handle) : base(true) + { + SetHandle(handle); + } + + /// + protected override bool ReleaseHandle() + { + return NativeMethods.CloseHandle(handle); + } +} diff --git a/WhiteMagic/WhiteMagic.csproj b/WhiteMagic/WhiteMagic.csproj index 2b1816a..edf34ce 100644 --- a/WhiteMagic/WhiteMagic.csproj +++ b/WhiteMagic/WhiteMagic.csproj @@ -9,4 +9,8 @@ true + + + + diff --git a/WhiteMagicTest/Native/NativeSurfaceTests.cs b/WhiteMagicTest/Native/NativeSurfaceTests.cs new file mode 100644 index 0000000..786b48e --- /dev/null +++ b/WhiteMagicTest/Native/NativeSurfaceTests.cs @@ -0,0 +1,112 @@ +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagicTest.Native; + +/// +/// Integration tests that exercise the P/Invoke surface against the current +/// process. They prove the marshalling signatures are correct end-to-end. +/// +public class NativeSurfaceTests +{ + private static SafeMemoryHandle OpenSelf(ProcessAccess access) + { + SafeMemoryHandle handle = NativeMethods.OpenProcess(access, false, Environment.ProcessId); + Assert.False(handle.IsInvalid, $"OpenProcess failed: {Marshal.GetLastPInvokeError()}"); + return handle; + } + + [Fact] + public void OpenProcess_on_self_returns_valid_handle_and_closes_on_dispose() + { + SafeMemoryHandle handle = OpenSelf(ProcessAccess.QueryInformation); + Assert.False(handle.IsClosed); + handle.Dispose(); + Assert.True(handle.IsClosed); + } + + [Fact] + public void ReadProcessMemory_reads_a_known_value_from_own_memory() + { + int value = 0x1BADB002; + GCHandle pin = GCHandle.Alloc(value, GCHandleType.Pinned); + try + { + using SafeMemoryHandle handle = OpenSelf(ProcessAccess.VmRead | ProcessAccess.QueryInformation); + Span buffer = stackalloc byte[sizeof(int)]; + + bool ok = NativeMethods.ReadProcessMemory( + handle, pin.AddrOfPinnedObject(), buffer, buffer.Length, out nint read); + + Assert.True(ok, $"ReadProcessMemory failed: {Marshal.GetLastPInvokeError()}"); + Assert.Equal(sizeof(int), (int)read); + Assert.Equal(value, BitConverter.ToInt32(buffer)); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void WriteProcessMemory_writes_a_value_into_own_memory() + { + int slot = 0; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + using SafeMemoryHandle handle = OpenSelf( + ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation); + ReadOnlySpan payload = BitConverter.GetBytes(0x5EED); + + bool ok = NativeMethods.WriteProcessMemory( + handle, pin.AddrOfPinnedObject(), payload, payload.Length, out nint written); + + Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}"); + Assert.Equal(payload.Length, (int)written); + Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject())); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void VirtualAllocEx_commits_then_protects_then_frees() + { + using SafeMemoryHandle handle = OpenSelf(ProcessAccess.VmOperation | ProcessAccess.QueryInformation); + + IntPtr region = NativeMethods.VirtualAllocEx( + handle, IntPtr.Zero, 0x1000, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ReadWrite); + Assert.NotEqual(IntPtr.Zero, region); + + bool protect = NativeMethods.VirtualProtectEx( + handle, region, 0x1000, MemoryProtectionType.ExecuteReadWrite, out MemoryProtectionType old); + Assert.True(protect, $"VirtualProtectEx failed: {Marshal.GetLastPInvokeError()}"); + Assert.Equal(MemoryProtectionType.ReadWrite, old); + + bool free = NativeMethods.VirtualFreeEx(handle, region, 0, MemoryFreeType.Release); + Assert.True(free, $"VirtualFreeEx failed: {Marshal.GetLastPInvokeError()}"); + } + + [Fact] + public void LoadLibrary_then_GetProcAddress_resolves_an_export() + { + IntPtr module = NativeMethods.LoadLibrary("kernel32.dll"); + Assert.NotEqual(IntPtr.Zero, module); + + IntPtr proc = NativeMethods.GetProcAddress(module, "CloseHandle"); + Assert.NotEqual(IntPtr.Zero, proc); + } + + [Theory] + [InlineData(typeof(Context32), 716)] + [InlineData(typeof(Context64), 1232)] + public void Thread_context_struct_has_the_exact_native_size(Type contextType, int expectedSize) + { + Assert.Equal(expectedSize, Marshal.SizeOf(contextType)); + } +} diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md index 10b5fb5..848a454 100644 --- a/openspec/changes/whitemagic-foundation/tasks.md +++ b/openspec/changes/whitemagic-foundation/tasks.md @@ -3,7 +3,7 @@ - [x] 1.1 Create `WhiteMagic/WhiteMagic.csproj` targeting `net8.0-windows`, `AllowUnsafeBlocks=true`, nullable enabled, `TreatWarningsAsErrors`, `Platforms=x86;x64;AnyCPU` - [x] 1.2 Create `WhiteMagicTest/WhiteMagicTest.csproj` (xUnit, `net8.0-windows`) referencing `WhiteMagic` - [x] 1.3 Create `WhiteMagic.slnx` (SDK 10 default solution format) and add both projects. (Built on SDK 10; `net8.0-windows` targeting pack auto-restored.) -- [ ] 1.4 Add `WhiteMagic/Native/` P/Invoke surface (`LibraryImport`): OpenProcess, Read/WriteProcessMemory, VirtualAllocEx/FreeEx/ProtectEx, CreateRemoteThread, Wow64Get/SetThreadContext, Get/SetThreadContext, LoadLibrary, GetProcAddress; add `SafeMemoryHandle` +- [x] 1.4 Add `WhiteMagic/Native/` P/Invoke surface (`LibraryImport`): OpenProcess, Read/WriteProcessMemory, VirtualAllocEx/FreeEx/ProtectEx, CreateRemoteThread, Wow64Get/SetThreadContext, Get/SetThreadContext, LoadLibrary, GetProcAddress; add `SafeMemoryHandle` - [x] 1.5 Verify empty projects build: `dotnet build WhiteMagic.slnx` — zero errors, zero warnings ## 2. Core Memory Access (spec: memory-access) From ccde012e4557ff05303e24bb34cc28d93e862c4f Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 17:03:23 +0200 Subject: [PATCH 2/9] task 2.1-2.2: implement MarshalCache with tests Add MarshalCache static class that computes Size, SizeU, TypeRequiresMarshal, IsIntPtr, TypeCode, and RealType once per type in the static constructor. Handles bool (size=1), enums (underlying type), and MarshalAs-attributed fields (TypeRequiresMarshal). 12 new tests covering: blittable sizes, bool size, enum size, struct size, marshal-required flag, IsIntPtr, computed-once caching. All passing. --- WhiteMagic/MarshalCache.cs | 69 +++++++++++++++++ WhiteMagicTest/MarshalCacheTests.cs | 111 ++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 WhiteMagic/MarshalCache.cs create mode 100644 WhiteMagicTest/MarshalCacheTests.cs diff --git a/WhiteMagic/MarshalCache.cs b/WhiteMagic/MarshalCache.cs new file mode 100644 index 0000000..0b34953 --- /dev/null +++ b/WhiteMagic/MarshalCache.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace WhiteMagic; + +/// +/// Computes and caches marshal-related metadata for type +/// exactly once. and +/// branch on these cached flags to decide between blittable Span/MemoryMarshal +/// paths and the fallback marshal path. +/// +/// The type to cache metadata for. +public static class MarshalCache +{ + /// The unmanaged size of in bytes. + public static readonly int Size; + + /// The unmanaged size of as an unsigned integer. + public static readonly uint SizeU; + + /// + /// when has at least one field + /// decorated with , meaning it cannot be copied + /// via a simple pointer dereference. + /// + public static readonly bool TypeRequiresMarshal; + + /// when is . + public static readonly bool IsIntPtr; + + /// The underlying type code of . + public static readonly TypeCode TypeCode; + + /// + /// The effective type that the marshaler uses. For an enum this is the underlying + /// integer type; for all other types it is itself. + /// + public static readonly Type RealType; + + static MarshalCache() + { + TypeCode = Type.GetTypeCode(typeof(T)); + + if (typeof(T) == typeof(bool)) + { + Size = 1; + RealType = typeof(T); + } + else if (typeof(T).IsEnum) + { + Type underlying = typeof(T).GetEnumUnderlyingType(); + Size = Marshal.SizeOf(underlying); + RealType = underlying; + TypeCode = Type.GetTypeCode(underlying); + } + else + { + Size = Marshal.SizeOf(typeof(T)); + RealType = typeof(T); + } + + SizeU = (uint)Size; + IsIntPtr = RealType == typeof(IntPtr); + + TypeRequiresMarshal = + RealType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0); + } +} diff --git a/WhiteMagicTest/MarshalCacheTests.cs b/WhiteMagicTest/MarshalCacheTests.cs new file mode 100644 index 0000000..6511eac --- /dev/null +++ b/WhiteMagicTest/MarshalCacheTests.cs @@ -0,0 +1,111 @@ +using System.Runtime.InteropServices; +using WhiteMagic; + +namespace WhiteMagicTest; + +/// +/// Tests for : blittable size, marshal-required flag, +/// IsIntPtr, and computed-once behavior. +/// +public class MarshalCacheTests +{ + [Fact] + public void Size_for_int_is_4() + { + Assert.Equal(4, MarshalCache.Size); + } + + [Fact] + public void Size_for_byte_is_1() + { + Assert.Equal(1, MarshalCache.Size); + } + + [Fact] + public void Size_for_IntPtr_matches_native_pointer_size() + { + Assert.Equal(IntPtr.Size, MarshalCache.Size); + } + + [Fact] + public void Size_for_bool_is_1() + { + Assert.Equal(1, MarshalCache.Size); + } + + [Fact] + public void Size_for_enum_matches_underlying_type() + { + Assert.Equal(4, MarshalCache.Size); + } + + [Fact] + public void Size_for_blittable_struct_is_accurate() + { + Assert.Equal(8, MarshalCache.Size); + } + + [Fact] + public void TypeRequiresMarshal_is_false_for_blittable_types() + { + Assert.False(MarshalCache.TypeRequiresMarshal); + Assert.False(MarshalCache.TypeRequiresMarshal); + Assert.False(MarshalCache.TypeRequiresMarshal); + } + + [Fact] + public void TypeRequiresMarshal_is_true_for_types_with_MarshalAs_field() + { + Assert.True(MarshalCache.TypeRequiresMarshal); + } + + [Fact] + public void IsIntPtr_is_true_for_IntPtr() + { + Assert.True(MarshalCache.IsIntPtr); + } + + [Fact] + public void IsIntPtr_is_false_for_non_IntPtr_types() + { + Assert.False(MarshalCache.IsIntPtr); + Assert.False(MarshalCache.IsIntPtr); + Assert.False(MarshalCache.IsIntPtr); + } + + [Fact] + public void All_properties_are_computed_once_and_cached() + { + int size1 = MarshalCache.Size; + bool marshal1 = MarshalCache.TypeRequiresMarshal; + bool intPtr1 = MarshalCache.IsIntPtr; + + int size2 = MarshalCache.Size; + bool marshal2 = MarshalCache.TypeRequiresMarshal; + bool intPtr2 = MarshalCache.IsIntPtr; + + Assert.Equal(size1, size2); + Assert.Equal(marshal1, marshal2); + Assert.Equal(intPtr1, intPtr2); + } + + [Fact] + public void SizeU_matches_Size_as_uint() + { + Assert.Equal((uint)MarshalCache.Size, MarshalCache.SizeU); + } + + [StructLayout(LayoutKind.Sequential)] + private struct BlittableStruct + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MarshalAsStruct + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] Data; + } +} From 6b34e0fab8a636f9671e64817661eb62764e90a1 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 17:05:29 +0200 Subject: [PATCH 3/9] task 2.3-2.4: implement MemoryBase + ExternalReader with tests Add abstract MemoryBase base class with typed Read/Write, array IO, string IO, and relative/absolute addressing. Uses MarshalCache to branch between blittable (MemoryMarshal) and marshal-required paths. Add ExternalReader (out-of-process via ReadProcessMemory/WriteProcessMemory) with SafeMemoryHandle lifecycle management. 11 new tests: ImageBase, Read/Write of int/byte/long/struct, byte array, int array, struct array, invalid address, dispose, double-dispose. All passing (total: 30). --- WhiteMagic/ExternalReader.cs | 86 ++++++++++++ WhiteMagic/MemoryBase.cs | 215 +++++++++++++++++++++++++++++ WhiteMagicTest/MemoryBaseTests.cs | 219 ++++++++++++++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 WhiteMagic/ExternalReader.cs create mode 100644 WhiteMagic/MemoryBase.cs create mode 100644 WhiteMagicTest/MemoryBaseTests.cs diff --git a/WhiteMagic/ExternalReader.cs b/WhiteMagic/ExternalReader.cs new file mode 100644 index 0000000..b0d6826 --- /dev/null +++ b/WhiteMagic/ExternalReader.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic; + +/// +/// Out-of-process memory reader that accesses the target's memory through +/// and +/// . +/// +public sealed class ExternalReader : MemoryBase +{ + private readonly SafeMemoryHandle _handle; + private readonly IntPtr _imageBase; + private bool _disposed; + + /// + /// Opens a process for external memory access. + /// + /// The target process. + /// The access rights to request. Defaults to + /// . + public ExternalReader(Process process, ProcessAccess desiredAccess = ProcessAccess.AllAccess) + { + _handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id); + if (_handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"OpenProcess failed for PID {process.Id}: error {error}"); + } + + _imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero; + } + + /// + public override IntPtr ImageBase => _imageBase; + + /// + public override SafeMemoryHandle Handle => _handle; + + /// + public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) + { + if (isRelative) + address = GetAbsolute(address); + + byte[] buffer = new byte[count]; + if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead)) + { + return []; + } + + if ((int)bytesRead != count) + { + Array.Resize(ref buffer, (int)bytesRead); + } + + return buffer; + } + + /// + public override int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false) + { + if (isRelative) + address = GetAbsolute(address); + + if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written)) + { + return 0; + } + + return (int)written; + } + + /// + public override void Dispose() + { + if (!_disposed) + { + _disposed = true; + _handle.Dispose(); + } + } +} diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs new file mode 100644 index 0000000..2f7ef33 --- /dev/null +++ b/WhiteMagic/MemoryBase.cs @@ -0,0 +1,215 @@ +using WhiteMagic.Native; +using System.Runtime.InteropServices; +using System.Text; + +namespace WhiteMagic; + +/// +/// Abstract base for all memory-access readers and writers. Provides typed +/// /, array IO, string IO, and +/// relative/absolute addressing. Subclasses implement the concrete +/// and methods. +/// +public abstract class MemoryBase : IDisposable +{ + /// 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; } + + // ── Raw byte IO ──────────────────────────────────────────────────────── + + /// Reads a sequence of bytes from the target address. + public abstract byte[] ReadBytes(IntPtr address, int count, bool isRelative = false); + + /// Writes a sequence of bytes to the target address. + /// The number of bytes written. + public abstract int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false); + + // ── Typed IO ─────────────────────────────────────────────────────────── + + /// Reads a value of type from the target address. + public T Read(IntPtr address, bool isRelative = false) where T : struct + { + if (isRelative) + address = GetAbsolute(address); + + int size = MarshalCache.Size; + Span buffer = stackalloc byte[size]; + byte[] raw = ReadBytes(address, size); + raw.CopyTo(buffer); + + if (MarshalCache.TypeRequiresMarshal) + { + return MarshalByteArrayToStructure(raw); + } + + return MemoryMarshal.Read(buffer); + } + + /// Writes a value of type to the target address. + /// if all bytes were written. + public bool Write(IntPtr address, T value, bool isRelative = false) where T : struct + { + if (isRelative) + address = GetAbsolute(address); + + int size = MarshalCache.Size; + Span buffer = stackalloc byte[size]; + + if (MarshalCache.TypeRequiresMarshal) + { + StructureToByteArray(value, buffer, size); + } + else + { + MemoryMarshal.Write(buffer, in value); + } + + int written = WriteBytes(address, buffer, false); + return written == size; + } + + /// Reads an array of values of type from the target address. + public T[] Read(IntPtr address, int count, bool isRelative = false) where T : struct + { + if (isRelative) + address = GetAbsolute(address); + + int elementSize = MarshalCache.Size; + int totalSize = elementSize * count; + byte[] raw = ReadBytes(address, totalSize); + + var result = new T[count]; + + if (MarshalCache.TypeRequiresMarshal) + { + for (int i = 0; i < count; i++) + { + var elementBytes = new ReadOnlySpan(raw, i * elementSize, elementSize); + result[i] = MarshalByteArrayToStructure(elementBytes.ToArray()); + } + } + else + { + ReadOnlySpan span = raw; + for (int i = 0; i < count; i++) + { + result[i] = MemoryMarshal.Read(span.Slice(i * elementSize, elementSize)); + } + } + + return result; + } + + /// Writes an array of values of type to the target address. + /// if all bytes were written. + public bool Write(IntPtr address, T[] values, bool isRelative = false) where T : struct + { + if (isRelative) + address = GetAbsolute(address); + + if (values is null || values.Length == 0) + return true; + + int elementSize = MarshalCache.Size; + int totalSize = elementSize * values.Length; + byte[] raw = new byte[totalSize]; + + Span span = raw; + for (int i = 0; i < values.Length; i++) + { + Span slice = span.Slice(i * elementSize, elementSize); + if (MarshalCache.TypeRequiresMarshal) + { + StructureToByteArray(values[i], slice, elementSize); + } + else + { + MemoryMarshal.Write(slice, in values[i]); + } + } + + int written = WriteBytes(address, raw, false); + return written == totalSize; + } + + // ── String IO ────────────────────────────────────────────────────────── + + /// Reads a null-terminated string from the target address. + public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false) + { + byte[] buffer = ReadBytes(address, maxLength, relative); + int nullIndex = Array.IndexOf(buffer, 0); + if (nullIndex >= 0) + { + return encoding.GetString(buffer, 0, nullIndex); + } + return encoding.GetString(buffer); + } + + /// Writes a null-terminated string to the target address. + public virtual bool WriteString(IntPtr address, string value, Encoding encoding, bool relative = false) + { + // Ensure null terminator + if (value.Length == 0 || value[^1] != '\0') + value += '\0'; + + byte[] bytes = encoding.GetBytes(value); + int written = WriteBytes(address, bytes, relative); + return written == bytes.Length; + } + + // ── Addressing ───────────────────────────────────────────────────────── + + /// Converts a relative offset to an absolute address relative to . + public IntPtr GetAbsolute(IntPtr relative) + { + return ImageBase + (int)relative; + } + + /// Converts an absolute address to a relative offset from . + public IntPtr GetRelative(IntPtr absolute) + { + return (IntPtr)((int)ImageBase - (int)absolute); + } + + // ── Lifecycle ────────────────────────────────────────────────────────── + + /// + public virtual void Dispose() + { + Handle?.Dispose(); + } + + // ── Private helpers ──────────────────────────────────────────────────── + + private static T MarshalByteArrayToStructure(byte[] bytes) where T : struct + { + GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + return Marshal.PtrToStructure(pin.AddrOfPinnedObject()); + } + finally + { + pin.Free(); + } + } + + private static void StructureToByteArray(T value, Span destination, int size) where T : struct + { + byte[] temp = destination.ToArray(); + GCHandle pin = GCHandle.Alloc(temp, GCHandleType.Pinned); + try + { + Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false); + temp.CopyTo(destination); + } + finally + { + pin.Free(); + } + } +} diff --git a/WhiteMagicTest/MemoryBaseTests.cs b/WhiteMagicTest/MemoryBaseTests.cs new file mode 100644 index 0000000..223ba5a --- /dev/null +++ b/WhiteMagicTest/MemoryBaseTests.cs @@ -0,0 +1,219 @@ +using WhiteMagic.Native; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using WhiteMagic; + +namespace WhiteMagicTest; + +/// +/// Tests for abstract contract and +/// round-trip (Read<T>/Write<T>, arrays) using the current process as target. +/// +public class MemoryBaseTests +{ + private static ExternalReader OpenSelf() + { + return new ExternalReader( + Process.GetCurrentProcess(), + ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation); + } + + [Fact] + public void ImageBase_is_nonzero_for_self() + { + using var reader = OpenSelf(); + Assert.NotEqual(IntPtr.Zero, reader.ImageBase); + } + + [Fact] + public void Read_int_writes_and_reads_back() + { + using var reader = OpenSelf(); + + // Pin a local int to use as our "remote" address + int slot = 0; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.Write(addr, 0x1BADB002)); + Assert.Equal(0x1BADB002, reader.Read(addr)); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_byte_writes_and_reads_back() + { + using var reader = OpenSelf(); + byte slot = 0; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.Write(addr, (byte)0xAB)); + Assert.Equal(0xAB, reader.Read(addr)); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_long_writes_and_reads_back() + { + using var reader = OpenSelf(); + long slot = 0; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.Write(addr, unchecked((long)0xDEADBEEF_CAFEBABE))); + Assert.Equal(unchecked((long)0xDEADBEEF_CAFEBABE), reader.Read(addr)); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_blittable_struct_writes_and_reads_back() + { + using var reader = OpenSelf(); + var slot = new TestStruct { X = 42, Y = 99 }; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + + // Write a new value + Assert.True(reader.Write(addr, new TestStruct { X = 100, Y = 200 })); + + var result = reader.Read(addr); + Assert.Equal(100, result.X); + Assert.Equal(200, result.Y); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_bytes_writes_and_reads_back() + { + using var reader = OpenSelf(); + byte[] buffer = new byte[16]; + GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + byte[] expected = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]; + + int written = reader.WriteBytes(addr, expected); + Assert.Equal(expected.Length, written); + + byte[] actual = reader.ReadBytes(addr, expected.Length); + Assert.Equal(expected, actual); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_int_array_writes_and_reads_back() + { + using var reader = OpenSelf(); + int[] buffer = new int[4]; + GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + int[] expected = [10, 20, 30, 40]; + + Assert.True(reader.Write(addr, expected)); + + int[] actual = reader.Read(addr, 4); + Assert.Equal(expected, actual); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_struct_array_writes_and_reads_back() + { + using var reader = OpenSelf(); + var buffer = new TestStruct[4]; + GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + var expected = new[] + { + new TestStruct { X = 1, Y = 2 }, + new TestStruct { X = 3, Y = 4 }, + new TestStruct { X = 5, Y = 6 }, + new TestStruct { X = 7, Y = 8 }, + }; + + Assert.True(reader.Write(addr, expected)); + + var actual = reader.Read(addr, 4); + Assert.Equal(expected, actual); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Write_returns_false_for_invalid_address() + { + using var reader = OpenSelf(); + Assert.False(reader.Write(IntPtr.Zero, 42)); + } + + [Fact] + public void Dispose_closes_handle() + { + var reader = OpenSelf(); + Assert.False(reader.Handle.IsClosed); + reader.Dispose(); + Assert.True(reader.Handle.IsClosed); + } + + [Fact] + public void Double_dispose_does_not_throw() + { + var reader = OpenSelf(); + reader.Dispose(); + reader.Dispose(); // Should not throw + } +} + +/// +/// A simple blittable struct for use in tests. +/// +[StructLayout(LayoutKind.Sequential)] +public struct TestStruct : IEquatable +{ + public int X; + public int Y; + + public bool Equals(TestStruct other) => X == other.X && Y == other.Y; + public override bool Equals(object? obj) => obj is TestStruct other && Equals(other); + public override int GetHashCode() => HashCode.Combine(X, Y); + public override string ToString() => $"({X}, {Y})"; +} From b2a5090533ef4fa0661b7b543df06864a599488e Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 17:06:34 +0200 Subject: [PATCH 4/9] task 2.5-2.6: string Read/Write with encoding tests Add 8 tests for string IO: ASCII/UTF8/Unicode round-trip, null-terminator stop, max-length truncation, auto-append of null terminator, empty string. Fix ReadString null-terminator detection for multi-byte encodings (UTF-16): decode string first, then find \0 in characters not bytes. All passing (total: 38). --- WhiteMagic/MemoryBase.cs | 7 +- WhiteMagicTest/StringReadWriteTests.cs | 183 +++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 WhiteMagicTest/StringReadWriteTests.cs diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs index 2f7ef33..638d1cf 100644 --- a/WhiteMagic/MemoryBase.cs +++ b/WhiteMagic/MemoryBase.cs @@ -141,12 +141,13 @@ public abstract class MemoryBase : IDisposable public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false) { byte[] buffer = ReadBytes(address, maxLength, relative); - int nullIndex = Array.IndexOf(buffer, 0); + string decoded = encoding.GetString(buffer); + int nullIndex = decoded.IndexOf('\0'); if (nullIndex >= 0) { - return encoding.GetString(buffer, 0, nullIndex); + return decoded[..nullIndex]; } - return encoding.GetString(buffer); + return decoded; } /// Writes a null-terminated string to the target address. diff --git a/WhiteMagicTest/StringReadWriteTests.cs b/WhiteMagicTest/StringReadWriteTests.cs new file mode 100644 index 0000000..6e0cf37 --- /dev/null +++ b/WhiteMagicTest/StringReadWriteTests.cs @@ -0,0 +1,183 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using WhiteMagic; +using WhiteMagic.Native; + +namespace WhiteMagicTest; + +/// +/// Tests for and +/// with encoding, null-terminator stop, and max-length behavior. +/// +public class StringReadWriteTests +{ + private static ExternalReader OpenSelf() + { + return new ExternalReader( + Process.GetCurrentProcess(), + ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation); + } + + [Fact] + public void WriteString_ascii_then_ReadString_round_trips() + { + using var reader = OpenSelf(); + byte[] slot = new byte[64]; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.WriteString(addr, "hello", Encoding.ASCII)); + string result = reader.ReadString(addr, Encoding.ASCII); + Assert.Equal("hello", result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void WriteString_utf8_then_ReadString_round_trips() + { + using var reader = OpenSelf(); + byte[] slot = new byte[64]; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.WriteString(addr, "héllo wörld", Encoding.UTF8)); + string result = reader.ReadString(addr, Encoding.UTF8); + Assert.Equal("héllo wörld", result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void WriteString_unicode_then_ReadString_round_trips() + { + using var reader = OpenSelf(); + byte[] slot = new byte[128]; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.WriteString(addr, "Hello\u00A9\u00AE\u20AC", Encoding.Unicode)); + string result = reader.ReadString(addr, Encoding.Unicode); + Assert.Equal("Hello\u00A9\u00AE\u20AC", result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void ReadString_stops_at_null_terminator() + { + using var reader = OpenSelf(); + byte[] slot = Encoding.ASCII.GetBytes("hello\0world"); + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 64); + Assert.Equal("hello", result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void ReadString_respects_max_length() + { + using var reader = OpenSelf(); + byte[] slot = Encoding.ASCII.GetBytes("hello world this is a test"); + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 5); + Assert.Equal("hello", result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void WriteString_appends_null_terminator_automatically() + { + using var reader = OpenSelf(); + byte[] slot = new byte[32]; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + + // Write without terminator + Assert.True(reader.WriteString(addr, "test", Encoding.ASCII)); + + // The written bytes should end with \0 + byte[] read = reader.ReadBytes(addr, 8); + Assert.Equal((byte)'t', read[0]); + Assert.Equal((byte)'e', read[1]); + Assert.Equal((byte)'s', read[2]); + Assert.Equal((byte)'t', read[3]); + Assert.Equal(0, read[4]); // null terminator + } + finally + { + pin.Free(); + } + } + + [Fact] + public void ReadString_empty_buffer_returns_empty_string() + { + using var reader = OpenSelf(); + byte[] slot = new byte[1] { 0 }; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 1); + Assert.Equal("", result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void WriteString_empty_string_writes_only_null() + { + using var reader = OpenSelf(); + byte[] slot = new byte[8]; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + + // Write a marker first + reader.WriteBytes(addr, [0xAB, 0xCD, 0xEF, 0x00]); + // Now overwrite with empty string + Assert.True(reader.WriteString(addr, "", Encoding.ASCII)); + + byte[] read = reader.ReadBytes(addr, 4); + Assert.Equal(0, read[0]); // null + } + finally + { + pin.Free(); + } + } +} From 8219ac95a6873491a4f7d354a97a47bf28863e53 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 17:08:08 +0200 Subject: [PATCH 5/9] task 2.7-2.8: addressing helpers with tests Add GetAbsolute/GetRelative with nint-based arithmetic (bitness-agnostic). Add 6 tests: absolute resolution, relative computation, base-round-trip, isRelative flag on Read/Write/ReadBytes. Fix MemoryBase.cs: restore for-loop body damaged by prior edit, clean up StructureToByteArray overloads. All passing (total: 44). --- WhiteMagic/MemoryBase.cs | 33 ++++++++---- WhiteMagicTest/AddressingTests.cs | 89 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 WhiteMagicTest/AddressingTests.cs diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs index 638d1cf..931e8da 100644 --- a/WhiteMagic/MemoryBase.cs +++ b/WhiteMagic/MemoryBase.cs @@ -36,16 +36,14 @@ public abstract class MemoryBase : IDisposable address = GetAbsolute(address); int size = MarshalCache.Size; - Span buffer = stackalloc byte[size]; byte[] raw = ReadBytes(address, size); - raw.CopyTo(buffer); if (MarshalCache.TypeRequiresMarshal) { return MarshalByteArrayToStructure(raw); } - return MemoryMarshal.Read(buffer); + return MemoryMarshal.Read(raw.AsSpan()); } /// Writes a value of type to the target address. @@ -56,18 +54,19 @@ public abstract class MemoryBase : IDisposable address = GetAbsolute(address); int size = MarshalCache.Size; - Span buffer = stackalloc byte[size]; + byte[] raw; if (MarshalCache.TypeRequiresMarshal) { - StructureToByteArray(value, buffer, size); + raw = StructureToByteArray(value, size); } else { - MemoryMarshal.Write(buffer, in value); + raw = new byte[size]; + MemoryMarshal.Write(raw.AsSpan(), in value); } - int written = WriteBytes(address, buffer, false); + int written = WriteBytes(address, raw, false); return written == size; } @@ -153,7 +152,6 @@ public abstract class MemoryBase : IDisposable /// Writes a null-terminated string to the target address. public virtual bool WriteString(IntPtr address, string value, Encoding encoding, bool relative = false) { - // Ensure null terminator if (value.Length == 0 || value[^1] != '\0') value += '\0'; @@ -167,13 +165,13 @@ public abstract class MemoryBase : IDisposable /// Converts a relative offset to an absolute address relative to . public IntPtr GetAbsolute(IntPtr relative) { - return ImageBase + (int)relative; + return ImageBase + (nint)relative; } /// Converts an absolute address to a relative offset from . public IntPtr GetRelative(IntPtr absolute) { - return (IntPtr)((int)ImageBase - (int)absolute); + return (IntPtr)((nint)ImageBase - (nint)absolute); } // ── Lifecycle ────────────────────────────────────────────────────────── @@ -199,6 +197,21 @@ public abstract class MemoryBase : IDisposable } } + private static byte[] StructureToByteArray(T value, int size) where T : struct + { + byte[] bytes = new byte[size]; + GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false); + } + finally + { + pin.Free(); + } + return bytes; + } + private static void StructureToByteArray(T value, Span destination, int size) where T : struct { byte[] temp = destination.ToArray(); diff --git a/WhiteMagicTest/AddressingTests.cs b/WhiteMagicTest/AddressingTests.cs new file mode 100644 index 0000000..3245817 --- /dev/null +++ b/WhiteMagicTest/AddressingTests.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using WhiteMagic; +using WhiteMagic.Native; + +namespace WhiteMagicTest; + +/// +/// Tests for relative/absolute addressing in . +/// +public class AddressingTests +{ + private static ExternalReader OpenSelf() + { + return new ExternalReader( + Process.GetCurrentProcess(), + ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation); + } + + [Fact] + public void GetAbsolute_resolves_relative_offset() + { + using var reader = OpenSelf(); + IntPtr imageBase = reader.ImageBase; + IntPtr result = reader.GetAbsolute((IntPtr)0x1000); + Assert.Equal(imageBase + 0x1000, result); + } + + [Fact] + public void GetRelative_computes_offset_from_image_base() + { + using var reader = OpenSelf(); + IntPtr imageBase = reader.ImageBase; + IntPtr absolute = imageBase + 0x2000; + IntPtr relative = reader.GetRelative(absolute); + // GetRelative returns ImageBase - absolute (GreyMagic convention) + Assert.Equal((IntPtr)((int)imageBase - (int)absolute), relative); + } + + [Fact] + public void GetAbsolute_after_GetRelative_at_image_base_returns_to_base() + { + using var reader = OpenSelf(); + IntPtr atBase = reader.ImageBase; + IntPtr relative = reader.GetRelative(atBase); + IntPtr back = reader.GetAbsolute(relative); + Assert.Equal(atBase, back); + } + + [Fact] + public void Read_with_isRelative_true_uses_image_base() + { + using var reader = OpenSelf(); + // Read the first byte at ImageBase (should be MZ header: 0x4D = 'M') + byte firstByte = reader.Read(IntPtr.Zero, isRelative: true); + Assert.Equal(0x4D, firstByte); + } + + [Fact] + public void Write_with_isRelative_true_resolves_correctly() + { + using var reader = OpenSelf(); + int slot = 0; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr absolute = pin.AddrOfPinnedObject(); + nint relative = (nint)absolute - (nint)reader.ImageBase; + IntPtr relativePtr = (IntPtr)relative; + + Assert.True(reader.Write(relativePtr, 42, isRelative: true)); + Assert.Equal(42, reader.Read(absolute)); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void ReadBytes_with_isRelative_true_resolves_correctly() + { + using var reader = OpenSelf(); + // DOS header 'MZ' at the image base + byte[] data = reader.ReadBytes(IntPtr.Zero, 2, isRelative: true); + Assert.Equal(0x4D, data[0]); + Assert.Equal(0x5A, data[1]); + } +} From 6eb78e79748562cc481b8e6406b2fb4d574e6162 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 17:09:36 +0200 Subject: [PATCH 6/9] Phase 2: Core Memory Access complete task 2.9: implement InProcessReader with tests Add InProcessReader: direct pointer dereference (unsafe) against own process via Buffer.MemoryCopy. Implements MemoryBase API for in-process scenarios (injected managed DLL). 8 tests: ImageBase, Read/Write int, ReadBytes, WriteBytes, Read/Write struct, dispose lifecycle. All passing: 52 tests (7 Native + 12 MarshalCache + 11 MemoryBase + 8 String + 6 Addressing + 8 InProcessReader). --- WhiteMagic/InProcessReader.cs | 74 +++++++++ WhiteMagicTest/InProcessReaderTests.cs | 155 ++++++++++++++++++ .../changes/whitemagic-foundation/tasks.md | 18 +- 3 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 WhiteMagic/InProcessReader.cs create mode 100644 WhiteMagicTest/InProcessReaderTests.cs diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs new file mode 100644 index 0000000..518275d --- /dev/null +++ b/WhiteMagic/InProcessReader.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic; + +/// +/// In-process memory reader that accesses the owning process's memory through +/// direct pointer dereference (unsafe). Use this reader from within a +/// managed DLL injected into the target process. +/// +public sealed class InProcessReader : MemoryBase +{ + private readonly SafeMemoryHandle _handle; + private readonly IntPtr _imageBase; + private bool _disposed; + + /// + /// Creates an in-process reader for the current process. + /// + public InProcessReader() + { + Process current = Process.GetCurrentProcess(); + _handle = NativeMethods.OpenProcess( + ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation, + false, + current.Id); + + _imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero; + } + + /// + public override IntPtr ImageBase => _imageBase; + + /// + public override SafeMemoryHandle Handle => _handle; + + /// + public override unsafe byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) + { + if (isRelative) + address = GetAbsolute(address); + + byte[] buffer = new byte[count]; + fixed (byte* ptr = buffer) + { + Buffer.MemoryCopy((void*)address, ptr, count, count); + } + return buffer; + } + + /// + public override unsafe int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false) + { + if (isRelative) + address = GetAbsolute(address); + + fixed (byte* ptr = bytes) + { + Buffer.MemoryCopy(ptr, (void*)address, bytes.Length, bytes.Length); + } + return bytes.Length; + } + + /// + public override void Dispose() + { + if (!_disposed) + { + _disposed = true; + _handle.Dispose(); + } + } +} diff --git a/WhiteMagicTest/InProcessReaderTests.cs b/WhiteMagicTest/InProcessReaderTests.cs new file mode 100644 index 0000000..776b5aa --- /dev/null +++ b/WhiteMagicTest/InProcessReaderTests.cs @@ -0,0 +1,155 @@ +using System.Runtime.InteropServices; +using WhiteMagic; + +namespace WhiteMagicTest; + +/// +/// Tests for — direct pointer dereference against +/// the own process. Verifies the shared API works for +/// both external and in-process readers. +/// +public class InProcessReaderTests +{ + private static InProcessReader CreateReader() + { + return new InProcessReader(); + } + + [Fact] + public void ImageBase_is_nonzero() + { + using var reader = CreateReader(); + Assert.NotEqual(IntPtr.Zero, reader.ImageBase); + } + + [Fact] + public void Read_int_reads_known_value_from_own_memory() + { + using var reader = CreateReader(); + + int expected = 0x12345678; + GCHandle pin = GCHandle.Alloc(expected, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + int result = reader.Read(addr); + Assert.Equal(expected, result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Write_int_writes_and_reads_back() + { + using var reader = CreateReader(); + + int slot = 0; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.Write(addr, unchecked((int)0xCAFEBABE))); + Assert.Equal(unchecked((int)0xCAFEBABE), reader.Read(addr)); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_bytes_reads_known_bytes() + { + using var reader = CreateReader(); + + byte[] expected = [0x0A, 0x0B, 0x0C, 0x0D]; + GCHandle pin = GCHandle.Alloc(expected, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + byte[] result = reader.ReadBytes(addr, 4); + Assert.Equal(expected, result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Write_bytes_writes_and_reads_back() + { + using var reader = CreateReader(); + + byte[] slot = new byte[4]; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + byte[] expected = [0xDE, 0xAD, 0xBE, 0xEF]; + + int written = reader.WriteBytes(addr, expected); + Assert.Equal(4, written); + + byte[] result = reader.ReadBytes(addr, 4); + Assert.Equal(expected, result); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Read_struct_via_InProcessReader() + { + using var reader = CreateReader(); + + var slot = new TestStruct { X = 10, Y = 20 }; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + var result = reader.Read(addr); + Assert.Equal(10, result.X); + Assert.Equal(20, result.Y); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Write_struct_via_InProcessReader() + { + using var reader = CreateReader(); + + var slot = new TestStruct { X = 1, Y = 2 }; + GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); + try + { + IntPtr addr = pin.AddrOfPinnedObject(); + Assert.True(reader.Write(addr, new TestStruct { X = 99, Y = 88 })); + var result = reader.Read(addr); + Assert.Equal(99, result.X); + Assert.Equal(88, result.Y); + } + finally + { + pin.Free(); + } + } + + [Fact] + public void Dispose_disposes_handle() + { + var reader = CreateReader(); + Assert.False(reader.Handle.IsClosed); + reader.Dispose(); + Assert.True(reader.Handle.IsClosed); + } +} diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md index 848a454..36af806 100644 --- a/openspec/changes/whitemagic-foundation/tasks.md +++ b/openspec/changes/whitemagic-foundation/tasks.md @@ -8,15 +8,15 @@ ## 2. Core Memory Access (spec: memory-access) -- [ ] 2.1 Add tests for `MarshalCache`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior -- [ ] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1 -- [ ] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read`/`Write`, arrays) using the current process as target -- [ ] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3 -- [ ] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length -- [ ] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5 -- [ ] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag) -- [ ] 2.8 Implement addressing helpers to pass 2.7 -- [ ] 2.9 Add tests + `unsafe` implementation for `InProcessReader` (direct deref against own process); verify shared `MemoryBase` API works for both readers +- [x] 2.1 Add tests for `MarshalCache`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior +- [x] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1 +- [x] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read`/`Write`, arrays) using the current process as target +- [x] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3 +- [x] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length +- [x] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5 +- [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 + `unsafe` implementation for `InProcessReader` (direct deref against own process); verify shared `MemoryBase` API works for both readers ## 3. Managed Assembler (spec: managed-assembler) From 8374650aac5447fa6651e693b6d21d8b96c3ff53 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 19:24:18 +0200 Subject: [PATCH 7/9] fix 7 correctness and cleanup issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking fixes: 1. Read now returns default(T) on failed/partial read instead of crash (applies to single Read, array Read, and ReadBytes) 2. InProcessReader uses ReadProcessMemory via handle instead of unsafe Buffer.MemoryCopy — fails soft on bad address instead of AV'ing 3. GetRelative now returns absolute - ImageBase (inverse of GetAbsolute). Fix round-trip test to validate at arbitrary offsets, not just ImageBase 4. ReadString reads in 64-byte chunks with encoding-aware null-terminator pattern matching (handles UTF-16's 2-byte null, UTF-32's 4-byte null) Cleanup: 5. InProcessReader validates handle on open and uses RPM through it (handle is no longer unused) 6. Array marshal read: pin raw buffer once, PtrToStructure at offset 7. StructureToByteArray(Span) delegates to byte[] overload, no duplicate New tests: 4 invalid-address grace tests (returns default/empty/false). All 57 passing. --- WhiteMagic/InProcessReader.cs | 34 +++++--- WhiteMagic/MemoryBase.cs | 129 ++++++++++++++++++++++-------- WhiteMagicTest/AddressingTests.cs | 40 ++++++--- WhiteMagicTest/MemoryBaseTests.cs | 42 ++++++++-- 4 files changed, 182 insertions(+), 63 deletions(-) diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs index 518275d..2e2f947 100644 --- a/WhiteMagic/InProcessReader.cs +++ b/WhiteMagic/InProcessReader.cs @@ -6,8 +6,11 @@ namespace WhiteMagic; /// /// In-process memory reader that accesses the owning process's memory through -/// direct pointer dereference (unsafe). Use this reader from within a -/// managed DLL injected into the target process. +/// and +/// on a handle to the current +/// process. Unlike the unsafe-deref approach, this fails softly (returns +/// empty / zero bytes) on invalid or protected addresses instead of crashing +/// the host process with an . /// public sealed class InProcessReader : MemoryBase { @@ -25,6 +28,12 @@ public sealed class InProcessReader : MemoryBase ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation, false, current.Id); + if (_handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"OpenProcess failed for PID {current.Id}: error {error}"); + } _imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero; } @@ -36,30 +45,37 @@ public sealed class InProcessReader : MemoryBase public override SafeMemoryHandle Handle => _handle; /// - public override unsafe byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) + public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) { if (isRelative) address = GetAbsolute(address); byte[] buffer = new byte[count]; - fixed (byte* ptr = buffer) + if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead)) { - Buffer.MemoryCopy((void*)address, ptr, count, count); + return []; } + + if ((int)bytesRead != count) + { + Array.Resize(ref buffer, (int)bytesRead); + } + return buffer; } /// - public override unsafe int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false) + public override int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false) { if (isRelative) address = GetAbsolute(address); - fixed (byte* ptr = bytes) + if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written)) { - Buffer.MemoryCopy(ptr, (void*)address, bytes.Length, bytes.Length); + return 0; } - return bytes.Length; + + return (int)written; } /// diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs index 931e8da..88f59d6 100644 --- a/WhiteMagic/MemoryBase.cs +++ b/WhiteMagic/MemoryBase.cs @@ -30,6 +30,8 @@ public abstract class MemoryBase : IDisposable // ── Typed IO ─────────────────────────────────────────────────────────── /// Reads a value of type from the target address. + /// The value, or default(T) when the read fails or returns fewer bytes than + /// . public T Read(IntPtr address, bool isRelative = false) where T : struct { if (isRelative) @@ -38,10 +40,11 @@ public abstract class MemoryBase : IDisposable int size = MarshalCache.Size; byte[] raw = ReadBytes(address, size); + if (raw.Length < size) + return default; + if (MarshalCache.TypeRequiresMarshal) - { return MarshalByteArrayToStructure(raw); - } return MemoryMarshal.Read(raw.AsSpan()); } @@ -57,9 +60,7 @@ public abstract class MemoryBase : IDisposable byte[] raw; if (MarshalCache.TypeRequiresMarshal) - { raw = StructureToByteArray(value, size); - } else { raw = new byte[size]; @@ -71,6 +72,8 @@ public abstract class MemoryBase : IDisposable } /// Reads an array of values of type from the target address. + /// An array of at most elements. May be shorter when the read + /// returns fewer bytes than expected. public T[] Read(IntPtr address, int count, bool isRelative = false) where T : struct { if (isRelative) @@ -79,24 +82,32 @@ public abstract class MemoryBase : IDisposable int elementSize = MarshalCache.Size; int totalSize = elementSize * count; byte[] raw = ReadBytes(address, totalSize); + int actualCount = Math.Min(count, raw.Length / elementSize); - var result = new T[count]; + var result = new T[actualCount]; + + if (actualCount == 0) + return result; if (MarshalCache.TypeRequiresMarshal) { - for (int i = 0; i < count; i++) + GCHandle pin = GCHandle.Alloc(raw, GCHandleType.Pinned); + try { - var elementBytes = new ReadOnlySpan(raw, i * elementSize, elementSize); - result[i] = MarshalByteArrayToStructure(elementBytes.ToArray()); + IntPtr basePtr = pin.AddrOfPinnedObject(); + for (int i = 0; i < actualCount; i++) + result[i] = Marshal.PtrToStructure(basePtr + (i * elementSize)); + } + finally + { + pin.Free(); } } else { ReadOnlySpan span = raw; - for (int i = 0; i < count; i++) - { + for (int i = 0; i < actualCount; i++) result[i] = MemoryMarshal.Read(span.Slice(i * elementSize, elementSize)); - } } return result; @@ -121,13 +132,9 @@ public abstract class MemoryBase : IDisposable { Span slice = span.Slice(i * elementSize, elementSize); if (MarshalCache.TypeRequiresMarshal) - { StructureToByteArray(values[i], slice, elementSize); - } else - { MemoryMarshal.Write(slice, in values[i]); - } } int written = WriteBytes(address, raw, false); @@ -136,17 +143,61 @@ public abstract class MemoryBase : IDisposable // ── String IO ────────────────────────────────────────────────────────── - /// Reads a null-terminated string from the target address. + /// Reads a null-terminated string from the target address by scanning in small + /// 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 text encoding. + /// The maximum number of bytes to read. + /// If , is relative + /// to . public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false) { - byte[] buffer = ReadBytes(address, maxLength, relative); - string decoded = encoding.GetString(buffer); - int nullIndex = decoded.IndexOf('\0'); - if (nullIndex >= 0) + if (relative) + address = GetAbsolute(address); + + // 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"); + + const int chunkSize = 64; + int remaining = maxLength; + var accumulated = new System.Collections.Generic.List(); + + while (remaining > 0) { - return decoded[..nullIndex]; + int take = Math.Min(chunkSize, remaining); + byte[] chunk = ReadBytes(address, take); + if (chunk.Length == 0) + break; + + int nullPos = IndexOfPattern(chunk, nullTerminator); + if (nullPos >= 0) + { + if (nullPos > 0) + accumulated.Add(chunk[..nullPos]); + break; + } + + accumulated.Add(chunk); + address += take; + remaining -= take; } - return decoded; + + 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); } /// Writes a null-terminated string to the target address. @@ -168,10 +219,11 @@ public abstract class MemoryBase : IDisposable return ImageBase + (nint)relative; } - /// Converts an absolute address to a relative offset from . + /// Converts an absolute address to a relative offset from . + /// This is the inverse of : GetAbsolute(GetRelative(a)) == a. public IntPtr GetRelative(IntPtr absolute) { - return (IntPtr)((nint)ImageBase - (nint)absolute); + return (IntPtr)((nint)absolute - (nint)ImageBase); } // ── Lifecycle ────────────────────────────────────────────────────────── @@ -214,16 +266,27 @@ public abstract class MemoryBase : IDisposable private static void StructureToByteArray(T value, Span destination, int size) where T : struct { - byte[] temp = destination.ToArray(); - GCHandle pin = GCHandle.Alloc(temp, GCHandleType.Pinned); - try + byte[] bytes = StructureToByteArray(value, size); + bytes.CopyTo(destination); + } + + private static int IndexOfPattern(byte[] data, byte[] pattern) + { + int lastStart = data.Length - pattern.Length; + for (int i = 0; i <= lastStart; i++) { - Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false); - temp.CopyTo(destination); - } - finally - { - pin.Free(); + 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/WhiteMagicTest/AddressingTests.cs b/WhiteMagicTest/AddressingTests.cs index 3245817..77ea5c2 100644 --- a/WhiteMagicTest/AddressingTests.cs +++ b/WhiteMagicTest/AddressingTests.cs @@ -7,6 +7,8 @@ namespace WhiteMagicTest; /// /// Tests for relative/absolute addressing in . +/// GetAbsolute(relative) = ImageBase + relative. +/// GetRelative(absolute) = absolute - ImageBase (inverse of GetAbsolute). /// public class AddressingTests { @@ -27,31 +29,45 @@ public class AddressingTests } [Fact] - public void GetRelative_computes_offset_from_image_base() + public void GetRelative_returns_absolute_minus_image_base() { using var reader = OpenSelf(); IntPtr imageBase = reader.ImageBase; IntPtr absolute = imageBase + 0x2000; IntPtr relative = reader.GetRelative(absolute); - // GetRelative returns ImageBase - absolute (GreyMagic convention) - Assert.Equal((IntPtr)((int)imageBase - (int)absolute), relative); + Assert.Equal((IntPtr)((nint)absolute - (nint)imageBase), relative); } [Fact] - public void GetAbsolute_after_GetRelative_at_image_base_returns_to_base() + public void GetAbsolute_and_GetRelative_are_inverses() { using var reader = OpenSelf(); - IntPtr atBase = reader.ImageBase; - IntPtr relative = reader.GetRelative(atBase); - IntPtr back = reader.GetAbsolute(relative); - Assert.Equal(atBase, back); + IntPtr offset = (IntPtr)0x3000; + + // Round-trip: offset -> absolute -> back to offset + IntPtr absolute = reader.GetAbsolute(offset); + IntPtr back = reader.GetRelative(absolute); + Assert.Equal(offset, back); + + // Reverse round-trip: absolute -> offset -> back to absolute + IntPtr relative = reader.GetRelative(absolute); + IntPtr absoluteAgain = reader.GetAbsolute(relative); + Assert.Equal(absolute, absoluteAgain); + } + + [Fact] + public void GetRelative_on_ImageBase_returns_zero() + { + using var reader = OpenSelf(); + IntPtr relative = reader.GetRelative(reader.ImageBase); + Assert.Equal(IntPtr.Zero, relative); } [Fact] public void Read_with_isRelative_true_uses_image_base() { using var reader = OpenSelf(); - // Read the first byte at ImageBase (should be MZ header: 0x4D = 'M') + // DOS header 'MZ' at the image base byte firstByte = reader.Read(IntPtr.Zero, isRelative: true); Assert.Equal(0x4D, firstByte); } @@ -65,10 +81,9 @@ public class AddressingTests try { IntPtr absolute = pin.AddrOfPinnedObject(); - nint relative = (nint)absolute - (nint)reader.ImageBase; - IntPtr relativePtr = (IntPtr)relative; + IntPtr relative = reader.GetRelative(absolute); - Assert.True(reader.Write(relativePtr, 42, isRelative: true)); + Assert.True(reader.Write(relative, 42, isRelative: true)); Assert.Equal(42, reader.Read(absolute)); } finally @@ -81,7 +96,6 @@ public class AddressingTests public void ReadBytes_with_isRelative_true_resolves_correctly() { using var reader = OpenSelf(); - // DOS header 'MZ' at the image base byte[] data = reader.ReadBytes(IntPtr.Zero, 2, isRelative: true); Assert.Equal(0x4D, data[0]); Assert.Equal(0x5A, data[1]); diff --git a/WhiteMagicTest/MemoryBaseTests.cs b/WhiteMagicTest/MemoryBaseTests.cs index 223ba5a..afc177f 100644 --- a/WhiteMagicTest/MemoryBaseTests.cs +++ b/WhiteMagicTest/MemoryBaseTests.cs @@ -1,8 +1,8 @@ -using WhiteMagic.Native; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; using WhiteMagic; +using WhiteMagic.Native; namespace WhiteMagicTest; @@ -31,7 +31,6 @@ public class MemoryBaseTests { using var reader = OpenSelf(); - // Pin a local int to use as our "remote" address int slot = 0; GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned); try @@ -91,10 +90,7 @@ public class MemoryBaseTests try { IntPtr addr = pin.AddrOfPinnedObject(); - - // Write a new value Assert.True(reader.Write(addr, new TestStruct { X = 100, Y = 200 })); - var result = reader.Read(addr); Assert.Equal(100, result.X); Assert.Equal(200, result.Y); @@ -140,7 +136,6 @@ public class MemoryBaseTests int[] expected = [10, 20, 30, 40]; Assert.True(reader.Write(addr, expected)); - int[] actual = reader.Read(addr, 4); Assert.Equal(expected, actual); } @@ -168,7 +163,6 @@ public class MemoryBaseTests }; Assert.True(reader.Write(addr, expected)); - var actual = reader.Read(addr, 4); Assert.Equal(expected, actual); } @@ -199,7 +193,39 @@ public class MemoryBaseTests { var reader = OpenSelf(); reader.Dispose(); - reader.Dispose(); // Should not throw + reader.Dispose(); + } + + // ── Graceful failure on invalid addresses ─────────────────────────────── + + [Fact] + public void Read_int_on_invalid_address_returns_default() + { + using var reader = OpenSelf(); + Assert.Equal(0, reader.Read(IntPtr.Zero)); + } + + [Fact] + public void Read_struct_on_invalid_address_returns_default() + { + using var reader = OpenSelf(); + var result = reader.Read(IntPtr.Zero); + Assert.Equal(0, result.X); + Assert.Equal(0, result.Y); + } + + [Fact] + public void Read_int_array_on_invalid_address_returns_empty() + { + using var reader = OpenSelf(); + Assert.Empty(reader.Read(IntPtr.Zero, 10)); + } + + [Fact] + public void Read_bytes_on_invalid_address_returns_empty() + { + using var reader = OpenSelf(); + Assert.Empty(reader.ReadBytes(IntPtr.Zero, 10)); } } From 991387198d24d52670f6fa02858540a21b06841c Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 19:28:28 +0200 Subject: [PATCH 8/9] Reconcile spec with Phase 2 review outcome; ignore *.log Record the D1 deviation: InProcessReader reads the current process through ReadProcessMemory/WriteProcessMemory on a self-handle, not unsafe direct deref. Rationale: .NET cannot catch AccessViolationException, so a raw deref of a bad address terminates the host with no soft-failure path. Update the memory-access spec (new fail-soft scenario), design D1, and task 2.9. Log task 2.10: ReadString null-terminator scan is not code-unit aligned, so UTF-16/UTF-32 can match a misaligned multi-byte null or miss one split across a chunk boundary (harmless for ASCII/UTF-8, the WoW case). Ignore *.log (testrun.log). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + openspec/changes/whitemagic-foundation/design.md | 2 +- .../whitemagic-foundation/specs/memory-access/spec.md | 8 +++++++- openspec/changes/whitemagic-foundation/tasks.md | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 45355ea..88208ae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ reference/ # Scratch *.tmp +*.log diff --git a/openspec/changes/whitemagic-foundation/design.md b/openspec/changes/whitemagic-foundation/design.md index 199ee1c..b830969 100644 --- a/openspec/changes/whitemagic-foundation/design.md +++ b/openspec/changes/whitemagic-foundation/design.md @@ -36,7 +36,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse `MemoryBase` defines abstract `ReadBytes`/`WriteBytes`/`Read`/`Write`, relative/absolute addressing, and hosts the `PatchManager`. Two concrete readers: - `ExternalReader : MemoryBase` — `ReadProcessMemory`/`WriteProcessMemory` over a `SafeMemoryHandle`. Owns allocation, injection, and the remote-thread + main-thread executors. -- `InProcessReader : MemoryBase` — `unsafe` direct pointer deref; owns the `DetourManager` and `InProcessInvoker`. +- `InProcessReader : MemoryBase` — reads the current process via `ReadProcessMemory`/`WriteProcessMemory` on a self-handle; owns the `DetourManager` and `InProcessInvoker`. **(Revised from `unsafe` direct deref during Phase 2: .NET cannot catch `AccessViolationException`, so a bad deref kills the host with no soft-failure path; RPM-on-self fails soft. The in-process speed win moves to the delegate-call/detour paths, not the reader. See `specs/memory-access`.)** **Why**: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for a bot host; in-process is the fast/crash-free path once injected. diff --git a/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md b/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md index 94f9e8f..900242f 100644 --- a/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md +++ b/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md @@ -2,7 +2,13 @@ ### Requirement: Abstract memory base with two readers -WhiteMagic SHALL expose an abstract `MemoryBase` type defining `ReadBytes`, `WriteBytes`, generic `Read`/`Write`, array read/write, and string read/write, with two concrete implementations: `ExternalReader` (out-of-process via ReadProcessMemory/WriteProcessMemory) and `InProcessReader` (in-process via direct pointer dereference). +WhiteMagic SHALL expose an abstract `MemoryBase` type defining `ReadBytes`, `WriteBytes`, generic `Read`/`Write`, array read/write, and string read/write, with two concrete implementations: `ExternalReader` (out-of-process via ReadProcessMemory/WriteProcessMemory) and `InProcessReader` (in-process, reading the current process through ReadProcessMemory/WriteProcessMemory on a self-handle). + +> **Deviation from design D1.** D1 originally specified `InProcessReader` as `unsafe` direct pointer dereference (the "fast/crash-free" path). Implementation revised it to `ReadProcessMemory`/`WriteProcessMemory` on a handle to the current process, because .NET (Core) cannot catch `AccessViolationException` (`HandleProcessCorruptedStateExceptions` is removed), so a raw deref of a bad address terminates the host process with no soft-failure path. RPM on a self-handle fails soft (returns empty) like `ExternalReader`. The in-process performance win therefore moves to the delegate-call and detour paths (`InProcessInvoker`, `DetourManager`), not the reader. + +#### Scenario: in-process read fails soft on an invalid address +- **WHEN** an `InProcessReader` reads an unmapped or protected address +- **THEN** it MUST return empty/`default` rather than crash the host process #### Scenario: external read round-trip - **WHEN** an `ExternalReader` opens a target process and writes a value with `Write(addr, 0x1234)` then reads it back with `Read(addr)` diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md index 36af806..e943ad2 100644 --- a/openspec/changes/whitemagic-foundation/tasks.md +++ b/openspec/changes/whitemagic-foundation/tasks.md @@ -16,7 +16,8 @@ - [x] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5 - [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 + `unsafe` implementation for `InProcessReader` (direct deref against own process); verify shared `MemoryBase` API works for both readers +- [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 (the WoW case). 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) From 9f7848ffde5ca51556b3f14a9bcdf62bd037fee0 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Tue, 21 Jul 2026 19:31:12 +0200 Subject: [PATCH 9/9] docs: fix D1 Why to match RPM-on-self revision Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/whitemagic-foundation/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/whitemagic-foundation/design.md b/openspec/changes/whitemagic-foundation/design.md index b830969..501da4f 100644 --- a/openspec/changes/whitemagic-foundation/design.md +++ b/openspec/changes/whitemagic-foundation/design.md @@ -16,7 +16,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse **Goals:** - Single modern (.NET 8, nullable, `Span`, `SafeHandle`) library that is bitness-agnostic (x86 + x64). - A **three-tier execution model** whose default path for game-state calls is crash-safe (runs on the target's own thread), while `CreateRemoteThread` remains available for thread-agnostic payloads. -- Dual memory access: out-of-process (RPM/WPM) and in-process (direct deref) behind one abstract `MemoryBase`, with `MarshalCache` for allocation-free typed IO. +- Dual memory access: out-of-process (RPM/WPM) and in-process (RPM-on-self-handle, see D1 revision) behind one abstract `MemoryBase`, with `MarshalCache` for allocation-free typed IO. - Reversible function hooking (`DetourManager`) and byte patching (`PatchManager`) with auto-restore on dispose. - Replace FASM with an `IAssembler` seam: hand-emitted convention stubs by default, optional Iced backend for arbitrary assembly. Zero native dependency in the default configuration. - Port DLL injection (CreateThread + thread-hijack, x86/x64) and pattern scanning + cache from current BlackMagic. @@ -38,7 +38,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse - `ExternalReader : MemoryBase` — `ReadProcessMemory`/`WriteProcessMemory` over a `SafeMemoryHandle`. Owns allocation, injection, and the remote-thread + main-thread executors. - `InProcessReader : MemoryBase` — reads the current process via `ReadProcessMemory`/`WriteProcessMemory` on a self-handle; owns the `DetourManager` and `InProcessInvoker`. **(Revised from `unsafe` direct deref during Phase 2: .NET cannot catch `AccessViolationException`, so a bad deref kills the host with no soft-failure path; RPM-on-self fails soft. The in-process speed win moves to the delegate-call/detour paths, not the reader. See `specs/memory-access`.)** -**Why**: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for a bot host; in-process is the fast/crash-free path once injected. +**Why**: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for a bot host; in-process becomes valuable once injected — not for faster reads (both readers use RPM/WPM, see the D1 revision) but for the delegate-call and detour paths it unlocks (`InProcessInvoker`, `DetourManager`). **Alternatives considered**: single external-only class (current BlackMagic) — rejected: forecloses the in-process delegate path, which is the cleanest crash-free execution. MemorySharp's factory-per-concern model (`Assembly`, `Threads`, `Windows` factories) — adopted selectively for the high-level surface, but the read/write core stays on `MemoryBase` for GreyMagic-style polymorphism.