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
Copy link
Copy Markdown
Contributor

@tyrielv tyrielv commented Jun 4, 2026

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:

Scenario Result
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 staging PendingUpgradeHandler applies the staged upgrade within ~5s, version updates accordingly

818 unit tests pass.

@tyrielv tyrielv force-pushed the tyrielv/uac-free-upgrade branch from bbb5073 to 6d2cd77 Compare June 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 tyrielv force-pushed the tyrielv/uac-free-upgrade branch from 6d2cd77 to 1f55e7c Compare June 4, 2026 20:48
Sign up for free to 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