Skip to content

Complete #147, #152, #153 - the partial fixes from 0.74.0 - #155

Merged
sanderdewit merged 2 commits into
mainfrom
fix/audit-remainder
Aug 4, 2026
Merged

Complete #147, #152, #153 - the partial fixes from 0.74.0#155
sanderdewit merged 2 commits into
mainfrom
fix/audit-remainder

Conversation

@sanderdewit

Copy link
Copy Markdown
Owner

0.74.0 closed #147, #152 and #153 only partially. This finishes all three.

#147 — security, without requiring admin

The bypass hardening in 0.74.0 leaned on a machine-scoped environment variable, which would have left a non-admin on an air-gapped agent stuck. The module must never require elevation — that is the whole reason it parses hives through offreg.dll instead of reg.exe load, which demands SeBackupPrivilege/SeRestorePrivilege.

  • New Set-MsixToolVerification -Enabled $false — session-scoped, in-memory, no admin rights. This is the supported escape hatch for offline agents.
  • A process/user-scoped MSIX_SKIP_TOOL_VERIFICATION is now ignored (with a Warning pointing at the cmdlet). That is the actual attack: a non-admin persists it in HKCU\Environment and every future session silently trusts an arbitrary tools root. Machine scope is still honoured for fleet config — reading it needs no admin, only writing does.
  • Resolve-MsixProcMonPath / Resolve-MsixDebugViewPath now Authenticode-verify every candidate before returning it. ProcMon loads a kernel driver so it runs elevated, and the resolver trusted a user-settable override plus a fixed C:\PSF\ProcessMonitor\Procmon.exe fallback — a directory any standard user can create, since the root of C: grants Authenticated Users CreateDirectories. Local privilege escalation. Untrusted candidates are skipped, not fatal, so a poisoned override cannot deny service either.

Verified unelevated: session opt-out works, a planted unsigned ProcMon is refused, a genuinely signed binary is accepted, and the only reg.exe/privilege references left in the codebase are the comments explaining why offreg is used.

#153 — correctness

  • _MsixOfflineSaveHive return no longer discarded — a failed save shipped a signed modification package with none of the requested registry keys, exit code 0, and a log line claiming success.
  • Idempotency guards for Add-MsixFileTypeAssociation, Add-MsixShellVerbExtension, Add-MsixFileExplorerContextMenu, and the Rule element of Add-MsixFirewallRule.
  • The rest of the @($null) cluster — 15 sites across 15 files.
  • -CaptureTrace now works: it called Invoke-MsixProcMonCapture -PackagePath, not a parameter of that cmdlet, inside a catch that logged and continued — so the feature had never produced a .pml.
  • Test-MsixSignature now includes NotTrusted in NeedsSelfSign.
  • Add-MsixVcRuntimeBundle no longer coerces an undetectable/arm64 architecture to x86 while logging "auto-detected", and no longer packs a partial bundle as success.
  • Write-MsixLog writes UTF-8 with -LiteralPath (ANSI on 5.1 turned into a literal ?; -Path treated [ ] as wildcards and dropped the line).

