Skip to content

Service: UAC-free upgrade via RunInstallerRequest - #2007

Draft
tyrielv wants to merge 1 commit into
microsoft:masterfrom
tyrielv:tyrielv/uac-free-upgrade
Draft

Service: UAC-free upgrade via RunInstallerRequest#2007
tyrielv wants to merge 1 commit into
microsoft:masterfrom
tyrielv:tyrielv/uac-free-upgrade

Conversation

@tyrielv

@tyrielvtyrielv commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an upgrade flow that lets the user run gvfs upgrade <installer-path> from a non-elevated shell and have the already-elevated GVFS.Service launch the installer on the caller's behalf. Removes the UAC prompt that the old upgrade path required on every install.

Builds on #1994 (merged) which added the versioned install layout and mount-process detection for Versions\ subdirs.

How it works

  • CLI — New UpgradeVerb sends a RunInstallerRequest over the existing GVFS.Service named pipe. Returns immediately after the service confirms the installer launched (Upgrade started).
  • Service — New RunInstallerHandler verifies the installer, then launches it detached with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=<path>. Does NOT WaitForExit() — the installer stops GVFS.Service as part of its upgrade flow, so waiting would deadlock.
  • Staging/STAGEIFMOUNTED=true is non-disruptive. With mounts active, the installer stages binaries to PendingUpgrade\ instead of unmounting. The existing PendingUpgradeHandler applies the staged upgrade on the next unmount.
  • Capability detection — Pre-2.1 services don't know the new RunInstallerRequest header and respond with the UnknownRequest sentinel. The verb detects this and emits a clear "your GVFS service is too old; install a newer GVFS first" error instead of a JSON deserialization stack trace. Clients can also probe the capability via gvfs version (>=2.1.x supports this flow).
  • Pipeline version bumped 2.02.1 so the capability is detectable from the version number alone.

Security model

