fix: resolve winget to its trusted WindowsApps path to close a binary-planting LPE#1434
Merged
Conversation
…-planting LPE
winget operations (Bulk Installer install/list/search, App Updates upgrade,
Uninstaller list) route through PowerShellRunner.RunProcessAsync("winget", ...),
which hardens the target via SystemPaths.ResolveSystemTool. But winget is not a
System32 tool -- it is an MSIX app under
%ProgramFiles%\WindowsApps\Microsoft.DesktopAppInstaller_*__8wekyb3d8bbwe, whose
only PATH entry is a per-user execution alias in the user-writable
%LOCALAPPDATA%\Microsoft\WindowsApps. ResolveSystemTool probes only System32, so
both probes missed and it returned the unrooted bare string "winget". Launched
with UseShellExecute=false, CreateProcess searches the calling process's own load
directory first, so a winget.exe planted next to SysManager's portable .exe (often
run from Downloads, sometimes elevated) would run with the app's rights. The
previously-relied-on WorkingDirectory=System32 pin does not cover this (CWD is
search item #2; the app-load dir is #1).
Adds SystemPaths.ResolveWinget: resolves winget to the highest-versioned App
Installer package under the admin-only-writable WindowsApps folder (matched by the
fixed publisher hash and by actually containing winget.exe), and fails closed --
returns a rooted System32\winget.exe path (normally non-existent, surfacing the
Win32Exception callers already handle) rather than the plantable bare name. The
per-user %LOCALAPPDATA% alias is deliberately never used for the elevated launch.
ResolveSystemTool now delegates winget/winget.exe to it.
Regression tests: winget/winget.exe never resolve to the bare name and are always
rooted; ResolveWinget never points under user-writable LocalAppData; version
segment parses numerically (1.29 > 1.9). Runtime-verified via a temp harness
(resolves to the real trusted path, launches winget successfully).
Closes the last winget binary-planting vector after the v1.52.91 (powershell) and
v1.52.92 (Bulk Installer CWD) hardening.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem (P1 — local privilege escalation)
Every winget operation (Bulk Installer install/list/search, App Updates upgrade, Uninstaller list) routes through
PowerShellRunner.RunProcessAsync("winget", …), which hardens the launch target viaSystemPaths.ResolveSystemTool. But winget is not a System32 tool — it is an MSIX app whose real binary lives under%ProgramFiles%\WindowsApps\Microsoft.DesktopAppInstaller_*__8wekyb3d8bbwe\winget.exe, and whose only PATH entry is a per-user execution alias in the user-writable%LOCALAPPDATA%\Microsoft\WindowsApps.ResolveSystemToolprobes only System32 (and the Windows PowerShell folder), so both probes missed and it returned the unrooted bare string"winget". Launched withUseShellExecute=false, Win32CreateProcesssearches the calling process's own load directory first — so awinget.exeplanted next to SysManager's portable.exe(commonly run from a user-writable Downloads folder, sometimes elevated via the prominent "Run as administrator" buttons on the winget tabs) would execute with the app's privileges.The
WorkingDirectory=System32pin the code relied on does not cover this vector: the working directory isCreateProcesssearch item #2, the application load directory is item #1. This is the unclosed sibling of the System32 LPE fixed for pinned tools in v1.52.91 / v1.52.92.Fix
Adds
SystemPaths.ResolveWinget():%ProgramFiles%\WindowsApps\Microsoft.DesktopAppInstaller_*__8wekyb3d8bbwe(the fixed publisher hash is the trust anchor; that folder is writable by administrators / TrustedInstaller only).System.Version, so 1.29 > 1.9) that actually containswinget.exe(the_neutral_~_resources package doesn't).System32\winget.exepath (normally non-existent, surfacing the sameWin32Exceptionthe winget callers already handle) rather than the plantable bare name.%LOCALAPPDATA%alias is deliberately never used for the (potentially elevated) launch.ResolveSystemToolnow delegateswinget/winget.exetoResolveWinget(). All 7 winget call sites across 3 services flow through the singleRunProcessAsyncseam, so the class is fixed in one place. Two stale code comments that credited the WorkingDirectory pin with closing this vector are corrected.Tests
New
SystemPathsTestscases:winget/winget.exe(and case variants) never return the bare name and are always rooted.ResolveWingetnever points under user-writable LocalAppData.WindowsAppsto an existingwinget.exe.ParsePackageVersionorders numerically (1.29 > 1.9) and returns0.0(sorts last, never throws) for unexpected shapes.Runtime-verified via a temp .NET 10 harness (banned
dotnet testnot used locally):ResolveWinget()→ the real trusted path…\Microsoft.DesktopAppInstaller_1.29.279.0_x64__8wekyb3d8bbwe\winget.exe, rooted + exists + not under LocalAppData; launching winget from that path succeeds (--version→ v1.29.280). Red→green: pre-fixResolveSystemTool("winget")returned bare"winget"(test'sNotEqual/IsPathRootedasserts fail); post-fix it returns the rooted trusted path.Regression sweep
dotnet buildmain + tests: 0 warnings / 0 errors.