Skip to content

Security hardening: launcher TOCTOU fix and safe force-Edge removal - #732

Open
plwrd wants to merge 5 commits into
Raphire:masterfrom
plwrd:security-hardening
Open

plwrd wants to merge 5 commits into
Raphire:masterfrom
plwrd:security-hardening

Conversation

@plwrd

@plwrd plwrd commented Aug 9, 2026 •

Copy link
Copy Markdown

Summary

Two security fixes found during a review of the codebase:

1. Scripts/Get.ps1 — unpacked script files were executed elevated from a user-writable directory

The launcher downloaded and unpacked the archive into %TEMP%\Win11Debloat and then ran Win11Debloat.ps1 elevated from there. Between unpacking and elevation (and during the elevated run), any non-elevated process running as the same user could swap the unpacked .ps1 files - which Win11Debloat.ps1 dot-sources - and get its own code executed with administrator rights behind the UAC prompt the user intended for Win11Debloat.

The launcher now:

  • records the SHA-256 of the archive immediately after download
  • elevates a bootstrap that creates (or takes over) %ProgramData%\Win11Debloat, restricting it to Administrators + SYSTEM with inheritance disabled - this also neutralizes a pre-created attacker directory
  • copies the archive into the protected directory and re-verifies the hash there, aborting on mismatch
  • unpacks, restores LastUsedSettings.json, runs Win11Debloat.ps1 and cleans up entirely inside the protected directory
  • migrates existing Config, Logs and Backups from the old %TEMP%\Win11Debloat location on first run

Behavior change: the working directory moves from %TEMP%\Win11Debloat to %ProgramData%\Win11Debloat, which is inherent to the fix - the staging area must be somewhere a non-admin process cannot write.

2. Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 — could delete the real EdgeHTML package on Windows 10

ForceRemoveEdge unconditionally created a stub at %SystemRoot%\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe and later recursively deleted that path. On Windows 10 this folder is the genuine legacy Edge (EdgeHTML) system package: New-Item failed non-terminally, execution continued, and the real system component was deleted. ForceRemoveEdge has no MinVersion gate in Features.json, so nothing prevented this on Windows 10.

Fixed by:

  • locating the uninstaller before mutating anything, bailing out early when missing
  • only creating the stub when the folder does not already exist, and only deleting it if this run created it
  • cleaning up the stub in a finally block so no fake MicrosoftEdge.exe is left in SystemApps when the uninstall fails (previously it was left behind permanently when the uninstall registry key was missing)
  • invoking the UninstallString directly via Start-Process instead of interpolating the raw registry value into a cmd.exe /c command line

Test plan

  • Full Pester suite: 450 passed, 0 failed (Scripts/Run-Tests.ps1)
  • Both files pass PowerShell parser validation; the embedded elevated bootstrap template was rendered and parser-validated as well

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Hardened Scripts/Get.ps1 against launcher TOCTOU attacks.
  • Downloaded, verified, extracted, and executed archives in the elevated %ProgramData%\Win11Debloat staging directory.
  • Secured staging with ACL checks, reparse-point protection, unique archive names, and concurrent-run serialization.
  • Migrated configuration, logs, and backups from %TEMP%\Win11Debloat.
  • Preserved configuration after failed unpacking and retained staged files when the launched script fails.
  • Propagated elevated bootstrap failures to the launcher.
  • Escaped literal bootstrap inputs to prevent recursive token-replacement corruption.
  • Hardened Edge removal by validating the uninstaller, invoking it directly, and cleaning up conditional stubs with finally.
  • Added comment-based help for Format-EmbeddedLiteral.
  • All 450 Pester tests pass.
  • PowerShell parser validation passes for the modified files and elevated bootstrap template.

plwrd and others added 2 commits August 9, 2026 14:31
…ge on Windows 10

- Locate the uninstaller before mutating the system; bail out early when missing
- Only create the SystemApps stub when the folder does not already exist, and
  only delete it if this run created it, so the genuine legacy Edge package on
  Windows 10 is never removed
- Clean up the stub in a finally block so no fake MicrosoftEdge.exe is left in
  SystemApps when the uninstall fails
- Invoke the UninstallString directly via Start-Process instead of
  interpolating the raw registry value into a cmd.exe command line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ory before elevated execution

The launcher previously downloaded and unpacked the script into the
user-writable %TEMP%\Win11Debloat folder and then executed it elevated,
leaving a window in which any non-elevated process could swap the unpacked
script files and have its code run with administrator rights behind the
UAC prompt the user intended for Win11Debloat.

