ADSTALE-004: Expired Passwords Not Disabled

Platform
Active Directory
Category
AD Stale & Obsolete Objects
Severity
Medium
Zero Trust pillar
Governance (weight 1)
Golden fixtures
3
Branch coverage
Observed: fixtures prove the verdicts they exercise
Provenance
baseline

What it checks

The check queries every user object in the domain via Get-ADUser -Filter * -Properties PasswordLastSet, PasswordNeverExpires, Enabled, LastLogonTimestamp, userAccountControl, msDS-ResultantPSO, SamAccountName, DistinguishedName, AdminCount, and then evaluates the following predicates on each row. Enabled equals true (the account is not already disabled). PasswordNeverExpires equals false (the DONT_EXPIRE_PASSWORD bit 0x10000 is not set on userAccountControl, so the account is in scope of the password-age policy). The applicable maxPwdAge is resolved: if msDS-ResultantPSO is populated, the check binds to that Password Settings Object and reads msDS-MaximumPasswordAge; otherwise it reads maxPwdAge from the domain naming-context head (the Default Domain Policy value). PasswordLastSet is then compared against (Now minus maxPwdAge). When PasswordLastSet is older than the threshold and the account remains enabled, the row is emitted as a finding. The check also flags the special case PasswordLastSet equals 1601-01-01 (FILETIME zero), which indicates the password has never been set and the account is in a must-change-at-next-logon state from creation. Accounts where PasswordNeverExpires is true are reported separately (they are out of scope of this check but cross-referenced to ADPWD-005 for documented-service-account review). AdminCount equals 1 rows are tagged as elevated risk because a stale password on a current or former privileged account is the worst variant of this finding.

Why it matters

A domain account whose password has aged past the policy maximum but is still enabled sits in an exploitable seam. The KDC will refuse a fresh authentication that requires the user to change at next logon, but the underlying secrets in the directory remain valid for several attacker workflows that do not go through the normal interactive change-password path. First, the NT hash stored in NTDS.dit is still the live hash for the account. An attacker who has reached Domain Controller access (DCSync, ntdsutil ifm, VSS shadow copy) and runs secretsdump or Mimikatz lsadump::dcsync receives the same hash regardless of whether the account is in the must-change state, and that hash is then usable for pass-the-hash against any host that accepts NTLM. Second, accounts in this state are almost by definition unmonitored: the human owner has either left the company, moved teams, or never existed (orphaned service identity), so a successful credential reset by the attacker, or a silent enable-and-set-password by a compromised helpdesk role, will not be noticed. Third, the age of the password is the age of the cleartext that may be circulating outside the environment. Passwords that were strong at creation in 2018 are routinely present in 2026 credential-stuffing lists derived from third-party breaches; the longer the password has been unchanged, the higher the probability that it has been leaked through an unrelated incident and is now buyable on initial-access-broker markets. Fourth, this state frequently correlates with broken offboarding: when a leaver is processed by HR but never reaches the identity team, the AD account remains enabled with the leavers cached password, providing a quiet re-entry vector for that individual or for anyone who acquires their credentials. Stale-but-enabled accounts are a recurring root cause of incidents in the Verizon DBIR, Mandiant M-Trends, and CISA joint advisories on ransomware initial access.

Attack path

