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

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve. I went through this on the security question and could not find a bypass: the verified binary reaches disk only through an exclusively-created, no-follow handle at an unpredictable path, then gets renamed into place.

Checked on head f62c4fc:

  • POSIX (stage_other.go): createStagingFile is O_CREATE|O_EXCL|O_WRONLY, so it fails on any pre-existing name including a dangling symlink, without following it. The suffix is 16 bytes from crypto/rand, not math/rand or time-derived.
  • Windows (stage_windows.go): CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT operates on the reparse point itself and fails on a planted symlink or junction, and verifyFreshRegularFile then rejects reparse point / directory / NumberOfLinks>1 via GetFileInformationByHandle before any bytes are written. Belt and suspenders.
  • Ordering is right: the checksum is verified before the binary is moved into the live target, and copyFile writes through the exact handle from createStagingFile with no reopen-by-path in between. No residual predictable .new name remains.
  • Built and ran internal/update locally: gofmt, vet and build clean, tests pass including the hardlink/symlink refusal and checksum-mismatch cases. CI green.

One non-blocking nit worth a quick follow-up: installBinary registers defer os.Remove(stagedPath) after the copyFile error check (apply.go ~229-234), and copyFile only closes dest on an io.Copy failure, never removes it. So a mid-write copy failure (short write, full disk) leaks the partial staging file in the install directory. Not a security issue (128-bit random O_EXCL name, contents are a partial copy of the already-public binary), but easy to close by moving the removal defer into copyFile right after createStagingFile succeeds, plus a test for the mid-write path. Both bots flagged the same thing.

LGTM.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE — the core hardening is correct and well-tested; the only residue is orphaned staging files, which is a pre-existing cleanup gap the randomization mildly amplifies (minor), plus a test-coverage nit.

The substantive fix for #742 is solid: createStagingFile opens with O_CREATE|O_EXCL|O_WRONLY (stage_other.go), so a pre-created hard link or symlink at the staging path fails EEXIST instead of being truncated through, and the Windows path adds CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus the verifyFreshRegularFile link-count/reparse checks. I reproduced the base arbitrary-file-truncate primitive (fixed <target>.new + O_TRUNC opened through a pre-created hard link) and confirmed the new code refuses it. The regression tests in stage_other_test.go genuinely exercise both the hard-link and symlink variants and assert the victim is untouched. Good, well-commented work.


[Minor] Orphaned <binary>.<hex>.new staging files are never reclaimed and now accumulate — PR-introduced amplification of a pre-existing gap
internal/update/apply.go:229-234

Two paths leave a staged file behind, and no code ever removes .new files (CleanupStaleBinary only handles <binary>.old, and on non-Windows it is a no-op — replace_other.go:19 / replace_windows.go:56):

  • Copy failure (pre-existing ordering, PR-introduced amplification): the defer os.Remove(stagedPath) is registered at line 232, after copyFile at line 229. If copyFile fails (ENOSPC on the install volume, unreadable source), installBinary returns at line 230 before the defer is ever registered, so the partial file survives. On base this ordering existed too, but the fixed <target>.new name meant the next attempt truncated and reused the same orphan (self-healing). With the PR's random suffix, each retry on a persistently full disk writes a fresh <binary>.<hex>.new, so partial-binary-sized files pile up in the install directory.
  • Hard crash mid-window (PR-introduced): if the process is SIGKILLed / loses power after copyFile succeeds (line 229) but before replaceBinary renames (line 235), the deferred remove never runs. On base the single fixed orphan was reused by the next upgrade; now each crashed upgrade leaves a uniquely-named orphan that nothing collects.

Neither is a security or correctness bug — the swap still works and disk pressure is the only cost — hence Minor. Two independent fixes close it: (a) register the staged-file cleanup immediately after stagingFilePath succeeds (or have copyFile/createStagingFile remove its own dest on a non-nil return) so the copy-failure exit path is covered; and (b) on startup, best-effort glob and remove stale <binary>.*.new files in the target directory, analogous to CleanupStaleBinary's .old handling, to sweep crash leftovers.


[Nit] The "unpredictable path" half of the fix has no mutation-sensitive test — PR-introduced
internal/update/apply.go:260

I reverted stagingFilePath to a fixed filepath.Base(targetPath) + ".new" (dropping the random suffix) while keeping O_EXCL, and go test -count=1 ./internal/update/ still passed. The new tests call createStagingFile directly with hand-built paths, and TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails computes the occupied path via stagingFilePath itself, so both pass regardless of whether the suffix is random. The randomness (the title's stated defense-in-depth against pre-creation/guessing) can therefore be removed with zero test signal. Not a security hole — removing O_EXCL instead fails 3 tests, confirming that check is the real guard — but a small unit test asserting stagingFilePath(target) places the file in filepath.Dir(target), contains the base name, and returns two different paths on successive calls with the real randomStagingSuffix would lock in the property.


Tests: gofmt, go vet (incl. GOOS=windows), and go test -race -count=1 ./internal/update/... all pass on the PR head; no PR-attributable failures, no environmental interference in this package.

Merge is kevin's call per the program gate.

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

4 participants