Files
whitemagic/WhiteMagic/Assembly/IcedAssembler.cs
T
kbeandClaude Opus 4.8 e8c84f0ba1 Fix IcedAssembler immediate overflow and unwrap invoke exceptions
- TryBind wraps Convert.ChangeType in TryChangeType so an immediate that
  overflows a candidate parameter type returns false (letting a wider overload
  be tried) instead of throwing OverflowException out of assembly. Verified:
  "mov eax, 4294967295" and "mov eax, -2147483649" no longer crash.
- Immediate now carries a boxed long OR ulong; TryParseImmediate parses decimal
  values above long.MaxValue via a ulong fallback, and hex via ulong. Previously
  such literals were rejected at parse time.
- Unwrap TargetInvocationException from method.Invoke so callers see the real
  Iced failure, not the reflection wrapper.

Tests: 231 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:43:48 +02:00

334 lines
12 KiB
C#

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);
}
}