| 1 | //go:build windows |
| 2 | |
| 3 | package winaclresidue |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "strings" |
| 12 | "unsafe" |
| 13 | |
| 14 | "golang.org/x/sys/windows" |
| 15 | ) |
| 16 | |
| 17 | const legacyCredentialDenyMask = windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE) |
| 18 | |
| 19 | // RepairLegacyCredentialDeny removes the exact current-user DENY RX ACE that |
| 20 | // the retired backend placed on the credential file, provided a dead run's |
| 21 | // marker recorded this path. A mask alone cannot prove origin, so ambiguous |
| 22 | // ACLs and markers with live or unverifiable owners are never modified. |
| 23 | func RepairLegacyCredentialDeny(path string) error { |
| 24 | if path == "" { |
| 25 | return nil |
| 26 | } |
| 27 | if _, err := os.Stat(path); err != nil { |
| 28 | if os.IsNotExist(err) { |
| 29 | return nil |
| 30 | } |
| 31 | return err |
| 32 | } |
| 33 | userSID, err := currentProcessUserSIDString() |
| 34 | if err != nil { |
| 35 | return err |
| 36 | } |
| 37 | legacy, other, err := currentUserDenyACECounts(path, userSID) |
| 38 | if err != nil { |
| 39 | return err |
| 40 | } |
| 41 | if legacy == 0 { |
| 42 | return nil |
| 43 | } |
| 44 | if other != 0 || legacy != 1 { |
| 45 | return fmt.Errorf("refusing to alter non-legacy current-user deny ACL on %q", path) |
| 46 | } |
| 47 | markers := staleCredentialDenyMarkers(path, canonicalWindowsPath(path)) |
| 48 | if len(markers) == 0 { |
| 49 | return fmt.Errorf("refusing to alter credential deny ACL without a stale sandbox record on %q", path) |
| 50 | } |
| 51 | |
| 52 | removeErr := icacls(path, "/remove:d", "*"+userSID, "/C") |
| 53 | remainingLegacy, remainingOther, verifyErr := currentUserDenyACECounts(path, userSID) |
| 54 | if verifyErr == nil && remainingLegacy == 0 && remainingOther == 0 { |
| 55 | sids := residueSIDs() |
| 56 | for _, marker := range markers { |
| 57 | sweepMarkerFile(marker, sids) |
| 58 | } |
| 59 | return nil |
| 60 | } |
| 61 | if removeErr != nil { |
| 62 | return fmt.Errorf("remove legacy credential deny ACL: %w", removeErr) |
| 63 | } |
| 64 | if verifyErr != nil { |
| 65 | return fmt.Errorf("verify legacy credential deny ACL removal: %w", verifyErr) |
| 66 | } |
| 67 | return fmt.Errorf("legacy credential deny ACL remains on %q", path) |
| 68 | } |
| 69 | |
| 70 | // staleCredentialDenyMarkers returns the markers of exited runs that recorded |
| 71 | // a deny on path. Any marker with a live or unverifiable owner vetoes repair. |
| 72 | func staleCredentialDenyMarkers(path, canonicalPath string) []string { |
| 73 | entries, err := os.ReadDir(markerDir()) |
| 74 | if err != nil { |
| 75 | return nil |
| 76 | } |
| 77 | var found []string |
| 78 | for _, entry := range entries { |
| 79 | pid, ok := markerOwnerPID(entry.Name()) |
| 80 | if entry.IsDir() || !ok { |
| 81 | continue |
| 82 | } |
| 83 | marker := filepath.Join(markerDir(), entry.Name()) |
| 84 | for _, residue := range readResidueMarker(marker) { |
| 85 | if residue.kind != residueDeny { |
| 86 | continue |
| 87 | } |
| 88 | if !strings.EqualFold(filepath.Clean(residue.path), filepath.Clean(path)) && |
| 89 | !strings.EqualFold(canonicalWindowsPath(residue.path), canonicalPath) { |
| 90 | continue |
| 91 | } |
| 92 | if !processExited(pid) { |
| 93 | return nil |
| 94 | } |
| 95 | found = append(found, marker) |
| 96 | break |
| 97 | } |
| 98 | } |
| 99 | return found |
| 100 | } |
| 101 | |
| 102 | // canonicalWindowsPath expands 8.3 aliases without opening the protected file. |
| 103 | // The original spelling is kept on failure so the exact-path comparison still |
| 104 | // works on volumes without long-name expansion. |
| 105 | func canonicalWindowsPath(path string) string { |
| 106 | input, err := windows.UTF16PtrFromString(filepath.Clean(path)) |
| 107 | if err != nil { |
| 108 | return filepath.Clean(path) |
| 109 | } |
| 110 | buffer := make([]uint16, 32768) |
| 111 | n, err := windows.GetLongPathName(input, &buffer[0], uint32(len(buffer))) |
| 112 | if err != nil || n == 0 || n >= uint32(len(buffer)) { |
| 113 | return filepath.Clean(path) |
| 114 | } |
| 115 | return filepath.Clean(windows.UTF16ToString(buffer[:n])) |
| 116 | } |
| 117 | |
| 118 | func currentUserDenyACECounts(path, userSID string) (legacy, other int, err error) { |
| 119 | sid, err := windows.StringToSid(userSID) |
| 120 | if err != nil { |
| 121 | return 0, 0, fmt.Errorf("parse current user SID: %w", err) |
| 122 | } |
| 123 | sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) |
| 124 | if err != nil { |
| 125 | return 0, 0, fmt.Errorf("read credential DACL %q: %w", path, err) |
| 126 | } |
| 127 | if sd == nil { |
| 128 | return 0, 0, nil |
| 129 | } |
| 130 | acl, _, err := sd.DACL() |
| 131 | if errors.Is(err, windows.ERROR_OBJECT_NOT_FOUND) { |
| 132 | return 0, 0, nil |
| 133 | } |
| 134 | if err != nil { |
| 135 | return 0, 0, fmt.Errorf("read credential DACL entries %q: %w", path, err) |
| 136 | } |
| 137 | if acl == nil { |
| 138 | return 0, 0, nil |
| 139 | } |
| 140 | for index := range uint32(acl.AceCount) { |
| 141 | var ace *windows.ACCESS_ALLOWED_ACE |
| 142 | if err := windows.GetAce(acl, index, &ace); err != nil { |
| 143 | return 0, 0, fmt.Errorf("read credential DACL ACE %d for %q: %w", index, path, err) |
| 144 | } |
| 145 | if ace == nil || ace.Header.AceType == windows.ACCESS_ALLOWED_ACE_TYPE { |
| 146 | continue |
| 147 | } |
| 148 | // SID-wide icacls removal must not erase object or conditional deny |
| 149 | // entries whose trustee layout differs from a basic deny ACE. |
| 150 | if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE { |
| 151 | return 0, 0, fmt.Errorf("refusing to alter unsupported credential ACE type %d on %q", ace.Header.AceType, path) |
| 152 | } |
| 153 | aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) |
| 154 | if !windows.EqualSid(aceSID, sid) { |
| 155 | continue |
| 156 | } |
| 157 | if ace.Header.AceFlags == 0 && ace.Mask == legacyCredentialDenyMask { |
| 158 | legacy++ |
| 159 | } else { |
| 160 | other++ |
| 161 | } |
| 162 | } |
| 163 | runtime.KeepAlive(sd) |
| 164 | return legacy, other, nil |
| 165 | } |
| 166 | |
| 167 | // ResetCredentialDACL replaces the DACL of the credential store with a |
| 168 | // protected entry that grants only the current user full control. It opens |
| 169 | // the file for WRITE_DAC alone and never reads the existing descriptor, so it |
| 170 | // works when READ_CONTROL is denied and the caller is not the owner. Callers |
| 171 | // reserve it for an explicit save of Reasonix's own credential file after the |
| 172 | // provenance-checked repair could not run. |
| 173 | func ResetCredentialDACL(path string) error { |
| 174 | ptr, err := windows.UTF16PtrFromString(path) |
| 175 | if err != nil { |
| 176 | return err |
| 177 | } |
| 178 | // Attributes are readable through the parent directory's list right even |
| 179 | // while the file itself denies FILE_READ_ATTRIBUTES. |
| 180 | attrs, err := windows.GetFileAttributes(ptr) |
| 181 | if err != nil { |
| 182 | return fmt.Errorf("inspect credential store %q: %w", path, err) |
| 183 | } |
| 184 | if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { |
| 185 | return fmt.Errorf("refusing to reset the ACL of reparse point %q", path) |
| 186 | } |
| 187 | handle, err := openExact(path, windows.WRITE_DAC) |
| 188 | if err != nil { |
| 189 | return fmt.Errorf("open credential store for WRITE_DAC: %w", err) |
| 190 | } |
| 191 | defer windows.CloseHandle(handle) |
| 192 | user, err := windows.GetCurrentProcessToken().GetTokenUser() |
| 193 | if err != nil { |
| 194 | return err |
| 195 | } |
| 196 | if user == nil || user.User.Sid == nil { |
| 197 | return fmt.Errorf("current process token has no user SID") |
| 198 | } |
| 199 | entry := windows.EXPLICIT_ACCESS{ |
| 200 | AccessPermissions: windows.STANDARD_RIGHTS_ALL | windows.SPECIFIC_RIGHTS_ALL, |
| 201 | AccessMode: windows.GRANT_ACCESS, |
| 202 | Inheritance: windows.NO_INHERITANCE, |
| 203 | Trustee: windows.TRUSTEE{TrusteeForm: windows.TRUSTEE_IS_SID, TrusteeType: windows.TRUSTEE_IS_USER, TrusteeValue: windows.TrusteeValueFromSID(user.User.Sid)}, |
| 204 | } |
| 205 | acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{entry}, nil) |
| 206 | if err != nil { |
| 207 | return fmt.Errorf("build credential store DACL: %w", err) |
| 208 | } |
| 209 | sd, err := windows.NewSecurityDescriptor() |
| 210 | if err != nil { |
| 211 | return err |
| 212 | } |
| 213 | if err := sd.SetDACL(acl, true, false); err != nil { |
| 214 | return err |
| 215 | } |
| 216 | // Protected: inherited entries from the profile directory must not bring |
| 217 | // back the trustees the reset is meant to replace. |
| 218 | if err := sd.SetControl(windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED); err != nil { |
| 219 | return err |
| 220 | } |
| 221 | if err := windows.SetKernelObjectSecurity(handle, windows.DACL_SECURITY_INFORMATION, sd); err != nil { |
| 222 | return fmt.Errorf("reset credential store DACL %q: %w", path, err) |
| 223 | } |
| 224 | return nil |
| 225 | } |
| 226 |