Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da342d355e | ||
|
|
1169fdb994 | ||
|
|
3e294dc846 | ||
|
|
8f988768fe | ||
|
|
9aef9c21e3 | ||
|
|
f0faca3112 | ||
|
|
e8c84f0ba1 | ||
|
|
0ddd812829 | ||
|
|
30d5a9a5ec | ||
|
|
af7e3dc1b9 | ||
|
|
678cb00895 |
@@ -10,3 +10,7 @@ reference/
|
|||||||
# Scratch
|
# Scratch
|
||||||
*.tmp
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Test run artifacts
|
||||||
|
WhiteMagicTest/TestResults/
|
||||||
|
**/TestResults/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
using WhiteMagic.Native;
|
using WhiteMagic.Native;
|
||||||
|
|
||||||
namespace WhiteMagic.Discovery;
|
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>
|
/// <summary>
|
||||||
/// Parses the DOS header, PE signature, and optional header.
|
/// Parses the DOS header, PE signature, and optional header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -472,27 +472,22 @@ public sealed class RemoteThreadExecutor
|
|||||||
nuint mask = AllocationGranularity - (nuint)1;
|
nuint mask = AllocationGranularity - (nuint)1;
|
||||||
nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask;
|
nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask;
|
||||||
|
|
||||||
for (int i = 0; i < NearAllocationAttempts; i++)
|
for (long delta = 0; delta <= (long)0x7FFF; delta++)
|
||||||
{
|
{
|
||||||
nuint candidate;
|
long signedOffset = delta * (long)AllocationGranularity;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
IntPtr result = NativeMethods.VirtualAllocEx(
|
||||||
handle,
|
handle,
|
||||||
@@ -503,15 +498,17 @@ public sealed class RemoteThreadExecutor
|
|||||||
|
|
||||||
if (result != IntPtr.Zero)
|
if (result != IntPtr.Zero)
|
||||||
{
|
{
|
||||||
|
long distance = (long)(nuint)(nint)result - (long)(nuint)(nint)preferredAddress;
|
||||||
|
if (distance >= int.MinValue && distance <= int.MaxValue)
|
||||||
return result;
|
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(
|
return IntPtr.Zero;
|
||||||
handle,
|
|
||||||
IntPtr.Zero,
|
|
||||||
size,
|
|
||||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
|
||||||
MemoryProtectionType.ExecuteReadWrite);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ namespace WhiteMagic.Hooking;
|
|||||||
public sealed class Detour : IDisposable
|
public sealed class Detour : IDisposable
|
||||||
{
|
{
|
||||||
private readonly MemoryBase _memory;
|
private readonly MemoryBase _memory;
|
||||||
|
private readonly PrologueLengthResolver _prologueLength;
|
||||||
|
|
||||||
/// <summary>The unique name of this detour.</summary>
|
/// <summary>The unique name of this detour.</summary>
|
||||||
public string Name { get; }
|
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>
|
/// <summary><see langword="true"/> while the detour bytes are live at <see cref="Target"/>.</summary>
|
||||||
public bool IsApplied { get; private set; }
|
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(hook);
|
||||||
|
ArgumentNullException.ThrowIfNull(prologueLength);
|
||||||
|
|
||||||
_memory = memory;
|
_memory = memory;
|
||||||
Name = name;
|
Name = name;
|
||||||
Target = target;
|
Target = target;
|
||||||
Hook = hook;
|
Hook = hook;
|
||||||
|
_prologueLength = prologueLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -83,7 +91,7 @@ public sealed class Detour : IDisposable
|
|||||||
"Could not read enough bytes from the target function to install a detour.");
|
"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];
|
OverwrittenBytes = new byte[preserveLength];
|
||||||
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
|
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public sealed class DetourManager
|
|||||||
_memory = memory;
|
_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>
|
/// <summary>
|
||||||
/// Creates a new detour and registers it with the manager.
|
/// Creates a new detour and registers it with the manager.
|
||||||
/// The <paramref name="hook"/> delegate's type must match the native signature of
|
/// The <paramref name="hook"/> delegate's type must match the native signature of
|
||||||
@@ -26,7 +35,7 @@ public sealed class DetourManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Detour Create(string name, IntPtr target, Delegate hook)
|
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;
|
_detours[name] = detour;
|
||||||
return detour;
|
return detour;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ using System;
|
|||||||
|
|
||||||
namespace WhiteMagic.Hooking;
|
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>
|
/// <summary>
|
||||||
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
|
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
|
||||||
/// The set is intentionally small: any opcode outside the covered set is rejected
|
/// The set is intentionally small: any opcode outside the covered set is rejected
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ public sealed class DllInjector
|
|||||||
if (value != IntPtr.Zero)
|
if (value != IntPtr.Zero)
|
||||||
return value;
|
return value;
|
||||||
|
|
||||||
Thread.Sleep(5);
|
System.Threading.Thread.Sleep(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
return IntPtr.Zero;
|
return IntPtr.Zero;
|
||||||
|
|||||||
+52
-1
@@ -1,7 +1,12 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Process = System.Diagnostics.Process;
|
using Process = System.Diagnostics.Process;
|
||||||
using WhiteMagic.Execution;
|
using WhiteMagic.Execution;
|
||||||
using WhiteMagic.Hooking;
|
using WhiteMagic.Hooking;
|
||||||
|
using WhiteMagic.Memory;
|
||||||
|
using WhiteMagic.ProcessDiscovery;
|
||||||
|
using WhiteMagic.Thread;
|
||||||
|
using WhiteMagic.Windows;
|
||||||
|
|
||||||
namespace WhiteMagic;
|
namespace WhiteMagic;
|
||||||
|
|
||||||
@@ -24,6 +29,21 @@ public sealed class Magic : IDisposable
|
|||||||
/// <summary>Inline-detour manager (in-process only).</summary>
|
/// <summary>Inline-detour manager (in-process only).</summary>
|
||||||
public DetourManager DetourManager => Memory.DetourManager;
|
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)
|
private Magic(MemoryBase memory)
|
||||||
{
|
{
|
||||||
Memory = memory;
|
Memory = memory;
|
||||||
@@ -31,11 +51,38 @@ public sealed class Magic : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens an external process for reading, writing, and execution.</summary>
|
/// <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));
|
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>
|
/// <summary>Creates an in-process session for the current process.</summary>
|
||||||
public static Magic OpenInProcess()
|
public static Magic OpenInProcess()
|
||||||
{
|
{
|
||||||
@@ -54,6 +101,10 @@ public sealed class Magic : IDisposable
|
|||||||
/// <summary>Returns a <see cref="RemotePointer"/> at <paramref name="address"/>.</summary>
|
/// <summary>Returns a <see cref="RemotePointer"/> at <paramref name="address"/>.</summary>
|
||||||
public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
|
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 />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using WhiteMagic.Hooking;
|
using WhiteMagic.Hooking;
|
||||||
|
using WhiteMagic.Memory;
|
||||||
using WhiteMagic.Native;
|
using WhiteMagic.Native;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -268,6 +270,62 @@ public abstract class MemoryBase : IDisposable
|
|||||||
return (IntPtr)((nint)absolute - (nint)ImageBase);
|
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 ──────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -156,3 +156,61 @@ public static class ContextFlags
|
|||||||
/// <summary>AMD64: control, integer, and segment registers.</summary>
|
/// <summary>AMD64: control, integer, and segment registers.</summary>
|
||||||
public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,4 +173,46 @@ internal static partial class NativeMethods
|
|||||||
SafeMemoryHandle handle,
|
SafeMemoryHandle handle,
|
||||||
uint milliseconds);
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,3 +205,61 @@ public unsafe struct Context64
|
|||||||
/// <summary>The source RIP of the last exception.</summary>
|
/// <summary>The source RIP of the last exception.</summary>
|
||||||
public ulong LastExceptionFromRip;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,13 @@
|
|||||||
<InternalsVisibleTo Include="WhiteMagicTest" />
|
<InternalsVisibleTo Include="WhiteMagicTest" />
|
||||||
</ItemGroup>
|
</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>
|
</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.1–8.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
|
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
|
// xor eax, eax
|
||||||
// cmp byte ptr [rcx+rax], 0
|
// cmp byte ptr [rcx+rax], 0
|
||||||
// je done
|
// je done
|
||||||
@@ -110,6 +137,21 @@ public sealed class RemoteThreadExecutorTests
|
|||||||
Assert.Equal(0, misalign);
|
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]
|
[Fact]
|
||||||
public void Execute_marshals_string_as_utf8_pointer()
|
public void Execute_marshals_string_as_utf8_pointer()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using Thread = System.Threading.Thread;
|
||||||
using WhiteMagic;
|
using WhiteMagic;
|
||||||
using WhiteMagic.Injection;
|
using WhiteMagic.Injection;
|
||||||
using WhiteMagic.Native;
|
using WhiteMagic.Native;
|
||||||
@@ -69,7 +70,7 @@ public class DllInjectorTests
|
|||||||
int osThreadId = 0;
|
int osThreadId = 0;
|
||||||
Exception? threadError = null;
|
Exception? threadError = null;
|
||||||
|
|
||||||
var helper = new Thread(() =>
|
var helper = new System.Threading.Thread(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -80,7 +81,7 @@ public class DllInjectorTests
|
|||||||
// also be stopped once its original context is restored.
|
// also be stopped once its original context is restored.
|
||||||
while (!stopEvent.IsSet)
|
while (!stopEvent.IsSet)
|
||||||
{
|
{
|
||||||
Thread.Sleep(10);
|
System.Threading.Thread.Sleep(10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ WhiteMagic (facade — BM-old ergonomics)
|
|||||||
├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
|
├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
|
||||||
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
|
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
|
||||||
│ ├─ ExternalReader (RPM/WPM)
|
│ ├─ ExternalReader (RPM/WPM)
|
||||||
│ └─ InProcessReader (direct deref, injected)
|
│ └─ InProcessReader (RPM/WPM on self-handle, injected)
|
||||||
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
|
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
|
||||||
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
|
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
|
||||||
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
|
├─ 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.
|
**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.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.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.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)
|
- [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)
|
## 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.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.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.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.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.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)
|
- [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)
|
## 7. High-Level Ergonomics (spec: high-level-api)
|
||||||
|
|
||||||
- [x] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
|
- [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.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.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
|
- [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. 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
|
- [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.
|
||||||
- [ ] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding
|
- [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.
|
||||||
- [ ] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present
|
- [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
|
## 9. Verification
|
||||||
|
|
||||||
- [x] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass (180 pass, 4 integration/interactive skipped)
|
- [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`
|
- [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
|
- [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).
|
||||||
- [ ] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation
|
- [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.
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# non-blocking-execute Specification
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
TBD - created by archiving change inject-and-assemble. Update Purpose after archive.
|
|
||||||
## Requirements
|
|
||||||
### Requirement: InjectAndExecuteEx creates remote thread without waiting
|
|
||||||
|
|
||||||
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` injects code at `startAddress` into the opened process, creates a remote thread with `parameter`, and returns the thread handle immediately without waiting for the thread to exit.
|
|
||||||
|
|
||||||
#### Scenario: successful non-blocking execution
|
|
||||||
- **WHEN** a process is open and `InjectAndExecuteEx(addr, param)` is called with a valid code address
|
|
||||||
- **THEN** a remote thread is created in the target process and a valid `SafeMemoryHandle` is returned
|
|
||||||
|
|
||||||
#### Scenario: no process open
|
|
||||||
- **WHEN** no process is open and `InjectAndExecuteEx(addr, param)` is called
|
|
||||||
- **THEN** `null` is returned
|
|
||||||
|
|
||||||
### Requirement: InjectAndExecuteEx single-parameter overload
|
|
||||||
|
|
||||||
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress)` calls `InjectAndExecuteEx(startAddress, IntPtr.Zero)`.
|
|
||||||
|
|
||||||
#### Scenario: parameter-less non-blocking execution
|
|
||||||
- **WHEN** `InjectAndExecuteEx(addr)` is called with a valid address
|
|
||||||
- **THEN** the thread is created with parameter `IntPtr.Zero`
|
|
||||||
|
|
||||||
### Requirement: InjectAndExecuteEx from assembly text
|
|
||||||
|
|
||||||
`BlackMagic.InjectAndExecuteEx(string asm)` assembles the text via `AsmBuilder`, allocates remote memory, writes the bytes, calls `InjectAndExecuteEx` on the allocated address, and returns the thread handle.
|
|
||||||
|
|
||||||
#### Scenario: execute assembly text non-blocking
|
|
||||||
- **WHEN** `InjectAndExecuteEx("nop")` is called with a process open
|
|
||||||
- **THEN** the text is assembled to bytes, written to remote memory, a thread is started, and the handle is returned
|
|
||||||
|
|
||||||
#### Scenario: assembly failure
|
|
||||||
- **WHEN** `InjectAndExecuteEx("invalidinstruction")` is called
|
|
||||||
- **THEN** `ArgumentException` is thrown with the assembly error
|
|
||||||
|
|
||||||
### Requirement: InjectAndExecute from assembly text (blocking convenience)
|
|
||||||
|
|
||||||
`BlackMagic.InjectAndExecute(string asm)` assembles the text, allocates remote memory, writes the bytes, calls `Execute` (blocking, 10s timeout), and returns the exit code.
|
|
||||||
|
|
||||||
#### Scenario: execute assembly text blocking
|
|
||||||
- **WHEN** `InjectAndExecute("mov eax, 42\nret")` is called with a process open
|
|
||||||
- **THEN** the text is assembled, injected, executed, and the thread exit code is returned
|
|
||||||
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# text-assembler Specification
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
TBD - created by archiving change inject-and-assemble. Update Purpose after archive.
|
|
||||||
## Requirements
|
|
||||||
### Requirement: AsmBuilder assembles x86 instruction text to byte array
|
|
||||||
|
|
||||||
`AsmBuilder.Assemble(string source)` parses x86 assembly text and returns the corresponding `byte[]` machine code.
|
|
||||||
|
|
||||||
#### Scenario: single instruction
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("nop")` is called
|
|
||||||
- **THEN** the result is `[0x90]`
|
|
||||||
|
|
||||||
#### Scenario: multiple instructions
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("pushad\npopad")` is called
|
|
||||||
- **THEN** the result is `[0x60, 0x61]`
|
|
||||||
|
|
||||||
#### Scenario: instruction with immediate operand
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("mov eax, 1")` is called
|
|
||||||
- **THEN** the result is `[0xB8, 0x01, 0x00, 0x00, 0x00]`
|
|
||||||
|
|
||||||
### Requirement: AsmBuilder supports register operands
|
|
||||||
|
|
||||||
Supported registers: `eax`, `ecx`, `edx`, `ebx`, `esp`, `ebp`, `esi`, `edi` (and 8-bit: `al`, `cl`, `dl`, `bl`, `ah`, `ch`, `dh`, `bh`).
|
|
||||||
|
|
||||||
#### Scenario: register-to-register move
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("mov eax, ecx")` is called
|
|
||||||
- **THEN** the result is `[0x89, 0xC8]` (mov eax, ecx encoding)
|
|
||||||
|
|
||||||
#### Scenario: register encoding
|
|
||||||
- **WHEN** registers are used in instructions
|
|
||||||
- **THEN** each register maps to its correct 3-bit encoding (eax=0, ecx=1, edx=2, ebx=3, esp=4, ebp=5, esi=6, edi=7)
|
|
||||||
|
|
||||||
### Requirement: AsmBuilder supports labels and jumps
|
|
||||||
|
|
||||||
Labels are defined with `@name:` and referenced with `jmp @name` or `je @name`. Forward and backward references are resolved in a second pass.
|
|
||||||
|
|
||||||
#### Scenario: forward jump
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("jmp @skip\nnop\n@skip:\nret")` is called
|
|
||||||
- **THEN** the jump skips exactly over the `nop` (2 bytes) and lands on `ret`
|
|
||||||
|
|
||||||
#### Scenario: backward jump
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("@loop:\nnop\njmp @loop")` is called
|
|
||||||
- **THEN** the jump targets the earlier label correctly
|
|
||||||
|
|
||||||
#### Scenario: multiple labels
|
|
||||||
- **WHEN** multiple labels are used in one source
|
|
||||||
- **THEN** each label resolves to its correct byte offset
|
|
||||||
|
|
||||||
### Requirement: AsmBuilder SetPassLimit controls iteration
|
|
||||||
|
|
||||||
`AsmBuilder.SetPassLimit(int limit)` sets the maximum number of assembly passes for label resolution. Default is 10. If the limit is exceeded before all labels resolve, `InvalidOperationException` is thrown.
|
|
||||||
|
|
||||||
#### Scenario: default pass limit
|
|
||||||
- **WHEN** no `SetPassLimit` is called
|
|
||||||
- **THEN** the assembler uses 10 passes maximum
|
|
||||||
|
|
||||||
#### Scenario: custom pass limit
|
|
||||||
- **WHEN** `SetPassLimit(20)` is called
|
|
||||||
- **THEN** the assembler uses 20 passes maximum
|
|
||||||
|
|
||||||
#### Scenario: pass limit exceeded
|
|
||||||
- **WHEN** forward references cannot resolve within the pass limit
|
|
||||||
- **THEN** `InvalidOperationException` is thrown with label resolution details
|
|
||||||
|
|
||||||
### Requirement: AsmBuilder reports clear errors
|
|
||||||
|
|
||||||
Unknown instructions, missing operands, and invalid register names produce `ArgumentException` with the line number and offending text.
|
|
||||||
|
|
||||||
#### Scenario: unknown instruction
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("xyzw")` is called
|
|
||||||
- **THEN** `ArgumentException` is thrown mentioning line 1 and "xyzw"
|
|
||||||
|
|
||||||
#### Scenario: missing operand
|
|
||||||
- **WHEN** `AsmBuilder.Assemble("mov")` is called (no operands)
|
|
||||||
- **THEN** `ArgumentException` is thrown mentioning missing operand
|
|
||||||
|
|
||||||
### Requirement: AsmBuilder supported instruction set
|
|
||||||
|
|
||||||
The following x86 instructions are supported:
|
|
||||||
- **Data movement**: `mov`, `push`, `pop`, `pushad`, `popad`, `lea`
|
|
||||||
- **Arithmetic**: `add`, `sub`, `inc`, `dec`, `xor`, `and`, `or`, `cmp`, `test`
|
|
||||||
- **Control flow**: `jmp`, `je`, `jne`, `call`, `ret`, `nop`, `hlt`
|
|
||||||
|
|
||||||
#### Scenario: all instructions produce valid bytes
|
|
||||||
- **WHEN** each supported instruction is assembled individually
|
|
||||||
- **THEN** it produces the correct x86 machine code encoding
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user