Implement RemoteModule/RemoteFunction and prove x64 ABI at runtime

Task 7.2: export resolution + module/function facade.
- PeHeaderParser.GetExportAddress walks the PE32/PE32+ export directory and
  follows export forwarders (e.g. kernel32!HeapAlloc -> NTDLL.RtlAllocateHeap)
  into other loaded modules; ordinal and unresolvable API-set forwarders throw
  NotSupportedException.
- RemoteModule resolves a module base via Process.Modules (name match tolerant
  of .dll/case); RemoteFunction executes via RemoteThreadExecutor by default,
  exposes Address for pump routing and CreateDelegate<T> for in-process.
- Magic gains a string indexer: magic["user32"]["MessageBoxA"].

Task 3.8: add the missing live-execution ABI test - an SSE callee whose aligned
movaps #GPs unless the stub delivers a 16-byte-aligned stack, combined with a
5th stack argument. Runtime-proves shadow space, alignment, and arg placement.

Tests: 214 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kbe
2026-07-22 02:49:51 +02:00
co-authored by Claude Opus 4.8
parent eda467bc46
commit 678cb00895
7 changed files with 438 additions and 2 deletions
+133
View File
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic.Native;
namespace WhiteMagic.Discovery;
@@ -103,6 +104,138 @@ public sealed class PeHeaderParser
}
}
/// <summary>
/// Resolves an exported function's absolute address by name, following export
/// forwarders (e.g. <c>kernel32!HeapAlloc</c> → <c>NTDLL.RtlAllocateHeap</c>) into
/// other modules loaded in the same target process.
/// </summary>
/// <param name="functionName">The exported symbol name (case-sensitive, as stored
/// in the export name table).</param>
/// <returns>The absolute address of the export in the target process.</returns>
/// <exception cref="InvalidOperationException">The export is not present.</exception>
/// <exception cref="NotSupportedException">The export forwards to an ordinal or to a
/// module (such as an API set) that is not resolvable from the target's module list.</exception>
/// <exception cref="InvalidDataException">The PE export data is malformed.</exception>
public IntPtr GetExportAddress(string functionName)
{
ArgumentException.ThrowIfNullOrEmpty(functionName);
return ResolveExport(functionName, 0);
}
// Maximum forwarder hops before giving up, to bound pathological chains.
private const int MaxForwarderDepth = 16;
private IntPtr ResolveExport(string functionName, int depth)
{
if (depth > MaxForwarderDepth)
throw new InvalidDataException($"Export forwarder chain for '{functionName}' is too deep.");
var (optionalHeader, _) = ParseOptionalHeader();
if (optionalHeader is null || optionalHeader.Length < 2)
throw new InvalidDataException("Optional header unavailable.");
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit). Data directories start at a
// different offset in each: 96 for PE32, 112 for PE32+. The export table is
// directory index 0, so its 8-byte entry sits at that offset.
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
bool pe32Plus = magic == 0x20b;
int exportDirOffset = pe32Plus ? 112 : 96;
if (optionalHeader.Length < exportDirOffset + 8)
throw new InvalidDataException("Optional header does not contain the export data directory.");
uint exportRva = BitConverter.ToUInt32(optionalHeader, exportDirOffset);
uint exportSize = BitConverter.ToUInt32(optionalHeader, exportDirOffset + 4);
if (exportRva == 0 || exportSize == 0)
throw new InvalidOperationException("Module has no export table.");
// IMAGE_EXPORT_DIRECTORY is 40 bytes.
byte[] dir = _memory.ReadBytes(_baseAddress + (nint)exportRva, 40);
if (dir.Length < 40)
throw new InvalidDataException("Failed to read the export directory.");
uint numberOfFunctions = BitConverter.ToUInt32(dir, 20);
uint numberOfNames = BitConverter.ToUInt32(dir, 24);
uint addressOfFunctions = BitConverter.ToUInt32(dir, 28);
uint addressOfNames = BitConverter.ToUInt32(dir, 32);
uint addressOfNameOrdinals = BitConverter.ToUInt32(dir, 36);
// Guard against corrupt counts before allocating arrays sized from them.
if (numberOfNames > 0x10000 || numberOfFunctions > 0x10000)
throw new InvalidDataException("Export table entry count is out of range.");
if (numberOfNames == 0)
throw new InvalidOperationException($"Export '{functionName}' not found (module exports no names).");
byte[] nameRvas = _memory.ReadBytes(_baseAddress + (nint)addressOfNames, checked((int)(numberOfNames * 4)));
byte[] nameOrdinals = _memory.ReadBytes(_baseAddress + (nint)addressOfNameOrdinals, checked((int)(numberOfNames * 2)));
if (nameRvas.Length < numberOfNames * 4 || nameOrdinals.Length < numberOfNames * 2)
throw new InvalidDataException("Failed to read the export name tables.");
int nameIndex = -1;
for (int i = 0; i < numberOfNames; i++)
{
uint nameRva = BitConverter.ToUInt32(nameRvas, i * 4);
string name = _memory.ReadString(_baseAddress + (nint)nameRva, Encoding.ASCII, 512);
if (string.Equals(name, functionName, StringComparison.Ordinal))
{
nameIndex = i;
break;
}
}
if (nameIndex < 0)
throw new InvalidOperationException($"Export '{functionName}' not found in module.");
ushort ordinal = BitConverter.ToUInt16(nameOrdinals, nameIndex * 2);
if (ordinal >= numberOfFunctions)
throw new InvalidDataException("Export name ordinal is out of range.");
byte[] funcRvaBytes = _memory.ReadBytes(
_baseAddress + (nint)(addressOfFunctions + (uint)ordinal * 4u), 4);
if (funcRvaBytes.Length < 4)
throw new InvalidDataException("Failed to read the export address table entry.");
uint funcRva = BitConverter.ToUInt32(funcRvaBytes, 0);
if (funcRva == 0)
throw new InvalidOperationException($"Export '{functionName}' has no address.");
// A function RVA that lands inside the export directory region is not code but a
// null-terminated "Module.Function" forwarder string.
if (funcRva >= exportRva && funcRva < exportRva + exportSize)
{
string forwarder = _memory.ReadString(_baseAddress + (nint)funcRva, Encoding.ASCII, 512);
return ResolveForwarder(forwarder, depth);
}
return _baseAddress + (nint)funcRva;
}
private IntPtr ResolveForwarder(string forwarder, int depth)
{
int dot = forwarder.LastIndexOf('.');
if (dot <= 0 || dot >= forwarder.Length - 1)
throw new InvalidDataException($"Malformed export forwarder string '{forwarder}'.");
string moduleName = forwarder[..dot];
string target = forwarder[(dot + 1)..];
if (target.StartsWith('#'))
{
throw new NotSupportedException(
$"Ordinal export forwarders are not supported (forwarder '{forwarder}').");
}
IntPtr targetBase = RemoteModule.ResolveBase(_memory.ProcessId, moduleName);
if (targetBase == IntPtr.Zero)
{
throw new NotSupportedException(
$"Export forwarder target module '{moduleName}' is not loaded in the target " +
$"process, or is an unresolvable API set (forwarder '{forwarder}').");
}
return new PeHeaderParser(_memory, targetBase).ResolveExport(target, depth + 1);
}
/// <summary>
/// Parses the DOS header, PE signature, and optional header.
/// </summary>
+4
View File
@@ -54,6 +54,10 @@ public sealed class Magic : IDisposable
/// <summary>Returns a <see cref="RemotePointer"/> at <paramref name="address"/>.</summary>
public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
/// <summary>Returns the loaded <see cref="RemoteModule"/> named <paramref name="moduleName"/>
/// (e.g. <c>magic["user32"]["MessageBoxA"]</c>).</summary>
public RemoteModule this[string moduleName] => new RemoteModule(this, moduleName);
/// <inheritdoc />
public void Dispose()
{
+62
View File
@@ -0,0 +1,62 @@
using System.Threading.Tasks;
using WhiteMagic.Assembly;
using WhiteMagic.Execution;
namespace WhiteMagic;
/// <summary>
/// An exported function resolved in the target process, obtained via
/// <c>magic["module"]["function"]</c>. Executes through one of the session's execution
/// strategies.
/// </summary>
/// <remarks>
/// The default <see cref="Execute{T}"/> path uses the always-available
/// <see cref="RemoteThreadExecutor"/> (<c>CreateRemoteThread</c>), which is safe for
/// thread-agnostic exports. For a call that touches single-threaded target state, obtain
/// the <see cref="Address"/> and route it through a <see cref="MainThreadPump"/>, or use
/// <see cref="CreateDelegate{TDelegate}"/> when running in-process.
/// </remarks>
public sealed class RemoteFunction
{
private readonly Magic _magic;
/// <summary>The export name this function was resolved from.</summary>
public string Name { get; }
/// <summary>The absolute address of the function in the target process.</summary>
public IntPtr Address { get; }
internal RemoteFunction(Magic magic, string name, IntPtr address)
{
_magic = magic;
Name = name;
Address = address;
}
/// <summary>
/// Calls the function via a remote thread and returns its result cast to
/// <typeparamref name="T"/>.
/// </summary>
/// <param name="convention">The calling convention (ignored on x64 targets).</param>
/// <param name="args">Arguments to pass; primitives, pointers, enums, strings and
/// structs are supported.</param>
public T Execute<T>(CallConvention convention, params object?[] args)
{
return _magic.RemoteThread.Execute<T>(Address, convention, args);
}
/// <summary>Asynchronous variant of <see cref="Execute{T}"/>.</summary>
public Task<T> ExecuteAsync<T>(CallConvention convention, params object?[] args)
{
return _magic.RemoteThread.ExecuteAsync<T>(Address, convention, args);
}
/// <summary>
/// Creates a managed delegate bound to this function for the in-process scenario.
/// Only valid when the session was opened in-process.
/// </summary>
public TDelegate CreateDelegate<TDelegate>() where TDelegate : Delegate
{
return new InProcessInvoker(_magic.Memory).CreateFunction<TDelegate>(Address);
}
}
+101
View File
@@ -0,0 +1,101 @@
using WhiteMagic.Discovery;
using Process = System.Diagnostics.Process;
using ProcessModule = System.Diagnostics.ProcessModule;
namespace WhiteMagic;
/// <summary>
/// A module (loaded DLL/EXE image) in the target process, obtained by indexing the
/// facade with a module name (e.g. <c>magic["user32"]</c>). Exposes the module's base
/// address and resolves exported functions by name.
/// </summary>
public sealed class RemoteModule
{
private readonly Magic _magic;
/// <summary>The module's file name as reported by the OS (e.g. <c>user32.dll</c>).</summary>
public string Name { get; }
/// <summary>The module's load address in the target process.</summary>
public IntPtr BaseAddress { get; }
internal RemoteModule(Magic magic, string moduleName)
{
ArgumentNullException.ThrowIfNull(magic);
ArgumentException.ThrowIfNullOrEmpty(moduleName);
_magic = magic;
(string name, IntPtr baseAddress) = FindModule(magic.Memory.ProcessId, moduleName);
Name = name;
BaseAddress = baseAddress;
}
/// <summary>
/// Resolves an exported function by name and returns a <see cref="RemoteFunction"/>
/// bound to its address. Export forwarders are followed.
/// </summary>
public RemoteFunction this[string functionName]
{
get
{
IntPtr address = GetExportAddress(functionName);
return new RemoteFunction(_magic, functionName, address);
}
}
/// <summary>Resolves the absolute address of an exported function by name.</summary>
public IntPtr GetExportAddress(string functionName)
{
ArgumentException.ThrowIfNullOrEmpty(functionName);
var parser = new PeHeaderParser(_magic.Memory, BaseAddress);
return parser.GetExportAddress(functionName);
}
private static (string Name, IntPtr BaseAddress) FindModule(int processId, string moduleName)
{
using Process process = Process.GetProcessById(processId);
foreach (ProcessModule module in process.Modules)
{
if (NameMatches(module.ModuleName, moduleName))
return (module.ModuleName, module.BaseAddress);
}
throw new DllNotFoundException(
$"Module '{moduleName}' is not loaded in process {processId}.");
}
/// <summary>
/// Resolves a module's base address by name within a target process, returning
/// <see cref="IntPtr.Zero"/> if it is not loaded. Used by export-forwarder resolution.
/// </summary>
internal static IntPtr ResolveBase(int processId, string moduleName)
{
using Process process = Process.GetProcessById(processId);
foreach (ProcessModule module in process.Modules)
{
if (NameMatches(module.ModuleName, moduleName))
return module.BaseAddress;
}
return IntPtr.Zero;
}
/// <summary>
/// Matches a loaded module's file name against a requested name, tolerating a missing
/// or present <c>.dll</c> extension and ignoring case (e.g. <c>KERNEL32</c> matches
/// <c>kernel32.dll</c>).
/// </summary>
private static bool NameMatches(string actual, string requested)
{
if (string.Equals(actual, requested, StringComparison.OrdinalIgnoreCase))
return true;
string actualNoExt = Path.GetFileNameWithoutExtension(actual);
string requestedNoExt = requested.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
? requested[..^4]
: requested;
return string.Equals(actualNoExt, requestedNoExt, StringComparison.OrdinalIgnoreCase);
}
}