PatchDayAlert
Analysis · 7 min read · 1,468 words By Colten Anderson · Commentary

One in ten AD accounts is dormant. Here is the blast radius.

More than one in ten Active Directory user accounts is dormant, per Microsoft. Each one keeps its rights while nobody watches. The math, the queries to find them, and how to reap them without breaking a service.

One in ten AD accounts is dormant. Here is the blast radius.

More than ten percent of the user accounts in a typical Active Directory are inactive. That is not a security vendor’s scare number, it is Microsoft’s own figure, measured by the last time the password changed and the last logon timestamp. Lepide’s State of Active Directory Security puts it higher, at 21 percent either inactive or abandoned. Take the low end and hold it against a real directory: ten percent of a 2,000 user domain is 200 accounts that still authenticate, still carry whatever rights they were granted, and that no one is watching, because no one uses them.

The obvious read is that this is housekeeping. Stale accounts sound like disk space and tidiness, a chore that lands somewhere below patching and backups. They are the cheapest foothold an intruder can find, because a dormant account holds three properties at once. It keeps its permissions. It produces no activity, so nothing alerts on it. And its password is usually old enough to have leaked in someone else’s breach by now.

CISA and MS-ISAC documented exactly that chain in February 2024. A threat actor logged into a US state government network over the VPN using a former employee’s administrator account that was never disabled after the person left. The credentials had already surfaced in a separate, earlier data breach. From that single login the actor ran LDAP queries against a domain controller and walked out with host and user metadata, which then showed up for sale on a dark web broker. One account, still enabled, still privileged. That was the whole intrusion (advisory AA24-046A).

The blast radius, worked

Do the arithmetic for your own directory. Count enabled user accounts, apply a dormant rate, and you have the number of unmonitored footholds sitting in the domain right now. The privileged column is the one that matters.

Enabled user accountsDormant at 10% (Microsoft floor)Dormant at 21% (Lepide observed)Privileged and dormant (3% privileged, same rate)
500501053
2,00020042013
10,0001,0002,10063

The first two columns are just the rate times the count. The last one assumes three percent of your accounts hold elevated rights, a deliberately low stand-in you should replace with your real number, and that dormancy hits them at the same rate as everyone else. That assumption is generous to the defender. Privileged accounts skew toward role leftovers, break-glass accounts, and service identities nobody signs into daily, which means they go dormant more often than ordinary users, not less. And the CISA case is the reminder that the count you care about is not thirteen or sixty-three. It is one. One dormant admin account was the entire breach.

Users are also only half the directory. Every machine that was reimaged, retired, or lost leaves an enabled computer object behind, and those accounts authenticate too. A full accounting doubles the surface you just calculated.

”Dormant” carries a two week margin before you even start

Before trusting any of this, know what the timestamp actually means. The attribute Active Directory uses to find stale accounts, lastLogonTimestamp, does not update on every logon. By Sean Metcalf’s writeup of the replication behavior, the controlling value, msDS-LogonTimeSyncInterval, defaults to 14 days with a randomization skew of roughly five, so the stamp runs about 9 to 14 days behind real activity by design. Microsoft built it that way to keep replication traffic down, not to report when someone last logged in.

lastLogonTimestamp was built to find dead accounts cheaply, not to report when a user last signed in. Read it as a two week smear, not a timestamp.

That cuts both ways. An account flagged dormant on a 90 day threshold has really been silent for somewhere between 76 and 90 days, so the flag is conservative and safe to act on. But an account that looks active inside the window might have gone quiet up to two weeks before the stamp admits it. The fix is to never trust the logon time alone. Cross-check it against PwdLastSet, which is exact and replicates immediately.

Finding them: two queries that should disagree

Microsoft’s own tool is one line. Search-ADAccount -AccountInactive walks lastLogonTimestamp and returns anything past the window, including accounts that have never logged in at all, which are the most suspicious of the lot.

# Enabled users with no logon in 90 days (TimeSpan format is D.HH:MM:SS)
Search-ADAccount -AccountInactive -UsersOnly -TimeSpan 90.00:00:00 |
    Where-Object { $_.Enabled } |
    Select-Object Name, SamAccountName, LastLogonDate |
    Sort-Object LastLogonDate

