PatchDayAlert
Field Note · 9 min read · 1,832 words By Colten Anderson · Field Notes

Find the service accounts nobody has rotated

The Commvault Metallic breach ended with CISA telling customers to rotate static application secrets. Your Active Directory is full of the same thing: service accounts whose passwords were set once, years ago, and never touched. Here is the PowerShell to find them and put them on a cadence.

Find the service accounts nobody has rotated

The Commvault Metallic breach did not end with a patch. It ended with a rotation order.

In May 2025, CISA advised the affected customers, the limited set that controlled their own Commvault application secrets, to rotate those secrets and the credentials on their Metallic apps and service principals that had been live between February and May 2025. A suspected nation-state actor had used CVE-2025-3928 to plant web shells on Commvault’s Azure-hosted web server and read the client secrets Commvault stored on behalf of its Microsoft 365 backup customers. One static secret, sitting where it had always sat, reached into other tenants. The fix was not a new build. It was changing credentials that should have been on a schedule and never were.

Your Active Directory has the same class of secret, and probably more of it. Every database service account, monitoring agent, backup job, CI/CD runner, and scheduled task runs as some identity, and a lot of those identities are user accounts whose password was typed in once during a project years ago, flagged “password never expires,” and forgotten. A leaked one is durable initial access. This is how to find them, in about ten minutes of PowerShell, and what to do with the list.

It pairs with the host-hardening checks from the same series: those tell you whether a box is safe to trust, this tells you whether the identities running on it are.

Who this affects

Any Windows and Active Directory shop that runs services, scheduled tasks, or integrations under named accounts. The ones that matter most: SQL Server and IIS app-pool identities, which carry a service principal name and are kerberoastable; backup and monitoring agents; and any account whose password is pasted into a config file or a vendor console. Cloud tenants have the same problem in a different shape, in Entra app registrations with long-lived client secrets, which is exactly what bit Commvault. Run all three checks below from a management host with the AD PowerShell module and RSAT installed.

Check 1: AD accounts that carry a service principal name

An account with an SPN is almost always a service account, and an SPN plus a never-expiring password is the kerberoasting target crews go looking for first. Microsoft documents the base filters for finding these accounts; this composes them into one ranked list.

Get-ADUser -Filter * -Properties servicePrincipalName, PasswordLastSet, PasswordNeverExpires, Enabled |
    Where-Object { $_.servicePrincipalName -and $_.Enabled } |
    Select-Object SamAccountName, PasswordLastSet, PasswordNeverExpires,
        @{N='PwdAgeDays'; E={ if ($_.PasswordLastSet) {
            [int]((Get-Date) - $_.PasswordLastSet).TotalDays } else { 'never-set' } }} |
    Sort-Object PasswordLastSet

Bad answer: any enabled account here with PasswordNeverExpires = True and PwdAgeDays in the hundreds or thousands. That password is a permanent static secret, and because the account has an SPN, any domain user can request a service ticket and crack it offline at their leisure. Every day it stays is another day the same hash keeps working. A PwdAgeDays of never-set is its own flag: the password was never changed through a normal reset.

Check 2: Scheduled tasks running as a named account

Get-ScheduledTask | Where-Object {
    $_.Principal.LogonType -eq 'Password' -and
    $_.Principal.UserId -notmatch '^(NT AUTHORITY\\|.*\$$)'
} | Select-Object TaskName, TaskPath,
    @{N='RunAs';     E={ $_.Principal.UserId }},
    @{N='LogonType'; E={ $_.Principal.LogonType }}

A task with LogonType of Password stores the account’s password so it can run unattended, and that stored credential is static until someone changes it. The filter drops the built-in NT AUTHORITY principals and anything ending in $ (gMSAs and computer accounts, which Windows rotates for you), leaving the tasks that run as a named user with a saved password.

Bad answer: any task outside \Microsoft\ running as a domain account with LogonType Password. Note what it runs as, because you will rotate that account and the task’s stored password has to be updated in the same motion, or the task starts failing at its next scheduled run.

Check 3: Windows services running as a named account

Get-CimInstance Win32_Service |
    Where-Object {
        $_.StartName -and
        $_.StartName -notmatch '^(LocalSystem|NT AUTHORITY\\|NT SERVICE\\|.*\$$)'
    } |
    Select-Object Name, StartName, State, StartMode |
    Sort-Object StartName

StartName is the identity the service runs as. LocalSystem, the NT AUTHORITY built-ins, and NT SERVICE\ virtual accounts manage themselves. An account ending in $ is a gMSA or a computer account. What the filter leaves is a service running as a plain domain or local user account, which means a password someone set by hand and a service that breaks the moment that password changes without the service being updated to match.

