fix(update): relaunch without a hidden PowerShell

Windows Defender removed 0.27.14 from a station as
Trojan:Script/Wacatac.H!ml. That detection is machine-learning, not a
signature, and the behaviour it scored is ours: an unsigned binary
replaces itself on disk, clears the mark-of-the-web, and spawns a
windowless PowerShell that waits for its own process to die before
starting another executable. Byte for byte, that is a dropper; the model
reads the shape, not the intention, and "Script/" names the PowerShell.

The wait it was written for is not needed. --post-update already makes
the new instance patient with the single-instance mutex — twenty seconds
of it — so the new exe can be started directly while this one is still
shutting down and simply wait its turn.

The deferred-swap fallback keeps its helper: nothing else on a stock
Windows can wait for a pid and then move a file over an image that is
still running. It is reached only when the rename failed, never on the
ordinary path.
This commit is contained in:
2026-09-07 09:36:02 +02:00
parent 5d526d29db
commit 0430aab78e
2 changed files with 28 additions and 12 deletions
+24 -10
View File
@@ -212,20 +212,28 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
_ = os.Remove(exe + ":Zone.Identifier")
applog.Printf("update: installed new exe, scheduling relaunch")
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
// the new exe directly while we're still alive raced the mutex and often left
// nothing running; waiting for our own exit first makes the restart reliable,
// and the launcher outlives us.
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
// THE NEW EXE STARTS ITSELF. No helper, no script.
//
// This used to go through a hidden PowerShell that waited for our process to
// die and then launched the new image — which is, byte for byte, the shape of
// a dropper: an unsigned binary replaces itself on disk, clears the
// mark-of-the-web, and spawns a windowless PowerShell that starts another
// executable. Windows Defender's machine-learning model reads that shape and
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
//
// The wait it existed for is not needed: --post-update already makes the new
// instance patient with the single-instance mutex (see acquireInstance), so it
// can start while this one is still shutting down and simply wait its turn.
cmd := exec.Command(exe, "--post-update")
cmd.Dir = dir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
// Released rather than waited on: this process is about to exit, and a child
// that outlives its parent must not be left as a zombie handle.
_ = cmd.Process.Release()
if a.ctx != nil {
wruntime.Quit(a.ctx)
} else {
@@ -249,6 +257,12 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
// than an update that waits. Only a successful swap passes --post-update, so a
// failure leaves the .new file in place for the next attempt rather than having
// the cleanup delete the download.
// The LAST resort still needs a helper that outlives this process: nothing else
// can move a file over an image that is still running. It stays PowerShell —
// there is no smaller tool on a stock Windows that can wait for a pid and then
// move a file — but it is reached only when the rename above failed, which is
// rare, and never on the ordinary update path (see the relaunch there for why
// that matters to Defender).
func (a *App) scheduleDeferredSwap(exe, pending string) error {
// Clear the "downloaded from the internet" mark before it becomes the exe —
// SmartScreen silently blocks a programmatic launch of a marked file, and the