The launcher now records the SHA-256 of the archive at download time and
elevates a bootstrap that:

- creates (or takes over) %ProgramData%\Win11Debloat and restricts it to
  Administrators and SYSTEM with inheritance disabled, neutralizing a
  pre-created attacker directory
- copies the archive inside and re-verifies the hash after the copy, so a
  swapped archive aborts with an integrity error
- unpacks, preserves LastUsedSettings.json, runs Win11Debloat.ps1 and
  cleans up entirely within the protected directory
- migrates existing Config, Logs and Backups from the old temp location

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR hardens Edge removal and replaces direct temporary execution with an elevated bootstrap. The bootstrap uses protected %ProgramData% staging, preserves configuration data, runs the staged script, and handles success and failure cleanup.

Changes

Edge removal hardening

Layer / File(s) Summary
Validated Edge uninstallation
Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1
The script validates and parses the uninstaller, invokes it with --force-uninstall, and removes only stubs created during the current invocation.

Elevated launcher bootstrap

Layer / File(s) Summary
Bootstrap construction and elevated launch
Scripts/Get.ps1
The launcher escapes embedded values, serializes concurrent runs, builds a UTF-16 Base64 bootstrap, and propagates its exit code.
Protected staging and data migration
Scripts/Get.ps1
The bootstrap secures %ProgramData% staging, downloads the archive there, migrates legacy data, and preserves configuration files.
Release extraction and execution
Scripts/Get.ps1
The bootstrap validates and extracts the release, runs the staged script, retains files after failure, and cleans successful runs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: raphire

Sequence Diagram(s)

sequenceDiagram
  participant Launcher as Get.ps1
  participant ElevatedPowerShell
  participant Staging as ProgramData staging
  participant Script as Staged debloat script
  Launcher->>ElevatedPowerShell: Start encoded elevated bootstrap
  ElevatedPowerShell->>Staging: Secure staging directory
  ElevatedPowerShell->>Staging: Download and extract release
  ElevatedPowerShell->>Script: Launch staged script
  Script-->>ElevatedPowerShell: Return exit status
  ElevatedPowerShell->>Staging: Retain files on failure or clean on success
  ElevatedPowerShell-->>Launcher: Return bootstrap exit code
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes both security changes, although it exceeds the preferred 50-character limit.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Powershell Docstrings ✅ Passed Both changed functions have attached comment-based help; Format-EmbeddedLiteral documents its escaping and output, and Invoke-ForceRemoveEdge's synopsis matches its uninstall and cleanup purpose.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1`:
- Around line 52-55: Update the Invoke-NonBlocking script block to capture the
Start-Process result with -PassThru, validate its ExitCode against the accepted
success codes, and propagate failure before cleanup or success output runs.
Preserve the stub cleanup by keeping it in the existing finally path.

In `@Scripts/Get.ps1`:
- Around line 247-255: Track deployment completion across the extraction,
release-folder validation, and file-movement flow surrounding Expand-Archive,
then add a finally block that restores ConfigOld whenever deployment did not
complete. Only remove ConfigOld after the configuration has been restored
successfully, and preserve the existing cleanup behavior for successful
deployments.
- Line 122: Update the launcher flow around $tempArchivePath and the referenced
cleanup/bootstrap sections to isolate concurrent invocations: generate a unique
archive path per run and use a named mutex or unique protected run directory to
serialize access to shared Config, Logs, and Backups data. Ensure cleanup cannot
remove files while another bootstrap or Win11Debloat.ps1 process is using them,
and release the synchronization resource on every exit path.
- Around line 175-178: Add comment-based help immediately above
Format-EmbeddedLiteral, including .SYNOPSIS, .PARAMETER Value, and .OUTPUTS
sections that describe wrapping the input in single quotes, escaping embedded
single quotes, and returning the resulting string.
- Around line 191-207: Update the staging-directory setup around $stagingRoot to
obtain the application-data location through a trusted Windows known-folder API
rather than relying on the predictable environment path. Before applying ACLs,
reject any existing reparse point in the resolved path and its relevant parent
components, then create and protect the staging directory without following
attacker-controlled links before the archive copy flow.
- Around line 281-288: Update the Win11Debloat invocation around Start-Process
to include -PassThru and retain the returned process handle while waiting for
completion. Read the handle’s ExitCode after the process finishes and throw when
it is nonzero, ensuring cleanup and subsequent bootstrap success handling do not
proceed after a Win11Debloat.ps1 failure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a172f7d-5ee1-4775-9685-0c627127ff1d

