diff --git a/src/DiffEngine.Tests/Wow64CommandLineTests.cs b/src/DiffEngine.Tests/Wow64CommandLineTests.cs new file mode 100644 index 00000000..98e5ac22 --- /dev/null +++ b/src/DiffEngine.Tests/Wow64CommandLineTests.cs @@ -0,0 +1,83 @@ +#if NET10_0 +/// +/// Reading the command line of a 32-bit process from this 64-bit one. +/// +/// NtQueryInformationProcess with ProcessBasicInformation answers a 64-bit caller with the 64-bit +/// PEB, even when the target is running under WOW64. Reading that with 32-bit offsets produced +/// nothing, so every 32-bit diff tool - which is most of the %ProgramFiles(x86)% installs the +/// resolver goes out of its way to find - had no command line: never seen as already running, and +/// never killed. The 32-bit PEB has to be asked for by name. +/// +/// +[NotInParallel] +[RunOn(TUnit.Core.Enums.OS.Windows)] +public class Wow64CommandLineTests +{ + [Test] + public async Task AThirtyTwoBitProcessHasAReadableCommandLine() + { + var wow = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "SysWOW64", + "cmd.exe"); + if (!Environment.Is64BitProcess || + !File.Exists(wow)) + { + // A 32-bit host, or a Windows with no WOW64 layer. Nothing to say here + return; + } + + // Distinctive, so this cannot match any other cmd on the machine + var marker = $"DiffEngineWow64Probe{Guid.NewGuid():N}"; + var process = Process.Start( + new ProcessStartInfo + { + FileName = wow, + // Shaped like a diff tool invocation, because FindAll only keeps command + // lines with two file path arguments + Arguments = $"/c ping -n 30 127.0.0.1 >nul & rem C:\\probe\\{marker}.received.txt C:\\probe\\{marker}.verified.txt", + UseShellExecute = false, + CreateNoWindow = true + })!; + + try + { + await Assert.That(await WaitForCommandLine(marker)).IsTrue(); + } + finally + { + try + { + if (!process.HasExited) + { + process.Kill(); + } + } + catch + { + // Nothing useful to do if it has already gone + } + + process.Dispose(); + } + } + + static async Task WaitForCommandLine(string marker) + { + for (var attempt = 0; attempt < 40; attempt++) + { + var found = WindowsProcess + .FindAll([with(StringComparer.OrdinalIgnoreCase), "cmd.exe"]) + .Any(_ => _.Command.Contains(marker, StringComparison.Ordinal)); + if (found) + { + return true; + } + + await Task.Delay(250); + } + + return false; + } +} +#endif diff --git a/src/DiffEngine/Process/WindowsProcess.cs b/src/DiffEngine/Process/WindowsProcess.cs index be506675..5711398d 100644 --- a/src/DiffEngine/Process/WindowsProcess.cs +++ b/src/DiffEngine/Process/WindowsProcess.cs @@ -29,6 +29,18 @@ private static partial int NtQueryInformationProcess( int size, out int returnLength); + /// + /// The same export, asked for a pointer sized answer rather than a struct: the ProcessWow64 + /// Information class returns the address of a WOW64 target's 32-bit PEB. + /// + [LibraryImport("ntdll.dll", EntryPoint = "NtQueryInformationProcess")] + private static partial int NtQueryWow64Peb( + SafeProcessHandle handle, + int processInformationClass, + ref IntPtr info, + int size, + out int returnLength); + [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool IsWow64Process( @@ -89,6 +101,14 @@ static extern bool ReadProcessMemory( IntPtr size, out IntPtr bytesRead); + [DllImport("ntdll.dll", EntryPoint = "NtQueryInformationProcess")] + static extern int NtQueryWow64Peb( + SafeProcessHandle handle, + int processInformationClass, + ref IntPtr info, + int size, + out int returnLength); + [DllImport("kernel32.dll", SetLastError = true)] static extern bool IsWow64Process(SafeProcessHandle handle, out bool isWow64); #endif @@ -99,6 +119,12 @@ static extern bool ReadProcessMemory( const int processTerminate = 0x0001; const int processBasicInformation = 0; + /// + /// ProcessWow64Information. Returns the address of the 32-bit PEB for a WOW64 target, or zero + /// for one that is not running under WOW64. + /// + const int processWow64Information = 26; + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct PROCESSENTRY32W { @@ -298,9 +324,32 @@ static int FindDotSeparatedPath(CharSpan span) return null; } - return Environment.Is64BitProcess && !isTarget32Bit - ? ReadCommandLine64(handle, pbi.PebBaseAddress) - : ReadCommandLine32(handle, pbi.PebBaseAddress); + if (!Environment.Is64BitProcess) + { + // A 32-bit caller cannot reach a 64-bit target's PEB, so there is nothing useful to + // read rather than something wrong to read + if (Environment.Is64BitOperatingSystem && + !isTarget32Bit) + { + return null; + } + + return ReadCommandLine32(handle, pbi.PebBaseAddress); + } + + if (!isTarget32Bit) + { + return ReadCommandLine64(handle, pbi.PebBaseAddress); + } + + // A WOW64 target seen from a 64-bit caller. ProcessBasicInformation answers with the + // 64-bit PEB the system keeps for it, and reading that with 32-bit offsets yields + // nothing usable - so every 32-bit diff tool, which is most of the %ProgramFiles(x86)% + // installs the resolver goes out of its way to find, had no command line at all: never + // detected as already running, and never killed. The 32-bit PEB has to be asked for + return TryGetWow64Peb(handle, out var wow64Peb) + ? ReadCommandLine32(handle, wow64Peb) + : null; } catch { @@ -308,6 +357,13 @@ static int FindDotSeparatedPath(CharSpan span) } } + static bool TryGetWow64Peb(SafeProcessHandle handle, out IntPtr peb) + { + peb = IntPtr.Zero; + return NtQueryWow64Peb(handle, processWow64Information, ref peb, IntPtr.Size, out _) == 0 && + peb != IntPtr.Zero; + } + static string? ReadCommandLine64(SafeProcessHandle handle, IntPtr pebAddress) { // In 64-bit PEB, ProcessParameters is at offset 0x20