The service runs as LocalSystem and its named pipe is ACL'd for BUILTIN\Users, so the handler must assume the caller is untrusted.

  • Authenticode — Verified via WinVerifyTrust (WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that actually checks the file's signed digest against its contents. Extracting just the signer cert (X509Certificate.CreateFromSignedFile + X509Chain.Build) is not sufficient: it leaves a tampered binary with an intact signature blob fully accepted. The common HRESULTs (TRUST_E_NOSIGNATURE, TRUST_E_BAD_DIGEST, ...) are mapped to specific user-facing errors.
  • Publisher — Exact-string match against certificate.GetNameInfo(X509NameType.SimpleName) == "Microsoft Corporation". Avoids substring-collision attacks such as CN="Microsoft Corporation Ltd" or DNs that place the attacker's CN alongside "Microsoft Corporation" in other fields (a Subject.Contains check would accept either).
  • Product identity — PE ProductName must equal "VFS for Git", checked even when --allow-unsigned is in effect. Rejects other Microsoft-signed binaries (e.g. notepad.exe) at the Authenticode-trusted-but-wrong-product stage.
  • --allow-unsigned — Available in DEBUG builds only. In release builds the CLI does not register the option and the service rejects AllowUnsigned=true requests outright (defense-in-depth against a hand-crafted pipe request bypassing the CLI). In debug builds the service additionally impersonates the pipe client (NamedPipeServerStream.RunAsClient, exposed via Connection.TryRunAsClient) and rejects unless the caller is in BUILTIN\Administrators. Without this gate, any local non-admin user could stamp ProductName="VFS for Git" onto an arbitrary binary and get LocalSystem code execution.
  • TOCTOU — The handler opens the installer with FileShare.Read (no SHARE_WRITE, no SHARE_DELETE) at the start of Run() and holds the handle across verify and Process.Start. On Windows this blocks any rename, delete, or write of the path, closing the window where an attacker could swap the file between verification and launch.

Other notes

  • GVFSJsonContext registers RunInstallerRequest and its Response for source-generated System.Text.Json serialization (NativeAOT-compatible).
  • RunInstallerHandler creates a fresh EventMetadata for each tracer call via CreateBaseMetadata(). JsonTracer mutates the passed-in metadata by adding the "Message" key, so a single shared instance would throw a duplicate-key exception on the second tracer call — and the throw was being caught and reported back as a false-positive "Upgrade failed" even though the installer had launched successfully. Matches the fresh-per-call pattern used by GetActiveRepoListHandler and RequestHandler.

Testing

Manual end-to-end on Windows:

ScenarioResult
Unsigned dev installer without --allow-unsigned"Installer is not signed"
notepad.exe (Microsoft-signed, wrong ProductName)✅ Rejected by ProductName check
--allow-unsigned from non-admin shell (DEBUG build)"requires Administrator privileges"
Tampered Microsoft-signed installer (one byte flipped)"Authenticode hash does not match — file has been tampered with"
Real signed SetupGVFS.2.0.26147.6.exe with mounts active✅ Accepted, 23 files staged to PendingUpgrade\, mount stayed Ready
Unmount after stagingPendingUpgradeHandler applies the staged upgrade within ~5s, version updates accordingly

818 unit tests pass.

@tyrielv
tyrielvforce-pushed the tyrielv/uac-free-upgrade branch from bbb5073 to 6d2cd77CompareJune 4, 2026 20:34
Add an upgrade flow that lets the user run `gvfs upgrade
<installer-path> [--allow-unsigned]` from a non-elevated shell and
have the already-elevated GVFS.Service launch the installer on the
caller's behalf. Removes the UAC prompt that the old upgrade path
required on every install.
Builds on PR microsoft#1994 (merged) which added the versioned install layout
and mount-process detection for Versions\ subdirs.
How it works:
- CLI: New UpgradeVerb sends a RunInstallerRequest over the
existing GVFS.Service named pipe. Returns immediately after the
service confirms the installer launched ("Upgrade started").
- Service: New RunInstallerHandler verifies the installer, then
launches it detached with /VERYSILENT /SUPPRESSMSGBOXES
/NORESTART /STAGEIFMOUNTED=true /LOG=<path>. Does NOT
WaitForExit() — the installer stops GVFS.Service as part of its
upgrade flow, so waiting would deadlock.
- Staging: /STAGEIFMOUNTED=true is non-disruptive. With mounts
active, the installer stages binaries to PendingUpgrade\
instead of unmounting. The existing PendingUpgradeHandler
applies the staged upgrade on the next unmount.
- Capability detection: Pre-2.1 services don't know the new
RunInstallerRequest header and respond with "UnknownRequest".
The verb detects this and emits a clear "your GVFS service is
too old; install a newer GVFS first" error instead of a JSON
deserialization stack trace. Clients can also probe the
capability via `gvfs version` (>=2.1.x supports this flow).
Security model — the service runs as LocalSystem and its named pipe
is ACL'd for BUILTIN\Users, so the handler must assume the caller
is untrusted:
- Authenticode: Verified via WinVerifyTrust
(WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that
actually checks the file's signed digest against its contents.
Extracting just the signer cert (X509Certificate.CreateFromSignedFile
+ X509Chain.Build) is NOT sufficient: it leaves a tampered
binary with an intact signature blob fully accepted. Maps the
common HRESULTs (TRUST_E_NOSIGNATURE, TRUST_E_BAD_DIGEST, ...)
to specific user-facing errors.
- Publisher: Exact-string match against
certificate.GetNameInfo(X509NameType.SimpleName) ==
"Microsoft Corporation". Avoids substring-collision attacks
such as CN="Microsoft Corporation Ltd" or DNs that place the
attacker's CN alongside "Microsoft Corporation" in other
fields (a Subject.Contains check would accept either).
- Product identity: PE ProductName must equal "VFS for Git",
checked even when --allow-unsigned is in effect. Rejects
other Microsoft-signed binaries (notepad.exe etc.) at the
Authenticode-trusted but wrong-product stage.
- --allow-unsigned: Available in DEBUG builds only. In release
builds the CLI does not register the option and the service
rejects AllowUnsigned=true requests outright (defense-in-depth
against a hand-crafted pipe request bypassing the CLI). In
debug builds the service additionally impersonates the pipe
client (NamedPipeServerStream.RunAsClient, exposed via
Connection.TryRunAsClient) and rejects unless the caller is in
BUILTIN\Administrators. Without this gate, any local non-admin
user could stamp ProductName="VFS for Git" onto an arbitrary
binary and get LocalSystem code execution.
- TOCTOU: The handler opens the installer with FileShare.Read
(no SHARE_WRITE, no SHARE_DELETE) at the start of Run() and
holds the handle across verify and Process.Start. On Windows
this blocks any rename, delete, or write of the path, closing
the window where an attacker could swap the file between
verification and launch.
Other notes:
- GVFSJsonContext registers RunInstallerRequest and its Response
for source-generated System.Text.Json serialization
(NativeAOT-compatible).
- RunInstallerHandler creates a fresh EventMetadata for each
tracer call via CreateBaseMetadata(). JsonTracer mutates the
passed-in metadata by adding the "Message" key, so a single
shared instance would throw a duplicate-key exception on the
second tracer call — and the throw was being caught and
reported back as a false-positive "Upgrade failed" even though
the installer had launched successfully. This matches the
fresh-per-call pattern used by GetActiveRepoListHandler and
RequestHandler.
- Pipeline version bumped 2.0 -> 2.1 so the "service supports
UAC-free upgrade" capability is detectable from the version
number alone.
Testing (manual end-to-end on Windows):
- Unsigned dev installer without --allow-unsigned -> rejected
("Installer is not signed")
- notepad.exe -> rejected by ProductName check
- --allow-unsigned from non-admin shell (DEBUG build) -> rejected
("requires Administrator privileges")
- Tampered Microsoft-signed installer (one byte flipped) ->
rejected ("Authenticode hash does not match — file has been
tampered with")
- Real signed SetupGVFS.2.0.26147.6.exe with mounts active ->
accepted, stages 23 files to PendingUpgrade\, mount status
stays Ready
- Unmount after staging -> PendingUpgradeHandler applies the
staged upgrade within ~5s, version updates accordingly
- 818 unit tests pass
Assisted-by: Claude Opus 4.7
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@tyrielv
tyrielvforce-pushed the tyrielv/uac-free-upgrade branch from 6d2cd77 to 1f55e7cCompareJune 4, 2026 20:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tyrielv