github.com/google/osv-scalibr@v0.4.1/detector/weakcredentials/winlocal/winlocal_windows.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 //go:build windows 16 17 // Package winlocal implements a weak passwords detector for local accounts on Windows. 18 package winlocal 19 20 import ( 21 "bufio" 22 "context" 23 _ "embed" 24 "errors" 25 "fmt" 26 "os" 27 "path/filepath" 28 "strings" 29 "syscall" 30 31 "github.com/google/osv-scalibr/detector" 32 "github.com/google/osv-scalibr/detector/weakcredentials/winlocal/samreg" 33 "github.com/google/osv-scalibr/detector/weakcredentials/winlocal/systemreg" 34 scalibrfs "github.com/google/osv-scalibr/fs" 35 "github.com/google/osv-scalibr/inventory" 36 "github.com/google/osv-scalibr/packageindex" 37 "github.com/google/osv-scalibr/plugin" 38 "golang.org/x/sys/windows/registry" 39 ) 40 41 var ( 42 //go:embed data/top100_nt_hashes.csv 43 knownNTHashesFile string 44 //go:embed data/top100_lm_hashes.csv 45 knownLMHashesFile string 46 ) 47 48 const ( 49 // Name of the detector. 50 Name = "weakcredentials/winlocal" 51 52 samDumpFile = `C:\ProgramData\Scalibr\private\SAM` 53 systemDumpFile = `C:\ProgramData\Scalibr\private\SYSTEM` 54 vulnRefLMPassword = "PASSWORD_HASH_LM_FORMAT" 55 vulnRefWeakPass = "WINDOWS_WEAK_PASSWORD" 56 ) 57 58 // Detector is a SCALIBR Detector for weak passwords detector for local accounts on Windows. 59 type Detector struct { 60 knownNTHashes map[string]string 61 knownLMHashes map[string]string 62 } 63 64 // New returns a detector. 65 func New() detector.Detector { 66 return &Detector{} 67 } 68 69 // userHashInfo contains the hashes of a user. Note that both hashes represents the same password. 70 type userHashInfo struct { 71 username string 72 lmHash string 73 ntHash string 74 } 75 76 // Name of the detector. 77 func (Detector) Name() string { return Name } 78 79 // Version of the detector. 80 func (Detector) Version() int { return 0 } 81 82 // Requirements of the detector. 83 func (Detector) Requirements() *plugin.Capabilities { 84 return &plugin.Capabilities{OS: plugin.OSWindows} 85 } 86 87 // RequiredExtractors returns an empty list as there are no dependencies. 88 func (Detector) RequiredExtractors() []string { return nil } 89 90 // DetectedFinding returns generic vulnerability information about what is detected. 91 func (d Detector) DetectedFinding() inventory.Finding { 92 return inventory.Finding{ 93 GenericFindings: []*inventory.GenericFinding{ 94 d.findingForFormatLM(nil), 95 d.findingForWeakPasswords(nil), 96 }, 97 } 98 } 99 100 // Scan starts the scan. 101 func (d Detector) Scan(ctx context.Context, _ *scalibrfs.ScanRoot, _ *packageindex.PackageIndex) (inventory.Finding, error) { 102 hashes, err := d.hashes(ctx) 103 if err != nil || len(hashes) == 0 { 104 return inventory.Finding{}, err 105 } 106 107 return d.internalScan(ctx, hashes) 108 } 109 110 // internalScan is the internal portion of the Scan function. The function was split in two to 111 // dissociate registry operation from finding the vulnerabilities to allow unit testing. 112 func (d Detector) internalScan(ctx context.Context, hashes []*userHashInfo) (inventory.Finding, error) { 113 // first part of the detection: if any user's password is stored using the LM format, this is a 114 // vulnerability given the weakness of the algorithm. 115 var usersWithLM []string 116 for _, user := range hashes { 117 if user.lmHash != "" { 118 usersWithLM = append(usersWithLM, user.username) 119 } 120 } 121 122 var findings []*inventory.GenericFinding 123 if len(usersWithLM) > 0 { 124 target := &inventory.GenericFindingTargetDetails{Extra: fmt.Sprintf("%v", usersWithLM)} 125 findings = append(findings, d.findingForFormatLM(target)) 126 } 127 128 // then, we can actually try to find weak passwords. 129 weakUsers, err := d.bruteforce(ctx, hashes) 130 if err != nil { 131 return inventory.Finding{}, err 132 } 133 134 if len(weakUsers) > 0 { 135 target := &inventory.GenericFindingTargetDetails{Extra: fmt.Sprintf("%v", weakUsers)} 136 findings = append(findings, d.findingForWeakPasswords(target)) 137 } 138 139 return inventory.Finding{GenericFindings: findings}, nil 140 } 141 142 // findingForFormatLM creates a Scalibr finding when passwords are stored using the LM format. 143 func (d Detector) findingForFormatLM(target *inventory.GenericFindingTargetDetails) *inventory.GenericFinding { 144 return &inventory.GenericFinding{ 145 Adv: &inventory.GenericFindingAdvisory{ 146 ID: &inventory.AdvisoryID{ 147 Publisher: "GOOGLE", 148 Reference: vulnRefLMPassword, 149 }, 150 Title: "Password hashes are stored in the LM format", 151 Sev: inventory.SeverityHigh, 152 Description: "Password hashes are stored in the LM format. Please switch local storage to use NT format and regenerate the hashes.", 153 Recommendation: "Change the password of the user after changing the storage format.", 154 }, 155 Target: target, 156 } 157 } 158 159 // findingForWeakPasswords creates a Scalibr finding when passwords were found from the 160 // dictionaries. 161 func (d Detector) findingForWeakPasswords(target *inventory.GenericFindingTargetDetails) *inventory.GenericFinding { 162 return &inventory.GenericFinding{ 163 Adv: &inventory.GenericFindingAdvisory{ 164 ID: &inventory.AdvisoryID{ 165 Publisher: "GOOGLE", 166 Reference: vulnRefWeakPass, 167 }, 168 Title: "Weak passwords on Windows", 169 Sev: inventory.SeverityCritical, 170 Description: "Some passwords were identified as being weak.", 171 Recommendation: "Change the password of the user affected users.", 172 }, 173 Target: target, 174 } 175 } 176 177 // saveSensitiveReg saves a registry key to a file. It handles registries that are considered 178 // sensitive and thus will try to take measures to limit access to the file. 179 // Note that it is still the responsibility of the caller to delete the file once it is no longer 180 // needed. 181 func (d Detector) saveSensitiveReg(hive registry.Key, regPath string, file string) error { 182 if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { 183 return err 184 } 185 186 if _, err := os.Stat(file); err == nil || !os.IsNotExist(err) { 187 if err := os.Remove(file); err != nil { 188 return err 189 } 190 } 191 192 key, err := registry.OpenKey(hive, regPath, registry.ALL_ACCESS) 193 if err != nil { 194 return err 195 } 196 197 defer key.Close() 198 199 // Only give full access to SYSTEM but allow admins to delete the file. 200 // 201 // O:SY; Owner: SYSTEM 202 // G:SY; Group: SYSTEM 203 // D:PAI; DACL - SDDL_AUTO_INHERITED, SDDL_PROTECTED 204 // 205 // (A;;FA;;;SY); SDDL_ACCESS_ALLOWED - FULL_ACCESS - SYSTEM 206 // (A;;SD;;;BA); SDDL_ACCESS_ALLOWED - SDDL_STANDARD_DELETE - Builtin admins 207 sddl := "O:SYG:SYD:PAI(A;;FA;;;SY)(A;;SD;;;BA)" 208 return RegSaveKey(syscall.Handle(key), file, sddl) 209 } 210 211 func (d Detector) dumpSAM(samFile string) (*samreg.SAMRegistry, error) { 212 if err := d.saveSensitiveReg(registry.LOCAL_MACHINE, `SAM`, samFile); err != nil { 213 return nil, err 214 } 215 216 reg, err := samreg.NewFromFile(samFile) 217 if err != nil { 218 os.Remove(samFile) 219 return nil, err 220 } 221 222 return reg, nil 223 } 224 225 func (d Detector) dumpSYSTEM(systemFile string) (*systemreg.SystemRegistry, error) { 226 if err := d.saveSensitiveReg(registry.LOCAL_MACHINE, `SYSTEM`, systemFile); err != nil { 227 return nil, err 228 } 229 230 reg, err := systemreg.NewFromFile(systemFile) 231 if err != nil { 232 os.Remove(systemFile) 233 return nil, err 234 } 235 236 return reg, nil 237 } 238 239 // loadDictionary loads a dictionary (*in place*) of known passwords from a file. 240 // Each line is expected to be in the format: 241 // 242 // hash;clearPass 243 func (d Detector) loadDictionary(file string, dict map[string]string) error { 244 if dict == nil { 245 return errors.New("dictionary is nil") 246 } 247 248 scanner := bufio.NewScanner(strings.NewReader(file)) 249 for scanner.Scan() { 250 line := scanner.Text() 251 parts := strings.Split(line, ";") 252 if len(parts) != 2 { 253 continue 254 } 255 256 hash := parts[0] 257 clearPass := parts[1] 258 dict[hash] = clearPass 259 } 260 261 return nil 262 } 263 264 func (d Detector) knownHashes() (map[string]string, map[string]string, error) { 265 if d.knownNTHashes == nil { 266 d.knownNTHashes = make(map[string]string) 267 if err := d.loadDictionary(knownNTHashesFile, d.knownNTHashes); err != nil { 268 return nil, nil, err 269 } 270 } 271 272 if d.knownLMHashes == nil { 273 d.knownLMHashes = make(map[string]string) 274 if err := d.loadDictionary(knownLMHashesFile, d.knownLMHashes); err != nil { 275 return nil, nil, err 276 } 277 } 278 279 return d.knownNTHashes, d.knownLMHashes, nil 280 } 281 282 func (d Detector) hashesForUser(sam *samreg.SAMRegistry, rid string, derivedKey []byte) (*userHashInfo, error) { 283 info, err := sam.UserInfo(rid) 284 if err != nil { 285 return nil, err 286 } 287 288 enabled, err := info.Enabled() 289 if err != nil { 290 return nil, err 291 } 292 293 // if the user is disabled, we do not waste cycle cracking their password. 294 if !enabled { 295 return nil, nil 296 } 297 298 username, err := info.Username() 299 if err != nil { 300 return nil, err 301 } 302 303 lmHash, ntHash, err := info.Hashes(derivedKey) 304 if err != nil { 305 return nil, err 306 } 307 308 return &userHashInfo{ 309 username: username, 310 lmHash: fmt.Sprintf("%X", string(lmHash)), 311 ntHash: fmt.Sprintf("%X", string(ntHash)), 312 }, nil 313 } 314 315 // hashes returns the hashes of all (enabled) users on the system. 316 func (d Detector) hashes(ctx context.Context) ([]*userHashInfo, error) { 317 system, err := d.dumpSYSTEM(systemDumpFile) 318 if err != nil { 319 return nil, err 320 } 321 322 defer os.Remove(systemDumpFile) 323 defer system.Close() 324 325 syskey, err := system.Syskey() 326 if err != nil { 327 return nil, err 328 } 329 330 sam, err := d.dumpSAM(samDumpFile) 331 if err != nil { 332 return nil, err 333 } 334 335 defer os.Remove(samDumpFile) 336 defer sam.Close() 337 338 derivedKey, err := sam.DeriveSyskey(syskey) 339 if err != nil { 340 return nil, err 341 } 342 343 rids, err := sam.UsersRIDs() 344 if err != nil { 345 return nil, err 346 } 347 348 var users []*userHashInfo 349 for _, rid := range rids { 350 if err := ctx.Err(); err != nil { 351 return nil, err 352 } 353 354 user, err := d.hashesForUser(sam, rid, derivedKey) 355 if err != nil { 356 return nil, err 357 } 358 359 // there was no error but no hashes were found. Most likely the user was disabled. 360 if user == nil { 361 continue 362 } 363 364 users = append(users, user) 365 } 366 367 return users, nil 368 } 369 370 func (d Detector) bruteforce(ctx context.Context, hashes []*userHashInfo) (map[string]string, error) { 371 knownNTHashes, knownLMHashes, err := d.knownHashes() 372 if err != nil { 373 return nil, err 374 } 375 376 results := make(map[string]string) 377 378 for _, user := range hashes { 379 if err := ctx.Err(); err != nil { 380 return nil, err 381 } 382 383 if len(user.lmHash) > 0 { 384 if password, ok := knownLMHashes[user.lmHash]; ok { 385 results[user.username] = password 386 continue 387 } 388 } 389 390 if len(user.ntHash) > 0 { 391 if password, ok := knownNTHashes[user.ntHash]; ok { 392 results[user.username] = password 393 continue 394 } 395 } 396 } 397 398 return results, nil 399 }