Fix x64 stub ABI and marshal-path sizing; dedupe memory readers

x64 call stub was ABI-broken: fixed 0x20 frame left rsp misaligned at the
inner call (callee entry rsp ≡ 0, ABI requires ≡ 8) and, for 5+ args, wrote
stack args over the return address. Compute frame K ≡ 8 (mod 16), K ≥
0x20 + 8*stackArgs, so the callee sees a 16-aligned stack and stack args land
above the shadow window. Load register args as full 64-bit imm64 (was imm32,
which truncated pointers > 4 GiB). BuildCallStub now takes nuint[]; x86 range-
checks each arg against uint.MaxValue instead of silently truncating.

MarshalCache conflated managed and unmanaged width in one Size field: the
blittable path needs Unsafe.SizeOf<T> (bool = 1) while the marshal path needs
Marshal.SizeOf<T> (inline ByValTStr/ByValArray expand past the managed
pointer). Add MarshalSize; MemoryBase picks per TypeRequiresMarshal at all four
IO sites. Prevents PtrToStructure/StructureToPtr from over-reading/overwriting
the pinned scratch buffer (heap corruption on write).

Extract shared RPM/WPM into RpmHelper: honor partial reads (dead Array.Resize
removed), consistent write-return semantics; InProcessReader now guards
MainModule like ExternalReader.

