diff --git a/WhiteMagic/Discovery/PeHeaderParser.cs b/WhiteMagic/Discovery/PeHeaderParser.cs
index bb24d2d..d92b914 100644
--- a/WhiteMagic/Discovery/PeHeaderParser.cs
+++ b/WhiteMagic/Discovery/PeHeaderParser.cs
@@ -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
}
}
+ ///
+ /// Resolves an exported function's absolute address by name, following export
+ /// forwarders (e.g. kernel32!HeapAlloc → NTDLL.RtlAllocateHeap) into
+ /// other modules loaded in the same target process.
+ ///
+ /// The exported symbol name (case-sensitive, as stored
+ /// in the export name table).
+ /// The absolute address of the export in the target process.
+ /// The export is not present.
+ /// 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.
+ /// The PE export data is malformed.
+ 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);
+ }
+
///
/// Parses the DOS header, PE signature, and optional header.
///
diff --git a/WhiteMagic/Magic.cs b/WhiteMagic/Magic.cs
index c25a558..d3990d3 100644
--- a/WhiteMagic/Magic.cs
+++ b/WhiteMagic/Magic.cs
@@ -54,6 +54,10 @@ public sealed class Magic : IDisposable
/// Returns a at .
public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
+ /// Returns the loaded named
+ /// (e.g. magic["user32"]["MessageBoxA"]).
+ public RemoteModule this[string moduleName] => new RemoteModule(this, moduleName);
+
///
public void Dispose()
{
diff --git a/WhiteMagic/RemoteFunction.cs b/WhiteMagic/RemoteFunction.cs
new file mode 100644
index 0000000..e353063
--- /dev/null
+++ b/WhiteMagic/RemoteFunction.cs
@@ -0,0 +1,62 @@
+using System.Threading.Tasks;
+using WhiteMagic.Assembly;
+using WhiteMagic.Execution;
+
+namespace WhiteMagic;
+
+///
+/// An exported function resolved in the target process, obtained via
+/// magic["module"]["function"]. Executes through one of the session's execution
+/// strategies.
+///
+///
+/// The default path uses the always-available
+/// (CreateRemoteThread), which is safe for
+/// thread-agnostic exports. For a call that touches single-threaded target state, obtain
+/// the and route it through a , or use
+/// when running in-process.
+///
+public sealed class RemoteFunction
+{
+ private readonly Magic _magic;
+
+ /// The export name this function was resolved from.
+ public string Name { get; }
+
+ /// The absolute address of the function in the target process.
+ public IntPtr Address { get; }
+
+ internal RemoteFunction(Magic magic, string name, IntPtr address)
+ {
+ _magic = magic;
+ Name = name;
+ Address = address;
+ }
+
+ ///
+ /// Calls the function via a remote thread and returns its result cast to
+ /// .
+ ///
+ /// The calling convention (ignored on x64 targets).
+ /// Arguments to pass; primitives, pointers, enums, strings and
+ /// structs are supported.
+ public T Execute(CallConvention convention, params object?[] args)
+ {
+ return _magic.RemoteThread.Execute(Address, convention, args);
+ }
+
+ /// Asynchronous variant of .
+ public Task ExecuteAsync(CallConvention convention, params object?[] args)
+ {
+ return _magic.RemoteThread.ExecuteAsync(Address, convention, args);
+ }
+
+ ///
+ /// Creates a managed delegate bound to this function for the in-process scenario.
+ /// Only valid when the session was opened in-process.
+ ///
+ public TDelegate CreateDelegate() where TDelegate : Delegate
+ {
+ return new InProcessInvoker(_magic.Memory).CreateFunction(Address);
+ }
+}
diff --git a/WhiteMagic/RemoteModule.cs b/WhiteMagic/RemoteModule.cs
new file mode 100644
index 0000000..f380016
--- /dev/null
+++ b/WhiteMagic/RemoteModule.cs
@@ -0,0 +1,101 @@
+using WhiteMagic.Discovery;
+using Process = System.Diagnostics.Process;
+using ProcessModule = System.Diagnostics.ProcessModule;
+
+namespace WhiteMagic;
+
+///
+/// A module (loaded DLL/EXE image) in the target process, obtained by indexing the
+/// facade with a module name (e.g. magic["user32"]). Exposes the module's base
+/// address and resolves exported functions by name.
+///
+public sealed class RemoteModule
+{
+ private readonly Magic _magic;
+
+ /// The module's file name as reported by the OS (e.g. user32.dll).
+ public string Name { get; }
+
+ /// The module's load address in the target process.
+ 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;
+ }
+
+ ///
+ /// Resolves an exported function by name and returns a
+ /// bound to its address. Export forwarders are followed.
+ ///
+ public RemoteFunction this[string functionName]
+ {
+ get
+ {
+ IntPtr address = GetExportAddress(functionName);
+ return new RemoteFunction(_magic, functionName, address);
+ }
+ }
+
+ /// Resolves the absolute address of an exported function by name.
+ 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}.");
+ }
+
+ ///
+ /// Resolves a module's base address by name within a target process, returning
+ /// if it is not loaded. Used by export-forwarder resolution.
+ ///
+ 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;
+ }
+
+ ///
+ /// Matches a loaded module's file name against a requested name, tolerating a missing
+ /// or present .dll extension and ignoring case (e.g. KERNEL32 matches
+ /// kernel32.dll).
+ ///
+ 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);
+ }
+}
diff --git a/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs b/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs
index cb462b5..161c8fa 100644
--- a/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs
+++ b/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs
@@ -35,6 +35,32 @@ public sealed class RemoteThreadExecutorTests
0xC3
];
+ // Five-arg callee that also executes an alignment-sensitive SSE instruction, proving
+ // the stub delivers a 16-byte-aligned stack the CPU actually accepts (movaps #GPs on a
+ // misaligned address) alongside correct register+stack argument placement.
+ // sub rsp, 24 ; entry rsp ≡ 8 (mod 16) -> rsp ≡ 0 (16-aligned), 16-byte
+ // ; scratch at [rsp..rsp+16) that clears the return slot ([rsp+24])
+ // movaps [rsp], xmm0 ; aligned 16-byte store — faults unless rsp is 16-aligned
+ // add rsp, 24 ; restore
+ // mov eax, ecx
+ // add eax, edx
+ // add eax, r8d
+ // add eax, r9d
+ // add eax, [rsp+0x28] ; 5th arg above the shadow space
+ // ret
+ private static readonly byte[] SseAlignedSumPayload =
+ [
+ 0x48, 0x83, 0xEC, 0x18,
+ 0x0F, 0x29, 0x04, 0x24,
+ 0x48, 0x83, 0xC4, 0x18,
+ 0x89, 0xC8,
+ 0x01, 0xD0,
+ 0x44, 0x01, 0xC0,
+ 0x44, 0x01, 0xC8,
+ 0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
+ 0xC3
+ ];
+
// xor eax, eax
// cmp byte ptr [rcx+rax], 0
// je done
@@ -110,6 +136,21 @@ public sealed class RemoteThreadExecutorTests
Assert.Equal(0, misalign);
}
+ [Fact]
+ public void Execute_runs_sse_callee_with_five_args()
+ {
+ if (!Environment.Is64BitProcess)
+ {
+ return;
+ }
+
+ // Correct result (150) requires BOTH the 5th arg reaching [rsp+0x28] AND the
+ // aligned movaps not faulting. A broken frame size/alignment either mis-sums or
+ // #GPs in the callee.
+ int result = RunPayload(SseAlignedSumPayload, CallConvention.Cdecl, 10, 20, 30, 40, 50);
+ Assert.Equal(150, result);
+ }
+
[Fact]
public void Execute_marshals_string_as_utf8_pointer()
{
diff --git a/WhiteMagicTest/ModuleFunctionTests.cs b/WhiteMagicTest/ModuleFunctionTests.cs
new file mode 100644
index 0000000..68cff17
--- /dev/null
+++ b/WhiteMagicTest/ModuleFunctionTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Diagnostics;
+using WhiteMagic;
+using WhiteMagic.Assembly;
+using WhiteMagic.Native;
+using Xunit;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for / resolution and
+/// execution through the facade (task 7.2).
+///
+public class ModuleFunctionTests
+{
+ // Ensure a module is loaded in this process before resolving it.
+ private static IntPtr Load(string module)
+ {
+ IntPtr handle = NativeMethods.LoadLibrary(module);
+ Assert.NotEqual(IntPtr.Zero, handle);
+ return handle;
+ }
+
+ [Fact]
+ public void Module_indexer_resolves_base_address()
+ {
+ IntPtr handle = Load("kernel32.dll");
+
+ using var magic = Magic.OpenInProcess();
+ RemoteModule module = magic["kernel32"];
+
+ // The module handle returned by LoadLibrary is the module's base address.
+ Assert.Equal(handle, module.BaseAddress);
+ Assert.Equal("KERNEL32.DLL", module.Name, ignoreCase: true);
+ }
+
+ [Fact]
+ public void Function_indexer_resolves_direct_export()
+ {
+ IntPtr handle = Load("user32.dll");
+ IntPtr expected = NativeMethods.GetProcAddress(handle, "MessageBoxA");
+ Assert.NotEqual(IntPtr.Zero, expected);
+
+ using var magic = Magic.OpenInProcess();
+ RemoteFunction fn = magic["user32"]["MessageBoxA"];
+
+ Assert.Equal(expected, fn.Address);
+ Assert.Equal("MessageBoxA", fn.Name);
+ }
+
+ [Fact]
+ public void Function_indexer_follows_export_forwarder()
+ {
+ // kernel32!HeapAlloc is a classic forwarder to NTDLL.RtlAllocateHeap. Whatever the
+ // OS loader resolves it to, our parser must reach the same final address.
+ IntPtr handle = Load("kernel32.dll");
+ IntPtr expected = NativeMethods.GetProcAddress(handle, "HeapAlloc");
+ Assert.NotEqual(IntPtr.Zero, expected);
+
+ using var magic = Magic.OpenInProcess();
+ RemoteFunction fn = magic["kernel32"]["HeapAlloc"];
+
+ Assert.Equal(expected, fn.Address);
+ }
+
+ [Fact]
+ public void Module_indexer_throws_for_unloaded_module()
+ {
+ using var magic = Magic.OpenInProcess();
+ Assert.Throws(() => magic["definitely-not-loaded-xyz.dll"]);
+ }
+
+ [Fact]
+ public void Function_indexer_throws_for_unknown_export()
+ {
+ Load("kernel32.dll");
+
+ using var magic = Magic.OpenInProcess();
+ Assert.Throws(() => magic["kernel32"]["NoSuchExport_ZZZ"]);
+ }
+
+ [Fact]
+ public void Resolved_function_executes_via_remote_thread()
+ {
+ Load("kernel32.dll");
+
+ using var magic = Magic.OpenInProcess();
+ RemoteFunction getPid = magic["kernel32"]["GetCurrentProcessId"];
+
+ // GetCurrentProcessId takes no args and is thread-agnostic; a remote thread in our
+ // own process must report our PID.
+ uint pid = getPid.Execute(CallConvention.Stdcall);
+ Assert.Equal((uint)Process.GetCurrentProcess().Id, pid);
+ }
+}
diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md
index 8ea14ff..9df076c 100644
--- a/openspec/changes/whitemagic-foundation/tasks.md
+++ b/openspec/changes/whitemagic-foundation/tasks.md
@@ -28,7 +28,7 @@
- [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
-- [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Byte-level tests only — a live-execution test (5-arg + SSE callee via `CreateRemoteThread`) is still needed to prove the ABI at runtime.**
+- [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Runtime ABI now proven:** live-execution tests via `CreateRemoteThread` cover 5-arg register+stack delivery (`Execute_sums_register_and_stack_arguments`), 16-byte entry alignment arithmetically (`Execute_delivers_16byte_aligned_stack_to_callee`), and a hardware-alignment-sensitive SSE callee (`Execute_runs_sse_callee_with_five_args`: aligned `movaps` that #GPs unless the stub delivers a 16-byte-aligned stack, combined with a 5th stack arg).
- [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)
@@ -66,7 +66,7 @@
## 7. High-Level Ergonomics (spec: high-level-api)
- [x] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
-- [ ] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy
+- [x] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy. **Deviation:** export resolution added to `PeHeaderParser.GetExportAddress` (PE32/PE32+ export directory walk) and **follows export forwarders** (e.g. `kernel32!HeapAlloc` → `NTDLL.RtlAllocateHeap`) into other loaded modules; ordinal forwarders and unresolvable API-set targets throw `NotSupportedException`. `RemoteModule` resolves the base via `Process.Modules` (name match tolerant of `.dll`/case). `RemoteFunction.Execute` defaults to the always-available `RemoteThreadExecutor`; `Address` is exposed for pump routing and `CreateDelegate` for the in-process tier. Tests cross-check resolved addresses against the OS `GetProcAddress` (direct export + forwarder) and execute `kernel32!GetCurrentProcessId` end-to-end.
- [x] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
- [x] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
- [x] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window