Add optional Iced backend: arbitrary text assembly and full prologue validation
Section 8 of the whitemagic-foundation change. - 8.1: reference Iced 1.21.0 behind IcedAssembler:IAssembler. The default StubAssembler path never touches Iced; only constructing IcedAssembler pulls it into a behavioral path. - 8.2: IcedAssembler.Assemble bridges Intel-syntax text onto Iced's fluent Assembler by reflection (Iced ships no text parser). Registers, immediates and labels are supported with origin-relative encoding; memory operands throw NotSupportedException. - 8.3: IcedAssembler.GetPrologueLength decodes arbitrary instructions via Iced's Decoder. DetourManager.PrologueLengthResolver (new delegate) defaults to the built-in PrologueDecoder and is swappable to the Iced resolver, threaded into each Detour. This lifts the "partial boundary safety" caveat on the hooking slice when Iced is opted in. Also gitignore test-run TestResults artifacts. Tests: 221 passing, 4 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,3 +10,7 @@ reference/
|
||||
# Scratch
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# Test run artifacts
|
||||
WhiteMagicTest/TestResults/
|
||||
**/TestResults/
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
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))
|
||||
{
|
||||
method.Invoke(assembler, boundArgs);
|
||||
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):
|
||||
args[i] = Convert.ChangeType(imm.Value, paramType, CultureInfo.InvariantCulture);
|
||||
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 long value))
|
||||
return new Immediate(value);
|
||||
|
||||
throw new NotSupportedException(
|
||||
$"Unrecognized operand '{token}' (expected a register, an immediate, or a label).");
|
||||
}
|
||||
|
||||
private static bool TryParseImmediate(string token, out long value)
|
||||
{
|
||||
bool negative = token.StartsWith('-');
|
||||
string body = negative ? token[1..] : token;
|
||||
|
||||
bool ok;
|
||||
if (body.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
ok = long.TryParse(body[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
|
||||
else
|
||||
ok = long.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
|
||||
|
||||
if (ok && negative)
|
||||
value = -value;
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
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, distinguished from register/label operands so binding can widen
|
||||
// or narrow it to whichever integer parameter type the chosen overload expects.
|
||||
private readonly record struct Immediate(long Value);
|
||||
|
||||
private sealed class ByteListCodeWriter : CodeWriter
|
||||
{
|
||||
public List<byte> Bytes { get; } = new();
|
||||
|
||||
public override void WriteByte(byte value) => Bytes.Add(value);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ namespace WhiteMagic.Hooking;
|
||||
public sealed class Detour : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly PrologueLengthResolver _prologueLength;
|
||||
|
||||
/// <summary>The unique name of this detour.</summary>
|
||||
public string Name { get; }
|
||||
@@ -43,14 +44,21 @@ public sealed class Detour : IDisposable
|
||||
/// <summary><see langword="true"/> while the detour bytes are live at <see cref="Target"/>.</summary>
|
||||
public bool IsApplied { get; private set; }
|
||||
|
||||
internal Detour(MemoryBase memory, string name, IntPtr target, Delegate hook)
|
||||
internal Detour(
|
||||
MemoryBase memory,
|
||||
string name,
|
||||
IntPtr target,
|
||||
Delegate hook,
|
||||
PrologueLengthResolver prologueLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hook);
|
||||
ArgumentNullException.ThrowIfNull(prologueLength);
|
||||
|
||||
_memory = memory;
|
||||
Name = name;
|
||||
Target = target;
|
||||
Hook = hook;
|
||||
_prologueLength = prologueLength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -83,7 +91,7 @@ public sealed class Detour : IDisposable
|
||||
"Could not read enough bytes from the target function to install a detour.");
|
||||
}
|
||||
|
||||
int preserveLength = PrologueDecoder.GetWholeInstructionLength(prologue, detourLength, _memory.Is64Bit);
|
||||
int preserveLength = _prologueLength(prologue, detourLength, _memory.Is64Bit);
|
||||
OverwrittenBytes = new byte[preserveLength];
|
||||
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ public sealed class DetourManager
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves how many whole prologue-instruction bytes a splice must preserve. Defaults
|
||||
/// to the built-in <see cref="PrologueDecoder"/>, which covers only the common prologue
|
||||
/// shapes and rejects anything else. Assign <c>new IcedAssembler().GetPrologueLength</c>
|
||||
/// to validate arbitrary prologues via the optional Iced disassembler.
|
||||
/// </summary>
|
||||
public PrologueLengthResolver PrologueLengthResolver { get; set; } =
|
||||
PrologueDecoder.GetWholeInstructionLength;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new detour and registers it with the manager.
|
||||
/// The <paramref name="hook"/> delegate's type must match the native signature of
|
||||
@@ -26,7 +35,7 @@ public sealed class DetourManager
|
||||
/// </summary>
|
||||
public Detour Create(string name, IntPtr target, Delegate hook)
|
||||
{
|
||||
var detour = new Detour(_memory, name, target, hook);
|
||||
var detour = new Detour(_memory, name, target, hook, PrologueLengthResolver);
|
||||
_detours[name] = detour;
|
||||
return detour;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ using System;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves how many whole prologue-instruction bytes must be preserved to splice
|
||||
/// <paramref name="requiredBytes"/> bytes at a target. The built-in
|
||||
/// <see cref="PrologueDecoder.GetWholeInstructionLength"/> satisfies this delegate, as
|
||||
/// does <c>IcedAssembler.GetPrologueLength</c> for full instruction coverage.
|
||||
/// </summary>
|
||||
public delegate int PrologueLengthResolver(byte[] prologue, int requiredBytes, bool is64Bit);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
|
||||
/// The set is intentionally small: any opcode outside the covered set is rejected
|
||||
|
||||
@@ -13,4 +13,13 @@
|
||||
<InternalsVisibleTo Include="WhiteMagicTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Optional Iced backend (task 8.x). Isolated behind IAssembler: the default
|
||||
StubAssembler path never touches Iced, keeping the common configuration free of any
|
||||
behavioral dependency on it. Only callers that construct IcedAssembler pull it in.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Iced" Version="1.21.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ The design held, but building it surfaced corrections worth recording (each is d
|
||||
- **`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 ships partial** — the default `StubAssembler` length-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set; full arbitrary-prologue validation is gated on the optional Iced backend (tasks 4.6, 8.3).
|
||||
- **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).
|
||||
- **Injection bitness corrections** — the thread-hijack injector enforces matching host/target bitness, so the 32-bit path always runs from a 32-bit caller and uses native `GetThreadContext`/`SetThreadContext`; the WOW64 context APIs (for 64-bit callers inspecting WOW64 targets) never apply here and were removed. `ExternalReader` validates `QueryInformation`/`QueryLimitedInformation` access and surfaces `IsWow64Process` failures instead of silently assuming host bitness.
|
||||
- **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`.
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- [x] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
|
||||
- [x] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
|
||||
- [x] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
|
||||
- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
|
||||
- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety. **Resolved (8.3):** `DetourManager.PrologueLengthResolver` now accepts `IcedAssembler.GetPrologueLength` for full instruction-boundary validation of arbitrary prologues; the built-in decoder remains the zero-dependency default.
|
||||
- [x] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
|
||||
- [x] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
|
||||
- [x] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
|
||||
@@ -75,9 +75,9 @@
|
||||
|
||||
## 8. Optional Iced Backend (spec: managed-assembler)
|
||||
|
||||
- [ ] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free
|
||||
- [ ] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding
|
||||
- [ ] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present
|
||||
- [x] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free. Iced 1.21.0 added to `WhiteMagic.csproj`; only constructing `IcedAssembler` pulls it into a behavioral path. `StubAssembler` never references it.
|
||||
- [x] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding. **Deviation:** Iced ships a *fluent* code assembler and a decoder but **no text parser**, so `Assemble` bridges Intel-syntax text onto Iced's `Assembler` by reflection — the mnemonic selects the matching fluent method and operands bind to registers (reflected from `AssemblerRegisters`), immediates, or labels; origin-relative encoding via `Assembler.Assemble(writer, origin)`. Register/immediate/label operands and label-relative branches are supported; **memory operands (`[reg+disp]`) throw `NotSupportedException`** (a caller needing those emits bytes directly). Tests round-trip via Iced's decoder and assert origin-relative branch targets.
|
||||
- [x] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present. `IcedAssembler.GetPrologueLength` decodes arbitrary instructions via Iced's `Decoder`; `DetourManager.PrologueLengthResolver` (a `PrologueLengthResolver` delegate) defaults to the built-in `PrologueDecoder` and is swappable to the Iced resolver, threaded into each `Detour`. Tests prove Iced resolves a prologue (`mov rax,rcx` = `48 8B C1`) the built-in decoder rejects.
|
||||
|
||||
## 9. Verification
|
||||
|
||||
|
||||
Reference in New Issue
Block a user