diff --git a/WhiteMagic/Memory/MemoryRegion.cs b/WhiteMagic/Memory/MemoryRegion.cs new file mode 100644 index 0000000..b7bf50b --- /dev/null +++ b/WhiteMagic/Memory/MemoryRegion.cs @@ -0,0 +1,75 @@ +using System; +using WhiteMagic.Native; + +namespace WhiteMagic.Memory; + +/// +/// An immutable snapshot of a memory region as reported by VirtualQueryEx. +/// +public readonly record struct MemoryRegion +{ + /// The base address of the region of pages. + public IntPtr BaseAddress { get; } + + /// The size of the region, in bytes. + public nuint Size { get; } + + /// The access protection of the pages in the region. + public MemoryProtectionType Protection { get; } + + /// The state of the pages in the region. + public MemoryState State { get; } + + /// The type of pages in the region. + public MemoryType Type { get; } + + /// The base address of a range of pages allocated by VirtualAllocEx. + public IntPtr AllocationBase { get; } + + /// The memory protection option when the region was initially allocated. + public MemoryProtectionType AllocationProtect { get; } + + /// + /// Initializes a new from explicit values. + /// + public MemoryRegion( + IntPtr baseAddress, + nuint size, + MemoryProtectionType protection, + MemoryState state, + MemoryType type, + IntPtr allocationBase, + MemoryProtectionType allocationProtect) + { + BaseAddress = baseAddress; + Size = size; + Protection = protection; + State = state; + Type = type; + AllocationBase = allocationBase; + AllocationProtect = allocationProtect; + } + + /// + /// Initializes a new from a raw MEMORY_BASIC_INFORMATION. + /// + internal MemoryRegion(MemoryBasicInformation info) + { + BaseAddress = info.BaseAddress; + Size = info.RegionSize; + AllocationBase = info.AllocationBase; + AllocationProtect = (MemoryProtectionType)info.AllocationProtect; + Protection = (MemoryProtectionType)info.Protect; + State = (MemoryState)info.State; + Type = (MemoryType)info.Type; + } + + /// + /// Returns if is inside the region, + /// defined as [BaseAddress, BaseAddress + Size). + /// + public bool Contains(IntPtr address) + { + return (nuint)(address - BaseAddress) < Size; + } +} diff --git a/WhiteMagic/Memory/ProtectionScope.cs b/WhiteMagic/Memory/ProtectionScope.cs new file mode 100644 index 0000000..655769c --- /dev/null +++ b/WhiteMagic/Memory/ProtectionScope.cs @@ -0,0 +1,58 @@ +using System; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Memory; + +/// +/// A scope that temporarily changes page protection via VirtualProtectEx and +/// restores the original protection when disposed, including when the guarded body throws. +/// +public sealed class ProtectionScope : IDisposable +{ + private readonly MemoryBase _memory; + private readonly IntPtr _address; + private readonly nint _size; + private readonly MemoryProtectionType _originalProtection; + private bool _disposed; + + /// + /// Creates a new protection scope, applying to the + /// specified range immediately. + /// + internal ProtectionScope(MemoryBase memory, IntPtr address, nint size, MemoryProtectionType newProtection) + { + _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + + if (address == IntPtr.Zero) + throw new ArgumentException("Address cannot be zero.", nameof(address)); + + if (size <= 0) + throw new ArgumentOutOfRangeException(nameof(size), "Size must be positive."); + + _address = address; + _size = size; + + if (!NativeMethods.VirtualProtectEx( + memory.Handle, + address, + size, + newProtection, + out _originalProtection)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException( + $"VirtualProtectEx failed to change protection: error {error}."); + } + } + + /// Restores the original page protection if it has not already been restored. + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + NativeMethods.VirtualProtectEx(_memory.Handle, _address, _size, _originalProtection, out _); + } + } +} diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs index f7c864f..d53b8a4 100644 --- a/WhiteMagic/MemoryBase.cs +++ b/WhiteMagic/MemoryBase.cs @@ -1,5 +1,7 @@ using WhiteMagic.Hooking; +using WhiteMagic.Memory; using WhiteMagic.Native; +using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; @@ -268,6 +270,58 @@ public abstract class MemoryBase : IDisposable return (IntPtr)((nint)absolute - (nint)ImageBase); } + // ── Memory region query ──────────────────────────────────────────────── + + /// + /// Queries the memory region that contains in the target + /// process using VirtualQueryEx. + /// + /// An immutable snapshot of the region. + /// The query fails. + public MemoryRegion QueryRegion(IntPtr address) + { + nuint bufferSize = (nuint)Marshal.SizeOf(); + nuint result = NativeMethods.VirtualQueryEx(Handle, address, out MemoryBasicInformation info, bufferSize); + + if (result == 0) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"VirtualQueryEx failed for address 0x{address:X}: error {error}."); + } + + return new MemoryRegion(info); + } + + /// + /// Enumerates the memory regions of the target process from the lowest address upward. + /// The walk is lazy; callers can stop early without walking the entire address space. + /// + public IEnumerable EnumerateRegions() + { + IntPtr address = IntPtr.Zero; + nuint bufferSize = (nuint)Marshal.SizeOf(); + + while (true) + { + nuint result = NativeMethods.VirtualQueryEx(Handle, address, out MemoryBasicInformation info, bufferSize); + if (result == 0) + yield break; + + yield return new MemoryRegion(info); + address = info.BaseAddress + (nint)info.RegionSize; + } + } + + /// + /// Changes the page protection on a region of memory and returns a disposable scope + /// that restores the original protection on dispose, including when an exception escapes + /// the guarded body. + /// + public ProtectionScope ChangeProtection(IntPtr address, nint size, MemoryProtectionType protection) + { + return new ProtectionScope(this, address, size, protection); + } + // ── Lifecycle ────────────────────────────────────────────────────────── /// diff --git a/WhiteMagic/Native/NativeEnums.cs b/WhiteMagic/Native/NativeEnums.cs index 1d690b7..c12aa16 100644 --- a/WhiteMagic/Native/NativeEnums.cs +++ b/WhiteMagic/Native/NativeEnums.cs @@ -156,3 +156,61 @@ public static class ContextFlags /// AMD64: control, integer, and segment registers. public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments; } + +/// +/// Values that describe the state of memory pages returned by VirtualQueryEx. +/// +public enum MemoryState : uint +{ + /// Indicates committed pages for which physical storage has been allocated. + Commit = 0x1000, + + /// Indicates reserved pages where a range of the virtual address space is reserved without any physical storage being allocated. + Reserve = 0x2000, + + /// Indicates free pages not accessible to the calling process and available to be allocated. + Free = 0x10000, +} + +/// +/// Values that describe the type of memory pages returned by VirtualQueryEx. +/// +public enum MemoryType : uint +{ + /// Indicates that the memory pages within the region are private. + Private = 0x20000, + + /// Indicates that the memory pages within the region are mapped into the view of a section. + Mapped = 0x40000, + + /// Indicates that the memory pages within the region are mapped into the view of an image section. + Image = 0x1000000, +} + +/// +/// Flags used by CreateToolhelp32Snapshot to specify the portions of the system to include in the snapshot. +/// +[Flags] +public enum SnapshotFlags : uint +{ + /// Enumerate the heap list. + HeapList = 0x00000001, + + /// Enumerate the process list. + Process = 0x00000002, + + /// Enumerate the thread list. + Thread = 0x00000004, + + /// Enumerate the module list. + Module = 0x00000008, + + /// Enumerate the 32-bit module list for the specified process. + Module32 = 0x00000010, + + /// Include all processes and threads in the system. + All = 0x0000001F, + + /// Indicate that the snapshot handle is to be inheritable. + Inherit = 0x80000000, +} diff --git a/WhiteMagic/Native/NativeMethods.cs b/WhiteMagic/Native/NativeMethods.cs index ab51b08..bbd64a2 100644 --- a/WhiteMagic/Native/NativeMethods.cs +++ b/WhiteMagic/Native/NativeMethods.cs @@ -173,4 +173,46 @@ internal static partial class NativeMethods SafeMemoryHandle handle, uint milliseconds); + // ── Memory query ─────────────────────────────────────────────────────── + + /// Retrieves information about a range of pages in the virtual address space of a specified process. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial nuint VirtualQueryEx( + SafeMemoryHandle process, + IntPtr address, + out MemoryBasicInformation buffer, + nuint length); + + // ── Thread enumeration ───────────────────────────────────────────────── + + /// Takes a snapshot of the specified processes, as well as the heaps, modules, and threads used by these processes. + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeMemoryHandle CreateToolhelp32Snapshot( + SnapshotFlags dwFlags, + int th32ProcessID); + + /// Retrieves information about the first thread of any process encountered in a system snapshot. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool Thread32First( + SafeMemoryHandle hSnapshot, + ref ThreadEntry32 lpte); + + /// Retrieves information about the next thread of any process encountered in a system snapshot. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool Thread32Next( + SafeMemoryHandle hSnapshot, + ref ThreadEntry32 lpte); + + /// Retrieves timing information for the specified thread. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetThreadTimes( + SafeMemoryHandle thread, + out long creationTime, + out long exitTime, + out long kernelTime, + out long userTime); + } diff --git a/WhiteMagic/Native/NativeStructures.cs b/WhiteMagic/Native/NativeStructures.cs index 1395367..93808db 100644 --- a/WhiteMagic/Native/NativeStructures.cs +++ b/WhiteMagic/Native/NativeStructures.cs @@ -205,3 +205,61 @@ public unsafe struct Context64 /// The source RIP of the last exception. public ulong LastExceptionFromRip; } + +/// +/// Layout matches MEMORY_BASIC_INFORMATION. Uses pointer-sized fields so the +/// structure is 28 bytes on x86 and 48 bytes on x64, matching the layout the OS expects +/// from a caller of those bitnesses. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct MemoryBasicInformation +{ + /// A pointer to the base address of the region of pages. + public nint BaseAddress; + + /// A pointer to the base address of a range of pages allocated by the VirtualAllocEx function. + public nint AllocationBase; + + /// The memory protection option when the region was initially allocated. + public uint AllocationProtect; + + /// The size of the region beginning at the base address, in bytes. + public nuint RegionSize; + + /// The state of the pages in the region. + public uint State; + + /// The access protection of the pages in the region. + public uint Protect; + + /// The type of pages in the region. + public uint Type; +} + +/// +/// Layout matches THREADENTRY32 used by Thread32First/Thread32Next. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct ThreadEntry32 +{ + /// The size of the structure, in bytes. + public uint dwSize; + + /// This member is no longer used and is always zero. + public uint cntUsage; + + /// The thread identifier. + public uint th32ThreadID; + + /// The identifier of the process that owns the thread. + public uint th32OwnerProcessID; + + /// The kernel base priority level assigned to the thread. + public int tpBasePri; + + /// This member is no longer used. + public int tpDeltaPri; + + /// This member is reserved. + public uint dwFlags; +} diff --git a/WhiteMagicTest/Memory/MemoryRegionTests.cs b/WhiteMagicTest/Memory/MemoryRegionTests.cs new file mode 100644 index 0000000..a19c38c --- /dev/null +++ b/WhiteMagicTest/Memory/MemoryRegionTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using WhiteMagic; +using WhiteMagic.Memory; +using WhiteMagic.Native; +using Xunit; + +namespace WhiteMagicTest.Memory; + +/// +/// Tests for memory-region query, enumeration and scoped protection (tasks 1.2, 1.4, 1.6, 1.8). +/// +public sealed class MemoryRegionTests +{ + [Fact] + public void Contains_returns_true_for_addresses_inside_half_open_range() + { + var region = new MemoryRegion( + new IntPtr(0x10000), + 0x1000, + MemoryProtectionType.ReadWrite, + MemoryState.Commit, + MemoryType.Private, + new IntPtr(0x10000), + MemoryProtectionType.ReadWrite); + + Assert.True(region.Contains(new IntPtr(0x10000))); + Assert.True(region.Contains(new IntPtr(0x10FFF))); + Assert.False(region.Contains(new IntPtr(0x11000))); + Assert.False(region.Contains(new IntPtr(0x0FFF))); + } + + [Fact] + public void QueryRegion_returns_region_containing_committed_address() + { + using var reader = new InProcessReader(); + nint pageSize = Environment.SystemPageSize; + + IntPtr block = NativeMethods.VirtualAllocEx( + reader.Handle, + IntPtr.Zero, + pageSize, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ReadWrite); + + Assert.NotEqual(IntPtr.Zero, block); + + try + { + MemoryRegion region = reader.QueryRegion(block); + + Assert.Equal(block, region.BaseAddress); + Assert.True(region.Contains(block)); + Assert.True(region.Contains(block + (int)pageSize - 1)); + Assert.Equal(MemoryState.Commit, region.State); + Assert.Equal(MemoryType.Private, region.Type); + Assert.Equal(MemoryProtectionType.ReadWrite, region.Protection); + Assert.Equal(MemoryProtectionType.ReadWrite, region.AllocationProtect); + Assert.Equal(block, region.AllocationBase); + } + finally + { + NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release); + } + } + + [Fact] + public void EnumerateRegions_yields_ascending_non_overlapping_regions() + { + using var reader = new InProcessReader(); + + MemoryRegion[] regions = reader.EnumerateRegions().Take(5).ToArray(); + Assert.True(regions.Length > 0); + + for (int i = 1; i < regions.Length; i++) + { + Assert.True( + (nuint)regions[i].BaseAddress >= + (nuint)regions[i - 1].BaseAddress + regions[i - 1].Size); + } + } + + [Fact] + public void EnumerateRegions_is_lazy_and_stops_early() + { + using var reader = new InProcessReader(); + + // Taking a single item must not force a full address-space walk. + MemoryRegion first = reader.EnumerateRegions().First(); + Assert.True(first.Size > 0); + } + + [Fact] + public void ChangeProtection_applies_new_protection_inside_scope_and_restores_on_dispose() + { + using var reader = new InProcessReader(); + nint pageSize = Environment.SystemPageSize; + + IntPtr block = NativeMethods.VirtualAllocEx( + reader.Handle, + IntPtr.Zero, + pageSize, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ReadWrite); + + Assert.NotEqual(IntPtr.Zero, block); + + try + { + Assert.Equal(MemoryProtectionType.ReadWrite, reader.QueryRegion(block).Protection); + + using (reader.ChangeProtection(block, pageSize, MemoryProtectionType.ExecuteReadWrite)) + { + Assert.Equal(MemoryProtectionType.ExecuteReadWrite, reader.QueryRegion(block).Protection); + } + + Assert.Equal(MemoryProtectionType.ReadWrite, reader.QueryRegion(block).Protection); + } + finally + { + NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release); + } + } + + [Fact] + public void ChangeProtection_restores_original_protection_when_body_throws() + { + using var reader = new InProcessReader(); + nint pageSize = Environment.SystemPageSize; + + IntPtr block = NativeMethods.VirtualAllocEx( + reader.Handle, + IntPtr.Zero, + pageSize, + MemoryAllocationType.Commit | MemoryAllocationType.Reserve, + MemoryProtectionType.ReadWrite); + + Assert.NotEqual(IntPtr.Zero, block); + + try + { + Assert.Throws(new Action(() => + { + using (reader.ChangeProtection(block, pageSize, MemoryProtectionType.ExecuteReadWrite)) + { + Assert.Equal(MemoryProtectionType.ExecuteReadWrite, reader.QueryRegion(block).Protection); + throw new InvalidOperationException("Intentional failure inside scope."); + } + })); + + Assert.Equal(MemoryProtectionType.ReadWrite, reader.QueryRegion(block).Protection); + } + finally + { + NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release); + } + } +}