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!HeapAllocNTDLL.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