That list is a starting point, not a delete queue, because it only knows about logon time. Run a second pass with Get-ADUser that adds the password age and the two flags that separate a safe cleanup from an outage: PasswordNeverExpires, which is where service accounts hide, and adminCount, which marks accounts that are or were in a protected group.

$cutoff = (Get-Date).AddDays(-90)
Get-ADUser -Filter { Enabled -eq $true } -Properties LastLogonDate, PasswordLastSet, PasswordNeverExpires, adminCount |
    Where-Object { $_.LastLogonDate -lt $cutoff -and $_.PasswordLastSet -lt $cutoff } |
    Select-Object Name, SamAccountName, LastLogonDate, PasswordLastSet, PasswordNeverExpires, adminCount |
    Sort-Object PasswordLastSet

An account that is dormant by both logon time and password age is a real candidate. One that is dormant but had its password reset last week is not: someone is maintaining it. The rows where adminCount equals 1 are the ones to open first. Those are the thirteen, or the sixty-three.

Computer accounts rot on a different clock

Computers do not follow the user rules, so query them separately. A domain-joined Windows machine rotates its own machine account password every 30 days by default, and those passwords do not expire, and the domain will not disable a machine whose password is stale. As Metcalf puts it, the policy is “more of a guideline than a rule.” That behavior is what makes computer objects easy to read: a live member rotates three times inside 90 days, so a computer whose PasswordLastSet is older than that has almost certainly been offline the whole time.

$cutoff = (Get-Date).AddDays(-90)
Get-ADComputer -Filter { Enabled -eq $true } -Properties LastLogonDate, PasswordLastSet, OperatingSystem |
    Where-Object { $_.PasswordLastSet -lt $cutoff } |
    Select-Object Name, OperatingSystem, LastLogonDate, PasswordLastSet |
    Sort-Object PasswordLastSet

Disable, hold, wait, then delete

Do not delete anything you just found. Microsoft’s guidance is to turn the account off, wait several weeks, and delete only if nothing breaks, and the reason is sitting in the query above: a service account or a user on long leave can look identical to an abandoned one. Disabling is reversible in seconds. Deleting takes the SID and the group memberships with it, and the AD Recycle Bin only partly undoes that.

So stage it. Disable the account, stamp it with the date and reason, and move it to a holding OU so the pending set lives in one place.

$holdingOU = "OU=Disabled-Pending-Deletion,OU=Cleanup,DC=corp,DC=example,DC=com"
Search-ADAccount -AccountInactive -UsersOnly -TimeSpan 90.00:00:00 |
    Where-Object { $_.Enabled } |
    ForEach-Object {
        Disable-ADAccount -Identity $_.SamAccountName
        Set-ADUser    -Identity $_.SamAccountName -Description ("Disabled for inactivity {0:yyyy-MM-dd}" -f (Get-Date))
        Move-ADObject -Identity $_.DistinguishedName -TargetPath $holdingOU
    }

Then let the holding OU soak. Thirty days is a reasonable floor for user accounts, but a full monthly and quarterly business cycle is safer, because that is when the payroll run or the quarter-close job driven by a forgotten service account finally fires. The soak window is the actual test: if nobody screams, the account was dead. When the window closes, delete what is still sitting disabled.

# After the soak window. Drop -WhatIf once you have reviewed the list.
Get-ADUser -SearchBase $holdingOU -Filter { Enabled -eq $false } -Properties whenChanged |
    Where-Object { $_.whenChanged -lt (Get-Date).AddDays(-30) } |
    Remove-ADUser -WhatIf

Keep -WhatIf on until you have read the output. It is the difference between a cleanup and an incident.

What to watch

The number worth tracking is not how many accounts you deleted, it is how many enabled accounts have not authenticated in 90 days, plotted over time. If that count only climbs, the offboarding process is not closing accounts and you are accumulating footholds faster than you retire them. If it settles near zero after each turnover event, deprovisioning is working and the query is just confirming it.

The state agency in the CISA advisory was not negligent across the board. It ran a VPN, it had a domain, it responded once the stolen data surfaced. The one thing it skipped was disabling an account it no longer needed, and that single omission was the entire way in. Every dormant account you can count is a door you are not watching. The math only tells you how many.

Sources

Share

Related field notes

Get the free CVE triage cheat sheet

Subscribe and we'll email you the one-page triage flow for fresh CVEs. Plus the weekly digest.

Subscribe