diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6e4ce2..a94e5b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,7 +212,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: exe-standalone - path: src/D2BotNG/bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe + path: src/D2BotNG/bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe retention-days: 1 publish-framework: @@ -235,7 +235,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: exe-framework - path: src/D2BotNG/bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe + path: src/D2BotNG/bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe retention-days: 1 release: diff --git a/CLAUDE.md b/CLAUDE.md index 52875ff..0b264af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Modern Diablo II bot manager. Manages D2 game instances, handles CD key rotation ``` protos/ # Protobuf definitions (source of truth for all services) src/ - D2BotNG/ # .NET backend (x86 Windows) + D2BotNG/ # .NET backend (x64 Windows) Services/ # gRPC implementations (*ServiceImpl.cs) Engine/ # Profile lifecycle (ProfileEngine), scheduling (ScheduleEngine) Windows/ # Win32 interop: GameLauncher, ProcessManager, Patcher, MessageWindow @@ -70,7 +70,7 @@ npm run generate-grpc # Regenerate protobuf types from protos/ cd src/D2BotNG dotnet publish -c Release --self-contained # Bundles .NET runtime (~60-80MB) dotnet publish -c Release --no-self-contained # Requires .NET 10 runtime (~15-25MB) -# Output: bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe +# Output: bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe ``` ## gRPC Services @@ -112,8 +112,9 @@ Frontend uses a single gRPC server-stream for all real-time state: ### Windows Layer - **GameLauncher** (`Windows/GameLauncher.cs`) - 12-step launch pipeline: clear cache, build CLI args, create suspended process, patch memory, resume, inject D2BS.dll, set title -- **ProcessManager** (`Windows/ProcessManager.cs`) - DLL injection via LoadLibraryA remote thread, process creation, graceful shutdown (WM_CLOSE + force kill), job object for auto-killing child processes on crash +- **ProcessManager** (`Windows/ProcessManager.cs`) - DLL injection via LoadLibraryW remote thread, process creation, graceful shutdown (WM_CLOSE + force kill), job object for auto-killing child processes on crash - **Patcher** (`Windows/Patcher.cs`) - Binary memory patches via VirtualProtectEx + WriteProcessMemory +- **RemoteModule** (`Windows/RemoteModule.cs`) - Resolves a target's kernel32 `LoadLibrary` address for cross-bitness injection (x64 manager → 32-bit game) and reads target module bases, via a PE export walk over ReadProcessMemory - **MessageWindow** (`Windows/MessageWindow.cs`) - WM_COPYDATA receiver, parses JSON from D2BS, queues to Channel - **DaclOverwriter** - Changes DACL for elevated process access @@ -252,7 +253,7 @@ Item/mule data lives in `d2bs/kolbot/mules/` (*.txt files, watched by FileSystem ## Notes -- **x86 required** - D2BS compatibility (32-bit DLL injection into game process) +- **x64 build (forced)** - `Platforms`/`PlatformTarget`/`RuntimeIdentifier` in `D2BotNG.csproj` pin x64. Still injects the 32-bit D2BS into a 32-bit game cross-bitness via `RemoteModule` (see Windows Layer) - **Windows-only** - WinForms, WebView2, Win32 APIs, P/Invoke throughout - **Dual-mode** - GUI (WebView2 desktop) or headless (server-only with message-only window) - **Frontend embedded** - Production UI builds to `wwwroot/`, served by Kestrel diff --git a/D2BotNG.sln b/D2BotNG.sln index adf80fe..8a0a7b4 100644 --- a/D2BotNG.sln +++ b/D2BotNG.sln @@ -19,30 +19,22 @@ Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x64.ActiveCfg = Debug|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x64.Build.0 = Debug|Any CPU - {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x86.ActiveCfg = Debug|Any CPU - {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x86.Build.0 = Debug|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|Any CPU.Build.0 = Release|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x64.ActiveCfg = Release|Any CPU {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x64.Build.0 = Release|Any CPU - {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x86.ActiveCfg = Release|Any CPU - {43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x86.Build.0 = Release|Any CPU {074DA5D1-201F-45C9-9A23-5666D2A401D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {074DA5D1-201F-45C9-9A23-5666D2A401D0}.Debug|x64.ActiveCfg = Debug|Any CPU - {074DA5D1-201F-45C9-9A23-5666D2A401D0}.Debug|x86.ActiveCfg = Debug|Any CPU {074DA5D1-201F-45C9-9A23-5666D2A401D0}.Release|Any CPU.ActiveCfg = Release|Any CPU {074DA5D1-201F-45C9-9A23-5666D2A401D0}.Release|x64.ActiveCfg = Release|Any CPU - {074DA5D1-201F-45C9-9A23-5666D2A401D0}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 3280288..8663038 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ Download the latest release from the [Releases](../../releases) page. Two versio | Version | Size | Requirements | |---------|------|-------------| -| **Self-contained** | Larger | Windows 10/11 (x86) — just run, no prerequisites | -| **Framework-dependent** | Smaller | Windows 10/11 (x86) + [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) | +| **Self-contained** | Larger | Windows 10/11 (x64) — just run, no prerequisites | +| **Framework-dependent** | Smaller | Windows 10/11 (x64) + [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) | To get started: @@ -154,16 +154,18 @@ Full compatibility with D2BS: - Same command-line arguments (`-d2c`, `-d2x`, `-handle`, `-profile`) - WM_COPYDATA IPC messaging -- DLL injection (LoadLibraryA remote thread) +- DLL injection (LoadLibraryW remote thread) - Memory patches for various D2 versions - d2bs.ini generation with game paths and CD keys - All D2BS message types supported (heartbeat, status, console, items, key rotation, etc.) +> **Note:** Built for x64, but still injects the 32-bit D2BS into the 32-bit game (it resolves the game's `LoadLibrary` address across the bitness boundary). + ## Development ### Requirements -- Windows 10/11 (x86 build for D2BS compatibility) +- Windows 10/11 (x64) - .NET 10 SDK - Node.js 20+ @@ -210,7 +212,7 @@ dotnet publish -c Release --self-contained dotnet publish -c Release --no-self-contained ``` -Output: `bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe` +Output: `bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe` ### Architecture diff --git a/src/D2BotNG/D2BotNG.csproj b/src/D2BotNG/D2BotNG.csproj index 214d5fb..ff0a3c7 100644 --- a/src/D2BotNG/D2BotNG.csproj +++ b/src/D2BotNG/D2BotNG.csproj @@ -6,7 +6,8 @@ enable enable default - x86 + x64 + x64 true true app.ico @@ -89,7 +90,7 @@ - win-x86 + win-x64 true true diff --git a/src/D2BotNG/Windows/NativeMethods.cs b/src/D2BotNG/Windows/NativeMethods.cs index 75d9e74..c4ae57e 100644 --- a/src/D2BotNG/Windows/NativeMethods.cs +++ b/src/D2BotNG/Windows/NativeMethods.cs @@ -32,33 +32,50 @@ public static extern bool DuplicateHandle( public static extern nint GetCurrentProcess(); + // dwSize is SIZE_T (pointer-sized: 64-bit on x64), so it must be nuint, not uint. [DllImport("kernel32.dll", SetLastError = true)] - public static extern nint VirtualAllocEx(nint hProcess, nint lpAddress, uint dwSize, uint flAllocationType, uint flProtect); + public static extern nint VirtualAllocEx(nint hProcess, nint lpAddress, nuint dwSize, uint flAllocationType, uint flProtect); [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool VirtualFreeEx(nint hProcess, nint lpAddress, uint dwSize, uint dwFreeType); + public static extern bool VirtualFreeEx(nint hProcess, nint lpAddress, nuint dwSize, uint dwFreeType); + // lpflOldProtect is PDWORD (always 32-bit), so out uint is correct; only dwSize (SIZE_T) is pointer-sized. [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool VirtualProtectEx(nint hProcess, nint lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); + public static extern bool VirtualProtectEx(nint hProcess, nint lpAddress, nuint dwSize, uint flNewProtect, out uint lpflOldProtect); + // nSize and *lpNumberOfBytesWritten are both SIZE_T. The out param is critical: on x64 the + // kernel writes 8 bytes here, so a 4-byte (int) target would be a stack/heap overrun. [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool WriteProcessMemory(nint hProcess, nint lpBaseAddress, byte[] lpBuffer, uint nSize, out int lpNumberOfBytesWritten); + public static extern bool WriteProcessMemory(nint hProcess, nint lpBaseAddress, byte[] lpBuffer, nuint nSize, out nuint lpNumberOfBytesWritten); + + // nSize and *lpNumberOfBytesRead are both SIZE_T — pointer-sized on x64. Used to read a target + // process's PE headers/export table when resolving function addresses inside that process. + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool ReadProcessMemory(nint hProcess, nint lpBaseAddress, byte[] lpBuffer, nuint nSize, out nuint lpNumberOfBytesRead); [DllImport("kernel32.dll", SetLastError = true)] public static extern nint GetModuleHandle(string? lpModuleName); + // Resolves an export in *our own* loaded module — used as the same-bitness fast path, where the + // address is also valid in a same-flavour target (shared system-DLL base per boot). [DllImport("kernel32.dll", SetLastError = true)] public static extern nint GetProcAddress(nint hModule, string lpProcName); [DllImport("kernel32.dll")] public static extern nint LocalFree(nint hMem); + // Detects a target's bitness: on an x64 OS a WOW64 process is 32-bit. The manager is built x64, + // so a WOW64 target means cross-bitness injection. + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool IsWow64Process(nint hProcess, out bool wow64Process); + #endregion #region kernel32.dll - Thread + // dwStackSize is SIZE_T (pointer-sized); lpThreadId is LPDWORD (always 32-bit). [DllImport("kernel32.dll", SetLastError = true)] - public static extern nint CreateRemoteThread(nint hProcess, nint lpThreadAttributes, uint dwStackSize, nint lpStartAddress, nint lpParameter, uint dwCreationFlags, out uint lpThreadId); + public static extern nint CreateRemoteThread(nint hProcess, nint lpThreadAttributes, nuint dwStackSize, nint lpStartAddress, nint lpParameter, uint dwCreationFlags, out uint lpThreadId); [DllImport("kernel32.dll", SetLastError = true)] public static extern nint OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId); @@ -69,9 +86,6 @@ public static extern bool DuplicateHandle( [DllImport("kernel32.dll", SetLastError = true)] public static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds); - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool GetExitCodeThread(nint hThread, out uint lpExitCode); - #endregion #region kernel32.dll - Process Creation @@ -217,6 +231,19 @@ public static extern uint SetSecurityInfo( #endregion + #region psapi.dll - Module Enumeration + + // Reads a remote module's base address from the loader's module list. The base is the HMODULE + // value (pointer-width), so it must be captured as nint — on x64 a 32-bit thread exit code + // (the old approach) would truncate it. + [DllImport("psapi.dll", SetLastError = true)] + public static extern bool EnumProcessModulesEx(nint hProcess, [Out] nint[] lphModule, uint cb, out uint lpcbNeeded, uint dwFilterFlag); + + [DllImport("psapi.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern uint GetModuleFileNameExW(nint hProcess, nint hModule, System.Text.StringBuilder lpFilename, uint nSize); + + #endregion + /// /// Async polling wrapper around WaitForSingleObject. /// Polls instead of blocking so the calling thread is released between checks. diff --git a/src/D2BotNG/Windows/NativeTypes.cs b/src/D2BotNG/Windows/NativeTypes.cs index 101e32c..d7e5b75 100644 --- a/src/D2BotNG/Windows/NativeTypes.cs +++ b/src/D2BotNG/Windows/NativeTypes.cs @@ -161,6 +161,10 @@ public enum SE_OBJECT_TYPE // Special window handles public static readonly nint HWND_MESSAGE = -3; + // EnumProcessModulesEx filter: return 32-bit, 64-bit, and all other modules. LIST_MODULES_ALL + // is required so a 64-bit manager can see a 32-bit (WOW64) game's modules. + public const uint LIST_MODULES_ALL = 0x03; + // Job Object constants public const int JobObjectExtendedLimitInformation = 9; public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; diff --git a/src/D2BotNG/Windows/Patcher.cs b/src/D2BotNG/Windows/Patcher.cs index 0716b52..38701fe 100644 --- a/src/D2BotNG/Windows/Patcher.cs +++ b/src/D2BotNG/Windows/Patcher.cs @@ -45,7 +45,7 @@ public async Task ApplyPatchAsync(Process process, string module, Patch pa var targetAddress = moduleBase + patch.Offset; // Change memory protection - if (!VirtualProtectEx(hProcess, targetAddress, (uint)patch.Data.Length, PAGE_EXECUTE_READWRITE, out uint oldProtection)) + if (!VirtualProtectEx(hProcess, targetAddress, (nuint)patch.Data.Length, PAGE_EXECUTE_READWRITE, out uint oldProtection)) { _logger.LogError("Failed to change memory protection at {Address:X}", targetAddress); return false; @@ -54,7 +54,7 @@ public async Task ApplyPatchAsync(Process process, string module, Patch pa try { // Write the patch bytes - if (!WriteProcessMemory(hProcess, targetAddress, patch.Data.ToByteArray(), (uint)patch.Data.Length, out _)) + if (!WriteProcessMemory(hProcess, targetAddress, patch.Data.ToByteArray(), (nuint)patch.Data.Length, out _)) { _logger.LogError("Failed to write patch at {Address:X}", targetAddress); return false; @@ -65,7 +65,7 @@ public async Task ApplyPatchAsync(Process process, string module, Patch pa } finally { - VirtualProtectEx(hProcess, targetAddress, (uint)patch.Data.Length, oldProtection, out _); + VirtualProtectEx(hProcess, targetAddress, (nuint)patch.Data.Length, oldProtection, out _); } } catch (Exception ex) @@ -85,7 +85,7 @@ private async Task LoadModuleRemotelyAsync(nint processHandle, string modu var pathBytes = Encoding.Unicode.GetBytes(modulePath + '\0'); // Allocate memory in target process for the DLL path string - var remoteMemory = VirtualAllocEx(processHandle, 0, (uint)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + var remoteMemory = VirtualAllocEx(processHandle, 0, (nuint)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (remoteMemory == 0) { _logger.LogError("Failed to allocate memory for module path in target process"); @@ -95,18 +95,18 @@ private async Task LoadModuleRemotelyAsync(nint processHandle, string modu try { // Write the DLL path into the allocated memory - if (!WriteProcessMemory(processHandle, remoteMemory, pathBytes, (uint)pathBytes.Length, out _)) + if (!WriteProcessMemory(processHandle, remoteMemory, pathBytes, (nuint)pathBytes.Length, out _)) { _logger.LogError("Failed to write module path to target process"); return 0; } - // Get LoadLibraryW address from our own kernel32 (same address in target due to ASLR shared base) - var kernel32 = GetModuleHandle("kernel32.dll"); - var loadLibraryAddr = GetProcAddress(kernel32, "LoadLibraryW"); + // Resolve LoadLibraryW for the target. Same-bitness targets use our own kernel32 (shared + // base); a 32-bit target gets the 32-bit kernel32 address from a peer WOW64 process. + var loadLibraryAddr = RemoteModule.ResolveExportForTarget(processHandle, "kernel32.dll", "LoadLibraryW"); if (loadLibraryAddr == 0) { - _logger.LogError("Failed to get LoadLibraryW address"); + _logger.LogError("Failed to resolve LoadLibraryW for target process"); return 0; } @@ -127,26 +127,23 @@ private async Task LoadModuleRemotelyAsync(nint processHandle, string modu return 0; } - // LoadLibrary's return value is the module base address (or 0 on failure) - // Retrieved via the thread's exit code - if (!GetExitCodeThread(threadHandle.DangerousGetHandle(), out var moduleBase)) - { - _logger.LogError("Failed to get exit code from LoadLibraryW thread"); - return 0; - } - + // The remote thread's exit code can't carry the module base on x64: it's a 32-bit + // DWORD, while a 64-bit HMODULE needs the full pointer width. Read the base from the + // loader's module list instead — the LoadLibraryW above both forces the load and (as + // the first thread to run in the suspended process) initializes the loader data, so + // the module is now enumerable. + var moduleBase = RemoteModule.GetModuleBase(processHandle, Path.GetFileName(modulePath)); if (moduleBase == 0) { - _logger.LogError("LoadLibraryW failed in target process for {ModulePath}", modulePath); + _logger.LogError("Module {ModulePath} not loaded in target process after LoadLibraryW", modulePath); return 0; } - return (nint)moduleBase; + return moduleBase; } finally { VirtualFreeEx(processHandle, remoteMemory, 0, MEM_RELEASE); } } - } diff --git a/src/D2BotNG/Windows/ProcessManager.cs b/src/D2BotNG/Windows/ProcessManager.cs index 2acd277..00266a2 100644 --- a/src/D2BotNG/Windows/ProcessManager.cs +++ b/src/D2BotNG/Windows/ProcessManager.cs @@ -88,7 +88,8 @@ public async Task InjectDllAsync(Process process, string dllPath) } var fullPath = Path.GetFullPath(dllPath); - var pathBytes = Encoding.ASCII.GetBytes(fullPath + '\0'); + // UTF-16 + LoadLibraryW so non-ASCII paths (e.g. an accented user name) aren't mangled to '?'. + var pathBytes = Encoding.Unicode.GetBytes(fullPath + '\0'); try { @@ -115,7 +116,7 @@ public async Task InjectDllAsync(Process process, string dllPath) using var processHandle = new SafeProcessHandle(rawProcessHandle, ownsHandle: true); // Allocate memory in target process - var remoteMemory = VirtualAllocEx(processHandle.DangerousGetHandle(), 0, (uint)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + var remoteMemory = VirtualAllocEx(processHandle.DangerousGetHandle(), 0, (nuint)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (remoteMemory == 0) { _logger.LogError("Failed to allocate memory in target process"); @@ -125,18 +126,18 @@ public async Task InjectDllAsync(Process process, string dllPath) try { // Write DLL path - if (!WriteProcessMemory(processHandle.DangerousGetHandle(), remoteMemory, pathBytes, (uint)pathBytes.Length, out _)) + if (!WriteProcessMemory(processHandle.DangerousGetHandle(), remoteMemory, pathBytes, (nuint)pathBytes.Length, out _)) { _logger.LogError("Failed to write DLL path to target process"); return false; } - // Get LoadLibraryA address - var kernel32 = GetModuleHandle("kernel32.dll"); - var loadLibraryAddr = GetProcAddress(kernel32, "LoadLibraryA"); + // Resolve LoadLibraryW for the target. Same-bitness targets use our own kernel32 + // (shared base); a 32-bit target gets the 32-bit kernel32 address from a peer WOW64 process. + var loadLibraryAddr = RemoteModule.ResolveExportForTarget(processHandle.DangerousGetHandle(), "kernel32.dll", "LoadLibraryW"); if (loadLibraryAddr == 0) { - _logger.LogError("Failed to get LoadLibraryA address"); + _logger.LogError("Failed to resolve LoadLibraryW for target process {Pid}", process.Id); return false; } @@ -212,12 +213,12 @@ public void ShowWindow(nint hwnd) // Async variants post the request to the window's thread and return // immediately, so a hung game window can never block the caller (the // synchronous ShowWindow/SetWindowPos would block until USER32 gives up). - NativeMethods.ShowWindowAsync(hwnd, SW_SHOW); + ShowWindowAsync(hwnd, SW_SHOW); } public void HideWindow(nint hwnd) { - NativeMethods.ShowWindowAsync(hwnd, SW_HIDE); + ShowWindowAsync(hwnd, SW_HIDE); } public void SetWindowTitle(nint hwnd, string title) diff --git a/src/D2BotNG/Windows/RemoteModule.cs b/src/D2BotNG/Windows/RemoteModule.cs new file mode 100644 index 0000000..7dcbbbd --- /dev/null +++ b/src/D2BotNG/Windows/RemoteModule.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using D2BotNG.Logging; +using static D2BotNG.Windows.NativeMethods; +using static D2BotNG.Windows.NativeTypes; +using ILogger = Serilog.ILogger; + +namespace D2BotNG.Windows; + +/// +/// Resolves system-DLL export addresses (e.g. kernel32!LoadLibraryW) and module bases valid inside a +/// target game process — the inputs needed to drive injection/patching via CreateRemoteThread. +/// +/// +/// The classic shortcut — GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryW") — is only +/// valid in the target when it is the same bitness as us, since system DLLs load at the same base in every +/// same-flavour process for a boot session. The x64 manager keeps that fast path for x64 targets. For a +/// 32-bit (WOW64) game the 32-bit kernel32 is a different image at a different base — but that base is shared +/// across all WOW64 processes this boot, so the address is read once from a peer WOW64 process +/// (walking its PE export table over ReadProcessMemory, following forwarders) and cached. See +/// . +/// +/// +/// +/// reads the target's own loader list and is meant for use after a module is +/// mapped (e.g. a patch-target base). Handles must carry PROCESS_QUERY_INFORMATION and +/// PROCESS_VM_READ. Cold-start caveat: cross-bitness resolution needs at least one accessible WOW64 +/// process to read from; if none is running yet (e.g. the very first 32-bit launch) it returns 0. +/// +/// +public static class RemoteModule +{ + private static readonly ILogger Logger = TrackingLoggerFactory.ForContext(typeof(RemoteModule)); + + // PE optional-header magic values. + private const ushort OptionalHeaderMagicPe32Plus = 0x20B; // 64-bit image (PE32+) + private const int DataDirectoryOffsetPe32 = 96; // IMAGE_OPTIONAL_HEADER32.DataDirectory + private const int DataDirectoryOffsetPe32Plus = 112; // IMAGE_OPTIONAL_HEADER64.DataDirectory + + // 32-bit (WOW64) system-DLL export addresses are identical across every WOW64 process for a boot + // session, so resolve them once (from a peer process) and cache for reuse. + private static readonly Lock CacheLock = new(); + private static readonly Dictionary Wow64Exports = new(StringComparer.Ordinal); + + /// + /// Finds the base address of a module loaded in the target process, matched by file name + /// (case-insensitive, e.g. "kernel32.dll"). Returns 0 if it is not currently loaded. + /// + public static nint GetModuleBase(nint hProcess, string moduleName) + { + var capacity = 256; + + for (var attempt = 0; attempt < 4; attempt++) + { + var modules = new nint[capacity]; + var cb = (uint)(modules.Length * IntPtr.Size); + if (!EnumProcessModulesEx(hProcess, modules, cb, out var needed, LIST_MODULES_ALL)) + { + Logger.Error("EnumProcessModulesEx failed for {Module} (error {Error})", moduleName, Marshal.GetLastWin32Error()); + return 0; + } + + var available = (int)(needed / (uint)IntPtr.Size); + if (available > modules.Length) + { + // Module set grew between the size report and the read — retry with the exact size. + capacity = available; + continue; + } + + var sb = new StringBuilder(1024); + for (var i = 0; i < available; i++) + { + sb.Clear(); + if (GetModuleFileNameExW(hProcess, modules[i], sb, (uint)sb.Capacity) == 0) continue; + if (string.Equals(Path.GetFileName(sb.ToString()), moduleName, StringComparison.OrdinalIgnoreCase)) + return modules[i]; + } + + return 0; + } + + return 0; + } + + /// + /// Resolves an exported function address valid in 's address space, + /// picking a strategy by the target's bitness (the manager is built x64): + /// + /// Same bitness (x64 target): reuse our own module — system DLLs load at the same base in every + /// same-flavour process this boot, so GetProcAddress(GetModuleHandle(...)) is valid in the target + /// too, and works even while it is suspended. + /// Cross bitness (32-bit/WOW64 target): the 32-bit module isn't ours and isn't yet mapped in a + /// suspended target, but it loads at the same base in every WOW64 process this boot — so read the address + /// out of a peer WOW64 process and cache it. + /// + /// Returns 0 if it cannot be resolved. + /// + public static nint ResolveExportForTarget(nint targetHandle, string moduleName, string functionName) + { + if (!IsWow64Process(targetHandle, out var targetIsWow64)) + { + Logger.Warning("IsWow64Process failed (error {Error}); assuming same bitness as the manager", Marshal.GetLastWin32Error()); + targetIsWow64 = false; + } + + // Manager is x64-native, so a non-WOW64 target is also x64: same flavour, shared module base. + if (!targetIsWow64) + return GetProcAddress(GetModuleHandle(moduleName), functionName); + + return GetWow64Export(moduleName, functionName); + } + + /// + /// Resolves a 32-bit (WOW64) module export address, identical in every WOW64 process this boot. Read + /// once from any accessible peer WOW64 process and cached. Returns 0 if none can be read (e.g. a cold + /// start with no other 32-bit process running yet). + /// + private static nint GetWow64Export(string moduleName, string functionName) + { + var cacheKey = moduleName + "!" + functionName; + lock (CacheLock) + { + if (Wow64Exports.TryGetValue(cacheKey, out var cached)) return cached; + } + + var address = ResolveFromPeerWow64Process(moduleName, functionName); + if (address != 0) + lock (CacheLock) + { + Wow64Exports[cacheKey] = address; + } + + return address; + } + + private static nint ResolveFromPeerWow64Process(string moduleName, string functionName) + { + foreach (var process in Process.GetProcesses()) + { + nint handle = 0; + try + { + handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, process.Id); + if (handle == 0) continue; + if (!IsWow64Process(handle, out var isWow64) || !isWow64) continue; + + var moduleBase = GetModuleBase(handle, moduleName); + if (moduleBase == 0) continue; // not loaded here (e.g. a still-suspended process) + + var address = ResolveExport(handle, moduleBase, functionName, depth: 0); + if (address != 0) + { + Logger.Debug("Resolved 32-bit {Module}!{Function} from peer WOW64 PID {Pid}", moduleName, functionName, process.Id); + return address; + } + } + catch + { + // Process may have exited or be inaccessible; try the next one. + } + finally + { + if (handle != 0) CloseHandle(handle); + process.Dispose(); + } + } + + Logger.Error("Could not resolve 32-bit {Module}!{Function}: no readable WOW64 process available", moduleName, functionName); + return 0; + } + + private static nint ResolveExport(nint hProcess, nint moduleBase, string functionName, int depth) + { + if (depth > 8) return 0; // guard against pathological forwarder chains + + // IMAGE_DOS_HEADER.e_lfanew (offset 0x3C) → IMAGE_NT_HEADERS. + var ntHeaders = moduleBase + ReadInt32(hProcess, moduleBase + 0x3C); + + // NT headers: Signature (4) + IMAGE_FILE_HEADER (20) + IMAGE_OPTIONAL_HEADER. + var optionalHeader = ntHeaders + 4 + 20; + var magic = ReadUInt16(hProcess, optionalHeader); + var dataDirectory = optionalHeader + (magic == OptionalHeaderMagicPe32Plus ? DataDirectoryOffsetPe32Plus : DataDirectoryOffsetPe32); + + // Data directory entry 0 is the export table (VirtualAddress, Size). + var exportRva = ReadUInt32(hProcess, dataDirectory); + var exportSize = ReadUInt32(hProcess, dataDirectory + 4); + if (exportRva == 0) return 0; + + var exportDir = moduleBase + (nint)exportRva; + var numberOfNames = ReadUInt32(hProcess, exportDir + 0x18); + var addressOfFunctions = ReadUInt32(hProcess, exportDir + 0x1C); + var addressOfNames = ReadUInt32(hProcess, exportDir + 0x20); + var addressOfNameOrdinals = ReadUInt32(hProcess, exportDir + 0x24); + + for (uint i = 0; i < numberOfNames; i++) + { + var nameRva = ReadUInt32(hProcess, moduleBase + (nint)addressOfNames + (nint)(i * 4)); + if (!string.Equals(ReadAnsiString(hProcess, moduleBase + (nint)nameRva, 256), functionName, StringComparison.Ordinal)) + continue; + + var ordinal = ReadUInt16(hProcess, moduleBase + (nint)addressOfNameOrdinals + (nint)(i * 2)); + var functionRva = ReadUInt32(hProcess, moduleBase + (nint)addressOfFunctions + ordinal * 4); + + // A function RVA that points back inside the export directory is a forwarder string + // ("TargetDll.TargetFunction"), not code — resolve it in the forwarded module instead. + if (functionRva >= exportRva && functionRva < exportRva + exportSize) + { + var forwarder = ReadAnsiString(hProcess, moduleBase + (nint)functionRva, 256); + var dot = forwarder.IndexOf('.'); + if (dot <= 0) return 0; + + var forwardModule = forwarder[..dot] + ".dll"; + var forwardFunction = forwarder[(dot + 1)..]; + var forwardBase = GetModuleBase(hProcess, forwardModule); + if (forwardBase == 0) return 0; + + return ResolveExport(hProcess, forwardBase, forwardFunction, depth + 1); + } + + return moduleBase + (nint)functionRva; + } + + return 0; + } + + private static int ReadInt32(nint hProcess, nint address) => (int)ReadUInt32(hProcess, address); + + private static uint ReadUInt32(nint hProcess, nint address) => BitConverter.ToUInt32(ReadExact(hProcess, address, 4), 0); + + private static ushort ReadUInt16(nint hProcess, nint address) => BitConverter.ToUInt16(ReadExact(hProcess, address, 2), 0); + + // Reads exactly count bytes or throws. A short read means the walk hit unmapped/freed memory (e.g. + // the donor process exited mid-walk), so resolution must abort rather than derive an address from a + // partial/zero value — ResolveFromPeerWow64Process catches this and moves on to the next process. + private static byte[] ReadExact(nint hProcess, nint address, int count) + { + var buffer = new byte[count]; + if (!ReadProcessMemory(hProcess, address, buffer, (nuint)count, out var read) || (int)read != count) + throw new InvalidOperationException($"ReadProcessMemory at 0x{address:X} ({count} bytes) failed (error {Marshal.GetLastWin32Error()})"); + return buffer; + } + + private static string ReadAnsiString(nint hProcess, nint address, int maxLength) + { + var buffer = new byte[maxLength]; + ReadProcessMemory(hProcess, address, buffer, (nuint)maxLength, out var read); + var count = (int)read; + + if (count <= 0) + { + // The full span may straddle an unmapped page; fall back to a short read at the start. + buffer = new byte[Math.Min(maxLength, 64)]; + if (!ReadProcessMemory(hProcess, address, buffer, (nuint)buffer.Length, out read) || (int)read == 0) + return ""; + count = (int)read; + } + + var end = Array.IndexOf(buffer, (byte)0, 0, count); + if (end < 0) end = count; + return Encoding.ASCII.GetString(buffer, 0, end); + } +}