Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da342d355e | ||
|
|
1169fdb994 | ||
|
|
3e294dc846 | ||
|
|
8f988768fe | ||
|
|
9aef9c21e3 | ||
|
|
f0faca3112 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user