Compare commits

10 Commits
Author SHA1 Message Date
kbe 1169fdb994 Address review findings for thread control and process discovery
- Drop the false WOW64 claim from GetContext32/SetContext32 docs and guard them for 32-bit targets only.\n- Make FrozenThread dispose the thread handles it owns; make Freeze(predicate) dispose filtered-out threads.\n- Pass the already-validated handle through GetThreadById instead of opening a second one.\n- Add no-progress guard to MemoryBase.EnumerateRegions.\n- Dispose unmatched Process candidates in ApplicationFinder.OpenProcess.\n- Clean up RemoteThreadExecutor allocation formatting.
2026-07-22 17:18:48 +02:00
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
kbe 8f988768fe Fix thread namespace collision and tighten executable stub allocation
Fully qualifies System.Threading.Thread in DllInjector after introducing the WhiteMagic.Thread namespace, and replaces the broken+too-small near-allocation loop with a symmetric +/-2 GiB search so the x64 call stub always lands within rel32 range.
2026-07-22 16:04:41 +02:00
kbe 9aef9c21e3 Add thread control surfaces
Adds RemoteThread, ThreadFactory (enumeration, main-thread selection, get-by-id), and FrozenThread scoped freeze. Supports suspend/resume, 32/64-bit context round-trip, TEB query, and reverse-order resume on dispose. Closes section 2 of add-thread-region-finder.
2026-07-22 16:04:30 +02:00
kbe f0faca3112 Add memory-region query, enumeration, and scoped protection
Implements VirtualQueryEx + MEMORY_BASIC_INFORMATION wrappers, the immutable MemoryRegion record, the ProtectionScope disposable helper, and MemoryBase.QueryRegion/EnumerateRegions/ChangeProtection. Closes section 1 of add-thread-region-finder.
2026-07-22 16:04:15 +02:00
kbeandClaude Opus 4.8 e8c84f0ba1 Fix IcedAssembler immediate overflow and unwrap invoke exceptions
- TryBind wraps Convert.ChangeType in TryChangeType so an immediate that
  overflows a candidate parameter type returns false (letting a wider overload
  be tried) instead of throwing OverflowException out of assembly. Verified:
  "mov eax, 4294967295" and "mov eax, -2147483649" no longer crash.
- Immediate now carries a boxed long OR ulong; TryParseImmediate parses decimal
  values above long.MaxValue via a ulong fallback, and hex via ulong. Previously
  such literals were rejected at parse time.
- Unwrap TargetInvocationException from method.Invoke so callers see the real
  Iced failure, not the reflection wrapper.

Tests: 231 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:43:48 +02:00
kbeandClaude Opus 4.8 0ddd812829 Address review: forwarder split, CreateDelegate guard, API-set docs
- PeHeaderParser: split export forwarders on the FIRST dot (IndexOf), not the
  last. A forwarder is "Module.Function" and the module name has no extension, so
  the last-dot split misparsed export names that themselves contain a dot.
- PeHeaderParser: document that API-set (api-ms-win-*/ext-ms-*) and ordinal
  forwarders are unsupported and should be resolved via the OS loader.
- RemoteFunction.CreateDelegate now throws InvalidOperationException unless the
  session is in-process; an external target's address is not host-mapped and a
  delegate to it would access-violate on invocation. Tests cover both paths.
- Reword the SSE-payload comment: the 16-byte scratch sits below the saved
  return address, which the aligned store leaves intact (it never overwrote it).

Tests: 223 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:22:29 +02:00
kbeandClaude Opus 4.8 30d5a9a5ec Add optional Iced backend: arbitrary text assembly and full prologue validation
Section 8 of the whitemagic-foundation change.

- 8.1: reference Iced 1.21.0 behind IcedAssembler:IAssembler. The default
  StubAssembler path never touches Iced; only constructing IcedAssembler pulls
  it into a behavioral path.
- 8.2: IcedAssembler.Assemble bridges Intel-syntax text onto Iced's fluent
  Assembler by reflection (Iced ships no text parser). Registers, immediates and
  labels are supported with origin-relative encoding; memory operands throw
  NotSupportedException.
- 8.3: IcedAssembler.GetPrologueLength decodes arbitrary instructions via Iced's
  Decoder. DetourManager.PrologueLengthResolver (new delegate) defaults to the
  built-in PrologueDecoder and is swappable to the Iced resolver, threaded into
  each Detour. This lifts the "partial boundary safety" caveat on the hooking
  slice when Iced is opted in.

Also gitignore test-run TestResults artifacts.

Tests: 221 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:56:31 +02:00
kbeandClaude Opus 4.8 af7e3dc1b9 Close out verification tasks 9.3 and 9.4
- 9.3: BlackMagic reference tests confirmed green (17 passing, 0 failing);
  WhiteMagic is additive and shares no source or build with it.
- 9.4: document the implementation deviations in the comparison doc's
  synthesis section and correct the InProcessReader architecture line
  (RPM-on-self, not direct deref).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:54:28 +02:00
kbeandClaude Opus 4.8 678cb00895 Implement RemoteModule/RemoteFunction and prove x64 ABI at runtime
Task 7.2: export resolution + module/function facade.
- PeHeaderParser.GetExportAddress walks the PE32/PE32+ export directory and
  follows export forwarders (e.g. kernel32!HeapAlloc -> NTDLL.RtlAllocateHeap)
  into other loaded modules; ordinal and unresolvable API-set forwarders throw
  NotSupportedException.
- RemoteModule resolves a module base via Process.Modules (name match tolerant
  of .dll/case); RemoteFunction executes via RemoteThreadExecutor by default,
  exposes Address for pump routing and CreateDelegate<T> for in-process.
- Magic gains a string indexer: magic["user32"]["MessageBoxA"].

Task 3.8: add the missing live-execution ABI test - an SSE callee whose aligned
movaps #GPs unless the stub delivers a 16-byte-aligned stack, combined with a
5th stack argument. Runtime-proves shadow space, alignment, and arg placement.

