Skip to content

security(update): stage binary replacement at an unpredictable, exclusive path#751

Open
PierrunoYT wants to merge 1 commit into
Gitlawb:mainfrom
PierrunoYT:fix/windows-updater-staging-path-overwrite
Open

security(update): stage binary replacement at an unpredictable, exclusive path#751
PierrunoYT wants to merge 1 commit into
Gitlawb:mainfrom
PierrunoYT:fix/windows-updater-staging-path-overwrite

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #742 — the Windows updater staged verified executable bytes at the predictable path <target>.new and opened it with truncating, link-following semantics, letting a lower-privileged process pre-create that path as a hard link or reparse point to another file the (possibly elevated) updater can write.

Root cause

installBinary (internal/update/apply.go) always staged at a fixed <target>.new name via copyFile, which opened the destination with O_CREATE|O_WRONLY|O_TRUNC. In an installation directory writable by a lower-privileged principal, that principal could pre-create <target>.new as a hard link or supported reparse-point link to another file writable by the elevated updater. The subsequent staging write then truncated and overwrote that unintended file with the verified Zero executable bytes.

The .old rename/removal sequence in replace_windows.go was not itself the vulnerable primitive (per the issue) and is unchanged.

Changes

  • stagingFilePath now derives the staging name from crypto/rand (128 bits), so it can no longer be predicted or pre-created before the update runs.
  • New createStagingFile (platform-specific) opens the staging path exclusively without following any pre-existing link:
    • internal/update/stage_other.go (POSIX): O_CREATE|O_EXCL, which POSIX guarantees fails on a pre-existing path — including a dangling symlink — without resolving it.
    • internal/update/stage_windows.go: CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT so a pre-existing reparse point fails creation instead of being resolved through, plus a post-open GetFileInformationByHandle check (not a reparse point, not a directory, single hard link) as defense in depth.
  • randomStagingSuffix is a swappable package var so the existing helper-refresh-failure test can pin a deterministic suffix instead of relying on the old guessable name.

Tests

  • internal/update/stage_other_test.go / stage_windows_test.go reproduce the reported primitive directly: pre-create the staging path as a hard link (and, where privileges allow, a symlink) to a "victim" file, then assert createStagingFile refuses it and the victim is untouched. Also covers the fresh-path success control and a concurrent-race case (exactly one winner, no silent truncation).
  • Updated TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails to pin the staging suffix via the new test hook instead of relying on the removed fixed .new name.

Verification

  • go build ./... — pass (windows, plus cross-compiled linux/darwin for internal/update)
  • go vet ./... — pass (same platforms)
  • go test ./internal/update/... -count=1 — pass, including all new regression tests (hard link, symlink, concurrent race, fresh path) on Windows
  • gofmt -l — clean

Summary by CodeRabbit

  • Security Improvements
    • Improved standalone binary updates by using unpredictable temporary staging filenames.
    • Prevented updates from overwriting pre-existing files, hard links, or symbolic links during staging.
    • Added safeguards for concurrent update attempts to ensure only one staging operation succeeds.

…sive path

Fixes Gitlawb#742. installBinary staged downloaded release bytes at the fixed
<target>.new path and opened it with O_CREATE|O_WRONLY|O_TRUNC. In an
installation directory writable by a lower-privileged process, that
process could pre-create <target>.new as a hard link or reparse point
to another file the (possibly elevated) updater can write; the
truncating open then overwrote that unintended file with verified Zero
executable bytes.

stagingFilePath now generates a cryptographically random suffix so the
path can't be targeted in advance, and platform-specific
createStagingFile opens it exclusively without following a pre-existing
link: O_CREATE|O_EXCL on POSIX (which POSIX guarantees fails on a
pre-existing path, symlink or not, without resolving it), and
CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus a
post-open GetFileInformationByHandle check on Windows (not a reparse
point, not a directory, single hard link).

