6 Commits
Author SHA1 Message Date
kbe da342d355e Remove legacy BlackMagic specs 2026-07-22 19:12:24 +02:00
kbe 1169fdb994 Address review findings for thread control and process discovery
- Drop the false WOW64 claim from GetContext32/SetContext32 docs and guard them for 32-bit targets only.\n- Make FrozenThread dispose the thread handles it owns; make Freeze(predicate) dispose filtered-out threads.\n- Pass the already-validated handle through GetThreadById instead of opening a second one.\n- Add no-progress guard to MemoryBase.EnumerateRegions.\n- Dispose unmatched Process candidates in ApplicationFinder.OpenProcess.\n- Clean up RemoteThreadExecutor allocation formatting.
2026-07-22 17:18:48 +02:00
kbe 3e294dc846 Add process discovery helpers and Magic facade accessors
Introduces ApplicationFinder (by name/title/handle), Magic.Open overloads, and Magic.Threads/Regions/QueryRegion accessors. Closes section 3 of add-thread-region-finder and updates tasks/comparison doc.
2026-07-22 16:04:53 +02:00
kbe 8f988768fe Fix thread namespace collision and tighten executable stub allocation
Fully qualifies System.Threading.Thread in DllInjector after introducing the WhiteMagic.Thread namespace, and replaces the broken+too-small near-allocation loop with a symmetric +/-2 GiB search so the x64 call stub always lands within rel32 range.
2026-07-22 16:04:41 +02:00
kbe 9aef9c21e3 Add thread control surfaces
Adds RemoteThread, ThreadFactory (enumeration, main-thread selection, get-by-id), and FrozenThread scoped freeze. Supports suspend/resume, 32/64-bit context round-trip, TEB query, and reverse-order resume on dispose. Closes section 2 of add-thread-region-finder.
2026-07-22 16:04:30 +02:00
kbe f0faca3112 Add memory-region query, enumeration, and scoped protection
Implements VirtualQueryEx + MEMORY_BASIC_INFORMATION wrappers, the immutable MemoryRegion record, the ProtectionScope disposable helper, and MemoryBase.QueryRegion/EnumerateRegions/ChangeProtection. Closes section 1 of add-thread-region-finder.
2026-07-22 16:04:15 +02:00
31 changed files with 2198 additions and 172 deletions
+31 -34
View File
@@ -472,46 +472,43 @@ public sealed class RemoteThreadExecutor
nuint mask = AllocationGranularity - (nuint)1;
nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask;
for (int i = 0; i < NearAllocationAttempts; i++)
for (long delta = 0; delta <= (long)0x7FFF; delta++)
{
nuint candidate;
if (i == 0)
long signedOffset = delta * (long)AllocationGranularity;
// Try above, then below the target. Keep the original address as the first attempt.
for (int sign = 0; sign < 2; sign++)
{
candidate = aligned;
}
else if ((i & 1) == 1)
{
candidate = aligned + (nuint)i * AllocationGranularity;
}
else
{
nuint offset = (nuint)i * AllocationGranularity;
if (offset > aligned)
{
if (delta == 0 && sign != 0)
continue;
long offset = sign == 0 ? signedOffset : -signedOffset;
nuint candidate = (nuint)((long)aligned + offset);
// Avoid underflow to zero on below-target search.
if (offset < 0 && candidate >= aligned)
continue;
IntPtr result = NativeMethods.VirtualAllocEx(
handle,
(IntPtr)(nint)candidate,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (result != IntPtr.Zero)
{
long distance = (long)(nuint)(nint)result - (long)(nuint)(nint)preferredAddress;
if (distance >= int.MinValue && distance <= int.MaxValue)
return result;
// The allocator gave us a nearby candidate but on the wrong side
// of the 2 GiB boundary; treat it as unusable and keep searching.
NativeMethods.VirtualFreeEx(handle, result, 0, MemoryFreeType.Release);
}
candidate = aligned - offset;
}
IntPtr result = NativeMethods.VirtualAllocEx(
handle,
(IntPtr)(nint)candidate,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (result != IntPtr.Zero)
{
return result;
}
}
return NativeMethods.VirtualAllocEx(
handle,
IntPtr.Zero,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
return IntPtr.Zero;
}
}
+1 -1
View File
@@ -383,7 +383,7 @@ public sealed class DllInjector
if (value != IntPtr.Zero)
return value;
Thread.Sleep(5);
System.Threading.Thread.Sleep(5);
}
return IntPtr.Zero;
+48 -1
View File
@@ -1,7 +1,12 @@
using System.Collections.Generic;
using System.Diagnostics;
using Process = System.Diagnostics.Process;
using WhiteMagic.Execution;
using WhiteMagic.Hooking;
using WhiteMagic.Memory;
using WhiteMagic.ProcessDiscovery;
using WhiteMagic.Thread;
using WhiteMagic.Windows;
namespace WhiteMagic;
@@ -24,6 +29,21 @@ public sealed class Magic : IDisposable
/// <summary>Inline-detour manager (in-process only).</summary>
public DetourManager DetourManager => Memory.DetourManager;
/// <summary>
/// Returns the memory region that contains <paramref name="address"/>.
/// </summary>
public MemoryRegion QueryRegion(IntPtr address) => Memory.QueryRegion(address);
/// <summary>
/// Enumerates the committed and reserved regions of the target process address space.
/// </summary>
public IEnumerable<MemoryRegion> Regions => Memory.EnumerateRegions();
/// <summary>
/// Factory for discovering and operating on the target process's threads.
/// </summary>
public ThreadFactory Threads => new ThreadFactory(Memory);
private Magic(MemoryBase memory)
{
Memory = memory;
@@ -31,11 +51,38 @@ public sealed class Magic : IDisposable
}
/// <summary>Opens an external process for reading, writing, and execution.</summary>
public static Magic Open(System.Diagnostics.Process process)
public static Magic Open(Process process)
{
return new Magic(new ExternalReader(process));
}
/// <summary>
/// Opens a target process by its image name. Throws if zero or more than one match.
/// </summary>
public static Magic Open(string processName)
{
using Process process = ApplicationFinder.OpenProcess(processName);
return Open(process);
}
/// <summary>
/// Opens the process that owns the top-level window with the specified title.
/// </summary>
public static Magic OpenByWindowTitle(string title)
{
using Process process = ApplicationFinder.OpenByWindowTitle(title);
return Open(process);
}
/// <summary>
/// Opens the process that owns the specified window handle.
/// </summary>
public static Magic OpenByWindowHandle(IntPtr handle)
{
using Process process = ApplicationFinder.OpenByWindowHandle(handle);
return Open(process);
}
/// <summary>Creates an in-process session for the current process.</summary>
public static Magic OpenInProcess()
{
+75
View File
@@ -0,0 +1,75 @@
using System;
using WhiteMagic.Native;
namespace WhiteMagic.Memory;
/// <summary>
/// An immutable snapshot of a memory region as reported by <c>VirtualQueryEx</c>.
/// </summary>
public readonly record struct MemoryRegion
{
/// <summary>The base address of the region of pages.</summary>
public IntPtr BaseAddress { get; }
/// <summary>The size of the region, in bytes.</summary>
public nuint Size { get; }
/// <summary>The access protection of the pages in the region.</summary>
public MemoryProtectionType Protection { get; }
/// <summary>The state of the pages in the region.</summary>
public MemoryState State { get; }
/// <summary>The type of pages in the region.</summary>
public MemoryType Type { get; }
/// <summary>The base address of a range of pages allocated by VirtualAllocEx.</summary>
public IntPtr AllocationBase { get; }
/// <summary>The memory protection option when the region was initially allocated.</summary>
public MemoryProtectionType AllocationProtect { get; }
/// <summary>
/// Initializes a new <see cref="MemoryRegion"/> from explicit values.
/// </summary>
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;
}
/// <summary>
/// Initializes a new <see cref="MemoryRegion"/> from a raw <c>MEMORY_BASIC_INFORMATION</c>.
/// </summary>
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;
}
/// <summary>
/// Returns <see langword="true"/> if <paramref name="address"/> is inside the region,
/// defined as <c>[BaseAddress, BaseAddress + Size)</c>.
/// </summary>
public bool Contains(IntPtr address)
{
return (nuint)(address - BaseAddress) < Size;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Memory;
/// <summary>
/// A scope that temporarily changes page protection via <c>VirtualProtectEx</c> and
/// restores the original protection when disposed, including when the guarded body throws.
/// </summary>
public sealed class ProtectionScope : IDisposable
{
private readonly MemoryBase _memory;
private readonly IntPtr _address;
private readonly nint _size;
private readonly MemoryProtectionType _originalProtection;
private bool _disposed;
/// <summary>
/// Creates a new protection scope, applying <paramref name="newProtection"/> to the
/// specified range immediately.
/// </summary>
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}.");
}
}
/// <summary>Restores the original page protection if it has not already been restored.</summary>
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
NativeMethods.VirtualProtectEx(_memory.Handle, _address, _size, _originalProtection, out _);
}
}
}
+58
View File
@@ -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,62 @@ public abstract class MemoryBase : IDisposable
return (IntPtr)((nint)absolute - (nint)ImageBase);
}
// ── Memory region query ────────────────────────────────────────────────
/// <summary>
/// Queries the memory region that contains <paramref name="address"/> in the target
/// process using <c>VirtualQueryEx</c>.
/// </summary>
/// <returns>An immutable snapshot of the region.</returns>
/// <exception cref="InvalidOperationException">The query fails.</exception>
public MemoryRegion QueryRegion(IntPtr address)
{
nuint bufferSize = (nuint)Marshal.SizeOf<MemoryBasicInformation>();
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);
}
/// <summary>
/// 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.
/// </summary>
public IEnumerable<MemoryRegion> EnumerateRegions()
{
IntPtr address = IntPtr.Zero;
nuint bufferSize = (nuint)Marshal.SizeOf<MemoryBasicInformation>();
while (true)
{
nuint result = NativeMethods.VirtualQueryEx(Handle, address, out MemoryBasicInformation info, bufferSize);
if (result == 0)
yield break;
yield return new MemoryRegion(info);
IntPtr next = info.BaseAddress + (nint)info.RegionSize;
if (next.ToInt64() <= address.ToInt64())
yield break;
address = next;
}
}
/// <summary>
/// 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.
/// </summary>
public ProtectionScope ChangeProtection(IntPtr address, nint size, MemoryProtectionType protection)
{
return new ProtectionScope(this, address, size, protection);
}
// ── Lifecycle ──────────────────────────────────────────────────────────
/// <inheritdoc />
+58
View File
@@ -156,3 +156,61 @@ public static class ContextFlags
/// <summary>AMD64: control, integer, and segment registers.</summary>
public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
}
/// <summary>
/// Values that describe the state of memory pages returned by <c>VirtualQueryEx</c>.
/// </summary>
public enum MemoryState : uint
{
/// <summary>Indicates committed pages for which physical storage has been allocated.</summary>
Commit = 0x1000,
/// <summary>Indicates reserved pages where a range of the virtual address space is reserved without any physical storage being allocated.</summary>
Reserve = 0x2000,
/// <summary>Indicates free pages not accessible to the calling process and available to be allocated.</summary>
Free = 0x10000,
}
/// <summary>
/// Values that describe the type of memory pages returned by <c>VirtualQueryEx</c>.
/// </summary>
public enum MemoryType : uint
{
/// <summary>Indicates that the memory pages within the region are private.</summary>
Private = 0x20000,
/// <summary>Indicates that the memory pages within the region are mapped into the view of a section.</summary>
Mapped = 0x40000,
/// <summary>Indicates that the memory pages within the region are mapped into the view of an image section.</summary>
Image = 0x1000000,
}
/// <summary>
/// Flags used by <c>CreateToolhelp32Snapshot</c> to specify the portions of the system to include in the snapshot.
/// </summary>
[Flags]
public enum SnapshotFlags : uint
{
/// <summary>Enumerate the heap list.</summary>
HeapList = 0x00000001,
/// <summary>Enumerate the process list.</summary>
Process = 0x00000002,
/// <summary>Enumerate the thread list.</summary>
Thread = 0x00000004,
/// <summary>Enumerate the module list.</summary>
Module = 0x00000008,
/// <summary>Enumerate the 32-bit module list for the specified process.</summary>
Module32 = 0x00000010,
/// <summary>Include all processes and threads in the system.</summary>
All = 0x0000001F,
/// <summary>Indicate that the snapshot handle is to be inheritable.</summary>
Inherit = 0x80000000,
}
+42
View File
@@ -173,4 +173,46 @@ internal static partial class NativeMethods
SafeMemoryHandle handle,
uint milliseconds);
// ── Memory query ───────────────────────────────────────────────────────
/// <summary>Retrieves information about a range of pages in the virtual address space of a specified process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial nuint VirtualQueryEx(
SafeMemoryHandle process,
IntPtr address,
out MemoryBasicInformation buffer,
nuint length);
// ── Thread enumeration ─────────────────────────────────────────────────
/// <summary>Takes a snapshot of the specified processes, as well as the heaps, modules, and threads used by these processes.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle CreateToolhelp32Snapshot(
SnapshotFlags dwFlags,
int th32ProcessID);
/// <summary>Retrieves information about the first thread of any process encountered in a system snapshot.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Thread32First(
SafeMemoryHandle hSnapshot,
ref ThreadEntry32 lpte);
/// <summary>Retrieves information about the next thread of any process encountered in a system snapshot.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Thread32Next(
SafeMemoryHandle hSnapshot,
ref ThreadEntry32 lpte);
/// <summary>Retrieves timing information for the specified thread.</summary>
[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);
}
+58
View File
@@ -205,3 +205,61 @@ public unsafe struct Context64
/// <summary>The source RIP of the last exception.</summary>
public ulong LastExceptionFromRip;
}
/// <summary>
/// Layout matches <c>MEMORY_BASIC_INFORMATION</c>. 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.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MemoryBasicInformation
{
/// <summary>A pointer to the base address of the region of pages.</summary>
public nint BaseAddress;
/// <summary>A pointer to the base address of a range of pages allocated by the VirtualAllocEx function.</summary>
public nint AllocationBase;
/// <summary>The memory protection option when the region was initially allocated.</summary>
public uint AllocationProtect;
/// <summary>The size of the region beginning at the base address, in bytes.</summary>
public nuint RegionSize;
/// <summary>The state of the pages in the region.</summary>
public uint State;
/// <summary>The access protection of the pages in the region.</summary>
public uint Protect;
/// <summary>The type of pages in the region.</summary>
public uint Type;
}
/// <summary>
/// Layout matches <c>THREADENTRY32</c> used by <c>Thread32First</c>/<c>Thread32Next</c>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ThreadEntry32
{
/// <summary>The size of the structure, in bytes.</summary>
public uint dwSize;
/// <summary>This member is no longer used and is always zero.</summary>
public uint cntUsage;
/// <summary>The thread identifier.</summary>
public uint th32ThreadID;
/// <summary>The identifier of the process that owns the thread.</summary>
public uint th32OwnerProcessID;
/// <summary>The kernel base priority level assigned to the thread.</summary>
public int tpBasePri;
/// <summary>This member is no longer used.</summary>
public int tpDeltaPri;
/// <summary>This member is reserved.</summary>
public uint dwFlags;
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
using WhiteMagic.Windows;
namespace WhiteMagic.ProcessDiscovery;
/// <summary>
/// Discovers running processes by name, window title, or window handle so they can be
/// attached through a <see cref="Magic"/> session.
/// </summary>
public static class ApplicationFinder
{
/// <summary>
/// Enumerates processes whose image name matches <paramref name="processName"/>
/// (extension optional).
/// </summary>
public static IEnumerable<Process> Enumerate(string processName)
{
ArgumentException.ThrowIfNullOrEmpty(processName);
return Process.GetProcessesByName(GetNameWithoutExtension(processName));
}
/// <summary>
/// Returns the unique process whose image name matches <paramref name="processName"/>.
/// </summary>
/// <exception cref="InvalidOperationException">Zero or multiple processes match.</exception>
public static Process OpenProcess(string processName)
{
Process[] candidates = Enumerate(processName).ToArray();
if (candidates.Length == 0)
{
throw new InvalidOperationException(
$"No process named '{processName}' was found.");
}
if (candidates.Length > 1)
{
string list = string.Join(", ", candidates.Select(p => $"{p.ProcessName}:{p.Id}"));
foreach (Process candidate in candidates)
candidate.Dispose();
throw new InvalidOperationException(
$"Process name '{processName}' is ambiguous ({candidates.Length} matches): {list}");
}
Process result = candidates[0];
for (int i = 1; i < candidates.Length; i++)
candidates[i].Dispose();
return result;
}
/// <summary>
/// Enumerates processes that own a top-level window whose title equals
/// <paramref name="title"/>.
/// </summary>
public static IEnumerable<Process> FindByWindowTitle(string title)
{
ArgumentException.ThrowIfNullOrEmpty(title);
var seen = new HashSet<int>();
foreach (RemoteWindow window in WindowFactory.GetWindows())
{
if (!string.Equals(window.Text, title, StringComparison.Ordinal))
continue;
uint pid = window.ProcessId;
if (pid == 0 || !seen.Add((int)pid))
continue;
Process? process;
try
{
process = global::System.Diagnostics.Process.GetProcessById((int)pid);
}
catch
{
continue;
}
yield return process;
}
}
/// <summary>
/// Returns the unique process that owns a top-level window titled <paramref name="title"/>.
/// </summary>
/// <exception cref="InvalidOperationException">Zero or multiple windows match.</exception>
public static Process OpenByWindowTitle(string title)
{
Process[] candidates = FindByWindowTitle(title).ToArray();
if (candidates.Length == 0)
{
throw new InvalidOperationException(
$"No top-level window titled '{title}' was found.");
}
if (candidates.Length > 1)
{
throw new InvalidOperationException(
$"Window title '{title}' is ambiguous ({candidates.Length} matches): " +
string.Join(", ", candidates.Select(p => $"{p.ProcessName}:{p.Id}")));
}
return candidates[0];
}
/// <summary>
/// Returns the process that owns the specified window handle.
/// </summary>
public static Process OpenByWindowHandle(IntPtr handle)
{
if (handle == IntPtr.Zero)
throw new ArgumentException("Window handle cannot be zero.", nameof(handle));
uint tid = NativeMethods.GetWindowThreadProcessId(handle, out uint processId);
if (tid == 0 || processId == 0)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"GetWindowThreadProcessId failed for handle {handle:X}: error {error}.");
}
try
{
return global::System.Diagnostics.Process.GetProcessById((int)processId);
}
catch (ArgumentException)
{
throw new InvalidOperationException(
$"Process {processId} owning window {handle:X} is no longer running.");
}
}
private static string GetNameWithoutExtension(string name)
{
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
return name[..^4];
return name;
}
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace WhiteMagic.Thread;
/// <summary>
/// A disposable scope that tracks a set of threads frozen by <see cref="ThreadFactory.Freeze"/>.
/// Disposing the scope resumes exactly those threads, in reverse order, even if the guarded
/// body throws, and then disposes the underlying thread handles.
/// </summary>
public sealed class FrozenThread : IDisposable
{
private readonly IReadOnlyList<RemoteThread> _threads;
private bool _disposed;
internal FrozenThread(IReadOnlyList<RemoteThread> threads)
{
_threads = threads ?? throw new ArgumentNullException(nameof(threads));
}
/// <summary>The threads suspended by this freeze scope.</summary>
public IEnumerable<RemoteThread> Threads => _threads;
/// <summary>
/// Resumes the frozen threads in reverse order, then disposes every thread handle.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (RemoteThread thread in _threads.Reverse())
{
try
{
thread.Resume();
}
catch
{
// Resume-on-dispose is best-effort; the handle is still disposed below.
}
}
foreach (RemoteThread thread in _threads)
{
thread.Dispose();
}
}
}
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
using WhiteMagic.ThreadEnvironment;
namespace WhiteMagic.Thread;
/// <summary>
/// A handle to an existing thread in the target process. Provides suspend/resume,
/// context read/write, and TEB query.
/// </summary>
public sealed class RemoteThread : IDisposable
{
private readonly MemoryBase _memory;
private readonly SafeMemoryHandle _handle;
private readonly int _id;
private bool _disposed;
/// <summary>The operating-system identifier of this thread.</summary>
public int Id => _id;
/// <summary>The native thread handle.</summary>
internal SafeMemoryHandle Handle => _handle;
internal RemoteThread(MemoryBase memory, int threadId, SafeMemoryHandle handle)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_id = threadId;
_handle = handle ?? throw new ArgumentNullException(nameof(handle));
}
/// <summary>
/// Opens the thread specified by <paramref name="threadId"/> in the target process
/// represented by <paramref name="memory"/>.
/// </summary>
public RemoteThread(MemoryBase memory, int threadId)
: this(memory, threadId, OpenHandle(threadId))
{
}
private static SafeMemoryHandle OpenHandle(int threadId)
{
if (threadId <= 0)
throw new ArgumentException("Thread ID must be positive.", nameof(threadId));
const ThreadAccess requiredAccess =
ThreadAccess.SuspendResume |
ThreadAccess.GetContext |
ThreadAccess.SetContext |
ThreadAccess.QueryInformation;
SafeMemoryHandle handle = NativeMethods.OpenThread(requiredAccess, false, threadId);
if (handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
}
return handle;
}
/// <summary>
/// Suspends the thread and returns its previous suspend count.
/// </summary>
public uint Suspend()
{
uint result = NativeMethods.SuspendThread(_handle);
if (result == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed for thread {_id}: error {error}.");
}
return result;
}
/// <summary>
/// Resumes the thread and returns its previous suspend count.
/// </summary>
public uint Resume()
{
uint result = NativeMethods.ResumeThread(_handle);
if (result == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread failed for thread {_id}: error {error}.");
}
return result;
}
/// <summary>
/// Reads the 64-bit native context of the thread. Valid only for 64-bit targets.
/// </summary>
public unsafe void GetContext64(out Context64 context)
{
nint size = Marshal.SizeOf<Context64>();
void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16);
try
{
Unsafe.InitBlock(ptr, 0, (uint)size);
((Context64*)ptr)->ContextFlags = ContextFlags.Amd64Full;
if (!NativeMethods.GetThreadContext(_handle, ref *(Context64*)ptr))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}.");
}
context = *(Context64*)ptr;
}
finally
{
NativeMemory.AlignedFree(ptr);
}
}
/// <summary>
/// Writes the 64-bit native context of the thread. Valid only for 64-bit targets.
/// </summary>
public unsafe void SetContext64(ref Context64 context)
{
nint size = Marshal.SizeOf<Context64>();
void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16);
try
{
*(Context64*)ptr = context;
if (!NativeMethods.SetThreadContext(_handle, ref *(Context64*)ptr))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}.");
}
}
finally
{
NativeMemory.AlignedFree(ptr);
}
}
/// <summary>
/// Reads the 32-bit native context of the thread. Valid only for 32-bit targets.
/// </summary>
public void GetContext32(out Context32 context)
{
if (_memory.Is64Bit)
{
context = default;
throw new InvalidOperationException(
"Use GetContext64 for 64-bit targets; GetContext32 is valid for 32-bit targets only.");
}
context = new Context32 { ContextFlags = ContextFlags.X86Full };
if (!NativeMethods.GetThreadContext(_handle, ref context))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}.");
}
}
/// <summary>
/// Writes the 32-bit native context of the thread. Valid only for 32-bit targets.
/// </summary>
public void SetContext32(ref Context32 context)
{
if (_memory.Is64Bit)
throw new InvalidOperationException(
"Use SetContext64 for 64-bit targets; SetContext32 is valid for 32-bit targets only.");
if (!NativeMethods.SetThreadContext(_handle, ref context))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}.");
}
}
/// <summary>
/// Returns a managed reader for this thread's Thread Environment Block.
/// </summary>
public ManagedTeb GetTeb()
{
return new ManagedTeb(_memory, _id);
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_handle.Dispose();
}
}
}
+264
View File
@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Thread;
/// <summary>
/// Enumerates and selects threads belonging to the target process.
/// </summary>
public sealed class ThreadFactory
{
private readonly MemoryBase _memory;
/// <summary>Creates a factory bound to the target process represented by <paramref name="memory"/>.</summary>
public ThreadFactory(MemoryBase memory)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
}
/// <summary>
/// Enumerates every thread that belongs to the target process.
/// </summary>
public IEnumerable<RemoteThread> Enumerate()
{
foreach (int threadId in CollectThreadIds())
{
SafeMemoryHandle handle = NativeMethods.OpenThread(
ThreadAccess.SuspendResume |
ThreadAccess.GetContext |
ThreadAccess.SetContext |
ThreadAccess.QueryInformation,
false,
threadId);
if (handle.IsInvalid)
continue;
yield return new RemoteThread(_memory, threadId, handle);
}
}
private int[] CollectThreadIds()
{
using SafeMemoryHandle snapshot = NativeMethods.CreateToolhelp32Snapshot(SnapshotFlags.Thread, 0);
if (snapshot.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"CreateToolhelp32Snapshot failed: error {error}.");
}
var entry = new ThreadEntry32
{
dwSize = (uint)Marshal.SizeOf<ThreadEntry32>()
};
var ids = new List<int>();
if (!NativeMethods.Thread32First(snapshot, ref entry))
{
int error = Marshal.GetLastPInvokeError();
if (error == 18 || error == 259) // ERROR_NO_MORE_FILES / ERROR_NO_MORE_ITEMS
return ids.ToArray();
throw new InvalidOperationException($"Thread32First failed: error {error}.");
}
do
{
if (entry.th32OwnerProcessID == (uint)_memory.ProcessId)
ids.Add((int)entry.th32ThreadID);
}
while (NativeMethods.Thread32Next(snapshot, ref entry));
return ids.ToArray();
}
/// <summary>
/// Returns the thread with the specified operating-system identifier if it belongs
/// to the target process.
/// </summary>
/// <exception cref="InvalidOperationException">The thread does not belong to the target process.</exception>
public RemoteThread GetThreadById(int threadId)
{
if (threadId <= 0)
throw new ArgumentException("Thread ID must be positive.", nameof(threadId));
const ThreadAccess requiredAccess =
ThreadAccess.SuspendResume |
ThreadAccess.GetContext |
ThreadAccess.SetContext |
ThreadAccess.QueryInformation;
SafeMemoryHandle handle = NativeMethods.OpenThread(requiredAccess, false, threadId);
if (handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
}
try
{
var info = new ThreadBasicInformation();
int status = NativeMethods.NtQueryInformationThread(
handle,
0,
ref info,
(uint)Marshal.SizeOf<ThreadBasicInformation>(),
out _);
if (status < 0)
{
throw new InvalidOperationException(
$"NtQueryInformationThread failed for thread {threadId} (NTSTATUS {status:X8}).");
}
if ((uint)(nint)info.ClientId.UniqueProcess != (uint)_memory.ProcessId)
{
throw new InvalidOperationException(
$"Thread {threadId} does not belong to process {_memory.ProcessId}.");
}
// Ownership of the validated handle transfers to the RemoteThread.
return new RemoteThread(_memory, threadId, handle);
}
catch
{
handle.Dispose();
throw;
}
}
/// <summary>
/// Returns the earliest-created thread of the target process.
/// </summary>
public RemoteThread MainThread
{
get
{
RemoteThread? earliest = null;
long earliestTime = long.MaxValue;
foreach (RemoteThread thread in Enumerate())
{
long creationTime = GetCreationTime(thread.Id);
if (creationTime < earliestTime)
{
earliestTime = creationTime;
earliest?.Dispose();
earliest = thread;
}
else
{
thread.Dispose();
}
}
if (earliest is null)
{
throw new InvalidOperationException(
$"Process {_memory.ProcessId} has no observable threads.");
}
return earliest;
}
}
/// <summary>
/// Suspends the supplied threads and returns a disposable scope that resumes exactly
/// those threads when disposed, including when an exception escapes the guarded body.
/// </summary>
/// <remarks>
/// Do not freeze the target's threads while executing target code through a remote
/// thread or main-thread pump; doing so can deadlock because the frozen thread is the
/// one responsible for running the code.
/// </remarks>
public FrozenThread Freeze(IEnumerable<RemoteThread> threads)
{
ArgumentNullException.ThrowIfNull(threads);
var suspended = new List<RemoteThread>();
try
{
foreach (RemoteThread thread in threads)
{
thread.Suspend();
suspended.Add(thread);
}
return new FrozenThread(suspended);
}
catch
{
foreach (RemoteThread thread in suspended)
{
try
{
thread.Resume();
}
catch
{
// Best-effort unwind.
}
}
throw;
}
}
/// <summary>
/// Suspends all target threads selected by <paramref name="predicate"/>.
/// </summary>
public FrozenThread Freeze(Func<RemoteThread, bool> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var selected = new List<RemoteThread>();
try
{
foreach (RemoteThread thread in Enumerate())
{
try
{
if (predicate(thread))
selected.Add(thread);
else
thread.Dispose();
}
catch
{
thread.Dispose();
throw;
}
}
return Freeze(selected);
}
catch
{
foreach (RemoteThread thread in selected)
thread.Dispose();
throw;
}
}
private long GetCreationTime(int threadId)
{
using SafeMemoryHandle handle = NativeMethods.OpenThread(ThreadAccess.QueryInformation, false, threadId);
if (handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
}
if (!NativeMethods.GetThreadTimes(handle, out long creationTime, out _, out _, out _))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadTimes failed for thread {threadId}: error {error}.");
}
return creationTime;
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using Thread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Injection;
using WhiteMagic.Native;
@@ -69,7 +70,7 @@ public class DllInjectorTests
int osThreadId = 0;
Exception? threadError = null;
var helper = new Thread(() =>
var helper = new System.Threading.Thread(() =>
{
try
{
@@ -80,7 +81,7 @@ public class DllInjectorTests
// also be stopped once its original context is restored.
while (!stopEvent.IsSet)
{
Thread.Sleep(10);
System.Threading.Thread.Sleep(10);
}
}
catch (Exception ex)
+44
View File
@@ -0,0 +1,44 @@
using System.Linq;
using WhiteMagic;
using WhiteMagic.Memory;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for the convenience accessors exposed directly on <see cref="Magic"/>.
/// </summary>
public sealed class MagicFacadeTests
{
[Fact]
public void QueryRegion_returns_region_containing_image_base()
{
using var magic = Magic.OpenInProcess();
MemoryRegion region = magic.QueryRegion(magic.Memory.ImageBase);
Assert.True(region.Contains(magic.Memory.ImageBase));
}
[Fact]
public void Regions_enumerates_region_containing_image_base()
{
using var magic = Magic.OpenInProcess();
bool found = magic.Regions.Any(r => r.Contains(magic.Memory.ImageBase));
Assert.True(found);
}
[Fact]
public void Threads_factory_enumerates_current_thread()
{
using var magic = Magic.OpenInProcess();
ThreadFactory factory = magic.Threads;
int currentOsId = (int)NativeMethods.GetCurrentThreadId();
bool found = factory.Enumerate().Any(t => t.Id == currentOsId);
Assert.True(found);
}
}
+159
View File
@@ -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;
/// <summary>
/// Tests for memory-region query, enumeration and scoped protection (tasks 1.2, 1.4, 1.6, 1.8).
/// </summary>
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<InvalidOperationException>(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);
}
}
}
@@ -0,0 +1,105 @@
using System.Diagnostics;
using System.Linq;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.ProcessDiscovery;
using Xunit;
namespace WhiteMagicTest.ProcessDiscovery;
/// <summary>
/// Tests for process discovery via <see cref="ApplicationFinder"/> and the matching
/// <see cref="Magic.Open"/> overloads.
/// </summary>
public sealed class ApplicationFinderTests
{
[Fact]
public void Enumerate_finds_current_process_by_name()
{
string currentName = Process.GetCurrentProcess().ProcessName;
Process[] found = ApplicationFinder.Enumerate(currentName).ToArray();
Assert.True(found.Length >= 1);
Assert.Contains(found, p => p.Id == Process.GetCurrentProcess().Id);
}
[Fact]
public void Open_by_name_returns_current_process_when_unique()
{
string currentName = Process.GetCurrentProcess().ProcessName;
using Process process = ApplicationFinder.OpenProcess(currentName);
Assert.Equal(Process.GetCurrentProcess().Id, process.Id);
}
[Fact]
public void Open_throws_when_name_is_ambiguous()
{
// Look for a multi-instance system process; skip if the environment is not typical.
Process[] candidates = Process.GetProcessesByName("svchost");
if (candidates.Length <= 1)
{
return;
}
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => ApplicationFinder.OpenProcess("svchost"));
Assert.Contains("svchost", ex.Message);
Assert.Contains("ambiguous", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Open_throws_when_no_process_matches()
{
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => ApplicationFinder.OpenProcess("probably-not-loaded-xyz.exe"));
Assert.Contains("No process", ex.Message);
}
[Fact]
public void OpenByWindowHandle_returns_owning_process()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
return;
}
using Process process = ApplicationFinder.OpenByWindowHandle(handle);
Assert.Equal(Process.GetCurrentProcess().Id, process.Id);
}
[Fact]
public void OpenByWindowHandle_throws_for_zero_handle()
{
Assert.Throws<ArgumentException>("handle", () => ApplicationFinder.OpenByWindowHandle(IntPtr.Zero));
}
[Fact]
public void Magic_Open_by_name_attaches_to_current_process()
{
string currentName = Process.GetCurrentProcess().ProcessName;
using var magic = Magic.Open(currentName);
Assert.Equal(Process.GetCurrentProcess().Id, magic.Memory.ProcessId);
}
[Fact]
public void Magic_OpenByWindowHandle_attaches_to_owning_process()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
return;
}
using var magic = Magic.OpenByWindowHandle(handle);
Assert.Equal(Process.GetCurrentProcess().Id, magic.Memory.ProcessId);
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Linq;
using System.Threading;
using SysThread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for scoped thread freeze via <see cref="FrozenThread"/> and <see cref="ThreadFactory.Freeze"/>.
/// </summary>
public sealed class FrozenThreadTests
{
[Fact]
public void Freeze_suspends_selected_workers_until_disposed()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using var cts1 = new CancellationTokenSource();
using var cts2 = new CancellationTokenSource();
var started1 = new ManualResetEventSlim(false);
var started2 = new ManualResetEventSlim(false);
int osThreadId1 = 0;
int osThreadId2 = 0;
var worker1 = new SysThread(() =>
{
osThreadId1 = (int)NativeMethods.GetCurrentThreadId();
started1.Set();
while (!cts1.IsCancellationRequested)
SysThread.Sleep(10);
});
var worker2 = new SysThread(() =>
{
osThreadId2 = (int)NativeMethods.GetCurrentThreadId();
started2.Set();
while (!cts2.IsCancellationRequested)
SysThread.Sleep(10);
});
worker1.Start();
worker2.Start();
started1.Wait();
started2.Wait();
int[] targetIds = [osThreadId1, osThreadId2];
try
{
var selected = factory.Enumerate().Where(t => targetIds.Contains(t.Id)).ToList();
Assert.Equal(2, selected.Count);
using (factory.Freeze(selected))
{
cts1.Cancel();
cts2.Cancel();
Assert.False(worker1.Join(100));
Assert.False(worker2.Join(100));
}
Assert.True(worker1.Join(1000));
Assert.True(worker2.Join(1000));
}
finally
{
if (worker1.IsAlive)
{
cts1.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId1);
t.Resume();
worker1.Join(1000);
}
if (worker2.IsAlive)
{
cts2.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId2);
t.Resume();
worker2.Join(1000);
}
}
}
[Fact]
public void Dispose_resumes_only_frozen_threads_leaving_external_suspends_intact()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new SysThread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
SysThread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
// Suspend the worker externally first.
using (var external = new RemoteThread(magic.Memory, osThreadId))
{
external.Suspend();
var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList();
using (factory.Freeze(selected))
{
// Frozen scope adds one more suspend count.
}
// After the freeze scope disposes, the worker was resumed once.
// Because it was already externally suspended, it should still be suspended.
cts.Cancel();
Assert.False(worker.Join(100));
external.Resume();
}
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId);
t.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void Exception_in_body_still_resumes_frozen_threads()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new SysThread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
SysThread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList();
Assert.Throws<InvalidOperationException>(new Action(() =>
{
using (factory.Freeze(selected))
{
throw new InvalidOperationException("Intentional failure inside freeze scope.");
}
}));
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId);
t.Resume();
worker.Join(1000);
}
}
}
}
@@ -0,0 +1,131 @@
using System.Threading;
using Thread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for <see cref="RemoteThread.GetContext64"/> / <see cref="RemoteThread.SetContext64"/>.
/// 32-bit/WOW64 context is tested on a 32-bit host run.
/// </summary>
public sealed class RemoteThreadContextTests
{
[Fact]
public void GetContext64_SetContext64_round_trip_on_suspended_self_thread()
{
if (!Environment.Is64BitProcess)
return;
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
System.Threading.Thread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Suspend();
System.Threading.Thread.Sleep(100);
thread.GetContext64(out Context64 context);
Assert.NotEqual(0uL, context.Rip);
const ulong sentinel = 0x123456789ABCDEF0uL;
ulong originalRax = context.Rax;
context.Rax = sentinel;
thread.SetContext64(ref context);
thread.GetContext64(out context);
Assert.Equal(sentinel, context.Rax);
// Restore the original register before resuming so the worker keeps running.
context.Rax = originalRax;
thread.SetContext64(ref context);
thread.Resume();
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void GetContext32_SetContext32_round_trip_on_suspended_self_thread()
{
if (Environment.Is64BitProcess)
return;
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
System.Threading.Thread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Suspend();
thread.GetContext32(out Context32 context);
Assert.NotEqual(0u, context.Eip);
const uint sentinel = 0x89ABCDEFu;
uint originalEax = context.Eax;
context.Eax = sentinel;
thread.SetContext32(ref context);
thread.GetContext32(out context);
Assert.Equal(sentinel, context.Eax);
context.Eax = originalEax;
thread.SetContext32(ref context);
thread.Resume();
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using System.Threading;
using Thread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for <see cref="RemoteThread"/> open/suspend/resume and context round-trip.
/// </summary>
public sealed class RemoteThreadTests
{
[Fact]
public void Open_by_id_succeeds_for_current_thread()
{
using var magic = Magic.OpenInProcess();
int currentId = (int)NativeMethods.GetCurrentThreadId();
using var thread = new RemoteThread(magic.Memory, currentId);
Assert.Equal(currentId, thread.Id);
}
[Fact]
public void Suspend_returns_prior_count_and_stops_worker()
{
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
System.Threading.Thread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
uint prior = thread.Suspend();
Assert.True(prior < 0xFFFFFFFF);
cts.Cancel();
// Worker cannot observe cancellation while suspended.
Assert.False(worker.Join(100));
thread.Resume();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void Resume_restarts_a_suspended_worker()
{
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
var resumed = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
{
resumed.Set();
System.Threading.Thread.Sleep(10);
}
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Suspend();
resumed.Reset();
uint prior = thread.Resume();
Assert.True(prior < 0xFFFFFFFF);
// Worker must reach the resumed flag again.
Assert.True(resumed.Wait(1000));
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void GetTeb_returns_managed_teb_for_thread()
{
using var magic = Magic.OpenInProcess();
int currentId = (int)NativeMethods.GetCurrentThreadId();
using var thread = new RemoteThread(magic.Memory, currentId);
using var teb = thread.GetTeb();
Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
}
}
@@ -0,0 +1,61 @@
using System.Linq;
using System.Threading;
using SysThread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for <see cref="ThreadFactory"/> enumeration and main-thread selection.
/// </summary>
public sealed class ThreadFactoryTests
{
[Fact]
public void Enumerate_returns_only_target_threads()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
int currentOsId = (int)NativeMethods.GetCurrentThreadId();
var ids = factory.Enumerate().Select(t => t.Id).ToList();
Assert.True(ids.Count > 0);
Assert.Contains(currentOsId, ids);
}
[Fact]
public void GetThreadById_returns_matching_thread()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
int currentOsId = (int)NativeMethods.GetCurrentThreadId();
using RemoteThread thread = factory.GetThreadById(currentOsId);
Assert.Equal(currentOsId, thread.Id);
}
[Fact]
public void GetThreadById_throws_for_nonexistent_thread()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
Assert.Throws<InvalidOperationException>(() => factory.GetThreadById(0x7FFFFFFF));
}
[Fact]
public void MainThread_returns_a_thread_belonging_to_the_target()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using RemoteThread main = factory.MainThread;
Assert.NotNull(main);
var ids = factory.Enumerate().Select(t => t.Id).ToList();
Assert.Contains(main.Id, ids);
}
}
+1 -1
View File
@@ -102,5 +102,5 @@ The design held, but building it surfaced corrections worth recording (each is d
- **`RemoteModule`/`RemoteFunction` follow PE export forwarders** — `kernel32!HeapAlloc``NTDLL.RtlAllocateHeap` and similar resolve into the real target module; ordinal and API-set forwarders throw `NotSupportedException` rather than returning a wrong address (task 7.2).
- **Detour prologue safety is tiered** — the default `StubAssembler` length-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set (zero dependency); the optional `IcedAssembler.GetPrologueLength` decodes arbitrary prologues and is plugged in via `DetourManager.PrologueLengthResolver` when full validation is wanted (tasks 4.6, 8.3).
- **Iced has no text parser** — the design assumed arbitrary text assembly could be delegated to Iced, but Iced ships only a *fluent* code assembler and a decoder. `IcedAssembler.Assemble` bridges Intel-syntax text onto the fluent API by reflection (registers, immediates, labels; memory operands unsupported), rather than depending on a parser that does not exist (task 8.2).
- **Injection bitness corrections** — the thread-hijack injector enforces matching host/target bitness, so the 32-bit path always runs from a 32-bit caller and uses native `GetThreadContext`/`SetThreadContext`; the WOW64 context APIs (for 64-bit callers inspecting WOW64 targets) never apply here and were removed. `ExternalReader` validates `QueryInformation`/`QueryLimitedInformation` access and surfaces `IsWow64Process` failures instead of silently assuming host bitness.
- **Thread-control, memory-region, and process-discovery gaps are closed** — this change adds `MemoryBase.QueryRegion`/`EnumerateRegions`/`ChangeProtection`, `RemoteThread`/`ThreadFactory`/`FrozenThread`, and `ApplicationFinder` with `Magic.Open` overloads. WhiteMagic now covers the public surfaces of all four reference libraries.
- **Bounds and protection hardening** — `AllocatedMemory` range-checks typed IO against region size; `Patch` mirrors the detour's `VirtualProtectEx` dance; `MainThreadPump` guards the completion race on an already-completed `TaskCompletionSource`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-22
@@ -0,0 +1,86 @@
## Context
`whitemagic-foundation` shipped the core (dual `MemoryBase`, execution tiers, hooking, injection, discovery, high-level surface). The post-implementation review confirmed parity with GreyMagic and current BlackMagic but flagged three MemorySharp capabilities still absent: public thread control, memory-region query, and process discovery. This change closes those gaps. It is additive; nothing in `whitemagic-foundation` is reworked.
The consuming use case is unchanged — an external automation host over a legacy x86 desktop app — so every surface here must work **out-of-process** over a `SafeMemoryHandle`, and honor target bitness where the OS structures differ (thread `CONTEXT`).
## Goals / Non-Goals
**Goals:**
- Public thread surface: enumerate the target's threads, suspend/resume, read/write `CONTEXT`, read a thread's TEB, and a **scoped freeze** (`IDisposable`) that suspends a thread set and resumes on dispose even if the body throws.
- Memory-region surface: query the region containing an address, enumerate all mapped regions, and a **scoped protection change** (`IDisposable`) that restores original protection on dispose.
- Process discovery: attach a target by process name, window title, or window handle; enumerate candidate processes.
- Reuse existing `NativeMethods` (`OpenThread`, `Suspend`/`ResumeThread`, `Get`/`SetThreadContext`, `VirtualProtectEx`, `SafeMemoryHandle`) rather than duplicating them.
- Test-first for pure logic (region-contains math, freeze/dispose ordering, name/handle matching) with live-process integration tests gated on an available target (self-process).
**Non-Goals:**
- Managed-loader / in-process pump reachability (separate follow-up).
- Thread creation — `RemoteThreadExecutor` already owns `CreateRemoteThread`; `RemoteThread` here wraps *existing* target threads.
- Writing to arbitrary regions found by enumeration beyond what `MemoryBase` read/write already offers.
- Kernel-level or hidden-thread discovery — toolhelp/`NtQueryInformationThread` visibility is sufficient for the automation use case.
## Decisions
### D1: `RemoteThread` wraps an existing target thread over a `SafeMemoryHandle`
`RemoteThread` opens a thread by TID via `OpenThread(THREAD_ALL_ACCESS...)` into a `SafeMemoryHandle` and exposes `Suspend()`/`Resume()`, `GetContext()`/`SetContext()` (Wow64 variant selected by target bitness, mirroring `DllInjector`'s hijack path), `GetTeb()` (via `NtQueryInformationThread`/`ThreadBasicInformation``ManagedTeb`), and `Id`. `Suspend`/`Resume` return the prior suspend count so nested suspends are observable.
**Why**: Mirrors the proven bitness handling already in `DllInjector`; keeps thread handles inside a `SafeMemoryHandle` for deterministic cleanup like every other native handle in the library.
**Alternatives**: expose raw `System.Diagnostics.ProcessThread` — rejected: no suspend/resume/context and no soft handle ownership.
### D2: `ThreadFactory` enumerates via toolhelp snapshot
`ThreadFactory.Enumerate()` walks `CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD)` + `Thread32First`/`Thread32Next`, filtering by owning PID, yielding `RemoteThread`. `MainThread` returns the thread with the earliest creation time (via `GetThreadTimes`), matching MemorySharp's definition. `GetThreadById(id)` opens directly.
**Why**: toolhelp is the documented, x86/x64-uniform thread walk and needs no undocumented structures.
**Alternatives**: `NtQuerySystemInformation(SystemProcessInformation)` — rejected: larger undocumented surface for no gain here.
### D3: Scoped freeze is the default ergonomic
`ThreadFactory.Freeze(predicate = all-but-caller?)` suspends the selected threads and returns a `FrozenThread : IDisposable` whose `Dispose()` resumes exactly the threads it suspended, in reverse order. Individual `RemoteThread.Suspend/Resume` remain available for manual control.
**Why**: The dominant use ("freeze the target while I read/write a consistent snapshot") is a scope. An `IDisposable` makes leak-on-exception impossible: `using (factory.Freeze()) { ...edit... }`.
**Trade-off**: Freezing the target's own threads while calling *into* the target (pump/remote-thread) can deadlock. Documented: freeze is for passive read/write snapshots, not while executing target code.
### D4: `MemoryRegion` is an immutable `VirtualQueryEx` snapshot; enumeration is lazy
`MemoryRegion` holds `BaseAddress`, `Size`, `Protection`, `State`, `Type`, `AllocationBase`, `AllocationProtect`, and `Contains(address)`. `MemoryBase.QueryRegion(address)` returns the single region containing an address; `MemoryBase.EnumerateRegions()` yields regions from address 0 upward by repeatedly calling `VirtualQueryEx(base + size)` until it fails (end of address space). Enumeration is `IEnumerable<MemoryRegion>` (lazy) so a caller can stop early.
**Why**: `VirtualQueryEx` already returns contiguous non-overlapping regions; walking `base+size` is the canonical enumeration. Lazy avoids materializing the whole address space.
### D5: Protection change is a scoped, auto-restoring helper
`MemoryBase.ChangeProtection(address, size, newProtect)` calls `VirtualProtectEx`, captures the old protection, and returns a `ProtectionScope : IDisposable` that restores it on dispose. This is the same protect/restore pattern already inlined in `Detour.Apply`; extracting it lets callers guard their own writes: `using (mem.ChangeProtection(a, n, ExecuteReadWrite)) { mem.WriteBytes(a, patch); }`.
**Why**: Removes a foot-gun (leaving a page writable) and de-duplicates the pattern. `Detour`/`Patch` may later adopt it, but that refactor is out of scope here.
### D6: Process discovery via `System.Diagnostics.Process` + Win32 window queries
`ApplicationFinder` wraps `Process.GetProcessesByName`, a `GetWindow`/`EnumWindows` + `GetWindowThreadProcessId` path for window-title/handle attach, and exposes them as `Magic.Open(string processName)`, `Magic.OpenByWindowTitle(string)`, `Magic.OpenByWindowHandle(IntPtr)` overloads plus `ApplicationFinder.Enumerate()`. Ambiguous matches (multiple processes) throw with the candidate list rather than guessing.
**Why**: Managed `Process` covers name/PID; the existing `WindowFactory`/`RemoteWindow` P/Invoke already resolves windows, so window→PID reuses it. Throwing on ambiguity avoids attaching to the wrong instance.
## Risks / Trade-offs
- **Freeze-while-executing deadlock** → Documented non-use; `Freeze` default predicate can exclude the caller's own thread, but cross-process it cannot exclude the *target's* pump thread — caller must not freeze while the pump runs. (D3)
- **Suspend count skew** → `Suspend`/`Resume` return prior counts; `FrozenThread` tracks exactly what it suspended and resumes only those, so external suspends are not clobbered. (D3)
- **`VirtualQueryEx` over a 64-bit address space is large** → enumeration is lazy and `IEnumerable`; callers filtering by `State == Commit` or a range stop early. (D4)
- **Ambiguous process match** → throw with candidates, never auto-pick. (D6)
- **Bitness of thread `CONTEXT`** → reuse the exact Wow64/native selection already validated in `DllInjector`. (D1)
## Migration Plan
Additive; nothing to migrate. Suggested slices:
1. **Memory-region**`MemoryRegion`, `QueryRegion`, `EnumerateRegions`, `ChangeProtection`/`ProtectionScope`. Smallest, unlocks safe writes immediately.
2. **Thread-control**`RemoteThread`, `ThreadFactory`, `FrozenThread`.
3. **Process discovery**`ApplicationFinder`, `Magic.Open*` overloads.
**Rollback**: remove the new files and the additive `Magic` members; no existing type is modified.
## Open Questions
- **`Freeze` default predicate**: all target threads, or all-but-main? Leaning all-but-none (freeze everything the caller selects; no implicit exclusion cross-process). To confirm during slice 2.
- **TEB read for a thread**: `NtQueryInformationThread(ThreadBasicInformation)` (undocumented-ish but stable) vs. deriving from `GetThreadContext`. Leaning the former to match `ManagedTeb`'s existing shape.
@@ -0,0 +1,34 @@
## Why
The `whitemagic-foundation` review found WhiteMagic is a superset of GreyMagic and current BlackMagic, but **not yet of MemorySharp**. Three genuinely useful capabilities MemorySharp (and, for threads, current BlackMagic's `SThread`) shipped are missing from WhiteMagic:
1. **Thread control** — WhiteMagic calls `SuspendThread`/`ResumeThread` only *internally* inside `DllInjector` thread-hijack. There is no public surface to enumerate a target's threads, suspend/resume them, or **freeze** them for the duration of an edit. Freezing threads is table-stakes for memory editing/trainers (MemorySharp: `ThreadFactory`/`RemoteThread`/`FrozenThread`; BlackMagic: `SThread`).
2. **Memory-region query** — WhiteMagic changes page protection inline inside `Detour` but exposes no `VirtualQueryEx` region walk, no query-region-at-address, and no reusable scoped protection helper (MemorySharp: `RemoteRegion`/`MemoryProtection`). Callers cannot inspect what is mapped, its protection, or safely flip protection around a write.
3. **Process discovery** — no way to open a target by name/window/title; the caller must obtain a PID out of band (MemorySharp: `ApplicationFinder`).
These are all **additive, low-risk** surfaces that sit on the existing `MemoryBase`/`SafeMemoryHandle` and native P/Invoke layer. None requires the deferred managed-loader work.
## What Changes
- **Thread control** (new capability `thread-control`): `RemoteThread` (open by id, suspend/resume, get/set context, get TEB, join), `ThreadFactory` (enumerate the target's threads, get main thread, get-by-id), and `FrozenThread`/`Freeze()` returning an `IDisposable` scope that suspends a set of threads and resumes them on dispose.
- **Memory-region query** (new capability `memory-region`): `MemoryRegion` (a queried `VirtualQueryEx` result — base, size, protection, state, type), region enumeration across the target's address space, query-region-containing-an-address, and a `ChangeProtection(...)` helper returning an `IDisposable` scope that restores the original protection on dispose.
- **Process discovery** (added to existing capability `high-level-api`): an `ApplicationFinder`/`Magic.Open` overloads to attach by process name, window title, or window handle, plus enumeration of candidate processes.
No behavior of existing WhiteMagic types changes; these are new types plus additive `Magic` facade members and new native imports.
## Capabilities
### New Capabilities
- `thread-control`: Enumerate, suspend/resume, freeze (scoped), and read/write the context of a target process's threads.
- `memory-region`: Query and enumerate mapped memory regions (`VirtualQueryEx`) and change page protection through a scoped, auto-restoring helper.
### Modified Capabilities
- `high-level-api`: Adds process discovery — attach a target by name/window/handle and enumerate candidates.
## Impact
- **New code**: `WhiteMagic/Thread/RemoteThread.cs`, `ThreadFactory.cs`, `FrozenThread.cs`; `WhiteMagic/Memory/MemoryRegion.cs`, `MemoryRegionEnumerator` (or methods on `MemoryBase`), `ProtectionScope`; `WhiteMagic/Process/ApplicationFinder.cs`; additive `Magic` facade members.
- **New native imports**: `Thread32First`/`Thread32Next` + `CreateToolhelp32Snapshot` (or `NtQueryInformationProcess` thread walk), `VirtualQueryEx`, `MEMORY_BASIC_INFORMATION`. `OpenThread`/`Suspend`/`Resume`/`Get`/`SetThreadContext` already exist in `NativeMethods`.
- **No dependency change**: pure P/Invoke over the existing core. No FASM, no Iced, no managed loader.
- **No changes** to BlackMagic/MemorySharp/GreyMagic or their tests.
- **Platform**: unchanged — bitness-agnostic (x86 + x64); thread context read honors the target's bitness like the existing hijack path.
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Process discovery
WhiteMagic SHALL attach to a target process discovered by process name, window title, or window handle, and SHALL enumerate candidate processes. An ambiguous match MUST fail deterministically rather than attaching to an arbitrary candidate.
#### Scenario: open by process name
- **WHEN** a target is opened by a unique process name
- **THEN** it MUST attach to that process
#### Scenario: open by window title
- **WHEN** a target is opened by a window title
- **THEN** it MUST attach to the process owning the window with that title
#### Scenario: open by window handle
- **WHEN** a target is opened by a window handle
- **THEN** it MUST attach to the process that owns that window
#### Scenario: ambiguous match is rejected
- **WHEN** more than one process matches the given name or title
- **THEN** the open MUST fail and surface the set of candidate processes rather than picking one
#### Scenario: enumerate candidates
- **WHEN** candidate processes are enumerated
- **THEN** the result MUST list the processes eligible to be opened
@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Query the region containing an address
WhiteMagic SHALL return the mapped memory region that contains a given address, including its base, size, protection, state, and type.
#### Scenario: query a committed address
- **WHEN** the region containing a known committed address is queried
- **THEN** it MUST return a region whose base and size bracket that address and whose protection reflects the page's actual protection
#### Scenario: region membership test
- **WHEN** a region is asked whether it contains an address
- **THEN** it MUST return true only for addresses within `[base, base + size)`
### Requirement: Enumerate mapped regions
WhiteMagic SHALL enumerate the mapped memory regions of the target from the lowest address upward, lazily.
#### Scenario: enumeration walks the address space
- **WHEN** the target's regions are enumerated
- **THEN** the sequence MUST yield contiguous, non-overlapping regions ascending by base address until the end of the queryable address space
#### Scenario: early stop
- **WHEN** a caller stops consuming the enumeration after the first match
- **THEN** enumeration MUST NOT query the entire address space
### Requirement: Scoped protection change
WhiteMagic SHALL change the protection of a region and restore the original protection when the returned scope is disposed.
#### Scenario: protection is applied within the scope
- **WHEN** a protection-change scope is created for a region with a new protection
- **THEN** the region's protection MUST be the requested value for the duration of the scope
#### Scenario: protection is restored on dispose
- **WHEN** the protection-change scope is disposed
- **THEN** the region's protection MUST be restored to the value it had before the scope was created
#### Scenario: restore on exception
- **WHEN** the guarded body throws before the scope is disposed
- **THEN** the original protection MUST still be restored as the scope unwinds
@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Enumerate target threads
WhiteMagic SHALL enumerate the threads belonging to the target process and expose each as a controllable thread handle.
#### Scenario: enumerate returns the target's threads
- **WHEN** the threads of an open target are enumerated
- **THEN** the result MUST contain a handle for each thread owned by the target process and none owned by other processes
#### Scenario: resolve the main thread
- **WHEN** the main thread is requested
- **THEN** it MUST return the earliest-created thread of the target process
#### Scenario: get a thread by id
- **WHEN** a thread is requested by its thread id
- **THEN** it MUST return a handle bound to that thread, or fail deterministically if the id is not a thread of the target
### Requirement: Suspend and resume a thread
WhiteMagic SHALL suspend and resume an individual target thread and report the prior suspend count.
#### Scenario: suspend increments the suspend count
- **WHEN** a running thread is suspended
- **THEN** the thread MUST stop executing and the returned prior suspend count MUST reflect its state before the call
#### Scenario: resume restores execution
- **WHEN** a previously suspended thread is resumed to a zero suspend count
- **THEN** the thread MUST resume executing
### Requirement: Read and write thread context
WhiteMagic SHALL read and write a target thread's register context, selecting the context layout that matches the target's bitness.
#### Scenario: round-trip a register value
- **WHEN** a thread's context is read, a register is modified, and the context is written back
- **THEN** a subsequent read MUST reflect the modified register value
#### Scenario: bitness-correct context
- **WHEN** the target is a 32-bit (WOW64) process
- **THEN** the WOW64 context layout MUST be used, and for a 64-bit target the native layout MUST be used
### Requirement: Scoped thread freeze
WhiteMagic SHALL provide a scoped freeze that suspends a selected set of target threads and resumes exactly those threads when the scope is disposed, including when the guarded body throws.
#### Scenario: freeze suspends selected threads
- **WHEN** a freeze scope is created over a set of threads
- **THEN** each of those threads MUST be suspended for the duration of the scope
#### Scenario: dispose resumes only the frozen threads
- **WHEN** the freeze scope is disposed
- **THEN** exactly the threads it suspended MUST be resumed, and threads suspended by other callers MUST be left unchanged
#### Scenario: exception in the body still resumes
- **WHEN** the guarded body throws before the scope is disposed
- **THEN** the frozen threads MUST still be resumed as the scope unwinds
@@ -0,0 +1,39 @@
## 1. Memory-region query (spec: memory-region)
- [x] 1.1 Add `VirtualQueryEx` `LibraryImport` and `MEMORY_BASIC_INFORMATION` to `Native/` (32/64-bit-correct layout)
- [x] 1.2 Add tests for `MemoryRegion.Contains` (in-range true, boundary `[base, base+size)`, out-of-range false)
- [x] 1.3 Implement `WhiteMagic/Memory/MemoryRegion.cs` (immutable: BaseAddress, Size, Protection, State, Type, AllocationBase, AllocationProtect, Contains) to pass 1.2
- [x] 1.4 Add tests for `MemoryBase.QueryRegion(address)` against a known committed address in the current process
- [x] 1.5 Implement `QueryRegion` to pass 1.4
- [x] 1.6 Add tests for `EnumerateRegions()`: ascending non-overlapping bases, lazy (early stop does not walk whole space — assert via a bounded take)
- [x] 1.7 Implement lazy `EnumerateRegions()` (walk `base+size` until `VirtualQueryEx` fails) to pass 1.6
- [x] 1.8 Add tests for `ChangeProtection`/`ProtectionScope`: protection applied in scope, restored on dispose, restored on exception
- [x] 1.9 Implement `MemoryBase.ChangeProtection` returning `ProtectionScope : IDisposable` to pass 1.8
## 2. Thread control (spec: thread-control)
- [x] 2.1 Add `CreateToolhelp32Snapshot`/`Thread32First`/`Thread32Next` + `THREADENTRY32`, and `GetThreadTimes`, to `Native/` (reuse existing `OpenThread`/`Suspend`/`Resume`/`Get`/`SetThreadContext`)
- [x] 2.2 Add tests for `RemoteThread`: open by id, `Suspend` returns prior count and stops the thread, `Resume` restarts it (self-process worker thread)
- [x] 2.3 Implement `WhiteMagic/Thread/RemoteThread.cs` (OpenThread → `SafeMemoryHandle`, Suspend/Resume, Id) to pass 2.2
- [x] 2.4 Add tests for `GetContext`/`SetContext` round-trip on a suspended self-thread; assert WOW64 vs native selection by target bitness
- [x] 2.5 Implement context read/write reusing `DllInjector`'s bitness selection to pass 2.4
- [x] 2.6 Add tests + implement `RemoteThread.GetTeb()` (via `NtQueryInformationThread`/`ThreadBasicInformation``ManagedTeb`)
- [x] 2.7 Add tests for `ThreadFactory`: `Enumerate()` returns only target threads, `MainThread` = earliest-created, `GetThreadById`
- [x] 2.8 Implement `WhiteMagic/Thread/ThreadFactory.cs` (toolhelp walk filtered by PID; `GetThreadTimes` for main) to pass 2.7
- [x] 2.9 Add tests for `FrozenThread`/`Freeze()`: suspends selected set, dispose resumes exactly those, body-throws still resumes, external suspends untouched
- [x] 2.10 Implement `WhiteMagic/Thread/FrozenThread.cs` + `ThreadFactory.Freeze(...)` (reverse-order resume on dispose) to pass 2.9
## 3. Process discovery (spec: high-level-api)
- [x] 3.1 Add tests for `ApplicationFinder.Enumerate()` and open-by-name against the current process
- [x] 3.2 Implement `WhiteMagic/Process/ApplicationFinder.cs` (`Process.GetProcessesByName`; window-title/handle via existing `WindowFactory` + `GetWindowThreadProcessId`)
- [x] 3.3 Add tests for ambiguous-match rejection (multiple candidates → throws with candidate list) and open-by-window-handle
- [x] 3.4 Add `Magic.Open(string processName)`, `Magic.OpenByWindowTitle(string)`, `Magic.OpenByWindowHandle(IntPtr)` overloads delegating to `ApplicationFinder`; add tests
- [x] 3.5 Wire new surface into the `Magic` facade (expose `Threads` factory and `Regions`/`QueryRegion` accessors) and document freeze-while-executing deadlock caveat in XML docs
## 4. Verification
- [x] 4.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass
- [x] 4.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
- [x] 4.3 Update `docs/memory-library-comparison.md` — mark thread-control, memory-region, and process-discovery gaps closed; note WhiteMagic is now a superset of MemorySharp's public surface (or list any remaining minor helpers deliberately skipped)
- [x] 4.4 `openspec validate add-thread-region-finder --strict` passes
@@ -1,45 +0,0 @@
# non-blocking-execute Specification
## Purpose
TBD - created by archiving change inject-and-assemble. Update Purpose after archive.
## Requirements
### Requirement: InjectAndExecuteEx creates remote thread without waiting
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` injects code at `startAddress` into the opened process, creates a remote thread with `parameter`, and returns the thread handle immediately without waiting for the thread to exit.
#### Scenario: successful non-blocking execution
- **WHEN** a process is open and `InjectAndExecuteEx(addr, param)` is called with a valid code address
- **THEN** a remote thread is created in the target process and a valid `SafeMemoryHandle` is returned
#### Scenario: no process open
- **WHEN** no process is open and `InjectAndExecuteEx(addr, param)` is called
- **THEN** `null` is returned
### Requirement: InjectAndExecuteEx single-parameter overload
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress)` calls `InjectAndExecuteEx(startAddress, IntPtr.Zero)`.
#### Scenario: parameter-less non-blocking execution
- **WHEN** `InjectAndExecuteEx(addr)` is called with a valid address
- **THEN** the thread is created with parameter `IntPtr.Zero`
### Requirement: InjectAndExecuteEx from assembly text
`BlackMagic.InjectAndExecuteEx(string asm)` assembles the text via `AsmBuilder`, allocates remote memory, writes the bytes, calls `InjectAndExecuteEx` on the allocated address, and returns the thread handle.
#### Scenario: execute assembly text non-blocking
- **WHEN** `InjectAndExecuteEx("nop")` is called with a process open
- **THEN** the text is assembled to bytes, written to remote memory, a thread is started, and the handle is returned
#### Scenario: assembly failure
- **WHEN** `InjectAndExecuteEx("invalidinstruction")` is called
- **THEN** `ArgumentException` is thrown with the assembly error
### Requirement: InjectAndExecute from assembly text (blocking convenience)
`BlackMagic.InjectAndExecute(string asm)` assembles the text, allocates remote memory, writes the bytes, calls `Execute` (blocking, 10s timeout), and returns the exit code.
#### Scenario: execute assembly text blocking
- **WHEN** `InjectAndExecute("mov eax, 42\nret")` is called with a process open
- **THEN** the text is assembled, injected, executed, and the thread exit code is returned
-88
View File
@@ -1,88 +0,0 @@
# text-assembler Specification
## Purpose
TBD - created by archiving change inject-and-assemble. Update Purpose after archive.
## Requirements
### Requirement: AsmBuilder assembles x86 instruction text to byte array
`AsmBuilder.Assemble(string source)` parses x86 assembly text and returns the corresponding `byte[]` machine code.
#### Scenario: single instruction
- **WHEN** `AsmBuilder.Assemble("nop")` is called
- **THEN** the result is `[0x90]`
#### Scenario: multiple instructions
- **WHEN** `AsmBuilder.Assemble("pushad\npopad")` is called
- **THEN** the result is `[0x60, 0x61]`
#### Scenario: instruction with immediate operand
- **WHEN** `AsmBuilder.Assemble("mov eax, 1")` is called
- **THEN** the result is `[0xB8, 0x01, 0x00, 0x00, 0x00]`
### Requirement: AsmBuilder supports register operands
Supported registers: `eax`, `ecx`, `edx`, `ebx`, `esp`, `ebp`, `esi`, `edi` (and 8-bit: `al`, `cl`, `dl`, `bl`, `ah`, `ch`, `dh`, `bh`).
#### Scenario: register-to-register move
- **WHEN** `AsmBuilder.Assemble("mov eax, ecx")` is called
- **THEN** the result is `[0x89, 0xC8]` (mov eax, ecx encoding)
#### Scenario: register encoding
- **WHEN** registers are used in instructions
- **THEN** each register maps to its correct 3-bit encoding (eax=0, ecx=1, edx=2, ebx=3, esp=4, ebp=5, esi=6, edi=7)
### Requirement: AsmBuilder supports labels and jumps
Labels are defined with `@name:` and referenced with `jmp @name` or `je @name`. Forward and backward references are resolved in a second pass.
#### Scenario: forward jump
- **WHEN** `AsmBuilder.Assemble("jmp @skip\nnop\n@skip:\nret")` is called
- **THEN** the jump skips exactly over the `nop` (2 bytes) and lands on `ret`
#### Scenario: backward jump
- **WHEN** `AsmBuilder.Assemble("@loop:\nnop\njmp @loop")` is called
- **THEN** the jump targets the earlier label correctly
#### Scenario: multiple labels
- **WHEN** multiple labels are used in one source
- **THEN** each label resolves to its correct byte offset
### Requirement: AsmBuilder SetPassLimit controls iteration
`AsmBuilder.SetPassLimit(int limit)` sets the maximum number of assembly passes for label resolution. Default is 10. If the limit is exceeded before all labels resolve, `InvalidOperationException` is thrown.
#### Scenario: default pass limit
- **WHEN** no `SetPassLimit` is called
- **THEN** the assembler uses 10 passes maximum
#### Scenario: custom pass limit
- **WHEN** `SetPassLimit(20)` is called
- **THEN** the assembler uses 20 passes maximum
#### Scenario: pass limit exceeded
- **WHEN** forward references cannot resolve within the pass limit
- **THEN** `InvalidOperationException` is thrown with label resolution details
### Requirement: AsmBuilder reports clear errors
Unknown instructions, missing operands, and invalid register names produce `ArgumentException` with the line number and offending text.
#### Scenario: unknown instruction
- **WHEN** `AsmBuilder.Assemble("xyzw")` is called
- **THEN** `ArgumentException` is thrown mentioning line 1 and "xyzw"
#### Scenario: missing operand
- **WHEN** `AsmBuilder.Assemble("mov")` is called (no operands)
- **THEN** `ArgumentException` is thrown mentioning missing operand
### Requirement: AsmBuilder supported instruction set
The following x86 instructions are supported:
- **Data movement**: `mov`, `push`, `pop`, `pushad`, `popad`, `lea`
- **Arithmetic**: `add`, `sub`, `inc`, `dec`, `xor`, `and`, `or`, `cmp`, `test`
- **Control flow**: `jmp`, `je`, `jne`, `call`, `ret`, `nop`, `hlt`
#### Scenario: all instructions produce valid bytes
- **WHEN** each supported instruction is assembled individually
- **THEN** it produces the correct x86 machine code encoding