Step 1: Enumeration. The attacker, holding any authenticated domain credential, runs Get-ADUser -Filter {Enabled -eq $true -and PasswordNeverExpires -eq $false} -Properties PasswordLastSet, LastLogonTimestamp or the equivalent LDAP filter (&(objectCategory=person)(objectClass=user)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!userAccountControl:1.2.840.113556.1.4.803:=65536)). Tools such as PowerView (Get-DomainUser), BloodHound, ldapsearch, and Impacket GetADUsers.py all expose pwdLastSet without privileged rights. The attacker filters to rows where pwdLastSet is older than 365 days and LastLogonTimestamp is also stale. Step 2: Target selection. The attacker prioritizes accounts whose names suggest service identities (svc_, app_, sql_, backup_), former employees (firstname.lastname patterns with no recent logon), or contractors (contractor_, ext_, tmp_) and cross-references against AdminCount=1 to surface stale privileged accounts. Step 3: Password recovery. The attacker tries the candidate names against credential corpora (Have I Been Pwned, RockYou2024, leaked-credential markets, prior incident dumps from the same organization). Long-stale passwords have a high hit rate because they have been exposed to multiple unrelated breaches over their lifetime. Where corpora miss, the attacker pivots to AS-REP roasting (ADKERB-002) and Kerberoasting (ADKERB-001) if the account is in scope; both produce offline-crackable material whose viability is bounded by password age and entropy. Step 4: NTDS-backed pass-the-hash. If the attacker has already reached Domain Controller access for any reason, they pull the hash via DCSync (Impacket secretsdump.py, Mimikatz lsadump::dcsync /user:<sam>) and use the stale-but-still-valid NT hash directly with pass-the-hash (Impacket psexec.py -hashes, CrackMapExec, evil-winrm) against any host that the stale account once had access to. The must-change-at-next-logon state does not block NTLM authentication using the existing hash, only an interactive Kerberos password validation. Step 5: Reactivation through helpdesk pivot. The attacker who has compromised a helpdesk role calls Set-ADAccountPassword on the stale account, clears the must-change bit, and signs in cleanly. Because no one owns the account, no alert fires and the change is not contested. Step 6: Lateral movement and persistence. The recovered credential is used for VPN, RDP, SaaS single-sign-on, and any application that still trusts the directory. If the account previously held administrative rights (AdminCount=1 from a prior membership now removed but never reset), the SDProp-set ACL trail on the user object can be leveraged for graph traversal in BloodHound.

How Guerrilla assesses it

Guerrilla resolves the domain maxPwdAge by reading Get-ADDefaultDomainPasswordPolicy and the maxPwdAge attribute on the domain naming-context head, converting the FILETIME tick-interval value to a TimeSpan in days. Every Fine-Grained Password Policy is enumerated via Get-ADFineGrainedPasswordPolicy -Filter * and indexed by msDS-PSOAppliesTo. The check then runs a single Get-ADUser -Filter * -Properties PasswordLastSet, PasswordNeverExpires, Enabled, LastLogonTimestamp, userAccountControl, msDS-ResultantPSO, AdminCount, SamAccountName, DistinguishedName, MemberOf pass against a Global Catalog where available to avoid per-DC drift on LastLogonTimestamp. For each row, the applicable maxPwdAge is selected (PSO override if msDS-ResultantPSO is populated, otherwise domain default), and the row is included in the finding when Enabled equals true, PasswordNeverExpires equals false, and PasswordLastSet is older than (Now minus maxPwdAge). The finding includes per-account fields: SamAccountName, DistinguishedName, PasswordLastSet, days since password set, applicable maxPwdAge source (Default Domain Policy or PSO name), LastLogonTimestamp, AdminCount, group memberships of privileged-tier interest (Domain Admins, Enterprise Admins, Schema Admins, Account Operators, Backup Operators, Server Operators, Print Operators, DnsAdmins, Protected Users). The check is read-only, requires only authenticated user rights, runs in under 30 seconds against a 50,000-user domain, and does not write to the directory or generate Event 4738 / 4724 noise.

Recommended value

No accounts with passwords older than the maximum password age policy unless explicitly exempted as documented service accounts

Remediation

Query accounts where PasswordLastSet is older than the maximum password age using Get-ADUser -Filter * -Properties PasswordLastSet,PasswordNeverExpires. Exclude accounts with PasswordNeverExpires that are documented service accounts. Force password reset at next logon for accounts with expired passwords. Disable accounts that are not claimed after notification. Review PasswordNeverExpires exemptions annually

Fixture-proven verdicts

Every verdict below is proven by a golden fixture in the module's gating test suite. This table derives from the last green run; it cannot be edited by hand.

Verdict scenarios for ADSTALE-004
ScenarioExpected verdict
cleanPASS
known-badWARN
throttledNot Assessed

Framework mappings

NIST SP 800-53
IA-5(1)
CIS AD Benchmark
9.2.2
MITRE ATT&CK
T1078.002