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.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user