📥 Commits

Reviewing files that changed from the base of the PR and between c0599cb and 2c47170.

📒 Files selected for processing (2)
  • Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1
  • Scripts/Get.ps1

Comment thread Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 Outdated
Comment thread Scripts/Get.ps1 Outdated
Comment thread Scripts/Get.ps1 Outdated
Comment thread Scripts/Get.ps1 Outdated
Comment thread Scripts/Get.ps1 Outdated
Comment thread Scripts/Get.ps1 Outdated
- Check the Edge uninstaller exit code and skip shortcut/registry cleanup
  and the success message when it fails (stub cleanup still runs)
- Restore preserved config files in a finally block so LastUsedSettings.json
  survives a failed unpack instead of being stranded in ConfigOld
- Resolve ProgramData via the known-folder API instead of the environment,
  which a non-elevated process can override through HKCU\Environment
- Refuse to follow a reparse point planted at the staging path; delete the
  link entry itself without touching its target
- Serialize concurrent runs with a global mutex and use a unique per-run
  download archive name
- Check the Win11Debloat.ps1 exit code and fail the bootstrap (keeping the
  staged files for inspection) instead of cleaning up after a failure
- Add comment-based help to Format-EmbeddedLiteral

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Scripts/Get.ps1 (1)

369-375: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate the elevated bootstrap exit code.

Line 371 waits for the bootstrap, but the launcher does not inspect ExitCode. The bootstrap uses Exit 1 on failure at line 354. Scripts/Get.ps1 therefore returns success after an integrity, extraction, or release-script failure.

Store the exit code after WaitForExit(). Remove the temporary archive, then exit with that nonzero code.

Proposed fix
 if ($null -ne $elevatedProcess) {
     $elevatedProcess.WaitForExit()
+    $bootstrapExitCode = $elevatedProcess.ExitCode
 }
 
 # Remove the downloaded archive from the user temp folder
 Remove-Item -LiteralPath $tempArchivePath -Force -ErrorAction SilentlyContinue