#152 — coverage

  • Never-invoked exported functions: 73 → 26. All 26 remaining are genuinely environment-bound (network installers, Hyper-V/VHDX, ProcMon's kernel driver, Windows Sandbox, a live installed package) and each is listed with its reason.
  • The ratchet now guards the whole exported surface, not just Add/Remove/Set/Update — the narrow scope is exactly how 73 functions went uncovered with nothing reporting it. Three orchestrators are recorded as real debt with a ceiling so the list can only shrink.
  • Authenticode rejection is tested for the first time. Both prior tests mocked Get-AuthenticodeSignature to return Valid, so the throw branch — the control that stops a planted toolchain binary from executing — had never run. Every rejection reason is now driven, plus a valid-signature-but-untrusted-publisher case and a positive control.
  • Read-only scanner matrix: all 15 scanners against a well-formed package, against the degenerate manifest the module itself generates, and against a missing package (must fail loudly, not return a clean-looking empty result). Explicit named invocations back the matrix so coverage is AST-visible rather than trusting strings again.
  • Code coverage measured in CI and reported in the job summary — there was none before.
  • Fixed test isolation: the update-check tests wrote to the real user cache, poisoning it with a mocked version and producing a false "1.0.0 is available" notice.

Validation

  • Full suite green, PSSA clean at ParseError/Error/Warning.
  • Verified unelevated throughout — no new admin requirement anywhere.

🤖 Generated with Claude Code

Sander de Wit and others added 2 commits August 3, 2026 23:38
#147 - the remainder, WITHOUT requiring admin:
- Set-MsixToolVerification -Enabled $false is the new escape hatch for
  air-gapped agents: session-scoped and in-memory, so NO admin rights are
  needed. The module must never require elevation - that is why offreg.dll is
  used instead of reg.exe load in the first place.
- A process/user-scoped MSIX_SKIP_TOOL_VERIFICATION is now IGNORED (with a loud
  Warning pointing at the cmdlet). That is the actual attack: a non-admin can
  persist an env var in HKCU\Environment and every FUTURE session silently
  trusts an arbitrary tools root. A machine-scoped value is still honoured for
  fleet configuration; note that READING it needs no admin, only writing does.
- _MsixTestTrustedExecutable: Resolve-MsixProcMonPath and
  Resolve-MsixDebugViewPath now Authenticode-verify every candidate before
  returning it. ProcMon needs its kernel driver so it runs ELEVATED, and the
  old resolver trusted both a user-settable override and a fixed
  'C:\PSF\ProcessMonitor\Procmon.exe' fallback - a directory any standard user
  can create, since the root of C: grants Authenticated Users
  CreateDirectories. That was a local privilege-escalation path. Untrusted
  candidates are skipped rather than fatal, so a poisoned override cannot deny
  service either.

#153 - the remainder:
- _MsixOfflineSaveHive returns $false on failure and does not throw; the return
  was discarded, so a failed save shipped a SIGNED modification package with
  none of the requested registry keys, exit code 0, and a log line claiming
  success. Now throws (and no longer leaks a [bool] into the output stream).
- Idempotency guards added for Add-MsixFileTypeAssociation,
  Add-MsixShellVerbExtension, Add-MsixFileExplorerContextMenu and the Rule
  element of Add-MsixFirewallRule. All four appended unconditionally, so a
  re-run - or Invoke-MsixPlaybook replaying a step list verbatim - produced
  duplicates; a conflicting FTA makes Add-AppxPackage reject the package.
- The rest of the @($null) cluster: 15 more sites across 15 files. No
  <Applications> makes @(...) an array holding one $null whose .Count reports 1,
  so count-based guards pass and the element then throws on any method call -
  or, in Compare-MsixPackage, silently produced a phantom app entry.
- Invoke-MsixAutoFixLoop -CaptureTrace called Invoke-MsixProcMonCapture with
  -PackagePath, which is not a parameter, inside a catch that logged and
  continued - so the documented feature had never once worked. Now derives the
  -PackageFamilyName / -AppId the cmdlet actually takes.
- Test-MsixSignature: NotTrusted added to NeedsSelfSign; such a package will not
  install in a clean sandbox, which is exactly what -AutoSign is for.
- Add-MsixVcRuntimeBundle no longer coerces an undetectable (or arm64)
  architecture to x86 while logging "auto-detected", and no longer packs a
  PARTIAL bundle as success - both shipped a package that fails at launch.
- Write-MsixLog writes UTF-8 with -LiteralPath: the ANSI default on 5.1 turned
  the arrows and box drawing this module emits into literal '?', and -Path
  treated [ ] in a log path as wildcards and silently dropped the line.
- Test isolation: the update-check tests wrote to the REAL user cache at
  %LOCALAPPDATA%\MSIX\update-check.json, poisoning it with a mocked version -
  which produced a false "1.0.0 is available" notice on this machine. They now
  redirect the cache path.

Tests: +14 in MSIX.CorrectnessGuards.Tests.ps1 and MSIX.ToolVerification.Tests.ps1.
Full suite 765/0/1, PSSA clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Never-invoked exported functions: 73 -> 26, and all 26 that remain are genuinely
environment-bound (network installers, Hyper-V/VHDX, the ProcMon kernel driver,
Windows Sandbox, or a live installed package), each listed with its reason.

- The ratchet now guards the WHOLE exported surface, not just Add/Remove/Set/
  Update mutators. That narrow scope is exactly how 73 functions came to be
  uncovered with nothing reporting it. Three orchestrators
  (Invoke-MsixAccelerator, Invoke-MsixAutoFixLoop, Invoke-MsixRemediationPlan)
  are recorded as real debt with a ceiling so the list can only shrink.
- Authenticode REJECTION is tested for the first time. Both existing tests
  mocked Get-AuthenticodeSignature to return Valid, so the throw branch - the
  control that stops a planted toolchain binary from being executed - had never
  executed. Every rejection status is now driven, plus a valid-signature-but-
  untrusted-publisher case and a positive control so the suite cannot pass by
  simply throwing at everything.
- Read-only scanner matrix: all 15 scanners against a well-formed package,
  against the degenerate manifest shape the module GENERATES itself (no
  <Applications>/<Extensions>/<Capabilities> - the shape behind the null-deref
  class), and against a missing package to prove they fail loudly instead of
  returning an empty result that reads as "clean". Explicit named invocations
  back the matrix, because `& $Name` dispatch is invisible to AST coverage
  detection and teaching the ratchet to trust strings again is the exact
  weakness that was just removed.
- Local-surface tests for the version reporters, path resolvers, generators,
  accelerator import, live-store queries and Invoke-MsixSelfSign (verified
  unelevated).
- Code coverage is now measured in CI and reported in the job summary; there was
  no measurement at all before.
- Build-MsixTestFixture gains -ManifestXml so a test can build a deliberately
  degenerate package.
- Test isolation: the update-check tests wrote to the REAL user cache at
  %LOCALAPPDATA%\MSIX\update-check.json, poisoning it with a mocked version and
  producing a false "1.0.0 is available" notice on the developer's machine.

Full suite 848/0/1 (was 765), PSSA clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sanderdewit
sanderdewit merged commit b1932bf into main Aug 4, 2026
8 checks passed
@sanderdewit
sanderdewit deleted the fix/audit-remainder branch August 4, 2026 08:19
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.

SECURITY: signing-toolchain hijack chain (unverified SxS DLLs + unbounded parent-walk discovery)

1 participant