Tests: 214 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:49:51 +02:00
42 changed files with 3238 additions and 50 deletions
+4
View File
@@ -10,3 +10,7 @@ reference/
# Scratch
*.tmp
*.log
# Test run artifacts
WhiteMagicTest/TestResults/
**/TestResults/
+333
View File
@@ -0,0 +1,333 @@
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using Iced.Intel;
namespace WhiteMagic.Assembly;
/// <summary>
/// Optional <see cref="IAssembler"/> backend that assembles arbitrary x86/x64 mnemonic
/// text to machine code using the Iced library, and provides full instruction-boundary
/// decoding for detour prologue validation.
/// </summary>
/// <remarks>
/// <para>Iced ships a fluent code assembler (typed method calls) and a decoder, but no
/// text parser. This class bridges Intel-syntax text onto Iced's fluent
/// <see cref="Assembler"/> by reflection: each line's mnemonic selects the matching
/// <see cref="Assembler"/> method and its operands are bound to registers, immediates, or
/// labels. Register and immediate operands and label-relative branches are supported;
/// memory operands (<c>[reg+disp]</c>) are not — a caller needing those should emit bytes
/// directly.</para>
/// <para>This backend is entirely optional. Constructing it is the only thing that pulls
/// Iced into a behavioral path; the default <see cref="StubAssembler"/> never references it.</para>
/// </remarks>
public sealed class IcedAssembler : IAssembler
{
private const int DefaultBitness = 64;
private readonly int _bitness;
// Lowercased register name -> boxed AssemblerRegisterNN value, built once from
// Iced's AssemblerRegisters. Enables binding a text operand like "esp" to a typed
// fluent-API register argument.
private static readonly Dictionary<string, object> Registers = BuildRegisterMap();
/// <summary>Creates an assembler for the given bitness (32 or 64).</summary>
/// <param name="bitness">32 for x86, 64 for x64. Defaults to 64.</param>
public IcedAssembler(int bitness = DefaultBitness)
{
if (bitness != 32 && bitness != 64)
throw new ArgumentOutOfRangeException(nameof(bitness), "Bitness must be 32 or 64.");
_bitness = bitness;
}
/// <inheritdoc />
public byte[] Assemble(string assemblyText, ulong origin = 0)
{
ArgumentNullException.ThrowIfNull(assemblyText);
var assembler = new Assembler(_bitness);
List<(string Mnemonic, string[] Operands)> lines = Tokenize(assemblyText, out var labelNames);
// Pre-create every label so a forward branch can reference it before its definition.
var labels = new Dictionary<string, Label>(StringComparer.OrdinalIgnoreCase);
foreach (string name in labelNames)
labels[name] = assembler.CreateLabel(name);
foreach ((string mnemonic, string[] operands) in lines)
{
// A pure label definition (e.g. "loop:") marks the current position.
if (mnemonic.EndsWith(':'))
{
Label label = labels[mnemonic[..^1]];
assembler.Label(ref label);
continue;
}
EmitInstruction(assembler, mnemonic, operands, labels);
}
var writer = new ByteListCodeWriter();
assembler.Assemble(writer, origin);
return writer.Bytes.ToArray();
}
/// <summary>
/// Computes the number of whole prologue-instruction bytes that must be preserved for
/// a splice of <paramref name="requiredBytes"/> bytes, decoding arbitrary instructions
/// (not just the common prologue shapes the built-in decoder covers). Matches the
/// <c>PrologueLengthResolver</c> delegate so it can be assigned to
/// <see cref="WhiteMagic.Hooking.DetourManager.PrologueLengthResolver"/>.
/// </summary>
/// <exception cref="InvalidOperationException">A prologue byte sequence does not decode
/// to a valid instruction.</exception>
public int GetPrologueLength(byte[] prologue, int requiredBytes, bool is64Bit)
{
ArgumentNullException.ThrowIfNull(prologue);
var reader = new ByteArrayCodeReader(prologue);
var decoder = Decoder.Create(is64Bit ? 64 : 32, reader);
int total = 0;
while (total < requiredBytes)
{
decoder.Decode(out Instruction instruction);
if (instruction.IsInvalid)
{
throw new InvalidOperationException(
"The target prologue contains a byte sequence that does not decode to a valid instruction.");
}
total += instruction.Length;
}
return total;
}
private void EmitInstruction(
Assembler assembler,
string mnemonic,
string[] operandText,
Dictionary<string, Label> labels)
{
object?[] operands = new object?[operandText.Length];
for (int i = 0; i < operandText.Length; i++)
operands[i] = ParseOperand(operandText[i], labels);
// Find the fluent Assembler method whose name equals the mnemonic and whose
// parameters bind to the parsed operands.
foreach (MethodInfo method in typeof(Assembler).GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (!string.Equals(method.Name, mnemonic, StringComparison.OrdinalIgnoreCase))
continue;
ParameterInfo[] parameters = method.GetParameters();
if (parameters.Length != operands.Length)
continue;
if (TryBind(parameters, operands, out object?[]? boundArgs))
{
try
{
method.Invoke(assembler, boundArgs);
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
// Surface the real Iced failure rather than the reflection wrapper.
throw ex.InnerException;
}
return;
}
}
throw new NotSupportedException(
$"Cannot assemble '{mnemonic}{(operandText.Length > 0 ? " " + string.Join(", ", operandText) : "")}': " +
"no matching Iced assembler overload for the given operands (registers, immediates and " +
"labels are supported; memory operands are not).");
}
private static bool TryBind(ParameterInfo[] parameters, object?[] operands, out object?[]? boundArgs)
{
var args = new object?[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
Type paramType = parameters[i].ParameterType;
object? operand = operands[i];
switch (operand)
{
case Immediate imm when IsNumeric(paramType):
// An immediate that overflows this parameter's type means this overload
// is the wrong width; return false so a wider overload can be tried
// instead of crashing the whole assembly.
if (!TryChangeType(imm.Value, paramType, out object? converted))
{
boundArgs = null;
return false;
}
args[i] = converted;
break;
case not null when paramType.IsInstanceOfType(operand):
args[i] = operand;
break;
default:
boundArgs = null;
return false;
}
}
boundArgs = args;
return true;
}
private static object ParseOperand(string text, Dictionary<string, Label> labels)
{
string token = text.Trim();
if (Registers.TryGetValue(token, out object? register))
return register;
if (labels.TryGetValue(token, out Label label))
return label;
if (TryParseImmediate(token, out object? value))
return new Immediate(value!);
throw new NotSupportedException(
$"Unrecognized operand '{token}' (expected a register, an immediate, or a label).");
}
// Parses an immediate as the narrowest of long/ulong that holds it, boxed. Storing the
// widest representation lets TryChangeType later narrow it to whatever integer parameter
// the chosen overload expects — and reject (rather than crash on) values that do not fit.
private static bool TryParseImmediate(string token, out object? value)
{
value = null;
bool negative = token.StartsWith('-');
string body = negative ? token[1..] : token;
if (body.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
if (!ulong.TryParse(body[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong hex))
return false;
value = negative ? -(long)hex : hex;
return true;
}
if (negative)
{
if (!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long signed))
return false;
value = signed;
return true;
}
// Non-negative decimal: prefer long, fall back to ulong for values above long.MaxValue.
if (long.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out long asLong))
value = asLong;
else if (ulong.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong asULong))
value = asULong;
else
return false;
return true;
}
private static bool TryChangeType(object value, Type targetType, out object? result)
{
try
{
result = Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
return true;
}
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
{
result = null;
return false;
}
}
private static bool IsNumeric(Type type) => Type.GetTypeCode(type) is
TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or
TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64;
private static List<(string Mnemonic, string[] Operands)> Tokenize(string text, out List<string> labelNames)
{
var result = new List<(string, string[])>();
labelNames = new List<string>();
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine;
int comment = line.IndexOf(';');
if (comment >= 0)
line = line[..comment];
line = line.Trim();
if (line.Length == 0)
continue;
// A "name:" prefix is a label definition; keep any instruction that follows it
// on the same line as a separate entry.
int colon = line.IndexOf(':');
if (colon >= 0)
{
string labelName = line[..colon].Trim();
labelNames.Add(labelName);
result.Add((labelName + ":", Array.Empty<string>()));
line = line[(colon + 1)..].Trim();
if (line.Length == 0)
continue;
}
int space = line.IndexOfAny([' ', '\t']);
if (space < 0)
{
result.Add((line, Array.Empty<string>()));
continue;
}
string mnemonic = line[..space];
string[] operands = line[(space + 1)..]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
result.Add((mnemonic, operands));
}
return result;
}
private static Dictionary<string, object> BuildRegisterMap()
{
var map = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
foreach (FieldInfo field in typeof(AssemblerRegisters).GetFields(BindingFlags.Public | BindingFlags.Static))
{
object? value = field.GetValue(null);
if (value is not null)
map[field.Name] = value;
}
return map;
}
// A parsed immediate (boxed long or ulong), distinguished from register/label operands
// so binding can narrow it to whichever integer parameter type the chosen overload
// expects — or reject it when it does not fit.
private readonly record struct Immediate(object Value);
private sealed class ByteListCodeWriter : CodeWriter
{
public List<byte> Bytes { get; } = new();
public override void WriteByte(byte value) => Bytes.Add(value);
}
}
+145
View File
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic.Native;
namespace WhiteMagic.Discovery;
@@ -103,6 +104,150 @@ public sealed class PeHeaderParser
}
}
/// <summary>
/// Resolves an exported function's absolute address by name, following export
/// forwarders (e.g. <c>kernel32!HeapAlloc</c> → <c>NTDLL.RtlAllocateHeap</c>) into
/// other modules loaded in the same target process.
/// </summary>
/// <param name="functionName">The exported symbol name (case-sensitive, as stored
/// in the export name table).</param>
/// <returns>The absolute address of the export in the target process.</returns>
/// <remarks>
/// Forwarders are resolved by locating the target module in the process's loaded-module
/// list. API-set forwarders (virtual <c>api-ms-win-*</c> / <c>ext-ms-*</c> names) are NOT
/// supported: those are not real loaded modules, so resolution through the module list is
/// impossible without parsing the API-set schema — such a forwarder throws
/// <see cref="NotSupportedException"/>. On modern Windows many system-DLL exports forward
/// through API sets; resolve those via the OS loader (<c>GetProcAddress</c>) instead.
/// Ordinal forwarders (<c>Module.#N</c>) are likewise unsupported.
/// </remarks>
/// <exception cref="InvalidOperationException">The export is not present.</exception>
/// <exception cref="NotSupportedException">The export forwards to an ordinal or to a
/// module (such as an API set) that is not resolvable from the target's module list.</exception>
/// <exception cref="InvalidDataException">The PE export data is malformed.</exception>
public IntPtr GetExportAddress(string functionName)
{
ArgumentException.ThrowIfNullOrEmpty(functionName);
return ResolveExport(functionName, 0);
}
// Maximum forwarder hops before giving up, to bound pathological chains.
private const int MaxForwarderDepth = 16;
private IntPtr ResolveExport(string functionName, int depth)
{
if (depth > MaxForwarderDepth)
throw new InvalidDataException($"Export forwarder chain for '{functionName}' is too deep.");
var (optionalHeader, _) = ParseOptionalHeader();
if (optionalHeader is null || optionalHeader.Length < 2)
throw new InvalidDataException("Optional header unavailable.");
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit). Data directories start at a
// different offset in each: 96 for PE32, 112 for PE32+. The export table is
// directory index 0, so its 8-byte entry sits at that offset.
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
bool pe32Plus = magic == 0x20b;
int exportDirOffset = pe32Plus ? 112 : 96;
if (optionalHeader.Length < exportDirOffset + 8)
throw new InvalidDataException("Optional header does not contain the export data directory.");
uint exportRva = BitConverter.ToUInt32(optionalHeader, exportDirOffset);
uint exportSize = BitConverter.ToUInt32(optionalHeader, exportDirOffset + 4);
if (exportRva == 0 || exportSize == 0)
throw new InvalidOperationException("Module has no export table.");
// IMAGE_EXPORT_DIRECTORY is 40 bytes.
byte[] dir = _memory.ReadBytes(_baseAddress + (nint)exportRva, 40);
if (dir.Length < 40)
throw new InvalidDataException("Failed to read the export directory.");
uint numberOfFunctions = BitConverter.ToUInt32(dir, 20);
uint numberOfNames = BitConverter.ToUInt32(dir, 24);
uint addressOfFunctions = BitConverter.ToUInt32(dir, 28);
uint addressOfNames = BitConverter.ToUInt32(dir, 32);
uint addressOfNameOrdinals = BitConverter.ToUInt32(dir, 36);
// Guard against corrupt counts before allocating arrays sized from them.
if (numberOfNames > 0x10000 || numberOfFunctions > 0x10000)
throw new InvalidDataException("Export table entry count is out of range.");
if (numberOfNames == 0)
throw new InvalidOperationException($"Export '{functionName}' not found (module exports no names).");
byte[] nameRvas = _memory.ReadBytes(_baseAddress + (nint)addressOfNames, checked((int)(numberOfNames * 4)));
byte[] nameOrdinals = _memory.ReadBytes(_baseAddress + (nint)addressOfNameOrdinals, checked((int)(numberOfNames * 2)));
if (nameRvas.Length < numberOfNames * 4 || nameOrdinals.Length < numberOfNames * 2)
throw new InvalidDataException("Failed to read the export name tables.");
int nameIndex = -1;
for (int i = 0; i < numberOfNames; i++)
{
uint nameRva = BitConverter.ToUInt32(nameRvas, i * 4);
string name = _memory.ReadString(_baseAddress + (nint)nameRva, Encoding.ASCII, 512);
if (string.Equals(name, functionName, StringComparison.Ordinal))
{
nameIndex = i;
break;
}
}
if (nameIndex < 0)
throw new InvalidOperationException($"Export '{functionName}' not found in module.");
ushort ordinal = BitConverter.ToUInt16(nameOrdinals, nameIndex * 2);
if (ordinal >= numberOfFunctions)
throw new InvalidDataException("Export name ordinal is out of range.");
byte[] funcRvaBytes = _memory.ReadBytes(
_baseAddress + (nint)(addressOfFunctions + (uint)ordinal * 4u), 4);
if (funcRvaBytes.Length < 4)
throw new InvalidDataException("Failed to read the export address table entry.");
uint funcRva = BitConverter.ToUInt32(funcRvaBytes, 0);
if (funcRva == 0)
throw new InvalidOperationException($"Export '{functionName}' has no address.");
// A function RVA that lands inside the export directory region is not code but a
// null-terminated "Module.Function" forwarder string.
if (funcRva >= exportRva && funcRva < exportRva + exportSize)
{
string forwarder = _memory.ReadString(_baseAddress + (nint)funcRva, Encoding.ASCII, 512);
return ResolveForwarder(forwarder, depth);
}
return _baseAddress + (nint)funcRva;
}
private IntPtr ResolveForwarder(string forwarder, int depth)
{
// A forwarder is "Module.Function"; the module name carries no extension, so the
// FIRST dot is the boundary. Splitting on the last dot would misparse export names
// that themselves contain a dot (e.g. some C++/managed exports).
int dot = forwarder.IndexOf('.');
if (dot <= 0 || dot >= forwarder.Length - 1)
throw new InvalidDataException($"Malformed export forwarder string '{forwarder}'.");
string moduleName = forwarder[..dot];
string target = forwarder[(dot + 1)..];
if (target.StartsWith('#'))
{
throw new NotSupportedException(
$"Ordinal export forwarders are not supported (forwarder '{forwarder}').");
}
IntPtr targetBase = RemoteModule.ResolveBase(_memory.ProcessId, moduleName);
if (targetBase == IntPtr.Zero)
{
throw new NotSupportedException(
$"Export forwarder target module '{moduleName}' is not loaded in the target " +
$"process, or is an unresolvable API set (forwarder '{forwarder}').");
}
return new PeHeaderParser(_memory, targetBase).ResolveExport(target, depth + 1);
}
/// <summary>
/// Parses the DOS header, PE signature, and optional header.
/// </summary>
+22 -25
View File
@@ -472,27 +472,22 @@ 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)
{
candidate = aligned;
}
else if ((i & 1) == 1)
{
candidate = aligned + (nuint)i * AllocationGranularity;
}
else
{
nuint offset = (nuint)i * AllocationGranularity;
if (offset > aligned)
{
continue;
}
long signedOffset = delta * (long)AllocationGranularity;
candidate = aligned - offset;
}
// Try above, then below the target. Keep the original address as the first attempt.
for (int sign = 0; sign < 2; sign++)
{
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,
@@ -503,15 +498,17 @@ public sealed class RemoteThreadExecutor
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);
}
}
}
return NativeMethods.VirtualAllocEx(
handle,
IntPtr.Zero,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
return IntPtr.Zero;
}
}
+10 -2
View File
@@ -16,6 +16,7 @@ namespace WhiteMagic.Hooking;
public sealed class Detour : IDisposable
{
private readonly MemoryBase _memory;
private readonly PrologueLengthResolver _prologueLength;
/// <summary>The unique name of this detour.</summary>
public string Name { get; }
@@ -43,14 +44,21 @@ public sealed class Detour : IDisposable
/// <summary><see langword="true"/> while the detour bytes are live at <see cref="Target"/>.</summary>
public bool IsApplied { get; private set; }
internal Detour(MemoryBase memory, string name, IntPtr target, Delegate hook)
internal Detour(
MemoryBase memory,
string name,
IntPtr target,
Delegate hook,
PrologueLengthResolver prologueLength)
{
ArgumentNullException.ThrowIfNull(hook);
ArgumentNullException.ThrowIfNull(prologueLength);
_memory = memory;
Name = name;
Target = target;
Hook = hook;
_prologueLength = prologueLength;
}
/// <summary>
@@ -83,7 +91,7 @@ public sealed class Detour : IDisposable
"Could not read enough bytes from the target function to install a detour.");
}
int preserveLength = PrologueDecoder.GetWholeInstructionLength(prologue, detourLength, _memory.Is64Bit);
int preserveLength = _prologueLength(prologue, detourLength, _memory.Is64Bit);
OverwrittenBytes = new byte[preserveLength];
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
+10 -1
View File
@@ -19,6 +19,15 @@ public sealed class DetourManager
_memory = memory;
}
/// <summary>
/// Resolves how many whole prologue-instruction bytes a splice must preserve. Defaults
/// to the built-in <see cref="PrologueDecoder"/>, which covers only the common prologue
/// shapes and rejects anything else. Assign <c>new IcedAssembler().GetPrologueLength</c>
/// to validate arbitrary prologues via the optional Iced disassembler.
/// </summary>
public PrologueLengthResolver PrologueLengthResolver { get; set; } =
PrologueDecoder.GetWholeInstructionLength;
/// <summary>
/// Creates a new detour and registers it with the manager.
/// The <paramref name="hook"/> delegate's type must match the native signature of
@@ -26,7 +35,7 @@ public sealed class DetourManager
/// </summary>
public Detour Create(string name, IntPtr target, Delegate hook)
{
var detour = new Detour(_memory, name, target, hook);
var detour = new Detour(_memory, name, target, hook, PrologueLengthResolver);
_detours[name] = detour;
return detour;
}
+8
View File
@@ -2,6 +2,14 @@ using System;
namespace WhiteMagic.Hooking;
/// <summary>
/// Resolves how many whole prologue-instruction bytes must be preserved to splice
/// <paramref name="requiredBytes"/> bytes at a target. The built-in
/// <see cref="PrologueDecoder.GetWholeInstructionLength"/> satisfies this delegate, as
/// does <c>IcedAssembler.GetPrologueLength</c> for full instruction coverage.
/// </summary>
public delegate int PrologueLengthResolver(byte[] prologue, int requiredBytes, bool is64Bit);
/// <summary>
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
/// The set is intentionally small: any opcode outside the covered set is rejected
+1 -1
View File
@@ -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;
+52 -1
View File
@@ -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()
{
@@ -54,6 +101,10 @@ public sealed class Magic : IDisposable
/// <summary>Returns a <see cref="RemotePointer"/> at <paramref name="address"/>.</summary>
public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
/// <summary>Returns the loaded <see cref="RemoteModule"/> named <paramref name="moduleName"/>
/// (e.g. <c>magic["user32"]["MessageBoxA"]</c>).</summary>
public RemoteModule this[string moduleName] => new RemoteModule(this, moduleName);
/// <inheritdoc />
public void Dispose()
{
+75
View File
@@ -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;
}
}
+58
View File
@@ -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 _);
}
}
}
+58
View File
@@ -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 />
+58
View File
@@ -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,
}
+42
View File
@@ -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);
}
+58
View File
@@ -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;
}
+149
View File
@@ -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;
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Threading.Tasks;
using WhiteMagic.Assembly;
using WhiteMagic.Execution;
namespace WhiteMagic;
/// <summary>
/// An exported function resolved in the target process, obtained via
/// <c>magic["module"]["function"]</c>. Executes through one of the session's execution
/// strategies.
/// </summary>
/// <remarks>
/// The default <see cref="Execute{T}"/> path uses the always-available
/// <see cref="RemoteThreadExecutor"/> (<c>CreateRemoteThread</c>), which is safe for
/// thread-agnostic exports. For a call that touches single-threaded target state, obtain
/// the <see cref="Address"/> and route it through a <see cref="MainThreadPump"/>, or use
/// <see cref="CreateDelegate{TDelegate}"/> when running in-process.
/// </remarks>
public sealed class RemoteFunction
{
private readonly Magic _magic;
/// <summary>The export name this function was resolved from.</summary>
public string Name { get; }
/// <summary>The absolute address of the function in the target process.</summary>
public IntPtr Address { get; }
internal RemoteFunction(Magic magic, string name, IntPtr address)
{
_magic = magic;
Name = name;
Address = address;
}
/// <summary>
/// Calls the function via a remote thread and returns its result cast to
/// <typeparamref name="T"/>.
/// </summary>
/// <param name="convention">The calling convention (ignored on x64 targets).</param>
/// <param name="args">Arguments to pass; primitives, pointers, enums, strings and
/// structs are supported.</param>
public T Execute<T>(CallConvention convention, params object?[] args)
{
return _magic.RemoteThread.Execute<T>(Address, convention, args);
}
/// <summary>Asynchronous variant of <see cref="Execute{T}"/>.</summary>
public Task<T> ExecuteAsync<T>(CallConvention convention, params object?[] args)
{
return _magic.RemoteThread.ExecuteAsync<T>(Address, convention, args);
}
/// <summary>
/// Creates a managed delegate bound to this function for the in-process scenario.
/// </summary>
/// <exception cref="InvalidOperationException">The session is not in-process. The
/// resolved <see cref="Address"/> lives in the target process; a delegate to it would
/// access-violate when invoked from the host, so this is rejected for external sessions.
/// Use <see cref="Execute{T}"/> (remote thread) for external targets.</exception>
public TDelegate CreateDelegate<TDelegate>() where TDelegate : Delegate
{
if (_magic.Memory is not InProcessReader)
{
throw new InvalidOperationException(
"CreateDelegate is only valid for an in-process session (Magic.OpenInProcess). " +
"The function address is not mapped into the host process for an external target; " +
"use Execute<T> to call it via a remote thread.");
}
return new InProcessInvoker(_magic.Memory).CreateFunction<TDelegate>(Address);
}
}
+101
View File
@@ -0,0 +1,101 @@
using WhiteMagic.Discovery;
using Process = System.Diagnostics.Process;
using ProcessModule = System.Diagnostics.ProcessModule;
namespace WhiteMagic;
/// <summary>
/// A module (loaded DLL/EXE image) in the target process, obtained by indexing the
/// facade with a module name (e.g. <c>magic["user32"]</c>). Exposes the module's base
/// address and resolves exported functions by name.
/// </summary>
public sealed class RemoteModule
{
private readonly Magic _magic;
/// <summary>The module's file name as reported by the OS (e.g. <c>user32.dll</c>).</summary>
public string Name { get; }
/// <summary>The module's load address in the target process.</summary>
public IntPtr BaseAddress { get; }
internal RemoteModule(Magic magic, string moduleName)
{
ArgumentNullException.ThrowIfNull(magic);
ArgumentException.ThrowIfNullOrEmpty(moduleName);
_magic = magic;
(string name, IntPtr baseAddress) = FindModule(magic.Memory.ProcessId, moduleName);
Name = name;
BaseAddress = baseAddress;
}
/// <summary>
/// Resolves an exported function by name and returns a <see cref="RemoteFunction"/>
/// bound to its address. Export forwarders are followed.
/// </summary>
public RemoteFunction this[string functionName]
{
get
{
IntPtr address = GetExportAddress(functionName);
return new RemoteFunction(_magic, functionName, address);
}
}
/// <summary>Resolves the absolute address of an exported function by name.</summary>
public IntPtr GetExportAddress(string functionName)
{
ArgumentException.ThrowIfNullOrEmpty(functionName);
var parser = new PeHeaderParser(_magic.Memory, BaseAddress);
return parser.GetExportAddress(functionName);
}
private static (string Name, IntPtr BaseAddress) FindModule(int processId, string moduleName)
{
using Process process = Process.GetProcessById(processId);
foreach (ProcessModule module in process.Modules)
{
if (NameMatches(module.ModuleName, moduleName))
return (module.ModuleName, module.BaseAddress);
}
throw new DllNotFoundException(
$"Module '{moduleName}' is not loaded in process {processId}.");
}
/// <summary>
/// Resolves a module's base address by name within a target process, returning
/// <see cref="IntPtr.Zero"/> if it is not loaded. Used by export-forwarder resolution.
/// </summary>
internal static IntPtr ResolveBase(int processId, string moduleName)
{
using Process process = Process.GetProcessById(processId);
foreach (ProcessModule module in process.Modules)
{
if (NameMatches(module.ModuleName, moduleName))
return module.BaseAddress;
}
return IntPtr.Zero;
}
/// <summary>
/// Matches a loaded module's file name against a requested name, tolerating a missing
/// or present <c>.dll</c> extension and ignoring case (e.g. <c>KERNEL32</c> matches
/// <c>kernel32.dll</c>).
/// </summary>
private static bool NameMatches(string actual, string requested)
{
if (string.Equals(actual, requested, StringComparison.OrdinalIgnoreCase))
return true;
string actualNoExt = Path.GetFileNameWithoutExtension(actual);
string requestedNoExt = requested.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
? requested[..^4]
: requested;
return string.Equals(actualNoExt, requestedNoExt, StringComparison.OrdinalIgnoreCase);
}
}
+52
View File
@@ -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();
}
}
}
+194
View File
@@ -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();
}
}
}
+264
View File
@@ -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;
}
}
+9
View File
@@ -13,4 +13,13 @@
<InternalsVisibleTo Include="WhiteMagicTest" />
</ItemGroup>
<!--
Optional Iced backend (task 8.x). Isolated behind IAssembler: the default
StubAssembler path never touches Iced, keeping the common configuration free of any
behavioral dependency on it. Only callers that construct IcedAssembler pull it in.
-->
<ItemGroup>
<PackageReference Include="Iced" Version="1.21.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,159 @@
using System.Linq;
using Iced.Intel;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Hooking;
namespace WhiteMagicTest.Assembly;
/// <summary>
/// Tests for the optional <see cref="IcedAssembler"/> backend (tasks 8.18.3): arbitrary
/// text assembly, origin-relative encoding, and full prologue instruction decoding.
/// </summary>
public class IcedAssemblerTests
{
private static Instruction[] Disassemble(byte[] code, int bitness, ulong origin)
{
var decoder = Decoder.Create(bitness, new ByteArrayCodeReader(code));
decoder.IP = origin;
var result = new List<Instruction>();
ulong end = origin + (ulong)code.Length;
while (decoder.IP < end)
result.Add(decoder.Decode());
return result.ToArray();
}
[Fact]
public void Assemble_emits_single_instruction()
{
var assembler = new IcedAssembler(64);
byte[] code = assembler.Assemble("ret");
Assert.Equal(new byte[] { 0xC3 }, code);
}
[Fact]
public void Assemble_emits_multiple_instructions_with_operands()
{
var assembler = new IcedAssembler(32);
// The scenario from the managed-assembler spec.
byte[] code = assembler.Assemble("push 0\nadd esp, 4\nret");
Assert.NotEmpty(code);
Instruction[] instructions = Disassemble(code, 32, 0);
Assert.Equal(3, instructions.Length);
Assert.Equal(Mnemonic.Push, instructions[0].Mnemonic);
Assert.Equal(Mnemonic.Add, instructions[1].Mnemonic);
Assert.Equal(Register.ESP, instructions[1].Op0Register);
Assert.Equal(4UL, instructions[1].GetImmediate(1));
Assert.Equal(Mnemonic.Ret, instructions[2].Mnemonic);
}
[Fact]
public void Assemble_supports_comments_and_blank_lines()
{
var assembler = new IcedAssembler(64);
byte[] code = assembler.Assemble(" ; prologue\n\nnop ; a comment\nret\n");
Instruction[] instructions = Disassemble(code, 64, 0);
Assert.Equal(2, instructions.Length);
Assert.Equal(Mnemonic.Nop, instructions[0].Mnemonic);
Assert.Equal(Mnemonic.Ret, instructions[1].Mnemonic);
}
[Fact]
public void Assemble_encodes_label_branch_relative_to_origin()
{
var assembler = new IcedAssembler(64);
const ulong origin = 0x1_4000_1000UL;
// jmp forward over a nop to a label; the near-branch target must be resolved
// against the supplied origin, not zero.
byte[] code = assembler.Assemble("jmp done\nnop\ndone:\nret", origin);
Instruction[] instructions = Disassemble(code, 64, origin);
Instruction jmp = instructions[0];
Assert.Equal(Mnemonic.Jmp, jmp.Mnemonic);
// Target = origin + len(jmp) + len(nop): the address of the 'done: ret'.
ulong expected = origin + (ulong)jmp.Length + 1;
Assert.Equal(expected, jmp.NearBranchTarget);
}
[Theory]
[InlineData("mov eax, 4294967295")] // 0xFFFFFFFF — needs the uint overload, not int
[InlineData("mov eax, 0xFFFFFFFF")] // same value, hex form
[InlineData("mov rax, 18446744073709551615")] // ulong.MaxValue — decimal above long.MaxValue
public void Assemble_binds_wide_unsigned_immediates(string source)
{
var assembler = new IcedAssembler(64);
byte[] code = assembler.Assemble(source);
Assert.NotEmpty(code);
Instruction[] instructions = Disassemble(code, 64, 0);
Assert.Single(instructions);
Assert.Equal(Mnemonic.Mov, instructions[0].Mnemonic);
}
[Fact]
public void Assemble_rejects_immediate_that_fits_no_overload_without_crashing()
{
var assembler = new IcedAssembler(64);
// -2147483649 is below int.MinValue and eax has no wider signed overload; must be a
// clean NotSupportedException, not an OverflowException escaping from ChangeType.
Assert.Throws<NotSupportedException>(() => assembler.Assemble("mov eax, -2147483649"));
}
[Fact]
public void Assemble_throws_on_unsupported_operand()
{
var assembler = new IcedAssembler(64);
Assert.Throws<NotSupportedException>(() => assembler.Assemble("mov rax, [rbx]"));
}
[Fact]
public void GetPrologueLength_decodes_prologue_the_builtin_decoder_rejects()
{
// 48 8B C1 = mov rax, rcx — a register-to-register mov the built-in PrologueDecoder
// does not cover (it only recognizes the 8B FF / 8B EC forms).
// Followed by push rbp; mov rbp,rsp; sub rsp,0x20; mov rax,rcx to exceed 14 bytes.
byte[] prologue =
[
0x48, 0x8B, 0xC1, // mov rax, rcx (3)
0x55, // push rbp (1)
0x48, 0x8B, 0xEC, // mov rbp, rsp (3)
0x48, 0x83, 0xEC, 0x20, // sub rsp, 0x20 (4)
0x48, 0x8B, 0xC1 // mov rax, rcx (3) -> total 14
];
// The built-in decoder refuses the very first instruction.
Assert.Throws<InvalidOperationException>(() =>
PrologueDecoder.GetWholeInstructionLength(prologue, 14, is64Bit: true));
// The Iced backend decodes it and returns the whole-instruction length covering
// at least the 14 bytes a detour needs.
var iced = new IcedAssembler();
int length = iced.GetPrologueLength(prologue, 14, is64Bit: true);
Assert.Equal(14, length);
}
[Fact]
public void DetourManager_prologue_resolver_defaults_to_builtin_and_is_replaceable()
{
using var reader = new InProcessReader();
var manager = new DetourManager(reader);
// Default resolver is the built-in decoder.
Assert.Throws<InvalidOperationException>(() =>
manager.PrologueLengthResolver(new byte[] { 0x48, 0x8B, 0xC1, 0x90, 0x90 }, 4, true));
// Swapping in the Iced resolver validates the same bytes.
manager.PrologueLengthResolver = new IcedAssembler().GetPrologueLength;
int length = manager.PrologueLengthResolver(new byte[] { 0x48, 0x8B, 0xC1, 0x90, 0x90 }, 4, true);
Assert.True(length >= 4);
}
}
@@ -35,6 +35,33 @@ public sealed class RemoteThreadExecutorTests
0xC3
];
// Five-arg callee that also executes an alignment-sensitive SSE instruction, proving
// the stub delivers a 16-byte-aligned stack the CPU actually accepts (movaps #GPs on a
// misaligned address) alongside correct register+stack argument placement.
// sub rsp, 24 ; entry rsp ≡ 8 (mod 16) -> rsp ≡ 0 (16-aligned), giving a
// ; 16-byte aligned scratch at [rsp..rsp+16) below the saved
// ; return address ([rsp+24]) so the store leaves it intact
// movaps [rsp], xmm0 ; aligned 16-byte store — faults unless rsp is 16-aligned
// add rsp, 24 ; restore
// mov eax, ecx
// add eax, edx
// add eax, r8d
// add eax, r9d
// add eax, [rsp+0x28] ; 5th arg above the shadow space
// ret
private static readonly byte[] SseAlignedSumPayload =
[
0x48, 0x83, 0xEC, 0x18,
0x0F, 0x29, 0x04, 0x24,
0x48, 0x83, 0xC4, 0x18,
0x89, 0xC8,
0x01, 0xD0,
0x44, 0x01, 0xC0,
0x44, 0x01, 0xC8,
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
0xC3
];
// xor eax, eax
// cmp byte ptr [rcx+rax], 0
// je done
@@ -110,6 +137,21 @@ public sealed class RemoteThreadExecutorTests
Assert.Equal(0, misalign);
}
[Fact]
public void Execute_runs_sse_callee_with_five_args()
{
if (!Environment.Is64BitProcess)
{
return;
}
// Correct result (150) requires BOTH the 5th arg reaching [rsp+0x28] AND the
// aligned movaps not faulting. A broken frame size/alignment either mis-sums or
// #GPs in the callee.
int result = RunPayload(SseAlignedSumPayload, CallConvention.Cdecl, 10, 20, 30, 40, 50);
Assert.Equal(150, result);
}
[Fact]
public void Execute_marshals_string_as_utf8_pointer()
{
+3 -2
View File
@@ -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)
+44
View File
@@ -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);
}
}
+159
View File
@@ -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);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Diagnostics;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="RemoteModule"/> / <see cref="RemoteFunction"/> resolution and
/// execution through the <see cref="Magic"/> facade (task 7.2).
/// </summary>
public class ModuleFunctionTests
{
// Ensure a module is loaded in this process before resolving it.
private static IntPtr Load(string module)
{
IntPtr handle = NativeMethods.LoadLibrary(module);
Assert.NotEqual(IntPtr.Zero, handle);
return handle;
}
[Fact]
public void Module_indexer_resolves_base_address()
{
IntPtr handle = Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
RemoteModule module = magic["kernel32"];
// The module handle returned by LoadLibrary is the module's base address.
Assert.Equal(handle, module.BaseAddress);
Assert.Equal("KERNEL32.DLL", module.Name, ignoreCase: true);
}
[Fact]
public void Function_indexer_resolves_direct_export()
{
IntPtr handle = Load("user32.dll");
IntPtr expected = NativeMethods.GetProcAddress(handle, "MessageBoxA");
Assert.NotEqual(IntPtr.Zero, expected);
using var magic = Magic.OpenInProcess();
RemoteFunction fn = magic["user32"]["MessageBoxA"];
Assert.Equal(expected, fn.Address);
Assert.Equal("MessageBoxA", fn.Name);
}
[Fact]
public void Function_indexer_follows_export_forwarder()
{
// kernel32!HeapAlloc is a classic forwarder to NTDLL.RtlAllocateHeap. Whatever the
// OS loader resolves it to, our parser must reach the same final address.
IntPtr handle = Load("kernel32.dll");
IntPtr expected = NativeMethods.GetProcAddress(handle, "HeapAlloc");
Assert.NotEqual(IntPtr.Zero, expected);
using var magic = Magic.OpenInProcess();
RemoteFunction fn = magic["kernel32"]["HeapAlloc"];
Assert.Equal(expected, fn.Address);
}
[Fact]
public void Module_indexer_throws_for_unloaded_module()
{
using var magic = Magic.OpenInProcess();
Assert.Throws<DllNotFoundException>(() => magic["definitely-not-loaded-xyz.dll"]);
}
[Fact]
public void Function_indexer_throws_for_unknown_export()
{
Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
Assert.Throws<InvalidOperationException>(() => magic["kernel32"]["NoSuchExport_ZZZ"]);
}
private delegate uint GetCurrentProcessIdDelegate();
[Fact]
public void CreateDelegate_throws_for_external_session()
{
Load("kernel32.dll");
// External reader (even to self): the address is not treated as host-mapped, so a
// delegate to it is rejected rather than handed back to AV on invocation.
using var magic = Magic.Open(Process.GetCurrentProcess());
RemoteFunction fn = magic["kernel32"]["GetCurrentProcessId"];
Assert.Throws<InvalidOperationException>(() => fn.CreateDelegate<GetCurrentProcessIdDelegate>());
}
[Fact]
public void CreateDelegate_invokes_function_in_process()
{
Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
var getPid = magic["kernel32"]["GetCurrentProcessId"].CreateDelegate<GetCurrentProcessIdDelegate>();
Assert.Equal((uint)Process.GetCurrentProcess().Id, getPid());
}
[Fact]
public void Resolved_function_executes_via_remote_thread()
{
Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
RemoteFunction getPid = magic["kernel32"]["GetCurrentProcessId"];
// GetCurrentProcessId takes no args and is thread-agnostic; a remote thread in our
// own process must report our PID.
uint pid = getPid.Execute<uint>(CallConvention.Stdcall);
Assert.Equal((uint)Process.GetCurrentProcess().Id, pid);
}
}
@@ -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);
}
}
+192
View File
@@ -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);
}
}
}
}
+130
View File
@@ -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);
}
}
+14 -1
View File
@@ -74,7 +74,7 @@ WhiteMagic (facade — BM-old ergonomics)
├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
│ ├─ ExternalReader (RPM/WPM)
│ └─ InProcessReader (direct deref, injected)
│ └─ InProcessReader (RPM/WPM on self-handle, injected)
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
@@ -91,3 +91,16 @@ WhiteMagic (facade — BM-old ergonomics)
```
**Net result**: BM's modern, FASM-free, x64 core + GreyMagic's dual-mode / detour / patch / marshal-cache engine + MemorySharp's high-level ergonomics — with a three-tier execution model whose *default* for state-sensitive calls is the crash-safe main-thread pump, while `CreateRemoteThread` stays available for the payloads it is genuinely safe for.
### Deviations discovered during implementation
The design held, but building it surfaced corrections worth recording (each is detailed against its task in `openspec/changes/whitemagic-foundation/tasks.md`):
- **`InProcessReader` reads via RPM/WPM on a self-handle, not `unsafe` direct deref** — .NET cannot catch `AccessViolationException`, so a bad direct deref kills the host with no soft-failure path. The in-process speed win moves to the delegate-call and detour paths, not the reader (design decision D1, revised mid-Phase 2).
- **`MarshalCache<T>` splits `Size` (managed, blittable) from `MarshalSize` (`Marshal.SizeOf`, marshal path)** — a single size mis-sized structs whose unmanaged width differs (a `bool` field is managed-1 / unmanaged-4; inline `ByValTStr`/`ByValArray` under-sized the marshal buffer and corrupted the heap on write). `MemoryBase` picks per `TypeRequiresMarshal` at every IO site.
- **x64 call stub is fully MS-x64-ABI compliant** — 32-byte shadow space, 16-byte alignment at the inner `call`, full `imm64` register loads (no >4 GiB pointer truncation), stack args above the shadow window. Proven at runtime by a live SSE callee whose aligned `movaps` faults on any misalignment (task 3.8), not just by byte-level encoding tests.
- **`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).
- **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
@@ -28,7 +28,7 @@
- [x] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
- [x] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
- [x] 3.7 Add tests for x64 stub argument-register placement and call
- [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Byte-level tests only — a live-execution test (5-arg + SSE callee via `CreateRemoteThread`) is still needed to prove the ABI at runtime.**
- [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Runtime ABI now proven:** live-execution tests via `CreateRemoteThread` cover 5-arg register+stack delivery (`Execute_sums_register_and_stack_arguments`), 16-byte entry alignment arithmetically (`Execute_delivers_16byte_aligned_stack_to_callee`), and a hardware-alignment-sensitive SSE callee (`Execute_runs_sse_callee_with_five_args`: aligned `movaps` that #GPs unless the stub delivers a 16-byte-aligned stack, combined with a 5th stack arg).
- [x] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references)
## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking)
@@ -38,7 +38,7 @@
- [x] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
- [x] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
- [x] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety. **Resolved (8.3):** `DetourManager.PrologueLengthResolver` now accepts `IcedAssembler.GetPrologueLength` for full instruction-boundary validation of arbitrary prologues; the built-in decoder remains the zero-dependency default.
- [x] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
- [x] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
- [x] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
@@ -66,7 +66,7 @@
## 7. High-Level Ergonomics (spec: high-level-api)
- [x] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
- [ ] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy
- [x] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy. **Deviation:** export resolution added to `PeHeaderParser.GetExportAddress` (PE32/PE32+ export directory walk) and **follows export forwarders** (e.g. `kernel32!HeapAlloc``NTDLL.RtlAllocateHeap`) into other loaded modules; ordinal forwarders and unresolvable API-set targets throw `NotSupportedException`. `RemoteModule` resolves the base via `Process.Modules` (name match tolerant of `.dll`/case). `RemoteFunction.Execute<T>` defaults to the always-available `RemoteThreadExecutor`; `Address` is exposed for pump routing and `CreateDelegate<T>` for the in-process tier. Tests cross-check resolved addresses against the OS `GetProcAddress` (direct export + forwarder) and execute `kernel32!GetCurrentProcessId` end-to-end.
- [x] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
- [x] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
- [x] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
@@ -75,13 +75,13 @@
## 8. Optional Iced Backend (spec: managed-assembler)
- [ ] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free
- [ ] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding
- [ ] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present
- [x] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free. Iced 1.21.0 added to `WhiteMagic.csproj`; only constructing `IcedAssembler` pulls it into a behavioral path. `StubAssembler` never references it.
- [x] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding. **Deviation:** Iced ships a *fluent* code assembler and a decoder but **no text parser**, so `Assemble` bridges Intel-syntax text onto Iced's `Assembler` by reflection — the mnemonic selects the matching fluent method and operands bind to registers (reflected from `AssemblerRegisters`), immediates, or labels; origin-relative encoding via `Assembler.Assemble(writer, origin)`. Register/immediate/label operands and label-relative branches are supported; **memory operands (`[reg+disp]`) throw `NotSupportedException`** (a caller needing those emits bytes directly). Tests round-trip via Iced's decoder and assert origin-relative branch targets.
- [x] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present. `IcedAssembler.GetPrologueLength` decodes arbitrary instructions via Iced's `Decoder`; `DetourManager.PrologueLengthResolver` (a `PrologueLengthResolver` delegate) defaults to the built-in `PrologueDecoder` and is swappable to the Iced resolver, threaded into each `Detour`. Tests prove Iced resolves a prologue (`mov rax,rcx` = `48 8B C1`) the built-in decoder rejects.
## 9. Verification
- [x] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass (180 pass, 4 integration/interactive skipped)
- [x] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
- [ ] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green
- [ ] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation
- [x] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green. WhiteMagic is a separate, git-ignored project under `reference/` sharing no source or build with BlackMagic; `dotnet test reference/Blackmagic/BlackMagic.slnx` = 17 passing, 0 failing (only pre-existing XML-doc warnings).
- [x] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation. Added a "Deviations discovered during implementation" subsection (D1 RPM-on-self, MarshalCache size split, x64 ABI + SSE proof, export forwarders, partial prologue safety, injection bitness, bounds/protection hardening) and corrected the `InProcessReader` line in the architecture diagram.