Bad answer: a Running service under domain\svc-something whose account also came back stale in Check 1. That intersection is what you care about: a live service, a static password, and no automatic rotation behind it.

What’s already handled

Before you build a rotation list, subtract what you don’t have to touch. Get-ADServiceAccount -Filter * lists the managed service accounts in the domain, gMSAs and sMSAs, whose passwords Windows rotates on its own. The gap between the named accounts from Checks 1 through 3 and this list is your actual backlog. If the command returns nothing, you have no managed service accounts at all, and every service identity in the estate is hand-managed.

Rank, then rotate or replace

You now have three lists. Rank them on two axes before you touch anything: staleness, meaning password age, oldest first; and blast radius, meaning privilege and reachability. An SPN account that sits in Domain Admins or runs an internet-reachable service jumps the queue regardless of age. A stale account driving one internal report can wait a week.

Then decide, per account, rotate or replace. Rotating buys time. Replacing removes the problem. This table maps the common classes to both.

Account classHow to find itDurable fixInterim rotation cadence
AD user account with an SPN (SQL Server, IIS app pools)Check 1 (Get-ADUser SPN filter, sort by PasswordLastSet)Convert to gMSA1 year; 90 days if privileged or internet-reachable
Scheduled task with a stored password (LogonType Password)Check 2 (Get-ScheduledTaskPrincipal)gMSA-backed taskRotate with the account it runs as
Windows service under a named accountCheck 3 (Get-CimInstance Win32_Service, StartName)gMSARotate with the account it runs as
Local (non-domain) service or admin account on a member serverLocal SAM; check Windows LAPS coverageWindows LAPS-managed rotationLAPS handles it; 1 year if unmanaged
App integration secret / API token / OAuth client secretEntra app registrations, secrets vault, config filesManaged identity or workload identity federation90 days to 1 year; immediately on exposure
DB / monitoring / CI-CD / backup credential in a config fileConfig files, CI/CD variables, vendor consolesManaged identity or vault-brokered short-lived creds90 days to 1 year; immediately on exposure

The durable fix is to stop holding the password yourself

Rotation is maintenance you will forget again. The exit is an account type where nobody holds the password to begin with.

On-premises, that is a group managed service account. Windows generates a 240-byte random password and changes it every 30 days, with no scheduled downtime and no secret for anyone to leak, paste, or forget. The migration is documented and undramatic: deploy the KDS root key once, create the gMSA, install it on the hosts that run the service, switch the service or task to the gMSA with a blank password, confirm it works, and delete the old account. Microsoft’s own guidance is blunt about the alternative: it does not recommend using on-premises user accounts as service accounts and tells you to convert them to a gMSA or sMSA wherever the service supports it.

In Azure, the equivalent is a managed identity. Microsoft calls manual handling of secrets “a known source of security issues and outages,” and the point of a managed identity is that “credentials aren’t even accessible to you.” For an Entra app that needs a credential, workload identity federation lets a managed identity stand in for the client secret, so there is no secret to rotate or steal. That is the direct answer to the Commvault failure mode: the thing read out of Azure was a stored client secret, and a managed identity is that same account with no stored secret to read.

Not every service supports these. Some vendor apps still demand a plain user account with a typed password, and those accounts stay on a manual cadence. They are the ones worth tracking by name.

Cadence until you get there

For the accounts you cannot yet convert, put them on a schedule instead of on someone’s memory. What I run, and what I would hand my team:

  • Rotate every service-account password at least once a year. A gMSA changes its own password every 30 days; a hand-managed secret that has not moved in over a year is already far behind the platform’s own bar.
  • Tighten to every 90 days for anything privileged or internet-reachable.
  • Rotate off-cycle, immediately, on two triggers: an admin with access to the credential leaves, or you have any reason to think it was exposed. The Commvault customers who rotated were acting on the second one.
  • Rotate the account and update every place its password is stored inside the same change window. A rotated password that is not updated in the service, the task, and the config file is just an outage you scheduled for yourself.

These are operational defaults, not a compliance standard. Set the intervals where your risk sits and write them down, because the failure mode here is not a wrong number, it is no number.

When this becomes an incident

Three findings move an account out of the maintenance queue and into a ticket someone works today. An SPN account in a privileged group with a multi-year-old password, kerberoastable and high-value at the same time. A service account whose password is sitting in a plaintext config file or a wiki page. And any account you cannot tie to a current owner or a running service, which is the worst of the three, because you cannot safely rotate what you cannot prove is still in use, and you cannot safely leave it either.

The account that ends up in a breach writeup is never the one you rotated last month. It is the one you found this morning, set in 2019, still running, and still working exactly as configured.

Sources

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