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.
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 —
RemoteThreadExecutoralready ownsCreateRemoteThread;RemoteThreadhere wraps existing target threads. - Writing to arbitrary regions found by enumeration beyond what
MemoryBaseread/write already offers. - Kernel-level or hidden-thread discovery — toolhelp/
NtQueryInformationThreadvisibility 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;
Freezedefault 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/Resumereturn prior counts;FrozenThreadtracks exactly what it suspended and resumes only those, so external suspends are not clobbered. (D3) VirtualQueryExover a 64-bit address space is large → enumeration is lazy andIEnumerable; callers filtering byState == Commitor 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 inDllInjector. (D1)
Migration Plan
Additive; nothing to migrate. Suggested slices:
- Memory-region —
MemoryRegion,QueryRegion,EnumerateRegions,ChangeProtection/ProtectionScope. Smallest, unlocks safe writes immediately. - Thread-control —
RemoteThread,ThreadFactory,FrozenThread. - Process discovery —
ApplicationFinder,Magic.Open*overloads.
Rollback: remove the new files and the additive Magic members; no existing type is modified.
Open Questions
Freezedefault 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 fromGetThreadContext. Leaning the former to matchManagedTeb's existing shape.