Follow-up from #751, which closes the #742 overwrite primitive but leaves this narrower gap.
After a failed promotion where the restore is blocked, the only record that <target>.old holds the last verified binary is a .keep file sitting beside it in the install directory:
func oldBinaryPreserved(oldPath string) bool {
_, err := os.Lstat(oldPath + oldBinaryPreservedSuffix)
return err == nil || !errors.Is(err, os.ErrNotExist)
}
replace_windows.go:271. Pure pathname existence, no identity binding, nothing outside the install directory.
Under the same threat model as #742, a principal who can write that directory deletes the marker. On the next zero upgrade, preflightRecoveryStateLocked finds no marked recovery, os.Lstat(targetPath) succeeds because the attacker's file is there, and the run proceeds with no refusal. The operator is never told the target holds unverified bytes.
The recovery copy itself survives, so this is not the arbitrary-overwrite primitive. What is lost is the warning.
#751 already builds the mechanism to fix it. appendRecoveryCleanupRecord writes identity-bound records to a trusted per-user store under UserConfigDir()/zero/update-recovery, outside the attacker's reach. It is only reached on promote's success path (stage_windows.go:161); the failure branch returns first at :155. Writing an unresolved-state record there on failure too, and consulting it alongside the marker, closes this without new infrastructure.
Raised by @jatmn as a P2 on #751. Splitting it out so #751 can land on its own merits.
Follow-up from #751, which closes the #742 overwrite primitive but leaves this narrower gap.
After a failed promotion where the restore is blocked, the only record that
<target>.oldholds the last verified binary is a.keepfile sitting beside it in the install directory:replace_windows.go:271. Pure pathname existence, no identity binding, nothing outside the install directory.Under the same threat model as #742, a principal who can write that directory deletes the marker. On the next
zero upgrade,preflightRecoveryStateLockedfinds no marked recovery,os.Lstat(targetPath)succeeds because the attacker's file is there, and the run proceeds with no refusal. The operator is never told the target holds unverified bytes.The recovery copy itself survives, so this is not the arbitrary-overwrite primitive. What is lost is the warning.
#751 already builds the mechanism to fix it.
appendRecoveryCleanupRecordwrites identity-bound records to a trusted per-user store underUserConfigDir()/zero/update-recovery, outside the attacker's reach. It is only reached on promote's success path (stage_windows.go:161); the failure branch returns first at:155. Writing an unresolved-state record there on failure too, and consulting it alongside the marker, closes this without new infrastructure.Raised by @jatmn as a P2 on #751. Splitting it out so #751 can land on its own merits.