| 1 | //go:build windows |
| 2 | |
| 3 | package winaclresidue |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "golang.org/x/sys/windows" |
| 17 | |
| 18 | "reasonix/internal/proc" |
| 19 | ) |
| 20 | |
| 21 | const ( |
| 22 | allApplicationPackagesSID = "S-1-15-2-1" |
| 23 | allRestrictedApplicationPackagesSID = "S-1-15-2-2" |
| 24 | // stillActiveExitCode is STILL_ACTIVE from GetExitCodeProcess. |
| 25 | stillActiveExitCode = 259 |
| 26 | // icacls can stall on antivirus-scanned volumes; the bound keeps a stuck |
| 27 | // cleanup from pinning startup or a credential read. |
| 28 | icaclsTimeout = 30 * time.Second |
| 29 | ) |
| 30 | |
| 31 | // markerDir is the directory the retired backend used for crash-residue |
| 32 | // markers; the name is part of the on-disk contract with older builds. |
| 33 | func markerDir() string { |
| 34 | return filepath.Join(os.TempDir(), "windows-sandbox-denylocks") |
| 35 | } |
| 36 | |
| 37 | // SweepStaleMarkers removes the ACEs recorded by crashed runs of the retired |
| 38 | // backend whose owning process is provably gone, then deletes their markers. |
| 39 | // It is best-effort: errors keep the marker for a later sweep and never block |
| 40 | // the caller. |
| 41 | func SweepStaleMarkers() { |
| 42 | dir := markerDir() |
| 43 | entries, err := os.ReadDir(dir) |
| 44 | if err != nil { |
| 45 | return |
| 46 | } |
| 47 | sids := residueSIDs() |
| 48 | for _, entry := range entries { |
| 49 | if entry.IsDir() { |
| 50 | continue |
| 51 | } |
| 52 | pid, ok := markerOwnerPID(entry.Name()) |
| 53 | if !ok || !processExited(pid) { |
| 54 | continue |
| 55 | } |
| 56 | sweepMarkerFile(filepath.Join(dir, entry.Name()), sids) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // residueSIDs is the exact trustee set the retired backend applied; removing |
| 61 | // only these cannot disturb a legitimate ACL. |
| 62 | func residueSIDs() []string { |
| 63 | userSID, _ := currentProcessUserSIDString() |
| 64 | return dedupeSIDStrings([]string{allApplicationPackagesSID, allRestrictedApplicationPackagesSID, userSID}) |
| 65 | } |
| 66 | |
| 67 | // sweepMarkerFile removes the recorded ACEs and deletes the marker only when |
| 68 | // every removal succeeded, so a failed cleanup keeps its evidence for the next |
| 69 | // sweep instead of orphaning the ACE on disk. |
| 70 | func sweepMarkerFile(markerPath string, sids []string) { |
| 71 | clean := true |
| 72 | for _, e := range readResidueMarker(markerPath) { |
| 73 | if isWindowsSystemRoot(e.path) { |
| 74 | continue |
| 75 | } |
| 76 | if _, err := os.Stat(e.path); err != nil { |
| 77 | continue |
| 78 | } |
| 79 | flag := "/remove:g" |
| 80 | if e.kind == residueDeny { |
| 81 | flag = "/remove:d" |
| 82 | } |
| 83 | for _, sid := range sids { |
| 84 | if err := icacls(e.path, flag, "*"+sid, "/C"); err != nil { |
| 85 | clean = false |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | if clean { |
| 90 | _ = os.Remove(markerPath) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // processExited reports whether a marker owner is provably gone. This package |
| 95 | // writes no markers, so one carrying our own PID was left by a dead |
| 96 | // predecessor after PID reuse. Access denied does not prove death. |
| 97 | func processExited(pidText string) bool { |
| 98 | if pidText == strconv.Itoa(os.Getpid()) { |
| 99 | return true |
| 100 | } |
| 101 | pid, err := strconv.ParseUint(pidText, 10, 32) |
| 102 | if err != nil || pid == 0 { |
| 103 | return false |
| 104 | } |
| 105 | handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) |
| 106 | if err != nil { |
| 107 | return errors.Is(err, windows.ERROR_INVALID_PARAMETER) |
| 108 | } |
| 109 | defer windows.CloseHandle(handle) |
| 110 | var code uint32 |
| 111 | if err := windows.GetExitCodeProcess(handle, &code); err != nil { |
| 112 | return false |
| 113 | } |
| 114 | return code != stillActiveExitCode |
| 115 | } |
| 116 | |
| 117 | func currentProcessUserSIDString() (string, error) { |
| 118 | user, err := windows.GetCurrentProcessToken().GetTokenUser() |
| 119 | if err != nil { |
| 120 | return "", err |
| 121 | } |
| 122 | if user == nil || user.User.Sid == nil { |
| 123 | return "", fmt.Errorf("current process token has no user SID") |
| 124 | } |
| 125 | return user.User.Sid.String(), nil |
| 126 | } |
| 127 | |
| 128 | // systemRootTool resolves a Windows system tool below %SystemRoot%\System32 |
| 129 | // so a PATH-shadowed binary can never run in its place. |
| 130 | func systemRootTool(name string) string { |
| 131 | root := os.Getenv("SystemRoot") |
| 132 | if root == "" { |
| 133 | root = os.Getenv("windir") |
| 134 | } |
| 135 | if root == "" { |
| 136 | root = `C:\Windows` |
| 137 | } |
| 138 | full := filepath.Join(root, "System32", name) |
| 139 | if _, err := os.Stat(full); err == nil { |
| 140 | return full |
| 141 | } |
| 142 | return name |
| 143 | } |
| 144 | |
| 145 | func icacls(path string, args ...string) error { |
| 146 | ctx, cancel := context.WithTimeout(context.Background(), icaclsTimeout) |
| 147 | defer cancel() |
| 148 | cmd := proc.CommandContext(ctx, systemRootTool("icacls.exe"), append([]string{path}, args...)...) |
| 149 | proc.HideWindow(cmd) |
| 150 | var out bytes.Buffer |
| 151 | cmd.Stdout, cmd.Stderr = &out, &out |
| 152 | if err := cmd.Run(); err != nil { |
| 153 | return fmt.Errorf("icacls %q %s: %w: %s", path, strings.Join(args, " "), err, strings.TrimSpace(out.String())) |
| 154 | } |
| 155 | return nil |
| 156 | } |
| 157 |