Windows: resolve process image paths with one Win32_Process query

install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.

The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.

Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.
This commit is contained in:
danielhanchen 2026-08-12 14:20:22 +00:00
parent 28ac1357a6
commit 7897865c98
3 changed files with 85 additions and 39 deletions

View file

@ -131,6 +131,8 @@ jobs:
if ($LASTEXITCODE) { exit $LASTEXITCODE }
pwsh -NoProfile -File tests/studio/test_path_probe_access_denied.ps1
if ($LASTEXITCODE) { exit $LASTEXITCODE }
pwsh -NoProfile -File tests/studio/test_process_image_path_map.ps1
if ($LASTEXITCODE) { exit $LASTEXITCODE }
# uninstall.ps1: native uninstall must keep the shared unsloth.ico while a
# WSL shortcut still references it (dual install), else that shortcut blanks.

View file

@ -440,22 +440,6 @@ public static class UnslothStudioFinalPathV2
uint pathLength,
uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(
uint desiredAccess,
bool inheritHandle,
int processId);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool QueryFullProcessImageNameW(
IntPtr process,
uint flags,
StringBuilder path,
ref uint pathLength);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
public static string Resolve(string path)
{
using (SafeFileHandle handle = CreateFileW(
@ -488,28 +472,6 @@ public static class UnslothStudioFinalPathV2
return buffer.ToString();
}
}
public static string GetProcessImagePath(int processId)
{
const uint ProcessQueryLimitedInformation = 0x1000;
IntPtr process = OpenProcess(ProcessQueryLimitedInformation, false, processId);
if (process == IntPtr.Zero)
{
return null;
}
try
{
StringBuilder path = new StringBuilder(32768);
uint pathLength = (uint)path.Capacity;
return QueryFullProcessImageNameW(process, 0, path, ref pathLength)
? path.ToString()
: null;
}
finally
{
CloseHandle(process);
}
}
}
'@
}
@ -1906,6 +1868,23 @@ exit 0
}
}
# PID -> image path for every process this user can see, in ONE query. This used to open a
# handle to every running PID through the inline C# type above, which is a shape AV
# heuristics score hard and bought nothing here: Win32_Process reports ExecutablePath for
# exactly the processes that could be opened, and answers for all of them at once.
function Get-StudioProcessImagePathMap {
$map = @{}
try {
foreach ($entry in @(Get-CimInstance Win32_Process -ErrorAction Stop)) {
if ($entry.ExecutablePath) { $map[[int]$entry.ProcessId] = $entry.ExecutablePath }
}
} catch {
# A degraded WMI repository leaves the map empty; the per-process .Path fallback
# below still resolves the accessible ones.
}
return $map
}
function Get-RunningStudioVenvProcesses {
param(
[Parameter(Mandatory = $true)][string]$VenvPath,
@ -1917,11 +1896,18 @@ exit 0
throw "Could not resolve managed Studio process path '$VenvPath': $($_.Exception.Message)"
}
$imagePaths = Get-StudioProcessImagePathMap
# Block only confirmed executable identities: a command line or working
# directory that merely mentions the path is not proof of an open file.
foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) {
$executable = $null
try { $executable = [UnslothStudioFinalPathV2]::GetProcessImagePath($process.Id) } catch { continue }
if ($imagePaths.ContainsKey([int]$process.Id)) { $executable = $imagePaths[[int]$process.Id] }
# .Path reads MainModule, which needs rights Win32_Process does not: only a fallback,
# and a protected or cross-bitness process throws here exactly as it did before.
if (-not $executable) {
try { $executable = $process.Path } catch { continue }
}
if (-not $executable) { continue }
try { $executable = Get-StudioFinalPath -Path $executable } catch { continue }
if (Test-StudioProtectedPathMatch -Candidate $executable -ProtectedPath $resolvedPath -Exact:$Exact) {

View file

@ -0,0 +1,58 @@
# Regression tests for install.ps1's venv-holder probe.
#
# Resolving a PID to its image path used to open a handle to every running process from inline
# C# compiled at runtime -- a shape AV heuristics score hard, on top of the csc.exe compile the
# type already costs. Win32_Process answers the same question for the same set of processes in
# one query, so the pair was removed. These tests pin both halves: the P/Invoke must stay gone,
# and the replacement must actually resolve a real process.
$ErrorActionPreference = "Stop"
$script:failures = 0
function Check($name, $cond) {
if ($cond) { Write-Host " PASS $name" }
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
}
$repoRoot = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, "..", ".."))).Path
$installPath = [System.IO.Path]::Combine($repoRoot, "install.ps1")
$installText = Get-Content -Raw -LiteralPath $installPath
# ── Source contract ──
Check "install.ps1 no longer opens a handle per PID from inline C#" (
$installText -notmatch 'private static extern IntPtr OpenProcess')
Check "install.ps1 no longer imports the image-path query" (
$installText -notmatch 'QueryFullProcessImageNameW')
Check "the inline type still resolves final paths" (
$installText -match 'GetFinalPathNameByHandleW')
Check "the venv-holder probe reads the prefetched map" (
$installText -match '\$imagePaths = Get-StudioProcessImagePathMap')
Check "one Win32_Process query, not one per PID" (
([regex]::Matches($installText, 'Get-CimInstance Win32_Process')).Count -eq 1)
# ── Behaviour ──
. ([System.IO.Path]::Combine($repoRoot, "tests", "studio_setup_ps1", "Get-FunctionSource.ps1"))
$src = Get-FunctionSource -Path $installPath -Name "Get-StudioProcessImagePathMap"
Check "install.ps1 defines Get-StudioProcessImagePathMap" ($null -ne $src)
if ($src -and $IsWindows -ne $false) {
. ([scriptblock]::Create($src))
$map = Get-StudioProcessImagePathMap
Check "the map is a hashtable" ($map -is [hashtable])
# This process is running an interpreter off disk, so it must resolve.
$own = $PID
Check "the map resolves this process to an image path" (
$map.ContainsKey([int]$own) -and $map[[int]$own])
if ($map.ContainsKey([int]$own)) {
Check "the resolved image path exists on disk" (Test-Path -LiteralPath $map[[int]$own])
}
# Every value must be a path, never a bare process name: the venv match compares full paths.
$bad = @($map.Values | Where-Object { $_ -and -not ([System.IO.Path]::IsPathRooted($_)) })
Check "every resolved image path is rooted" ($bad.Count -eq 0)
} else {
Write-Host " SKIP runtime map checks (Win32_Process is Windows-only)"
}
if ($script:failures -gt 0) {
Write-Host "$($script:failures) check(s) failed" -ForegroundColor Red
exit 1
}
Write-Host "All checks passed"