replace_windows.go's rename-swap sequence is unchanged: the dangerous
operation was always the truncating open of the staged path, not the
.old rename/removal that follows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 19, 2026 12:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the standalone updater’s staging step to prevent an arbitrary-file-overwrite primitive (Issue #742) by removing the predictable <target>.new staging path and ensuring staging files are created exclusively without link/reparse-point traversal.

Changes:

  • Generate an unpredictable staging filename (crypto-random suffix) instead of using a fixed .new name.
  • Introduce platform-specific createStagingFile implementations to guarantee exclusive creation and avoid link/reparse-point following (with defense-in-depth verification on Windows).
  • Add regression tests covering pre-created hard links/symlinks and a concurrent creation race, and update existing tests to use a deterministic staging suffix hook.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/update/apply.go Randomizes staging filename and switches staging writes to createStagingFile.
internal/update/apply_test.go Pins staging suffix in tests to deterministically occupy the computed staging path.
internal/update/stage_other.go Adds POSIX `O_CREAT
internal/update/stage_other_test.go Adds non-Windows regression tests for hard link/symlink pre-creation and a concurrency race.
internal/update/stage_windows.go Adds Windows `CreateFile(CREATE_NEW
internal/update/stage_windows_test.go Adds Windows regression tests for hard link/symlink pre-creation and a concurrency race.
internal/update/stage_test_helpers_test.go Adds test helper to stub the random staging suffix deterministically.
Comments suppressed due to low confidence (1)

internal/update/apply.go:233

  • In installBinary, the staged file is only scheduled for removal after copyFile succeeds. If copyFile creates the staging file and then fails (e.g., disk full / short write), the partially-written random staging file will be left behind in the install directory. Consider deferring the cleanup immediately after stagingFilePath succeeds so failures during copyFile are also cleaned up.
	if err := copyFile(sourcePath, stagedPath); err != nil {
		return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
	}
	defer func() {
		_ = os.Remove(stagedPath)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const attempts = 16
var wg sync.WaitGroup
successes := make([]bool, attempts)
for i := range attempts {
const attempts = 16
var wg sync.WaitGroup
successes := make([]bool, attempts)
for i := range attempts {
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The updater now generates unpredictable staging filenames and creates staging files exclusively. POSIX and Windows implementations reject pre-existing links or invalid handles, while tests cover fresh creation, link protection, concurrent races, and helper refresh failures.

Changes

Secure updater staging

Layer / File(s) Summary
Platform-safe staging creation
internal/update/stage_other.go, internal/update/stage_windows.go, internal/update/*staging*_test.go
Platform-specific staging helpers use exclusive creation and validate fresh regular files; tests cover links, successful writes, and concurrent creation races.
Randomized staging integration
internal/update/apply.go, internal/update/apply_test.go, internal/update/stage_test_helpers_test.go
Binary installation uses cryptographically random staging paths and createStagingFile, with deterministic tests for staging failures.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant installBinary
  participant stagingFilePath
  participant copyFile
  participant createStagingFile
  installBinary->>stagingFilePath: Generate random staging path
  installBinary->>copyFile: Copy binary to staged path
  copyFile->>createStagingFile: Create path exclusively
  createStagingFile-->>copyFile: Return validated file handle
  copyFile-->>installBinary: Complete staged binary
Loading

Possibly related PRs

  • Gitlawb/zero#461: Adds the CLI path that invokes the standalone update flow changed here.

Suggested reviewers: copilot, vasanthdev2004, jatmn

🚥 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 is concise and accurately summarizes the main change: making the staging path unpredictable and exclusive.
Linked Issues check ✅ Passed The changes match issue #742 by adding random staging names, exclusive no-follow creation, Windows validation, and regression tests.
Out of Scope Changes check ✅ Passed The added code and tests are all directly tied to fixing the staging-path overwrite vulnerability.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Caution

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

⚠️ Outside diff range comments (1)
internal/update/apply.go (1)

225-234: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move the defer block before copyFile to prevent leaking temporary files.

If copyFile fails (e.g., due to a full disk or an interrupted read), installBinary returns early and the partially written staging file is never removed, causing a permanent resource leak on every failed update.

Moving the defer block immediately after stagedPath generation guarantees cleanup across all error paths. This is entirely safe: if replaceBinary successfully renames the file later, os.Remove will return a silent os.ErrNotExist that safely gets ignored by the blank identifier.

🛠 Proposed fix
 	stagedPath, err := stagingFilePath(targetPath)
 	if err != nil {
 		return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
 	}
+	defer func() {
+		_ = os.Remove(stagedPath)
+	}()
 	if err := copyFile(sourcePath, stagedPath); err != nil {
 		return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
 	}
-	defer func() {
-		_ = os.Remove(stagedPath)
-	}()
 	if err := replaceBinary(targetPath, stagedPath); err != nil {
 		return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err)
 	}
🤖 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 `@internal/update/apply.go` around lines 225 - 234, Move the cleanup defer in
installBinary to immediately after successful stagingFilePath generation and
before copyFile, so partially created staging files are removed when copying
fails. Keep the existing os.Remove callback and ignored error behavior
unchanged.
🧹 Nitpick comments (1)
internal/update/stage_other_test.go (1)

17-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate duplicated test files into a single cross-platform stage_test.go. Both test files contain virtually identical test suites. As per coding guidelines, prefer one cross-platform function with small conditional checks over duplicated helpers when behavior can remain unified.

  • internal/update/stage_other_test.go#L17-L129: merge this test suite into a new unified stage_test.go.
  • internal/update/stage_windows_test.go#L17-L130: merge this identical logic into the unified file, retaining the single conditional if runtime.GOOS == "windows" for the t.Skipf("symlink unavailable...") skip logic.
🤖 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 `@internal/update/stage_other_test.go` around lines 17 - 129, Merge the
duplicated test suites from internal/update/stage_other_test.go lines 17-129 and
internal/update/stage_windows_test.go lines 17-130 into a single cross-platform
internal/update/stage_test.go. Preserve the existing tests for
createStagingFile, including hard-link, symlink, fresh-path, and concurrent-race
coverage; retain one runtime.GOOS == "windows" conditional for the
symlink-unavailable t.Skipf behavior, and remove the duplicated
platform-specific test files.

Source: Coding guidelines

🤖 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 `@internal/update/apply.go`:
- Around line 225-234: Move the cleanup defer in installBinary to immediately
after successful stagingFilePath generation and before copyFile, so partially
created staging files are removed when copying fails. Keep the existing
os.Remove callback and ignored error behavior unchanged.

---

Nitpick comments:
In `@internal/update/stage_other_test.go`:
- Around line 17-129: Merge the duplicated test suites from
internal/update/stage_other_test.go lines 17-129 and
internal/update/stage_windows_test.go lines 17-130 into a single cross-platform
internal/update/stage_test.go. Preserve the existing tests for
createStagingFile, including hard-link, symlink, fresh-path, and concurrent-race
coverage; retain one runtime.GOOS == "windows" conditional for the
symlink-unavailable t.Skipf behavior, and remove the duplicated
platform-specific test files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 754ad950-6559-4c37-bbdf-f873fe80c35e

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and f62c4fc.

📒 Files selected for processing (7)
  • internal/update/apply.go
  • internal/update/apply_test.go
  • internal/update/stage_other.go
  • internal/update/stage_other_test.go
  • internal/update/stage_test_helpers_test.go
  • internal/update/stage_windows.go
  • internal/update/stage_windows_test.go

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(windows): predictable updater staging path can overwrite another file

2 participants