Tests: x64 frame-alignment property + inline-marshal round-trip added (both
fail against the pre-fix code); existing x64 byte-expectation tests updated to
the new frame. Build clean, 100/100 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kbe
2026-07-21 22:30:10 +02:00
co-authored by Claude Opus 4.8
parent 184dec86ca
commit 12b9b6c03e
7 changed files with 343 additions and 101 deletions
+45 -44
View File
@@ -170,56 +170,60 @@ public sealed class StubAssembler : IAssembler
/// <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.
/// above a 32-byte shadow space; 16-byte stack alignment at the inner <c>call</c>.
/// </summary>
/// <remarks>
/// <para>The stub frame:</para>
/// <para>Frame derivation. The ABI requires the <em>inner</em> <c>call</c> site to
/// land with call-site rsp ≡ 0 (mod 16), so that <c>call</c> pushes 8 bytes and the
/// callee sees entry rsp ≡ 8 — the value an MSVC prologue (<c>push rbp; sub rsp, 0x20</c>)
/// expects, and the only value for which locals land 16-aligned (SSE-safe).</para>
/// <list type="bullet">
/// <item>Stub entry: rsp ≡ 8 (mod 16).</item>
/// <item>Need post-sub rsp ≡ 0 → sub operand K satisfies K ≡ 8 (mod 16).</item>
/// <item>Frame must hold shadow space (0x20) + stack args (8 bytes each for args 4+).
/// Choose the smallest such K: <c>K = frameBytes + ((8 frameBytes) mod 16 + 16) mod 16</c>.
/// For 05 args, K ∈ {0x28, 0x38}; pattern scales linearly.</item>
/// </list>
/// <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
/// sub rsp, K ; K ≡ 8 (mod 16), K ≥ 0x20 + 8·stackArgs
/// mov rcx, arg0 ; REX.W + imm64 (10 bytes)
/// mov rdx, arg1 ; REX.W + imm64 (10 bytes)
/// mov r8, arg2 ; REX.WB+ imm64 (10 bytes, REX.R)
/// mov r9, arg3 ; REX.WB+ imm64 (10 bytes, REX.R)
/// mov rax, arg[N] ; REX.W + imm64 (10 bytes)
/// mov [rsp + 0x20 + 8*(N-4)], rax (5/8 bytes)
/// call target (rel32) ( 5 bytes)
/// add rsp, K ( 7 bytes)
/// ret ( 1 byte)
/// </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, nuint[] args)
{
ulong current = stubAddr;
// 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);
// Compute frame size K. K ≡ 8 (mod 16) so that the inner call sees
// post-sub rsp ≡ 0 and delivers target entry rsp ≡ 8 (mod 16).
int stackArgs = Math.Max(0, args.Length - 4);
int frameBytes = 0x20 + 8 * stackArgs;
int k = frameBytes + ((8 - (frameBytes % 16) + 16) % 16);
// sub rsp, imm32 (always imm32 form — constant 7 bytes regardless of K).
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC);
EmitU32(buffer, (uint)k);
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)
// 64-bit register loads for args 0..3. All encodings are exactly 10 bytes:
// REX.W (0x48) + 0xB9 + imm64 → mov rcx, imm64
// REX.W (0x48) + 0xBA + imm64 → mov rdx, imm64
// REX.WB(0x49) + 0xB8 + imm64 → mov r8, imm64 (REX.R for r8)
// REX.WB(0x49) + 0xB9 + imm64 → mov r9, imm64 (REX.R)
byte[][] regMoves =
[
[0x48, 0xB9], // mov rcx, imm64
[0x48, 0xBA], // mov rdx, imm64
[0x49, 0xB8], // mov r8, imm64
[0x49, 0xB9], // mov r9, imm64
[0x48, 0xB9],
[0x48, 0xBA],
[0x49, 0xB8],
[0x49, 0xB9],
];
int regCount = Math.Min(args.Length, 4);
@@ -232,10 +236,8 @@ public sealed class StubAssembler : IAssembler
current += (uint)(prefix.Length + 8);
}
// 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)
// Stack args: written at [post-sub-rsp + 0x20 + 8*(i-4)], i.e. above the
// shadow window, where the inner call's callee expects them.
for (int i = 4; i < args.Length; i++)
{
int offset = 0x20 + (i - 4) * 8;
@@ -258,7 +260,7 @@ public sealed class StubAssembler : IAssembler
}
}
// 4. call rel32
// call rel32
long distance = (long)target - (long)(current + 5);
if (distance is < int.MinValue or > int.MaxValue)
{
@@ -269,11 +271,10 @@ public sealed class StubAssembler : IAssembler
EmitU32(buffer, (uint)distance);
current += 5;
// 5. add rsp, 0x20 ; tear down shadow space
// Tear down the frame symmetrically.
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4);
EmitU32(buffer, 0x20);
EmitU32(buffer, (uint)k);
// 6. ret
buffer.Add(0xC3);
}
+48 -18
View File
@@ -5,25 +5,44 @@ using System.Runtime.InteropServices;
namespace WhiteMagic;
/// <summary>
/// Computes and caches the byte size and marshalling decision for type
/// <typeparamref name="T"/> exactly once. <see cref="MemoryBase.Read{T}"/> and
/// Caches the widths marshalling decisions for type <typeparamref name="T"/>
/// once, at static-constructor time. <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.
/// and pick the appropriate width from this cache.
/// </summary>
/// <typeparam name="T">The type to cache metadata for.</typeparam>
public static class MarshalCache<T>
{
/// <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.
/// The blittable (managed layout) width of <typeparamref name="T"/>. This is
/// what <see cref="MemoryMarshal.Read{T}"/> / <see cref="MemoryMarshal.Write{T}"/>
/// actually consume. Equals <see cref="Unsafe.SizeOf{T}"/> in the general case,
/// with fixed-width overrides for <see cref="bool"/>, <see cref="char"/>, and
/// enums so the cache value matches the primitive layout width used by those
/// paths.
/// </summary>
public static readonly int Size;
/// <summary>
/// The unmanaged (interop) width via <see cref="Marshal.SizeOf"/>. The marshal
/// path (<see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/>)
/// reads/writes this many bytes. Exceeds <see cref="Size"/> whenever a struct
/// carries inline unmanaged data that the marshaler expands — inline
/// <c>ByValTStr</c>/<c>ByValArray</c> buffers, <c>bool</c> fields (4 bytes per
/// default Win32 BOOL marshaling vs 1 byte managed), etc. For types that do
/// not go through the marshal path, this field is still populated but unused
/// by MemoryBase.
/// </summary>
/// <remarks>
/// <c>Marshal.SizeOf</c> throws for some reference-containing shapes (e.g. a
/// bare <see cref="string"/>). When that happens, we fall back to
/// <see cref="Size"/> — the fallback path is unreachable from production code
/// because types with a reference field always have
/// <see cref="TypeRequiresMarshal"/> true, so MemoryBase reads this field only
/// when it is known to be populated.
/// </remarks>
public static readonly int MarshalSize;
/// <summary>
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through
/// the blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path
@@ -35,9 +54,9 @@ public static class MarshalCache<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.
/// <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.
/// </remarks>
public static readonly bool TypeRequiresMarshal;
@@ -51,7 +70,6 @@ public static class MarshalCache<T>
{
// 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;
}
else if (typeof(T).IsEnum)
@@ -60,10 +78,10 @@ public static class MarshalCache<T>
}
else
{
// 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).
// 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> disagrees when a struct contains a
// `bool` field (unmanaged 4 vs managed 1).
Size = Unsafe.SizeOf<T>();
}
@@ -73,5 +91,17 @@ public static class MarshalCache<T>
TypeRequiresMarshal =
hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences<T>();
// MarshalSize is only consulted when TypeRequiresMarshal is true; for the
// rare case where Marshal.SizeOf refuses a shape (ref-containing structs),
// fall back to the managed size so the field stays populated.
try
{
MarshalSize = Marshal.SizeOf<T>();
}
catch (ArgumentException)
{
MarshalSize = Size;
}
}
}
+4 -4
View File
@@ -37,7 +37,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative)
address = GetAbsolute(address);
int size = MarshalCache<T>.Size;
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
byte[] raw = ReadBytes(address, size);
if (raw.Length < size)
@@ -56,7 +56,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative)
address = GetAbsolute(address);
int size = MarshalCache<T>.Size;
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
byte[] raw;
if (MarshalCache<T>.TypeRequiresMarshal)
@@ -81,7 +81,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative)
address = GetAbsolute(address);
int elementSize = MarshalCache<T>.Size;
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
long totalSize = (long)elementSize * count;
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
@@ -127,7 +127,7 @@ public abstract class MemoryBase : IDisposable
if (values is null || values.Length == 0)
return true;
int elementSize = MarshalCache<T>.Size;
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
long total = (long)elementSize * values.Length;
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
int totalSize = (int)total;
+61
View File
@@ -0,0 +1,61 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// Shared ReadProcessMemory / WriteProcessMemory wrappers used by both
/// <see cref="ExternalReader"/> and <see cref="InProcessReader"/>. Kept in a single
/// location to keep the two readers byte-for-byte consistent on partial-read handling,
/// write-return semantics, and failure modes.
/// </summary>
internal static class RpmHelper
{
/// <summary>
/// Reads up to <paramref name="count"/> bytes from <paramref name="address"/> in
/// the process identified by <paramref name="handle"/>. Returns:
/// <list type="bullet">
/// <item>An empty array if <see cref="NativeMethods.ReadProcessMemory"/> fails and
/// reports zero bytes read.</item>
/// <item>A truncated array of exactly <c>bytesRead</c> bytes when the call returns
/// <see langword="false"/> but the OS has placed a partial copy in the buffer
/// (for example, <c>ERROR_PARTIAL_COPY</c>).</item>
/// <item>The full buffer on success.</item>
/// </list>
/// </summary>
public static byte[] ReadBytes(SafeMemoryHandle handle, IntPtr address, int count)
{
byte[] buffer = new byte[count];
bool ok = NativeMethods.ReadProcessMemory(handle, address, buffer, count, out nint bytesRead);
if (!ok && bytesRead == 0)
{
return [];
}
if ((int)bytesRead < count)
{
// Either a successful short read, or a failed-but-partial RPM. In both
// cases honor the bytes the OS actually produced rather than padding.
byte[] partial = new byte[(int)bytesRead];
Buffer.BlockCopy(buffer, 0, partial, 0, (int)bytesRead);
return partial;
}
return buffer;
}
/// <summary>
/// Writes <paramref name="bytes"/> to <paramref name="address"/> in the process
/// identified by <paramref name="handle"/>. Returns the number of bytes actually
/// written, or 0 on total failure.
/// </summary>
public static int WriteBytes(SafeMemoryHandle handle, IntPtr address, ReadOnlySpan<byte> bytes)
{
if (!NativeMethods.WriteProcessMemory(handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
}
}