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  }