Files
kbe 3e294dc846 Add process discovery helpers and Magic facade accessors
Introduces ApplicationFinder (by name/title/handle), Magic.Open overloads, and Magic.Threads/Regions/QueryRegion accessors. Closes section 3 of add-thread-region-finder and updates tasks/comparison doc.
2026-07-22 16:04:53 +02:00

7.9 KiB

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/ThreadBasicInformationManagedTeb), 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 skewSuspend/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-regionMemoryRegion, QueryRegion, EnumerateRegions, ChangeProtection/ProtectionScope. Smallest, unlocks safe writes immediately.
  2. Thread-controlRemoteThread, ThreadFactory, FrozenThread.
  3. Process discoveryApplicationFinder, 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.