+
+if ($null -ne $elevatedProcess -and $bootstrapExitCode -ne 0) {
+    Exit $bootstrapExitCode
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Scripts/Get.ps1` around lines 369 - 375, Update the elevatedProcess handling
in Scripts/Get.ps1 to capture its ExitCode immediately after WaitForExit().
Perform the existing temporary archive cleanup, then exit with the captured code
so bootstrap failures from the elevated process are propagated while successful
execution remains successful.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Scripts/Get.ps1`:
- Around line 225-250: Replace the path-based staging setup around
$existingStaging, New-Item, and Set-Acl with handle-based open-or-create logic
that refuses reparse points, so an attacker cannot swap $stagingRoot between
inspection and securing it. Verify the opened handle refers to the intended
directory, apply the Administrators/SYSTEM ACL through that handle, and retain
the handle through the subsequent archive-copy operation.

---

Outside diff comments:
In `@Scripts/Get.ps1`:
- Around line 369-375: Update the elevatedProcess handling in Scripts/Get.ps1 to
capture its ExitCode immediately after WaitForExit(). Perform the existing
temporary archive cleanup, then exit with the captured code so bootstrap
failures from the elevated process are propagated while successful execution
remains successful.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f3e25af4-fef1-4642-a8be-950df23c489e

📥 Commits

Reviewing files that changed from the base of the PR and between 2c47170 and 56aa911.

📒 Files selected for processing (2)
  • Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1
  • Scripts/Get.ps1
🚧 Files skipped from review as they are similar to previous changes (1)
  • Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1

Comment thread Scripts/Get.ps1
- Reject an existing staging directory that is not owned by Administrators or
  SYSTEM (a non-elevated user's pre-created folder is owned by that user), in
  addition to the existing reparse-point rejection, so a planted directory is
  never adopted
- Re-verify the staging directory after the admin-only ACL is applied: since
  the ACL then blocks non-elevated writers, a still-valid non-reparse,
  admin-owned directory could not have been swapped during securing
- Capture the elevated bootstrap ExitCode after WaitForExit and exit the
  launcher with it, so integrity, extraction, or script failures are no longer
  reported as success

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Scripts/Get.ps1 (2)

383-387: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Replace bootstrap tokens in one pass.

At Line 386, an argument value such as __WINDOWSTYLE__ is inserted into $bootstrap. Line 387 then replaces that argument value with the window style. An archive path containing __HASH__ fails in the same way.

Use a single-pass token matcher, or reject reserved placeholder values before substitution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Scripts/Get.ps1` around lines 383 - 387, Update the bootstrap construction
around $bootstrapTemplate and the chained Replace calls to avoid recursive token
substitution: perform all placeholder replacements in a single pass using a
token matcher, or validate and reject argument/path values containing reserved
tokens such as __ARCHIVE__, __HASH__, __ARGS__, and __WINDOWSTYLE__ before
replacement.

122-147: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Protect the archive hash from pre-elevation replacement.

The non-elevated script hashes $tempArchivePath before elevation, but an attacker can replace the downloaded archive in %TEMP% before Get-FileHash runs. The elevated bootstrap then copies that attacker archive while the recorded hash still matches it. Do not hash a file in %TEMP% that a non-elevated process can modify; compute the digest in the elevated process after copying into an owner-secured staging directory, or compare the archive against an independently obtained release digest/signature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Scripts/Get.ps1` around lines 122 - 147, The current $archiveHash calculation
in the download flow trusts a replaceable file in $env:TEMP before elevation.
Remove this pre-elevation hash as the trust check, and make the elevated
bootstrap copy the archive into an owner-secured staging directory before
computing and validating its SHA256 digest; alternatively, validate against an
independently obtained release digest or signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Scripts/Get.ps1`:
- Around line 383-387: Update the bootstrap construction around
$bootstrapTemplate and the chained Replace calls to avoid recursive token
substitution: perform all placeholder replacements in a single pass using a
token matcher, or validate and reject argument/path values containing reserved
tokens such as __ARCHIVE__, __HASH__, __ARGS__, and __WINDOWSTYLE__ before
replacement.
- Around line 122-147: The current $archiveHash calculation in the download flow
trusts a replaceable file in $env:TEMP before elevation. Remove this
pre-elevation hash as the trust check, and make the elevated bootstrap copy the
archive into an owner-secured staging directory before computing and validating
its SHA256 digest; alternatively, validate against an independently obtained
release digest or signature.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f64336b-6cee-4e3f-9688-d125bf4d1125

📥 Commits

Reviewing files that changed from the base of the PR and between 56aa911 and 463676d.

📒 Files selected for processing (1)
  • Scripts/Get.ps1

…ging directory

The launcher previously downloaded the archive to %TEMP% and hashed it before
elevating. That hash was not a meaningful integrity check: an attacker able to
replace the %TEMP% file does so before the hash is computed, so the recorded
hash simply fingerprints the attacker's file. Copying that file into the
protected directory and re-checking the same hash detects only disk corruption.

The download now happens inside the elevated bootstrap, straight into the
Administrators/SYSTEM-only staging directory over TLS. The archive is never
written to a location a non-elevated process can modify, so it cannot be swapped
between download and use; the TLS-authenticated GitHub download is the integrity
boundary. The pre-elevation download, temp archive, and SHA-256 comparison are
removed.

Bootstrap inputs (dev flag, script arguments, window style) are now injected as
a header of escaped single-quoted literals concatenated ahead of the static
body, replacing the chained placeholder-token substitution. This removes the
recursive-substitution bug where an input value containing a token string such
as __HASH__ could be corrupted by a later replacement pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Scripts/Get.ps1 (3)

379-394: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return failure when elevation does not start.

Start-Process ... -PassThru returns $null when elevation is declined or launch fails. The wait block is skipped, $bootstrapExitCode stays 0, and the script exits successfully even though the bootstrap did not launch. Set the bootstrap result to nonzero before elevation, use -ErrorAction Stop in try/catch, report $null, and exit nonzero for process creation failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Scripts/Get.ps1` around lines 379 - 394, Update the elevated bootstrap launch
around $elevatedProcess so its default exit status is nonzero before calling
Start-Process. Use -ErrorAction Stop within try/catch, report cases where
Start-Process returns $null, and preserve the existing WaitForExit/ExitCode
handling for successful launches so any process-creation or elevation failure
exits nonzero.

187-198: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid blocking on a global Mutex.WaitOne() before the protected staging directory exists.

Scripts/Get.ps1:187-193 creates Global\Win11DebloatBootstrap before $stagingRoot is created or secured, and the no-argument WaitOne() blocks indefinitely until another process owns the same Global\ mutex. A non-elevated process can create or hold this fixed mutex in advance. Move the lock acquisition until after $stagingRoot is secured, use a timeout, and fail with a clear error when the lock cannot be acquired.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Scripts/Get.ps1` around lines 187 - 198, The bootstrap mutex acquisition
currently occurs before staging-root security and can block indefinitely on a
globally pre-created mutex. In the outer bootstrap flow, move creation and
waiting on $bootstrapMutex until after $stagingRoot is created and secured,
replace the no-argument WaitOne() with a finite timeout, and emit a clear
failure error when acquisition times out while preserving abandoned-mutex
ownership handling.

346-351: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Do not load profiles in the elevated child.

Line 348 starts powershell.exe without -NoProfile, so profile code can run elevated before Win11Debloat.ps1, outside the protected staging flow. Add -NoProfile to this child process.

Proposed fix
-$debloatProcess = Start-Process powershell.exe -WindowStyle $debloatWindowStyle -Wait -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $scriptArgs"
+$debloatProcess = Start-Process powershell.exe -WindowStyle $debloatWindowStyle -Wait -PassThru -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$debloatScriptPath`" $scriptArgs"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Scripts/Get.ps1` around lines 346 - 351, Add the PowerShell -NoProfile option
to the Start-Process invocation for the elevated child running Win11Debloat.ps1,
ensuring no user profiles execute before the staged script while preserving the
existing arguments and process handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Scripts/Get.ps1`:
- Around line 379-394: Update the elevated bootstrap launch around
$elevatedProcess so its default exit status is nonzero before calling
Start-Process. Use -ErrorAction Stop within try/catch, report cases where
Start-Process returns $null, and preserve the existing WaitForExit/ExitCode
handling for successful launches so any process-creation or elevation failure
exits nonzero.
- Around line 187-198: The bootstrap mutex acquisition currently occurs before
staging-root security and can block indefinitely on a globally pre-created
mutex. In the outer bootstrap flow, move creation and waiting on $bootstrapMutex
until after $stagingRoot is created and secured, replace the no-argument
WaitOne() with a finite timeout, and emit a clear failure error when acquisition
times out while preserving abandoned-mutex ownership handling.
- Around line 346-351: Add the PowerShell -NoProfile option to the Start-Process
invocation for the elevated child running Win11Debloat.ps1, ensuring no user
profiles execute before the staged script while preserving the existing
arguments and process handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f494a48-ce0f-4655-bae5-f5409e864436

📥 Commits

Reviewing files that changed from the base of the PR and between 463676d and 225fd70.

📒 Files selected for processing (1)
  • Scripts/Get.ps1

@plwrd

plwrd commented Aug 9, 2026

Copy link
Copy Markdown
Author

Thanks for the reviews — pushed fixes for all findings:

  • Bootstrap exit code now captured after WaitForExit() and propagated, so download/integrity/extraction/script failures no longer report success.
  • Pre-elevation hash removed — correct that it wasn't a real check; an attacker who can swap the %TEMP% file is present before the hash runs. The download now happens inside the elevated bootstrap, directly into the Administrators/SYSTEM-only staging directory over TLS, so there's no user-writable intermediate to swap.
  • Token substitution replaced with a header of escaped single-quoted literals, so an input containing a token string can't be corrupted by a later pass.
  • Staging TOCTOU — reject/delete reparse points, refuse to adopt a directory not already owned by Administrators/SYSTEM, and re-verify ownership + reparse status after the admin-only ACL is applied (once that ACL is in place a non-elevated process can't swap the object).

On opening the directory via a native handle with FILE_FLAG_OPEN_REPARSE_POINT: that needs P/Invoke, which I've kept out of this launcher since it ships as a plain bundled .ps1. With the download now landing directly in the owner-restricted directory plus the owner-check and post-ACL re-verification, I believe the residual window is closed for a non-elevated attacker in practice — happy to revisit if you'd prefer the native-handle approach.

@Raphire

Raphire commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Heya,

Thanks for taking the time to contribute! I haven't had a chance to test or fully review the code yet, but I do have some initial thoughts.

  1. While I can see the potential security issue here with a targeted attack replacing script files mid-run, I don't really like the current approach. It changes the behaviour quite considerably by saving everything to a shared directory and using a global mutex to guard against potential issues. The string encoded bootstrap function is also problematic as it's difficult to read, debug and extend in the future.

In the current state this adds a lot of complexity and introduces a lot of potential issues.

  1. Overall, I like he improvements to the edge removal script. I do see a potential issue with Start-Process returning $null and being treated as a success. Also, the $edgeStub existence check doesn't explicitly check for a folder.

Before we continue with this, would you mind splitting these into two separate PRs? Keeping each PR focused on a single change makes reviewing much easier and lets us merge them independently.

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.

2 participants