From 3e294dc846090ddbeb44e088dab1b29455f376fa Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Wed, 22 Jul 2026 16:04:53 +0200 Subject: [PATCH] Add process discovery helpers and Magic facade accessors Introduces ApplicationFinder (by name/title/handle), Magic.Open overloads, and Magic.Threads/Regions/QueryRegion accessors. Closes section 3 of add-thread-region-finder and updates tasks/comparison doc. --- WhiteMagic/Magic.cs | 49 +++++- WhiteMagic/Process/ApplicationFinder.cs | 142 ++++++++++++++++++ WhiteMagicTest/MagicFacadeTests.cs | 44 ++++++ .../Process/ApplicationFinderTests.cs | 105 +++++++++++++ docs/memory-library-comparison.md | 2 +- .../add-thread-region-finder/.openspec.yaml | 2 + .../add-thread-region-finder/design.md | 86 +++++++++++ .../add-thread-region-finder/proposal.md | 34 +++++ .../specs/high-level-api/spec.md | 25 +++ .../specs/memory-region/spec.md | 41 +++++ .../specs/thread-control/spec.md | 57 +++++++ .../changes/add-thread-region-finder/tasks.md | 39 +++++ 12 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 WhiteMagic/Process/ApplicationFinder.cs create mode 100644 WhiteMagicTest/MagicFacadeTests.cs create mode 100644 WhiteMagicTest/Process/ApplicationFinderTests.cs create mode 100644 openspec/changes/add-thread-region-finder/.openspec.yaml create mode 100644 openspec/changes/add-thread-region-finder/design.md create mode 100644 openspec/changes/add-thread-region-finder/proposal.md create mode 100644 openspec/changes/add-thread-region-finder/specs/high-level-api/spec.md create mode 100644 openspec/changes/add-thread-region-finder/specs/memory-region/spec.md create mode 100644 openspec/changes/add-thread-region-finder/specs/thread-control/spec.md create mode 100644 openspec/changes/add-thread-region-finder/tasks.md diff --git a/WhiteMagic/Magic.cs b/WhiteMagic/Magic.cs index d3990d3..0081b38 100644 --- a/WhiteMagic/Magic.cs +++ b/WhiteMagic/Magic.cs @@ -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 /// Inline-detour manager (in-process only). public DetourManager DetourManager => Memory.DetourManager; + /// + /// Returns the memory region that contains . + /// + public MemoryRegion QueryRegion(IntPtr address) => Memory.QueryRegion(address); + + /// + /// Enumerates the committed and reserved regions of the target process address space. + /// + public IEnumerable Regions => Memory.EnumerateRegions(); + + /// + /// Factory for discovering and operating on the target process's threads. + /// + public ThreadFactory Threads => new ThreadFactory(Memory); + private Magic(MemoryBase memory) { Memory = memory; @@ -31,11 +51,38 @@ public sealed class Magic : IDisposable } /// Opens an external process for reading, writing, and execution. - public static Magic Open(System.Diagnostics.Process process) + public static Magic Open(Process process) { return new Magic(new ExternalReader(process)); } + /// + /// Opens a target process by its image name. Throws if zero or more than one match. + /// + public static Magic Open(string processName) + { + using Process process = ApplicationFinder.OpenProcess(processName); + return Open(process); + } + + /// + /// Opens the process that owns the top-level window with the specified title. + /// + public static Magic OpenByWindowTitle(string title) + { + using Process process = ApplicationFinder.OpenByWindowTitle(title); + return Open(process); + } + + /// + /// Opens the process that owns the specified window handle. + /// + public static Magic OpenByWindowHandle(IntPtr handle) + { + using Process process = ApplicationFinder.OpenByWindowHandle(handle); + return Open(process); + } + /// Creates an in-process session for the current process. public static Magic OpenInProcess() { diff --git a/WhiteMagic/Process/ApplicationFinder.cs b/WhiteMagic/Process/ApplicationFinder.cs new file mode 100644 index 0000000..d8a97a4 --- /dev/null +++ b/WhiteMagic/Process/ApplicationFinder.cs @@ -0,0 +1,142 @@ +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; + +/// +/// Discovers running processes by name, window title, or window handle so they can be +/// attached through a session. +/// +public static class ApplicationFinder +{ + /// + /// Enumerates processes whose image name matches + /// (extension optional). + /// + public static IEnumerable Enumerate(string processName) + { + ArgumentException.ThrowIfNullOrEmpty(processName); + + return Process.GetProcessesByName(GetNameWithoutExtension(processName)); + } + + /// + /// Returns the unique process whose image name matches . + /// + /// Zero or multiple processes match. + 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) + { + throw new InvalidOperationException( + $"Process name '{processName}' is ambiguous ({candidates.Length} matches): " + + string.Join(", ", candidates.Select(p => $"{p.ProcessName}:{p.Id}"))); + } + + return candidates[0]; + } + + /// + /// Enumerates processes that own a top-level window whose title equals + /// . + /// + public static IEnumerable FindByWindowTitle(string title) + { + ArgumentException.ThrowIfNullOrEmpty(title); + + var seen = new HashSet(); + 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; + } + } + + /// + /// Returns the unique process that owns a top-level window titled . + /// + /// Zero or multiple windows match. + 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]; + } + + /// + /// Returns the process that owns the specified window handle. + /// + 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; + } +} diff --git a/WhiteMagicTest/MagicFacadeTests.cs b/WhiteMagicTest/MagicFacadeTests.cs new file mode 100644 index 0000000..b9b35ef --- /dev/null +++ b/WhiteMagicTest/MagicFacadeTests.cs @@ -0,0 +1,44 @@ +using System.Linq; +using WhiteMagic; +using WhiteMagic.Memory; +using WhiteMagic.Native; +using WhiteMagic.Thread; +using Xunit; + +namespace WhiteMagicTest; + +/// +/// Tests for the convenience accessors exposed directly on . +/// +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); + } +} diff --git a/WhiteMagicTest/Process/ApplicationFinderTests.cs b/WhiteMagicTest/Process/ApplicationFinderTests.cs new file mode 100644 index 0000000..23f9880 --- /dev/null +++ b/WhiteMagicTest/Process/ApplicationFinderTests.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.Linq; +using WhiteMagic; +using WhiteMagic.Native; +using WhiteMagic.ProcessDiscovery; +using Xunit; + +namespace WhiteMagicTest.ProcessDiscovery; + +/// +/// Tests for process discovery via and the matching +/// overloads. +/// +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( + () => 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( + () => 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("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); + } +} diff --git a/docs/memory-library-comparison.md b/docs/memory-library-comparison.md index ff5c748..3e06d4b 100644 --- a/docs/memory-library-comparison.md +++ b/docs/memory-library-comparison.md @@ -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`. diff --git a/openspec/changes/add-thread-region-finder/.openspec.yaml b/openspec/changes/add-thread-region-finder/.openspec.yaml new file mode 100644 index 0000000..7250f8f --- /dev/null +++ b/openspec/changes/add-thread-region-finder/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-22 diff --git a/openspec/changes/add-thread-region-finder/design.md b/openspec/changes/add-thread-region-finder/design.md new file mode 100644 index 0000000..d0ae63c --- /dev/null +++ b/openspec/changes/add-thread-region-finder/design.md @@ -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` (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. diff --git a/openspec/changes/add-thread-region-finder/proposal.md b/openspec/changes/add-thread-region-finder/proposal.md new file mode 100644 index 0000000..4f3d897 --- /dev/null +++ b/openspec/changes/add-thread-region-finder/proposal.md @@ -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. diff --git a/openspec/changes/add-thread-region-finder/specs/high-level-api/spec.md b/openspec/changes/add-thread-region-finder/specs/high-level-api/spec.md new file mode 100644 index 0000000..42ee586 --- /dev/null +++ b/openspec/changes/add-thread-region-finder/specs/high-level-api/spec.md @@ -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 diff --git a/openspec/changes/add-thread-region-finder/specs/memory-region/spec.md b/openspec/changes/add-thread-region-finder/specs/memory-region/spec.md new file mode 100644 index 0000000..89e8e47 --- /dev/null +++ b/openspec/changes/add-thread-region-finder/specs/memory-region/spec.md @@ -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 diff --git a/openspec/changes/add-thread-region-finder/specs/thread-control/spec.md b/openspec/changes/add-thread-region-finder/specs/thread-control/spec.md new file mode 100644 index 0000000..741d6df --- /dev/null +++ b/openspec/changes/add-thread-region-finder/specs/thread-control/spec.md @@ -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 diff --git a/openspec/changes/add-thread-region-finder/tasks.md b/openspec/changes/add-thread-region-finder/tasks.md new file mode 100644 index 0000000..2a9fc68 --- /dev/null +++ b/openspec/changes/add-thread-region-finder/tasks.md @@ -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