Blue Team Basics: Active Directory Security Assessments
If you manage Active Directory and you haven't run BloodHound or PingCastle against your own domain yet, you should probably do that before someone else does. This post covers the main ways attackers move through AD once they're inside, and what you can do to spot them.
I've been doing AD security assessments at client sites for a while now, and the same problems keep showing up. Overprivileged accounts, stale delegations, SID history nobody's looked at in years. The tools attackers use to find these are free and easy to run. Your defenses should be too.
Enumeration: what attackers see when they get in
Once someone has a foothold on a domain-joined machine, they can query AD for everything. User accounts, group memberships, service accounts, who's logged in where. Tools like BloodHound map out attack paths that would take hours to find manually, and PingCastle gives you a security risk score with specific findings in minutes.
You can't stop someone from querying AD if they're on a domain-joined machine (it's how AD works), but you can make it harder and you can detect it:
- Lock down who can query what. Most users don't need to enumerate the entire directory. Restrict read access where you can.
- Watch your logs. Failed logins, unusual LDAP queries, accounts accessing things they normally don't. Set up alerts in your SIEM (I use Sentinel, but whatever you've got works).
- Honeypot accounts. Create fake admin accounts that look tempting. Match your org's actual naming convention (if your service accounts are "SVC-APP-ENV," don't use "svc_backup_admin"), give them realistic metadata (logon history, group memberships, SPNs), and stick them in a visible OU so they show up in enumeration. If anyone touches them, you know something's wrong. Just make sure you're actually monitoring them, or they're pointless.
- Endpoint protection on everything. Defender for Endpoint is an endpoint protection platform with EDR, antivirus, and attack surface reduction built in. It catches a lot of enumeration tools. Not all of them, but it raises the bar.
Check for failed logins and special privilege logon events:
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4625} -MaxEvents 50 |
Select-Object TimeCreated, @{N='Account';E={$_.Properties[5].Value}}, @{N='SourceIP';E={$_.Properties[19].Value}}
# Special privileges assigned to a logon session
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4672} -MaxEvents 50 |
Select-Object TimeCreated, @{N='Account';E={$_.Properties[1].Value}}
Expected output:
TimeCreated Account SourceIP ----------- ------- -------- 2023-03-01 14:22:31 jsmith 10.0.1.45 2023-03-01 14:22:28 jsmith 10.0.1.45 2023-03-01 14:21:55 administrator 192.168.5.12 2023-03-01 14:20:03 svc_backup 10.0.1.100 2023-03-01 13:58:41 admin.legacy 172.16.0.88 TimeCreated Account ----------- ------- 2023-03-01 14:30:01 administrator 2023-03-01 14:22:31 svc_sqlprod 2023-03-01 14:15:12 administrator 2023-03-01 13:58:41 CORP\da-thomas
What you're looking at: The first table shows failed logon attempts: who tried to log in, when, and from which IP. Multiple rapid failures from the same IP against the same account suggests brute force. Failures from unusual IPs against admin accounts are worth investigating immediately. The second table shows every time an account with elevated privileges (Domain Admin, backup rights, debug privileges, etc.) started a new session. If you see accounts here that shouldn't have admin rights, or logon times that don't match normal working hours, dig deeper.
Note: the original version of this post used Get-EventLog which is not available in PowerShell 6+. Get-WinEvent is faster and works in PowerShell 7+.
Finding overprivileged accounts
At almost every client I've worked with, there are more Domain Admins than there should be. Service accounts with admin rights "because it was easier." Credentials cached on workstations from that one time someone logged in to fix a printer.
Attackers know this. They look for stored credentials first because it's the easiest win.
Start by finding out who's actually in your admin groups:
$AdminGroups = "Domain Admins", "Enterprise Admins", "Schema Admins", "Administrators"
$AdminUsers = foreach ($Group in $AdminGroups) {
Get-ADGroupMember -Identity $Group -Recursive |
Where-Object { $_.objectClass -eq "user" } |
Select-Object Name, SamAccountName, @{Name="Group"; Expression={$Group}}
}
$AdminUsers | Sort-Object Group | Format-Table -AutoSize
Expected output:
Name SamAccountName Group ---- -------------- ----- Thomas Grome tgrome Domain Admins SQL Service svc_sql Domain Admins Backup Admin svc_backup Domain Admins Old Contractor jdoe_ext Domain Admins Thomas Grome tgrome Enterprise Admins Admin Administrator Administrators Thomas Grome tgrome Administrators SQL Service svc_sql Administrators
What you're looking at: Every user in your four most privileged groups, broken out by group. If you see service accounts in Domain Admins (like svc_sql above), they almost certainly don't need to be there. Accounts appearing in multiple groups is common but worth questioning: does svc_backup really need Domain Admin, or just Backup Operators? Anything you don't recognize by name needs investigation. In a healthy environment, this table should be short — three to five accounts max across all groups.
If that list is longer than you expected, that's your first problem to fix.
While you're at it, check your file shares too. Attackers love finding shares with overly permissive ACLs:
# Replace the path with your actual shares (\\server\share or local paths)
Get-Acl -Path "\\FileServer\SharedData" |
Select-Object -ExpandProperty Access |
Where-Object {
$_.IdentityReference -notlike "BUILTIN\Administrators" -and
$_.IdentityReference -notlike "NT AUTHORITY\SYSTEM"
} | Format-Table IdentityReference, FileSystemRights, AccessControlType
Expected output:
IdentityReference FileSystemRights AccessControlType ----------------- ---------------- ----------------- CORP\Domain Users ReadAndExecute Allow Everyone Read Allow CORP\Helpdesk FullControl Allow
What you're looking at: Every non-standard permission on the share. "Everyone" with any access is a red flag. "Domain Users" with read access means every authenticated user in the domain can browse this share. "FullControl" for a helpdesk group means anyone in that group can modify, delete, or plant files. The dangerous ones to watch for: broad groups (Domain Users, Authenticated Users, Everyone) with write access, and any group with FullControl that doesn't absolutely need it.
This is separate from AD permissions, but it's where credentials and sensitive data often end up. I've found password spreadsheets on open shares more times than I'd like to admit.
SID history: the hidden backdoor
This one's sneaky. When you migrate accounts between domains, Windows keeps the old SID in a "SID history" attribute. The idea is that the migrated account can still access resources from the old domain. The problem is that attackers can inject privileged SIDs into that history and effectively give themselves admin access without being in any admin group.
Quick primer if you're not familiar with SIDs: a SID is just a unique identifier that Windows assigns to every account and group. Some are well-known, like S-1-5-32-544 (Administrators), S-1-5-32-545 (Users), and S-1-5-32-555 (Remote Desktop Users). If someone injects a privileged SID like 544 into an account's sIDHistory, Windows treats that account as a member of the Administrators group. No group membership change needed, no audit trail in group modification logs. It just works.
You should be checking for accounts with unexpected SID history, especially if you haven't done a domain migration recently. If nobody migrated anything and you find SID history entries, something's wrong.
Get-ADUser -Filter * -Properties sIDHistory |
Where-Object { $_.sIDHistory.Count -gt 0 } |
Select-Object Name, SamAccountName, @{N='SIDHistory';E={$_.sIDHistory -join ', '}}
# Same for groups
Get-ADGroup -Filter * -Properties sIDHistory |
Where-Object { $_.sIDHistory.Count -gt 0 } |
Select-Object Name, @{N='SIDHistory';E={$_.sIDHistory -join ', '}}
Expected output (clean environment — no SID history found):
# No output = good. No accounts have SID history.
Expected output (accounts with SID history found):
Name SamAccountName SIDHistory ---- -------------- ---------- Jane Smith jsmith S-1-5-21-8675309-1234567-7654321-1105 MigratedAdmin old_admin S-1-5-21-8675309-1234567-7654321-512, S-1-5-32-544
What you're looking at: Any account that has SID values from another domain baked into it. If you recently migrated domains, you'll see entries here and that's expected — the SIDs should belong to the old domain. The red flags: SID history containing well-known privileged SIDs like -512 (Domain Admins) or S-1-5-32-544 (Administrators), SIDs from domains you don't recognize, or SID history on accounts created after any migration was completed. The second example above (old_admin) has both a regular SID and the built-in Administrators SID in its history — that account effectively has admin rights without appearing in any admin group.
If you have forest trusts, check whether SID filtering is still turned on. SID filtering stops SIDs from untrusted domains from being honored in access tokens. It's on by default for forest trusts, but the default filter is weaker than you'd expect: it allows SID history from domains within the trusted forest through. For full protection, verify quarantine mode is explicitly enabled (netdom trust /quarantine:yes). Beyond that, someone might've disabled SID filtering entirely years ago for a migration and never turned it back on. If an attacker compromises a trusted forest and SID filtering is off, they get cross-forest privilege escalation for free. I've seen more than one environment where trusts were created and literally nobody ever checked the settings again.
Get-ADTrust -Filter * -Properties SIDFilteringQuarantined, SIDFilteringForestAware, TrustDirection |
Select-Object Name, TrustDirection, SIDFilteringQuarantined, SIDFilteringForestAware |
Format-Table -AutoSize
Expected output:
Name TrustDirection SIDFilteringQuarantined SIDFilteringForestAware ---- -------------- ----------------------- ----------------------- partner.local BiDirectional True False child.corp.local BiDirectional False False legacy.local Inbound False True
What you're looking at: Each trust and its SID filtering status. SIDFilteringQuarantined = True is the strictest setting — only SIDs from the trusted domain itself are allowed through (good). SIDFilteringQuarantined = False on an external trust means SID history abuse is possible. SIDFilteringForestAware = True means a forest trust has been relaxed to allow SID history with RID >= 1000 through — this is weaker than default forest trust filtering and worth investigating why it was set. In the example above, "legacy.local" has relaxed filtering that someone probably enabled for a migration and forgot to revert.
One more thing on SID history: not all of it is malicious. Accounts that were migrated during domain consolidations will have legitimate SID history. Same with built-in accounts from in-place upgrades, or child domain accounts that interact with parent trusts. That's all normal. The red flag is SID history showing up on accounts that were created after the migration was done, or SIDs from domains you don't recognize. Those are the ones worth investigating.
AdminSDHolder: the ACL nobody checks
AdminSDHolder is an AD object that controls the permissions on all built-in privileged groups (Domain Admins, Enterprise Admins, etc.). Every 60 minutes, a process called SDProp resets the ACLs on those groups to match whatever's on AdminSDHolder.
If an attacker modifies AdminSDHolder's ACL to include their account, SDProp will propagate that access to every protected group automatically. And most teams don't monitor AdminSDHolder because they don't know it exists.
# Note: you need the AD: drive from the ActiveDirectory module
Import-Module ActiveDirectory
$adminSDHolder = "AD:\CN=AdminSDHolder,CN=System," + (Get-ADDomain).DistinguishedName
(Get-Acl $adminSDHolder).Access |
Select-Object IdentityReference, ActiveDirectoryRights, AccessControlType |
Format-Table -AutoSize
# List recursive members of Domain Admins (cross-reference with above)
Get-ADGroupMember -Identity "Domain Admins" -Recursive |
Get-ADUser |
Select-Object SamAccountName, DistinguishedName
Expected output (AdminSDHolder ACL):
IdentityReference ActiveDirectoryRights AccessControlType ----------------- --------------------- ----------------- NT AUTHORITY\SYSTEM GenericAll Allow BUILTIN\Administrators GenericAll Allow CORP\Domain Admins GenericAll Allow CORP\Enterprise Admins GenericAll Allow CORP\Schema Admins ReadProperty, WriteP... Allow NT AUTHORITY\Authenticated... GenericRead Allow NT AUTHORITY\SELF ReadProperty, WriteP... Allow BUILTIN\Account Operators GenericRead Allow ... SamAccountName DistinguishedName -------------- ----------------- Administrator CN=Administrator,CN=Users,DC=corp,DC=local tgrome CN=Thomas Grome,OU=Admins,DC=corp,DC=local
What you're looking at: The full ACL on AdminSDHolder and your current Domain Admins. Cross-reference the two lists: any identity in the ACL that isn't a known admin group or built-in account needs investigation. The Domain Admins list at the bottom tells you who has legitimate access. If you see a random user account (like "CORP\backdooruser") in the ACL with GenericAll or WriteDACL rights, that's an attacker persistence mechanism — they'll get their access restored every 60 minutes by SDProp.
That first query shows everything. This one filters out the expected accounts so you only see anomalies:
# Adjust the domain prefix to match your environment
$domain = (Get-ADDomain).NetBIOSName
$expected = @(
"NT AUTHORITY\SYSTEM",
"NT AUTHORITY\SELF",
"NT AUTHORITY\Authenticated Users",
"BUILTIN\Administrators",
"BUILTIN\Account Operators",
"BUILTIN\Server Operators",
"BUILTIN\Print Operators",
"BUILTIN\Backup Operators",
"BUILTIN\Pre-Windows 2000 Compatible Access",
"$domain\Domain Admins",
"$domain\Enterprise Admins",
"$domain\Schema Admins",
"$domain\Key Admins",
"$domain\Enterprise Key Admins"
)
$adminSDHolder = "AD:\CN=AdminSDHolder,CN=System," + (Get-ADDomain).DistinguishedName
(Get-Acl $adminSDHolder).Access |
Where-Object { $_.IdentityReference.Value -notin $expected } |
Select-Object IdentityReference, ActiveDirectoryRights, AccessControlType |
Format-Table -AutoSize
Expected output (clean environment):
# No output = good. No unexpected ACL entries.
Expected output (compromised or misconfigured):
IdentityReference ActiveDirectoryRights AccessControlType ----------------- --------------------- ----------------- CORP\svc_webadmin GenericAll Allow
What you're looking at: Any ACL entry that isn't in the expected defaults list. Empty output is what you want. If you see anything here, someone (or something) added a non-standard account to AdminSDHolder. In the example above, svc_webadmin has GenericAll on AdminSDHolder, meaning SDProp will give that account full control over Domain Admins, Enterprise Admins, and every other protected group — every 60 minutes, automatically. This is either a serious misconfiguration or an attacker persistence mechanism.
If you see accounts in AdminSDHolder's ACL that you don't recognize, investigate immediately. That's not normal.
Kerberos attacks: golden and silver tickets
Kerberos is how Windows handles authentication in AD environments. When you log in, you get a Ticket Granting Ticket (TGT) that lets you request access to services without entering your password again. It works well until someone forges a ticket.
Golden ticket: an attacker who gets the KRBTGT account hash can forge TGTs for any account, with any group membership. Full domain compromise. They don't even need network access to the domain controller to use it.
Silver ticket: same idea but scoped to a single service. The attacker needs the service account's hash and can forge tickets for just that service. Smaller blast radius, harder to detect.
You can't prevent these entirely (if someone has your KRBTGT hash, you have bigger problems), but you can detect and limit the damage:
# This catches regular user accounts used as service accounts, not just MSAs/gMSAs
Get-ADUser -Filter {ServicePrincipalName -like "*"} -Properties ServicePrincipalName, PasswordLastSet |
Select-Object Name, SamAccountName, PasswordLastSet, ServicePrincipalName
# Check your own Kerberos ticket cache
klist tgt
# Find accounts with passwords that haven't been changed in a long time
# (old passwords = more time for attackers to crack them)
Get-ADUser -Filter {Enabled -eq $true} `
-Properties PasswordLastSet, LastLogonDate |
Where-Object { $_.PasswordLastSet -lt (Get-Date).AddDays(-90) } |
Select-Object Name, SamAccountName, PasswordLastSet, LastLogonDate |
Sort-Object PasswordLastSet
Expected output (accounts with SPNs):
Name SamAccountName PasswordLastSet ServicePrincipalName
---- -------------- --------------- --------------------
SQL Service svc_sql 2019-06-15 09:30:00 {MSSQLSvc/sql01.corp.local:1433}
Web Service svc_iis 2020-11-22 14:15:00 {HTTP/web01.corp.local}
Legacy App svc_legacy 2017-03-01 10:00:00 {HTTP/app02.corp.local, HTTP/app02}
krbtgt krbtgt 2023-02-28 16:45:00 {kadmin/changepw}
What you're looking at: Every user account with a Service Principal Name — these are your Kerberoasting and silver ticket attack surface. The key column is PasswordLastSet: older passwords have had more time to be cracked offline. In the example above, svc_legacy hasn't had its password changed since 2017 — that's six years for an attacker to brute-force the hash after requesting a Kerberos service ticket. krbtgt will appear here too, which is normal. Focus on accounts with old passwords and ask: can these be converted to Group Managed Service Accounts (gMSAs) which rotate passwords automatically?
Rotate your KRBTGT password. Twice (because AD keeps the previous password as a fallback). If you've never done it, do it now. If you can't remember when you last did it, do it now. Wait at least 12 hours between the two resets (or your domain's maximum ticket lifetime, whichever is greater) to let existing tickets expire and replication complete across all DCs. This won't prevent golden tickets if someone already has the hash, but it invalidates any forged tickets they've already made.
Here's how to check it actually worked:
Get-ADDomainController -Filter * | foreach {
$dc = $_.HostName
Get-ADUser "krbtgt" -Server $dc -Properties PasswordLastSet |
Select-Object @{N='DC';E={$dc}}, PasswordLastSet
}
# Quick replication health check
repadmin /replsummary
Expected output (KRBTGT verification):
DC PasswordLastSet -- --------------- dc01.corp.local 2023-03-01 16:45:22 dc02.corp.local 2023-03-01 16:45:22 dc03.corp.local 2023-03-01 16:45:22 Replication Summary Start Time: 2023-03-01 17:00:15 Beginning data collection for replication summary, this may take awhile: ..... Source DSA largest delta fails/total %% error DC01 04m:12s 0 / 5 0 DC02 03m:55s 0 / 5 0 DC03 04m:01s 0 / 5 0
What you're looking at: The first table shows the KRBTGT password's last reset time as seen by each domain controller. All dates should be identical — if one DC shows an older date, replication hasn't completed to that DC yet, and you must wait before doing the second reset. The repadmin output below shows replication health: "largest delta" is the time since the last successful replication, and "fails/total" should be 0. Any failures here mean you have a replication problem that needs fixing before KRBTGT rotation will work correctly.
If the PasswordLastSet dates don't match across DCs, replication hasn't finished yet. Don't do the second reset until they're consistent.
Related: Credential Guard: Protect Windows from pass-the-hash and pass-the-ticket attacks (note: Credential Guard uses virtualization-based security to protect NTLM hashes and Kerberos TGTs in LSA, which mitigates pass-the-hash and TGT theft respectively. It does not protect already-issued service tickets.)
Delegation: the feature that keeps backfiring
Delegation lets one account impersonate another to access services on a different server. It's useful for things like a web app accessing a database on behalf of a user. It's also one of the most common privilege escalation paths in AD.
Unconstrained delegation is the dangerous one. Any computer with unconstrained delegation caches the full TGT of any user who authenticates to it. That TGT can then be reused to access any service in the domain as that user. If you find this enabled on anything other than your domain controllers, fix it.
Constrained delegation limits which target services (SPNs) the account can delegate credentials to. Better, but still needs monitoring.
Find everything with delegation configured:
Get-ADObject -Filter {msDS-AllowedToDelegateTo -like "*"} -Properties msDS-AllowedToDelegateTo |
Format-Table Name, ObjectClass, msDS-AllowedToDelegateTo -AutoSize
# Computers with unconstrained delegation (EXCLUDING domain controllers)
# Uses PrimaryGroupID to reliably identify DCs (516) and RODCs (521)
Get-ADComputer -Filter {TrustedForDelegation -eq $true} -Properties TrustedForDelegation, PrimaryGroupID |
Where-Object { $_.PrimaryGroupID -notin @(516, 521) } |
Select-Object Name, DistinguishedName
Expected output (constrained delegation):
Name ObjectClass msDS-AllowedToDelegateTo
---- ----------- ------------------------
svc_web user {HTTP/app01.corp.local, HTTP/app01}
SQL01$ computer {MSSQLSvc/sql02.corp.local:1433}
Expected output (unconstrained delegation — clean environment):
# No output = good. No non-DC machines with unconstrained delegation.
Expected output (unconstrained delegation — problem found):
Name DistinguishedName ---- ----------------- YOURWEB01 CN=YOURWEB01,OU=Servers,DC=corp,DC=local YOURPRINT01 CN=YOURPRINT01,OU=Servers,DC=corp,DC=local
What you're looking at: The first table shows objects with constrained delegation — they can only delegate to the specific SPNs listed. Review these to confirm the target services are still valid. The second query is the critical one: any computer that appears here has unconstrained delegation and is NOT a domain controller. That means if an attacker compromises that machine and coerces a Domain Admin to authenticate to it (trivial with tools like PrinterBug or PetitPotam), they capture the admin's full TGT. Web servers and print servers are common offenders — they were configured this way years ago and nobody ever fixed it.
If you find computers with unconstrained delegation that aren't domain controllers, that's a priority fix. Constrained delegation entries should be reviewed to make sure the target services are still valid and necessary.
Disabled accounts that still have the keys
At one client, I found a contractor's account that had been disabled three years ago. Nobody had removed it from Domain Admins because, well, it was disabled. Who cares, right? The SID was still sitting in the group membership. If that account got re-enabled (helpdesk mistake, social engineering, whatever) or if the old credentials got reused somewhere, it's instant domain admin. No escalation needed.
I check for this every time now:
$AdminGroups = "Domain Admins", "Enterprise Admins", "Schema Admins", "Administrators"
foreach ($Group in $AdminGroups) {
Get-ADGroupMember -Identity $Group -Recursive |
Where-Object { $_.objectClass -eq "user" } |
Get-ADUser |
Where-Object { $_.Enabled -eq $false } |
Select-Object Name, SamAccountName, @{N='Group';E={$Group}}
}
Expected output (clean environment):
# No output = good. No disabled accounts in admin groups.
Expected output (problem found):
Name SamAccountName Group ---- -------------- ----- John Doe jdoe_ext Domain Admins Old Service svc_old Administrators
What you're looking at: Disabled accounts that still sit in privileged groups. These are dormant backdoors. If anyone re-enables jdoe_ext (a helpdesk mistake, a social engineering call, a compromised admin), that account instantly has Domain Admin rights — no escalation needed. The fix is simple: remove them from the groups. Disabled accounts don't need group memberships.
If it returns anything, pull those accounts out of the groups. Disabled isn't the same as gone.
What to do first
If you're looking at all of this thinking "where do I even start," here's my priority list:
- Run PingCastle. It takes 5 minutes and gives you a security score with specific findings. Run this first — it'll tell you where your biggest gaps are and inform everything else on this list.
- Audit your admin groups. Know who's in Domain Admins and why. Remove anyone who doesn't need to be there.
- Check for unconstrained delegation. If it exists on non-DC machines, fix it. This is one of the most common escalation paths.
- Set up basic log monitoring. At minimum, alert on Event IDs 4625 (failed logins), 4672 (special privileges), and 4768/4769 (Kerberos ticket requests). Fair warning: 4768/4769 will be noisy in large environments. You'll need to tune thresholds or filter out known service accounts, otherwise you'll drown in legitimate traffic. Also worth adding: Event 4738 (user account changed) filtered for sIDHistory modifications. It's rare enough that any hit is worth investigating.
- Rotate KRBTGT. Twice. If you've never done it, you're overdue. Do this after your assessment is complete — rotating mid-assessment can tip off an attacker holding golden tickets.
Here's a starting point for filtering the 4768/4769 noise:
# Look for accounts you don't recognize near the top
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4768,4769} -MaxEvents 10000 |
Group-Object { ($_.Properties[0].Value -split '@')[0] } |
Sort-Object Count -Descending |
Select-Object -First 20 Count, Name
Expected output:
Count Name
----- ----
4521 DC01$
3892 DC02$
1247 svc_exchange
891 svc_sql
443 SQL01$
312 tgrome
287 jsmith
43 unknown_user
2 admin.legacy
What you're looking at: Kerberos ticket requests grouped by account and sorted by volume. The top entries will be domain controllers (DC01$, DC02$) and service accounts (svc_exchange, svc_sql) — that's normal. Regular users like tgrome and jsmith will appear in the middle with moderate counts. What you're hunting for: accounts you don't recognize (unknown_user), accounts requesting an unusual volume of tickets relative to their role, or honeypot accounts that shouldn't be authenticating at all (admin.legacy with 2 requests is a red flag if it's a honeypot). An account suddenly appearing at the top that wasn't there last week could indicate Kerberoasting or lateral movement.
The top entries will almost always be your service accounts and computer accounts, which is normal. Scan for anything unexpected below those.
These are the things I see missed most often, and fixing them closes the most common attack paths.