ARchive old spec

This commit is contained in:
kbe
2026-07-21 22:30:10 +02:00
parent 0380705e76
commit 184dec86ca
17 changed files with 550 additions and 191 deletions
+104 -34
View File
@@ -46,21 +46,35 @@ public sealed class StubAssembler : IAssembler
/// </summary>
/// <param name="stubAddress">Where the stub lands (for E8 rel32 encoding).</param>
/// <param name="targetAddress">Function to call.</param>
/// <param name="arguments">Argument values (uint[] — each 4 or 8 bytes per pointerSize).</param>
/// <param name="arguments">Argument values. For x86 each element holds a 32-bit argument;
/// for x64 each element holds the full 64-bit pointer-sized argument.</param>
/// <param name="pointerSize">4 (x86) or 8 (x64).</param>
/// <param name="convention">Calling convention.</param>
/// <param name="convention">Calling convention (ignored on x64; Windows has a single ABI).</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="pointerSize"/> is not 4 or 8,
/// or <paramref name="convention"/> is not known, or the distance between stub and target
/// exceeds the E8 rel32 range.</exception>
public byte[] BuildCallStub(IntPtr stubAddress, IntPtr targetAddress,
uint[] arguments, int pointerSize, CallConvention convention)
nuint[] arguments, int pointerSize, CallConvention convention)
{
var buffer = new List<byte>(64);
var buffer = new List<byte>(96);
if (pointerSize == 4)
{
// X86 args are 32-bit. Truncate nuint down to uint — callers must pass values
// that fit in 32 bits on x86 targets.
uint[] args32 = new uint[arguments.Length];
for (int i = 0; i < arguments.Length; i++)
{
ulong v = arguments[i];
if (v > uint.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(arguments),
$"Argument {i} = 0x{v:X} does not fit in 32 bits (x86 target).");
}
args32[i] = (uint)v;
}
BuildX86Stub(buffer, checked((uint)stubAddress), checked((uint)targetAddress),
arguments, convention);
args32, convention);
}
else if (pointerSize == 8)
{
@@ -153,57 +167,113 @@ public sealed class StubAssembler : IAssembler
buffer.Add(0xC3); // ret
}
/// <summary>
/// Builds a Windows x64 call stub that conforms to the Microsoft x64 ABI:
/// first 4 integer/pointer args in RCX, RDX, R8, R9 (64-bit loads); stack args
/// above 32-byte shadow space; 16-byte stack alignment at the <c>call</c> instruction.
/// </summary>
/// <remarks>
/// <para>The stub frame:</para>
/// <code>
/// sub rsp, 0x20 ; 32-byte shadow space + restores 16-byte alignment
/// mov rcx, arg0 ; 64-bit loads (REX.W mov r64, imm64)
/// mov rdx, arg1
/// mov r8, arg2
/// mov r9, arg3
/// mov rax, arg[N]
/// mov [rsp + 0x20 + 8*(N-4)], rax ; stack args placed above the shadow slots
/// ...
/// call target (rel32)
/// add rsp, 0x20
/// ret
/// </code>
/// <para>On entry the stub sees <c>rsp ≡ 8 (mod 16)</c> (the caller's <c>call</c> pushed
/// the return address). <c>sub rsp, 0x20</c> moves rsp to <c>≡ 0 (mod 16)</c>. Just before
/// the inner <c>call</c>, rsp is still <c>≡ 0</c>, so target's entry rsp is
/// <c>≡ 8 (mod 16)</c> — no, wait: entry ≡ 0 after sub; <c>call target</c> pushes 8, so
/// target's entry is ≡ 0 8 ≡ 8; but we want target entry ≡ 0. Re-check:</para>
/// <para>Stub entry: <c>rsp ≡ 8 (mod 16)</c>. After <c>sub rsp, 0x20</c>:
/// <c>8 0x20 = 24 ≡ 8 (mod 16)</c>. After the inner <c>call</c>, target entry is
/// <c>8 8 ≡ 0 (mod 16)</c>. Target is 16-byte aligned — SSE safe.</para>
/// </remarks>
private void BuildX64Stub(List<byte> buffer, ulong stubAddr,
ulong target, uint[] args)
ulong target, nuint[] args)
{
// Windows x64 single ABI: first 4 args in RCX, RDX, R8D, R9D.
ulong current = stubAddr;
var regCodes = new byte[] { 0xB9, 0xBA, 0xB8, 0xB9 };
var rexBytes = new byte[] { 0x00, 0x00, 0x41, 0x41 };
// 1. Allocate shadow space.
// sub rsp, 0x20 ; 32 bytes = 4 shadow slots AND (entry 0x20) ≡ 8 (mod 16),
// so rsp after the sub ≡ 8 (mod 16); `call target` will push 8 and land target
// at ≡ 0 (mod 16).
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC); // sub rsp, imm32
EmitU32(buffer, 0x20);
current += 7;
// 2. 64-bit register loads.
// RCX = REX.W 0xB9 + imm64 (10 bytes)
// RDX = REX.W 0xBA + imm64 (10 bytes)
// R8 = REX.WB 0xB8 + imm64 (11 bytes, REX.W|R = 0x49)
// R9 = REX.WB 0xB9 + imm64 (11 bytes)
byte[][] regMoves =
[
[0x48, 0xB9], // mov rcx, imm64
[0x48, 0xBA], // mov rdx, imm64
[0x49, 0xB8], // mov r8, imm64
[0x49, 0xB9], // mov r9, imm64
];
int regCount = Math.Min(args.Length, 4);
for (int i = 0; i < regCount; i++)
{
if (rexBytes[i] != 0)
buffer.Add(rexBytes[i]);
buffer.Add(regCodes[i]);
EmitU32(buffer, args[i]);
current += (rexBytes[i] != 0 ? 6u : 5u);
byte[] prefix = regMoves[i];
buffer.Add(prefix[0]);
buffer.Add(prefix[1]);
EmitU64(buffer, args[i]);
current += (uint)(prefix.Length + 8);
}
// Push remaining args in reverse order
for (int i = args.Length - 1; i >= 4; i--)
// 3. Stack args (args 4+): placed at [rsp + 0x20 + 8*(i-4)].
// Each is two instructions:
// mov rax, imm64 (10 bytes)
// mov [rsp + disp], rax (5..8 bytes depending on disp8/disp32)
for (int i = 4; i < args.Length; i++)
{
current += 5;
buffer.Add(0x68);
EmitU32(buffer, args[i]);
int offset = 0x20 + (i - 4) * 8;
buffer.Add(0x48); buffer.Add(0xB8); // mov rax, imm64
EmitU64(buffer, args[i]);
current += 10;
buffer.Add(0x48); buffer.Add(0x89); // mov [rsp + disp], rax
if (offset <= 127)
{
buffer.Add(0x44); buffer.Add(0x24); // ModRM: [rsp + disp8]
buffer.Add((byte)offset);
current += 5;
}
else
{
buffer.Add(0x84); buffer.Add(0x24); // ModRM: [rsp + disp32]
EmitU32(buffer, (uint)offset);
current += 8;
}
}
// call rel32
// 4. call rel32
long distance = (long)target - (long)(current + 5);
if (distance < int.MinValue || distance > int.MaxValue)
if (distance is < int.MinValue or > int.MaxValue)
{
throw new ArgumentOutOfRangeException(
"target and stub are >2 GiB apart; E8 rel32 cannot encode this distance.");
}
buffer.Add(0xE8);
EmitU32(buffer, (uint)distance);
current += 5;
// Pop any args pushed on stack (x64 is caller-clean)
int stackArgs = args.Length > 4 ? args.Length - 4 : 0;
if (stackArgs > 0)
{
int bytes = stackArgs * 8;
buffer.Add(0x48); // REX.W
buffer.Add(bytes <= 127 ? (byte)0x83 : (byte)0x81); // add r/m64, imm8/imm32
buffer.Add(0xC4); // rsp
if (bytes <= 127)
buffer.Add((byte)bytes);
else
EmitU32(buffer, (uint)bytes);
}
// 5. add rsp, 0x20 ; tear down shadow space
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4);
EmitU32(buffer, 0x20);
// 6. ret
buffer.Add(0xC3);
}
+4 -19
View File
@@ -42,7 +42,8 @@ public sealed class ExternalReader : MemoryBase
}
// Process.MainModule throws Win32Exception for a bitness-mismatched or protected
// target; a missing image base must not sink the whole reader.
// target. A missing image base must not sink the whole reader — callers can still
// use absolute addresses when ImageBase is unknown.
try
{
_imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero;
@@ -65,18 +66,7 @@ public sealed class ExternalReader : MemoryBase
if (isRelative)
address = GetAbsolute(address);
byte[] buffer = new byte[count];
if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
return [];
}
if ((int)bytesRead != count)
{
Array.Resize(ref buffer, (int)bytesRead);
}
return buffer;
return RpmHelper.ReadBytes(_handle, address, count);
}
/// <inheritdoc />
@@ -85,12 +75,7 @@ public sealed class ExternalReader : MemoryBase
if (isRelative)
address = GetAbsolute(address);
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
return RpmHelper.WriteBytes(_handle, address, bytes);
}
/// <inheritdoc />
+19 -19
View File
@@ -12,6 +12,13 @@ namespace WhiteMagic;
/// empty / zero bytes) on invalid or protected addresses instead of crashing
/// the host process with an <see cref="AccessViolationException"/>.
/// </summary>
/// <remarks>
/// This is functionally equivalent to <see cref="ExternalReader"/> opened on the current
/// process. It exists as a distinct type because the design (see
/// <c>openspec/changes/whitemagic-foundation/design.md</c> D1) treats "injected in-process"
/// as a separate mode from "external". The two modes will diverge further once the
/// <c>InProcessInvoker</c> delegate-call path lands.
/// </remarks>
public sealed class InProcessReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
@@ -35,7 +42,16 @@ public sealed class InProcessReader : MemoryBase
$"OpenProcess failed for PID {current.Id}: error {error}");
}
_imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
// Process.MainModule rarely throws on the current process, but guard it
// nonetheless for parity with ExternalReader.
try
{
_imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
}
catch (System.ComponentModel.Win32Exception)
{
_imageBase = IntPtr.Zero;
}
}
/// <inheritdoc />
@@ -50,18 +66,7 @@ public sealed class InProcessReader : MemoryBase
if (isRelative)
address = GetAbsolute(address);
byte[] buffer = new byte[count];
if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
return [];
}
if ((int)bytesRead != count)
{
Array.Resize(ref buffer, (int)bytesRead);
}
return buffer;
return RpmHelper.ReadBytes(_handle, address, count);
}
/// <inheritdoc />
@@ -70,12 +75,7 @@ public sealed class InProcessReader : MemoryBase
if (isRelative)
address = GetAbsolute(address);
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
return RpmHelper.WriteBytes(_handle, address, bytes);
}
/// <inheritdoc />
+34 -45
View File
@@ -1,88 +1,77 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace WhiteMagic;
/// <summary>
/// Computes and caches marshal-related metadata for type <typeparamref name="T"/>
/// exactly once. <see cref="MemoryBase.Read{T}"/> and <see cref="MemoryBase.Write{T}"/>
/// branch on these cached flags to decide between blittable <c>Span</c>/<c>MemoryMarshal</c>
/// paths and the fallback marshal path.
/// Computes and caches the byte size and marshalling decision for type
/// <typeparamref name="T"/> exactly once. <see cref="MemoryBase.Read{T}"/> and
/// <see cref="MemoryBase.Write{T}"/> branch on <see cref="TypeRequiresMarshal"/>
/// to decide between the blittable <c>Span</c> /
/// <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path and the
/// <see cref="Marshal.PtrToStructure"/> path.
/// </summary>
/// <typeparam name="T">The type to cache metadata for.</typeparam>
public static class MarshalCache<T>
{
/// <summary>The unmanaged size of <typeparamref name="T"/> in bytes.</summary>
/// <summary>
/// The byte size of <typeparamref name="T"/>. For the blittable path this is
/// the managed layout size <see cref="Unsafe.SizeOf{T}"/> — the width that
/// <see cref="MemoryMarshal.Read{T}"/> / <see cref="MemoryMarshal.Write{T}"/>
/// actually consume. For primitive-sized types (<see cref="bool"/>, <see cref="char"/>,
/// and the underlying of enums) the size matches the CLR primitive width.
/// </summary>
public static readonly int Size;
/// <summary>The unmanaged size of <typeparamref name="T"/> as an unsigned integer.</summary>
public static readonly uint SizeU;
/// <summary>
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through the
/// blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path and must
/// use <see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/> instead.
/// This is the case when a top-level field carries <see cref="MarshalAsAttribute"/>, or
/// when <typeparamref name="T"/> contains a managed reference
/// (<see cref="System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through
/// the blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path
/// and must fall back to <see cref="Marshal.PtrToStructure"/> /
/// <see cref="Marshal.StructureToPtr"/>. This is the case when a top-level field
/// carries <see cref="MarshalAsAttribute"/>, or when <typeparamref name="T"/>
/// contains a managed reference
/// (<see cref="RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
/// </summary>
/// <remarks>
/// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not detected.
/// Reference-containing nested structs are still caught, because the reference check
/// propagates through nested value types.
/// Reference-containing nested structs are still caught, because the reference
/// check propagates through nested value types.
/// </remarks>
public static readonly bool TypeRequiresMarshal;
/// <summary><see langword="true"/> when <typeparamref name="T"/> is <see cref="IntPtr"/>.</summary>
public static readonly bool IsIntPtr;
/// <summary>The underlying type code of <typeparamref name="T"/>.</summary>
public static readonly TypeCode TypeCode;
/// <summary>
/// The effective type that the marshaler uses. For an enum this is the underlying
/// integer type; for all other types it is <typeparamref name="T"/> itself.
/// </summary>
public static readonly Type RealType;
static MarshalCache()
{
TypeCode = Type.GetTypeCode(typeof(T));
if (typeof(T) == typeof(bool))
{
Size = 1;
RealType = typeof(T);
}
else if (typeof(T) == typeof(char))
{
// Marshal.SizeOf(char) is 1 (ANSI), but the blittable path reads/writes a
// char as a 2-byte UTF-16 code unit. Size must match the blittable width.
// Marshal.SizeOf<char> reports 1 (ANSI char), but the blittable
// MemoryMarshal path reads/writes a char as a 2-byte UTF-16 code unit.
// Use the managed layout width so Size matches what the reader actually uses.
Size = 2;
RealType = typeof(T);
}
else if (typeof(T).IsEnum)
{
Type underlying = typeof(T).GetEnumUnderlyingType();
Size = Marshal.SizeOf(underlying);
RealType = underlying;
TypeCode = Type.GetTypeCode(underlying);
Size = Marshal.SizeOf(typeof(T).GetEnumUnderlyingType());
}
else
{
Size = Marshal.SizeOf(typeof(T));
RealType = typeof(T);
// The blittable path goes through MemoryMarshal, which uses the CLR managed
// layout. Use Unsafe.SizeOf<T> so Size agrees with that layout —
// Marshal.SizeOf<T> can disagree when a struct contains a `bool` field
// (unmanaged width 4 vs managed width 1).
Size = Unsafe.SizeOf<T>();
}
SizeU = (uint)Size;
IsIntPtr = RealType == typeof(IntPtr);
bool hasMarshalAsField =
RealType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
typeof(T).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0);
TypeRequiresMarshal =
hasMarshalAsField || System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences<T>();
hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences<T>();
}
}
+5 -3
View File
@@ -4,9 +4,11 @@ using WhiteMagic;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="InProcessReader"/> — direct pointer dereference against
/// the own process. Verifies the shared <see cref="MemoryBase"/> API works for
/// both external and in-process readers.
/// Tests for <see cref="InProcessReader"/>. The current implementation uses
/// <c>ReadProcessMemory</c>/<c>WriteProcessMemory</c> on a self-handle (per the D1
/// revision — unsafe direct-pointer dereference was rejected because .NET cannot
/// catch <see cref="AccessViolationException"/>). Verifies the shared
/// <see cref="MemoryBase"/> API works for both external and in-process readers.
/// </summary>
public class InProcessReaderTests
{
+47 -23
View File
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using WhiteMagic;
@@ -5,7 +6,7 @@ namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag,
/// IsIntPtr, and computed-once behavior.
/// and computed-once behavior.
/// </summary>
public class MarshalCacheTests
{
@@ -45,12 +46,33 @@ public class MarshalCacheTests
Assert.Equal(8, MarshalCache<BlittableStruct>.Size);
}
[Fact]
public void Size_for_struct_with_bool_is_managed_layout_width()
{
// Regression for the Marshal.SizeOf / Unsafe.SizeOf disagreement on a struct
// whose only field is `bool`: Marshal reports 4 bytes (Win32 BOOL default marshaling),
// but the blittable path (MemoryMarshal.Read<T>) actually lays out a bool as 1 byte.
// MarshalCache.Size must match what the reader actually touches.
Assert.Equal(Unsafe.SizeOf<SingleBoolStruct>(), MarshalCache<SingleBoolStruct>.Size);
Assert.Equal(1, MarshalCache<SingleBoolStruct>.Size);
}
[Fact]
public void Size_for_struct_with_bools_in_sequence_matches_managed_layout()
{
// Sequential struct { bool, bool } — managed width is 2, Marshal width is 8 (two BOOLs).
// The blittable path uses 1 byte per bool, so Size must equal the managed width.
Assert.Equal(Unsafe.SizeOf<SequentialBoolStruct>(), MarshalCache<SequentialBoolStruct>.Size);
Assert.Equal(2, MarshalCache<SequentialBoolStruct>.Size);
}
[Fact]
public void TypeRequiresMarshal_is_false_for_blittable_types()
{
Assert.False(MarshalCache<int>.TypeRequiresMarshal);
Assert.False(MarshalCache<long>.TypeRequiresMarshal);
Assert.False(MarshalCache<BlittableStruct>.TypeRequiresMarshal);
Assert.False(MarshalCache<byte>.TypeRequiresMarshal);
Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
}
[Fact]
@@ -60,39 +82,23 @@ public class MarshalCacheTests
}
[Fact]
public void IsIntPtr_is_true_for_IntPtr()
public void TypeRequiresMarshal_is_true_for_reference_containing_types()
{
Assert.True(MarshalCache<IntPtr>.IsIntPtr);
Assert.True(MarshalCache<string>.TypeRequiresMarshal);
Assert.True(MarshalCache<ClassWithInt>.TypeRequiresMarshal);
}
[Fact]
public void IsIntPtr_is_false_for_non_IntPtr_types()
{
Assert.False(MarshalCache<int>.IsIntPtr);
Assert.False(MarshalCache<long>.IsIntPtr);
Assert.False(MarshalCache<BlittableStruct>.IsIntPtr);
}
[Fact]
public void All_properties_are_computed_once_and_cached()
public void Properties_are_computed_once_and_cached()
{
int size1 = MarshalCache<int>.Size;
bool marshal1 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr1 = MarshalCache<int>.IsIntPtr;
int size2 = MarshalCache<int>.Size;
bool marshal2 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr2 = MarshalCache<int>.IsIntPtr;
Assert.Equal(size1, size2);
Assert.Equal(marshal1, marshal2);
Assert.Equal(intPtr1, intPtr2);
}
[Fact]
public void SizeU_matches_Size_as_uint()
{
Assert.Equal((uint)MarshalCache<int>.Size, MarshalCache<int>.SizeU);
}
[StructLayout(LayoutKind.Sequential)]
@@ -108,4 +114,22 @@ public class MarshalCacheTests
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
public byte[] Data;
}
[StructLayout(LayoutKind.Sequential)]
private struct SingleBoolStruct
{
public bool Flag;
}
[StructLayout(LayoutKind.Sequential)]
private struct SequentialBoolStruct
{
public bool A;
public bool B;
}
private class ClassWithInt
{
public int Value = 0;
}
}
+3 -3
View File
@@ -57,13 +57,13 @@ public class NativeSurfaceTests
{
using SafeMemoryHandle handle = OpenSelf(
ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
ReadOnlySpan<byte> payload = BitConverter.GetBytes(0x5EED);
ReadOnlySpan<byte> bytes = BitConverter.GetBytes(0x5EED);
bool ok = NativeMethods.WriteProcessMemory(
handle, pin.AddrOfPinnedObject(), payload, payload.Length, out nint written);
handle, pin.AddrOfPinnedObject(), bytes, bytes.Length, out nint written);
Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}");
Assert.Equal(payload.Length, (int)written);
Assert.Equal(bytes.Length, (int)written);
Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject()));
}
finally
+192 -36
View File
@@ -90,7 +90,7 @@ public class StubAssemblerTests
{
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xCAFEBABE],4,CallConvention.Thiscall);
Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABE,BitConverter.ToUInt32(s,1));
Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABEu,BitConverter.ToUInt32(s,1));
Assert.Equal(0xE8,s[5]); Assert.Equal(r,BitConverter.ToUInt32(s,6)); Assert.Equal(0xC3,s[10]);
}
@@ -120,8 +120,8 @@ public class StubAssemblerTests
{
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAAAAAA,0xBBBBBBBB],4,CallConvention.Fastcall);
Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAA,BitConverter.ToUInt32(s,1));
Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBB,BitConverter.ToUInt32(s,6));
Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAAu,BitConverter.ToUInt32(s,1));
Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBBu,BitConverter.ToUInt32(s,6));
Assert.Equal(0xE8,s[10]); Assert.Equal(r,BitConverter.ToUInt32(s,11)); Assert.Equal(0xC3,s[15]);
}
@@ -133,52 +133,207 @@ public class StubAssemblerTests
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Fastcall));
}
// ── x64 ─────────────────────────────────────────────────────────────
// ── x64 (Microsoft x64 ABI — shadow space + 16-byte alignment + 64-bit loads) ──
//
// Stub frame layout:
// bytes 0..6 sub rsp, 0x20 (7 bytes — shadow space + realignment)
// bytes 7..N mov r64, imm64 ... (10 bytes per reg move: 2-byte prefix + 8-byte imm)
// mov rax, imm64 / mov [rsp+0x20+8*(i-4)], rax for stack args (15 bytes each)
// E8 rel32 call target (5 bytes)
// 48 81 C4 20 00... add rsp, 0x20 (7 bytes)
// C3 ret (1 byte)
//
// Each `mov rNN, imm64` is 10 bytes regardless of the target register:
// RCX REX.W 0xB9 + 8 imm (0x48 0xB9)
// RDX REX.W 0xBA + 8 imm (0x48 0xBA)
// R8 REX.WB 0xB8 + 8 imm (0x49 0xB8 — REX.W|R = 0x49)
// R9 REX.WB 0xB9 + 8 imm (0x49 0xB9)
// RAX REX.W 0xB8 + 8 imm (0x48 0xB8)
[Fact]
public void X64_0args()
public void X64_0args_allocates_shadow_space_and_aligns()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint r=(uint)(t-(a+5));
byte[] stub=s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[],8,CallConvention.Cdecl);
Assert.Equal(6,stub.Length); Assert.Equal(0xE8,stub[0]); Assert.Equal(r,BitConverter.ToUInt32(stub,1)); Assert.Equal(0xC3,stub[5]);
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [], 8, CallConvention.Cdecl);
// sub (7) + call (5) + add (7) + ret (1) = 20
Assert.Equal(20, stub.Length);
uint rel = (uint)(t - (a + 7 + 5)); // = t - a - 12
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20
Assert.Equal(0xE8, stub[7]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 8));
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x20
Assert.Equal(0xC3, stub[19]); // ret
}
[Fact]
public void X64_1arg_mov_ecx()
public void X64_1arg_loads_rcx_as_64bit()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint r=(uint)(t-(a+5+5));
Assert.Equal([
0xB9,0xDD,0xCC,0xBB,0xAA,
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0xAABBCCDD],8,CallConvention.Cdecl));
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [0xAABBCCDDu], 8, CallConvention.Cdecl);
// sub (7) + mov rcx, imm64 (10) + call (5) + add (7) + ret (1) = 30
Assert.Equal(30, stub.Length);
uint rel = (uint)(t - (a + 7 + 10 + 5)); // = t - a - 22
// stub[0..6] = sub rsp, 0x20
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]);
// stub[7..16] = mov rcx, 0x00000000_AABBCCDD (zero-extended)
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0xDD, stub[9]); Assert.Equal(0xCC, stub[10]);
Assert.Equal(0xBB, stub[11]); Assert.Equal(0xAA, stub[12]);
Assert.Equal(0x00, stub[13]); Assert.Equal(0x00, stub[14]);
Assert.Equal(0x00, stub[15]); Assert.Equal(0x00, stub[16]);
// stub[17..21] = call rel32
Assert.Equal(0xE8, stub[17]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 18));
// stub[22..28] = add rsp, 0x20
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[22..29]);
Assert.Equal(0xC3, stub[29]); // ret
}
[Fact]
public void X64_4args_rcx_rdx_r8_r9()
public void X64_4args_loads_rcx_rdx_r8_r9_as_64bit()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint ca=(uint)a+5+5+6+6,r=(uint)(t-(ca+5));
Assert.Equal([
0xB9,0x11,0x11,0x11,0x11, 0xBA,0x22,0x22,0x22,0x22,
0x41,0xB8,0x33,0x33,0x33,0x33, 0x41,0xB9,0x44,0x44,0x44,0x44,
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0x11111111,0x22222222,0x33333333,0x44444444],8,CallConvention.Cdecl));
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t,
[0x11111111u, 0x22222222u, 0x33333333u, 0x44444444u], 8, CallConvention.Cdecl);
// sub (7) + 4 x mov (4*10=40) + call (5) + add (7) + ret (1) = 60
Assert.Equal(60, stub.Length);
uint rel = (uint)(t - (a + 7 + 40 + 5)); // = t - a - 52
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20
// mov rcx, 0x11111111 (48 B9 + 8 imm) at [7..16]
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0x11, stub[9]); Assert.Equal(0x11, stub[10]);
Assert.Equal(0x11, stub[11]); Assert.Equal(0x11, stub[12]);
Assert.Equal(0x00, stub[13]); Assert.Equal(0x00, stub[14]);
Assert.Equal(0x00, stub[15]); Assert.Equal(0x00, stub[16]);
// mov rdx, 0x22222222 (48 BA + 8 imm) at [17..26]
Assert.Equal(0x48, stub[17]); Assert.Equal(0xBA, stub[18]);
Assert.Equal(0x22, stub[19]); Assert.Equal(0x22, stub[20]);
Assert.Equal(0x22, stub[21]); Assert.Equal(0x22, stub[22]);
Assert.Equal(0x00, stub[23]); Assert.Equal(0x00, stub[24]);
Assert.Equal(0x00, stub[25]); Assert.Equal(0x00, stub[26]);
// mov r8, 0x33333333 (49 B8 + 8 imm) at [27..36]
Assert.Equal(0x49, stub[27]); Assert.Equal(0xB8, stub[28]);
Assert.Equal(0x33, stub[29]); Assert.Equal(0x33, stub[30]);
Assert.Equal(0x33, stub[31]); Assert.Equal(0x33, stub[32]);
Assert.Equal(0x00, stub[33]); Assert.Equal(0x00, stub[34]);
Assert.Equal(0x00, stub[35]); Assert.Equal(0x00, stub[36]);
// mov r9, 0x44444444 (49 B9 + 8 imm) at [37..46]
Assert.Equal(0x49, stub[37]); Assert.Equal(0xB9, stub[38]);
Assert.Equal(0x44, stub[39]); Assert.Equal(0x44, stub[40]);
Assert.Equal(0x44, stub[41]); Assert.Equal(0x44, stub[42]);
Assert.Equal(0x00, stub[43]); Assert.Equal(0x00, stub[44]);
Assert.Equal(0x00, stub[45]); Assert.Equal(0x00, stub[46]);
// call rel32 at [47..51]
Assert.Equal(0xE8, stub[47]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 48));
// add rsp, 0x20 at [52..58]
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[52..59]);
// ret at [59]
Assert.Equal(0xC3, stub[59]);
}
[Fact]
public void X64_5args_push_cleanup()
public void X64_5args_places_first_stack_arg_in_shadow_plus_0x20()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint ca=(uint)a+5+5+6+6+5,r=(uint)(t-(ca+5));
Assert.Equal([
0xB9,1,0,0,0, 0xBA,2,0,0,0,
0x41,0xB8,3,0,0,0, 0x41,0xB9,4,0,0,0,
0x68,5,0,0,0,
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
0x48,0x83,0xC4,8, 0xC3],
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[1,2,3,4,5],8,CallConvention.Cdecl));
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t,
[(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5], 8, CallConvention.Cdecl);
// sub (7) + 4 reg moves (40) + stack arg (mov rax 10 + mov [rsp+0x20],rax 5 = 15)
// + call (5) + add (7) + ret (1) = 75
Assert.Equal(75, stub.Length);
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]);
// mov rcx, 1 at [7..16]
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0x01, stub[9]); Assert.Equal(0x00, stub[10]);
Assert.Equal(0x00, stub[11]); Assert.Equal(0x00, stub[12]);
// mov rdx, 2 at [17..26]
Assert.Equal(0x48, stub[17]); Assert.Equal(0xBA, stub[18]);
Assert.Equal(0x02, stub[19]);
// mov r8, 3 at [27..36]
Assert.Equal(0x49, stub[27]); Assert.Equal(0xB8, stub[28]);
Assert.Equal(0x03, stub[29]);
// mov r9, 4 at [37..46]
Assert.Equal(0x49, stub[37]); Assert.Equal(0xB9, stub[38]);
Assert.Equal(0x04, stub[39]);
// mov rax, 5 (48 B8 + 8-byte imm) at [47..56]
Assert.Equal(0x48, stub[47]); Assert.Equal(0xB8, stub[48]);
Assert.Equal(0x05, stub[49]);
for (int k = 50; k <= 56; k++) Assert.Equal(0x00, stub[k]);
// mov [rsp + 0x20], rax (48 89 44 24 20) at [57..61]
Assert.Equal(0x48, stub[57]); Assert.Equal(0x89, stub[58]);
Assert.Equal(0x44, stub[59]); Assert.Equal(0x24, stub[60]);
Assert.Equal(0x20, stub[61]);
// call rel32 at [62..66]; distance = t - (a + 62 + 5) = t - a - 67
uint rel = (uint)(t - (a + 62 + 5));
Assert.Equal(0xE8, stub[62]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 63));
// add rsp, 0x20 at [67..73]
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[67..74]);
// ret at [74]
Assert.Equal(0xC3, stub[74]);
}
[Fact]
public void X64_full_64bit_args_are_preserved_not_truncated()
{
// The bug this catches: an earlier stub emitted "mov r32d, imm32" which zero-extended
// a 32-bit immediate into the lower half of the 64-bit register, silently dropping
// the high bits of any pointer-sized argument above 4 GiB.
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
nuint wideArg = unchecked((nuint)0xDEADBEEF_CAFEBABEUL);
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [wideArg], 8, CallConvention.Cdecl);
// The 8-byte immediate for arg0 lives inside `mov rcx, imm64` at bytes [9..16].
ulong read = BitConverter.ToUInt64(stub, 9);
Assert.Equal(0xDEADBEEF_CAFEBABEul, read);
}
[Fact]
public void X86_target_rejects_arg_value_larger_than_32_bits()
{
if (!Environment.Is64BitProcess)
{
// On a 32-bit host, nuint cannot exceed uint.MaxValue — the precondition
// cannot be exercised. Mark the test as an intentional no-op.
Assert.True(true);
return;
}
var s = Create();
nuint tooBig = unchecked((nuint)0x1_00000000UL);
Assert.Throws<ArgumentOutOfRangeException>(() =>
s.BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [tooBig], 4, CallConvention.Cdecl));
}
// ── Edge cases ────────────────────────────────────────────────────────
@@ -193,8 +348,9 @@ public class StubAssemblerTests
[Fact]
public void Many_args_cleanup_uses_imm32_form()
{
var args = new uint[33];
for (int i = 0; i < 33; i++) args[i] = (uint)(i * 0x10000 + i);
// x86 path: 33 args, stack cleanup > 127 bytes → must emit add esp, imm32 (81 C4)
var args = new nuint[33];
for (int i = 0; i < 33; i++) args[i] = (nuint)(uint)(i * 0x10000 + i);
byte[] stub = Create().BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, args, 4, CallConvention.Cdecl);
@@ -21,15 +21,15 @@
## 3. Managed Assembler (spec: managed-assembler)
- [ ] 3.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives
- [ ] 3.2 Implement `WhiteMagic/Assembly/StubAssembler.cs` emitters + `IAssembler` interface to pass 3.1
- [ ] 3.3 Add tests for x86 cdecl stub encoding (reverse push, call, `add esp, N*4`, ret) with known byte expectations
- [ ] 3.4 Implement x86 cdecl stub to pass 3.3
- [ ] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
- [ ] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
- [ ] 3.7 Add tests for x64 stub argument-register placement and call
- [ ] 3.8 Implement x64 stub to pass 3.7
- [ ] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references)
- [x] 3.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives
- [x] 3.2 Implement `WhiteMagic/Assembly/StubAssembler.cs` emitters + `IAssembler` interface to pass 3.1
- [x] 3.3 Add tests for x86 cdecl stub encoding (reverse push, call, `add esp, N*4`, ret) with known byte expectations
- [x] 3.4 Implement x86 cdecl stub to pass 3.3
- [x] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
- [x] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
- [x] 3.7 Add tests for x64 stub argument-register placement and call. Now also asserts Microsoft x64 ABI compliance: `sub rsp, 0x20` shadow-space allocation (stack args land at `[rsp + 0x20 + 8*(i-4)]`), 16-byte stack alignment at the inner `call target`, `mov r64, imm64` loads for RCX/RDX/R8/R9 with the full 64-bit immediate (regression test for the old `mov r32d, imm32` truncation bug).
- [x] 3.8 Implement x64 stub to pass 3.7. Builds a compliant Microsoft x64 ABI frame: `sub rsp, 0x20`, 64-bit register loads, stack-argv above the shadow window, `call rel32`, `add rsp, 0x20`, `ret`. Accepts `nuint[]` so callers can pass full 64-bit pointers unchanged (x86 path truncates `nuint``uint` with a range check).
- [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)
@@ -0,0 +1,45 @@
# 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
+88
View File
@@ -0,0 +1,88 @@
# 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