Full AD remediation after domain compromise is one of the most operationally complex IR activities — done wrong, it either leaves the attacker behind or takes the organization offline.
Pre-Remediation Assessment
Before touching anything, establish the full scope:
# 1. Document the current state of all privileged groups
$groups = @('Domain Admins', 'Enterprise Admins', 'Schema Admins',
'Account Operators', 'Backup Operators', 'Server Operators')
$groups | ForEach-Object {
$g = $_
Get-ADGroupMember -Identity $g -Recursive |
Select-Object @{N='Group';E={$g}}, SamAccountName, ObjectClass
} | Export-Csv -Path "C:\IR\privileged-groups-$(Get-Date -Format yyyyMMdd).csv" -NoTypeInformation
# 2. Document AdminSDHolder permissions (baseline vs. current)
$adminSDHolder = Get-ADObject -Identity "CN=AdminSDHolder,CN=System,DC=company,DC=local" `
-Properties nTSecurityDescriptor
$adminSDHolder.nTSecurityDescriptor.Access |
Export-Csv -Path "C:\IR\adminsdholder-acl.csv" -NoTypeInformation
# 3. Find attacker-created accounts (accounts created in attack window)
$attack_start = Get-Date "2024-09-01"
Get-ADUser -Filter {Created -gt $attack_start} `
-Properties Created, MemberOf |
Select-Object SamAccountName, Created, @{N='Groups';E={$_.MemberOf -join '; '}} |
Format-Table -AutoSize
# 4. Find all accounts added to privileged groups during attack window
Get-WinEvent -FilterHashtable @{
LogName = 'Security'; Id = 4728; StartTime = $attack_start
} | ForEach-Object {
$xml = [xml]$_.ToXml()
[PSCustomObject]@{
Time = $_.TimeCreated
Member = ($xml.Event.EventData.Data | Where-Object {$_.Name -eq 'MemberName'}).'#text'
Group = ($xml.Event.EventData.Data | Where-Object {$_.Name -eq 'GroupName'}).'#text'
AddedBy = ($xml.Event.EventData.Data | Where-Object {$_.Name -eq 'SubjectUserName'}).'#text'
}
}
krbtgt Double Reset Sequence
# Step 1: Record current krbtgt key version (for verification)
Get-ADUser krbtgt -Properties msDS-KeyVersionNumber |
Select-Object SamAccountName, msDS-KeyVersionNumber
# Step 2: First reset — generate new key, OLD key moves to 'previous'
Set-ADAccountPassword -Identity krbtgt `
-NewPassword (ConvertTo-SecureString -AsPlainText `
([System.Web.Security.Membership]::GeneratePassword(30, 5)) -Force) `
-Reset
# Verify key version incremented
Get-ADUser krbtgt -Properties msDS-KeyVersionNumber |
Select-Object SamAccountName, msDS-KeyVersionNumber
# Step 3: Force replication to all DCs immediately
# (ensures all DCs have the new krbtgt key, not just the PDC emulator)
repadmin /syncall /AdeP
# Monitor for replication errors
repadmin /showrepl
# Step 4: WAIT 10 HOURS (MaxTicketAge — allow all valid tickets to expire naturally)
# Set a reminder: second reset must happen 10+ hours after first reset
# Step 5: Second reset (10+ hours later) — original compromised key is now gone
Set-ADAccountPassword -Identity krbtgt `
-NewPassword (ConvertTo-SecureString -AsPlainText `
([System.Web.Security.Membership]::GeneratePassword(30, 5)) -Force) `
-Reset
# Force replication again
repadmin /syncall /AdeP
# Step 6: Verify — any Golden Tickets created before Step 2 are now invalid
Get-ADUser krbtgt -Properties msDS-KeyVersionNumber, PasswordLastSet |
Select-Object SamAccountName, msDS-KeyVersionNumber, PasswordLastSet
Full Credential Rotation
# Rotate Domain Admin accounts (run for each compromised admin)
$domain_admins = Get-ADGroupMember -Identity "Domain Admins" -Recursive |
Where-Object {$_.objectClass -eq 'user'}
$domain_admins | ForEach-Object {
$new_pwd = [System.Web.Security.Membership]::GeneratePassword(30, 8)
Set-ADAccountPassword -Identity $_.SamAccountName `
-NewPassword (ConvertTo-SecureString $new_pwd -AsPlainText -Force) `
-Reset
Set-ADUser -Identity $_.SamAccountName `
-ChangePasswordAtLogon $true
Write-Host "Rotated: $($_.SamAccountName)"
}
# Rotate service accounts — schedule during maintenance window
$svc_accounts = Get-ADUser -Filter {ServicePrincipalName -ne "$null"} `
-Properties ServicePrincipalName
$svc_accounts | ForEach-Object {
# CAUTION: Test each service before rotating — rotation breaks the service
Write-Host "Pending rotation: $($_.SamAccountName) | SPN: $($_.ServicePrincipalName)"
}
# Rotate local administrator accounts using LAPS or manual rotation
# For non-LAPS environments:
Invoke-Command -ComputerName (Get-ADComputer -Filter * | Select-Object -ExpandProperty Name) `
-ScriptBlock {
$new_pwd = [System.Web.Security.Membership]::GeneratePassword(25, 6)
net user administrator $new_pwd
Write-Host "$env:COMPUTERNAME : rotated"
} -ErrorAction SilentlyContinue
Post-Remediation Validation
# Validate no backdoor accounts remain
Get-ADUser -Filter {Enabled -eq $true} `
-Properties Created, LastLogonDate, MemberOf |
Where-Object {$_.Created -gt (Get-Date "2024-09-01")} |
Select-Object SamAccountName, Created, LastLogonDate |
Format-Table -AutoSize
# Validate no unauthorized DCSync rights
Get-ObjectAcl -DistinguishedName "DC=company,DC=local" -ResolveGUIDs |
Where-Object {$_.ActiveDirectoryRights -match "DS-Replication-Get-Changes"} |
Select-Object IdentityReference, ActiveDirectoryRights |
Where-Object {$_.IdentityReference -notmatch "Domain Controllers|Enterprise Domain Controllers|Administrators"}
# Validate AdminSDHolder is clean
$adminSDHolder = Get-ADObject `
-Identity "CN=AdminSDHolder,CN=System,DC=company,DC=local" `
-Properties nTSecurityDescriptor
$adminSDHolder.nTSecurityDescriptor.Access |
Where-Object {$_.IdentityReference -notmatch "SYSTEM|Domain Admins|Enterprise Admins|Administrators"} |
Select-Object IdentityReference, ActiveDirectoryRights
# Monitor for re-compromise — set SIEM alerts for:
# 1. Any 4662 with Replication-Get-Changes from non-DC accounts
# 2. Any 4769 with RC4 encryption (0x17)
# 3. Any new accounts added to Domain Admins (4728 on Group = Domain Admins)
# 4. Any Sysmon Event 10 against lsass.exe