Practical Guidance for IT Admins to respond after Ransomware attacks
Keep in mind that hiring an IR firm is recommended before executing all of these steps. Perform the steps that are applicable to you and your organization.
It’s been a while that I’ve blogged, but today. I’d would like to cover how we can respond to a ransomware attack, while focusing on the remediation part. This blog post will be specifically targeting the IT Professionals audience and will focus only on the technical aspects. My goal is for sure not to try and re-invent the wheel. I haven’t seen any practical guidance that covers step-by-step on how to perform remediation during a ransomware attack, which motivated me to write this blog post. This will be a long blog post because we will cover multiple topics, so be prepared for that. The following topics will be discussed:
- Stop the Ransomware Encryptor
- Restore Directory Services
- Rebuild impacted Tier-0 servers
- Active Directory Security Assessment
- Mitigating Lateral Movement
- Security Monitoring
Yes, there are thousands of things that we could do. It is never enough, which I also understand. However, these are some solid remediation steps that can be taken after a ransomware attack.
Stop the Ransomware Encryptor
Let’s cover a real example that we have read in the news. Last year (2021), Bleeping Computers reported that LockBit 2.0 ransomware has the capabilities to encrypt an entire Windows domain via AD Group Policy.

In this example, we are going to simulate this example in our lab environment. The sample that I’ve been using has the following SHA256 hash: a9cb8fb06970ee8652983bdad227d24f50133853b64e29b76bae98b4063409b3
LockBit typically deploys the ransomware encryptor via PsExec or GPO. Some LockBit samples have a flag enabled, which will leverage AD Group Policy. This example can be seen here. After the execution of this LockBit sample on the Domain Controller, we can see that it is leveraging the AD Group Policy. It will create a GPO with a random name and link it to the Domain level. The GPO has a scheduled task configured, which will be executed to launch the ransomware encryptor.

This section will cover step-by-step on how to clean up the ransomware from the environment if it has not been done yet. First, we have to ensure that the link of the GPO has been deleted. Quick reminder, but this won’t delete the GPO itself. It will just not be linked anymore at the domain level.

Final step is to backup this GPO and then delete it. This allows us to analyze it further without worrying that the GPO still exists in the environment.

After we made a backup of the GPO, we can start deleting it.

Based on the GPO that was configured, we can see that the ransomware encryptor was staged in SYSVOL. This means that we need to clean this up as well.

ZIP the ransomware encryptor up and then delete it from SYSVOL.

Make sure that you have stopped the bleeding before proceeding further. Otherwise, all the steps that you will perform will be for nothing, since the attacker may be still present in your environment.
Restore Directory Services
In this example, we have a single domain in a single forest with four Domain Controllers. The Domain Controllers that have been highlighted are the ones that have been encrypted in this example. What we are going to do is deploy new servers, promote it to a Domain Controller, and transfer the FSMO roles to a new installed DC. Basically, we will be replacing all the four Domain Controllers with four new ones. This section will cover the following steps in order:
- Deploy a new Windows Server that will be the new Domain Controller
- Exclude required URLs for Windows updates and usage of Log Analytics
- Ensure that the new server has the latest Windows updates installed
- Remove any potential unwanted software on the new server
- Cleanup scheduled tasks, services and processes
- Promote new server to Domain Controller
- Transfer FSMO Role to the new Domain Controller
- Decommission old Domain Controller(s)
- Metadata cleanup
- Patch Management for Tier-0 servers

- Deploy new Windows Servers
Start deploying new Windows Servers since these new servers will become Domain Controllers. We are going to replace all the DCs.
- Exclude required URLs from Firewall
This is a list of required URLs that Domain Controllers needs to have access to, which includes the required URLs from Windows Updates, Log Analytics, and so on. Before we can do everything, we should make sure that all the Tier-0 servers can connect to the following URLs:
Windows Update:
- *.download.windowsupdate.com
- *.update.microsoft.com
- *.windowsupdate.com
- *.windowsupdate.microsoft.com
- *.download.windowsupdate.com
- *.update.microsoft.com
- *.windowsupdate.com
- *.windowsupdate.microsoft.com
- *.dl.delivery.mp.microsoft.com
- http://download.windowsupdate.com
- http://go.microsoft.com
- http://ntservicepack.microsoft.com
- http://windowsupdate.microsoft.com
- http://wustat.windows.com
- *.update.microsoft.com
- *.windowsupdate.microsoft.com
- *.update.microsoft.com
- *.windowsupdate.microsoft.com
- https://dl.delivery.mp.microsoft.com
- https://download.microsoft.com
Log Analytics:
Outbound connection over port 443.
- *.ods.opinsights.azure.com
- *.oms.opinsights.azure.com
- *.blob.core.windows.net
- *.azure-automation.net
- Check for Windows Updates
Start deploying a new Windows Server. This should not be from a template or whatsoever. Before we are going to promote this server into a Domain Controller. We have to make sure that it is fully patched. This is a PowerShell script that can quickly check for missing Windows updates.
$UpdateSession = New-Object -ComObject Microsoft.Update.Session
$UpdateSearcher = $UpdateSession.CreateupdateSearcher()
$Updates = @($UpdateSearcher.Search("IsHidden=0 and IsInstalled=0").Updates)
$Updates | Select-Object Title, PSComputerName
We can see that there are Windows updates available, which we can install.


After all the patches have been installed, we can verify that the server is fully updated. This is a critical part of our overall process in restoring our directory services.

- Remove unwanted software from new server
Despite having a new clean Windows Server installed. We should never assume that it doesn’t have any crap installed. This PowerShell script will look for all the installed programs on a machine. Verify if there is any program that can be uninstalled.
function Get-InstalledSoftware {
param (
# filters displayname of installed apps to search for
[string]$filter = '*',
# tell the function what properties should be returned
[string[]]$properties = @("DisplayName","InstallDate","InstallLocation"),
# remote computername(s)
[string[]]$ComputerName,
# credential for remote computer(s)
[pscredential]$Credential
)
# reg paths to query
$regpath = @(
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
# put together the splat for remote usage of function
$splat = @{}
if ($ComputerName) { $splat['ComputerName'] = $ComputerName }
if ($Credential) { $splat['Credential'] = $Credential }
# Run the command, either locally or remote if $splat has values
Invoke-Command @splat -ScriptBlock {
param ($regpath, $filter, $properties)
$regpath | ForEach-Object { Get-ItemProperty $_ } |
Where-Object { ![string]::IsNullOrEmpty($_.DisplayName) -and $_.DisplayName -like $filter } |
Select-Object $properties
} -ArgumentList $regpath, $filter, $properties
}
Save this script as something like Get-InstalledSoftware.ps1
Example:
Get-InstalledSoftware -ComputerName NewDC01 -Credential CONTOSO\Testing | Out-GridView

- Remove scheduled tasks and services
This PowerShell script will look for all the scheduled tasks that are currently enabled on a machine. Delete all the scheduled tasks that are not required.
# Get-ScheduledTask.ps1
# Written by Bill Stewart (bstewart@iname.com)
#requires -version 2
# Version history:
#
# Version 1.2 (23 May 2012)
# * LastTaskResult property was incorrectly outputting previous task's result
# when null. Fixed.
#
# Version 1.1 (01 May 2012)
# * Added -Hidden parameter to output tasks marked as hidden.
#
# Version 1.0 (12 Jul 2011)
# * Initial version.
<#
.SYNOPSIS
Outputs scheduled task information.
.DESCRIPTION
Outputs scheduled task information. Requires Windows Vista/Server 2008 or later.
.PARAMETER TaskName
The name of a scheduled task to output. Wildcards are supported. The default value is * (i.e., output all tasks).
.PARAMETER ComputerName
A computer or list of computers on which to output scheduled tasks.
.PARAMETER Subfolders
Specifies whether to support task subfolders (Windows Vista/Server 2008 or later only).
.PARAMETER Hidden
Specifies whether to output hidden tasks.
.PARAMETER ConnectionCredential
The connection to the task scheduler service will be made using these credentials. If you don't specify this parameter, the currently logged on user's credentials are assumed. This parameter only supports connecting to the scheduler service on remote computers running Windows Vista/Server 2008 or later.
.OUTPUTS
PSObjects containing information about scheduled tasks.
.EXAMPLE
PS C:\> Get-ScheduledTask
This command outputs the scheduled tasks in the root tasks folder on the current computer.
.EXAMPLE
PS C:\> Get-ScheduledTask -Subfolders
This command outputs all scheduled tasks on the current computer, including those in subfolders.
.EXAMPLE
PS C:\> Get-ScheduledTask -TaskName \Microsoft\* -Subfolders
This command outputs all scheduled tasks in the \Microsoft task subfolder and its subfolders on the current computer.
.EXAMPLE
PS C:\> Get-ScheduledTask -ComputerName SERVER1
This command outputs scheduled tasks in the root tasks folder on the computer SERVER1.
.EXAMPLE
PS C:\> Get-ScheduledTask -ComputerName SERVER1 -ConnectionCredential (Get-Credential) | Export-CSV Tasks.csv -NoTypeInformation
This command prompts for credentials to connect to SERVER1 and exports the scheduled tasks in the computer's root tasks folder to the file Tasks.csv.
.EXAMPLE
PS C:\> Get-Content Computers.txt | Get-ScheduledTask
This command outputs all scheduled tasks for each computer listed in the file Computers.txt.
#>
[CmdletBinding()]
param(
[parameter(Position=0)] [String[]] $TaskName="*",
[parameter(Position=1,ValueFromPipeline=$TRUE)] [String[]] $ComputerName=$ENV:COMPUTERNAME,
[switch] $Subfolders,
[switch] $Hidden,
[System.Management.Automation.PSCredential] $ConnectionCredential
)
begin {
$PIPELINEINPUT = (-not $PSBOUNDPARAMETERS.ContainsKey("ComputerName")) -and (-not $ComputerName)
$MIN_SCHEDULER_VERSION = "1.2"
$TASK_ENUM_HIDDEN = 1
$TASK_STATE = @{0 = "Unknown"; 1 = "Disabled"; 2 = "Queued"; 3 = "Ready"; 4 = "Running"}
$ACTION_TYPE = @{0 = "Execute"; 5 = "COMhandler"; 6 = "Email"; 7 = "ShowMessage"}
# Try to create the TaskService object on the local computer; throw an error on failure
try {
$TaskService = new-object -comobject "Schedule.Service"
}
catch [System.Management.Automation.PSArgumentException] {
throw $_
}
# Returns the specified PSCredential object's password as a plain-text string
function get-plaintextpwd($credential) {
$credential.GetNetworkCredential().Password
}
# Returns a version number as a string (x.y); e.g. 65537 (10001 hex) returns "1.1"
function convertto-versionstr([Int] $version) {
$major = [Math]::Truncate($version / [Math]::Pow(2, 0x10)) -band 0xFFFF
$minor = $version -band 0xFFFF
"$($major).$($minor)"
}
# Returns a string "x.y" as a version number; e.g., "1.3" returns 65539 (10003 hex)
function convertto-versionint([String] $version) {
$parts = $version.Split(".")
$major = [Int] $parts[0] * [Math]::Pow(2, 0x10)
$major -bor [Int] $parts[1]
}
# Returns a list of all tasks starting at the specified task folder
function get-task($taskFolder) {
$tasks = $taskFolder.GetTasks($Hidden.IsPresent -as [Int])
$tasks | foreach-object { $_ }
if ($SubFolders) {
try {
$taskFolders = $taskFolder.GetFolders(0)
$taskFolders | foreach-object { get-task $_ $TRUE }
}
catch [System.Management.Automation.MethodInvocationException] {
}
}
}
# Returns a date if greater than 12/30/1899 00:00; otherwise, returns nothing
function get-OLEdate($date) {
if ($date -gt [DateTime] "12/30/1899") { $date }
}
function get-scheduledtask2($computerName) {
# Assume $NULL for the schedule service connection parameters unless -ConnectionCredential used
$userName = $domainName = $connectPwd = $NULL
if ($ConnectionCredential) {
# Get user name, domain name, and plain-text copy of password from PSCredential object
$userName = $ConnectionCredential.UserName.Split("\")[1]
$domainName = $ConnectionCredential.UserName.Split("\")[0]
$connectPwd = get-plaintextpwd $ConnectionCredential
}
try {
$TaskService.Connect($ComputerName, $userName, $domainName, $connectPwd)
}
catch [System.Management.Automation.MethodInvocationException] {
write-warning "$computerName - $_"
return
}
$serviceVersion = convertto-versionstr $TaskService.HighestVersion
$vistaOrNewer = (convertto-versionint $serviceVersion) -ge (convertto-versionint $MIN_SCHEDULER_VERSION)
$rootFolder = $TaskService.GetFolder("\")
$taskList = get-task $rootFolder
if (-not $taskList) { return }
foreach ($task in $taskList) {
foreach ($name in $TaskName) {
# Assume root tasks folder (\) if task folders supported
if ($vistaOrNewer) {
if (-not $name.Contains("\")) { $name = "\$name" }
}
if ($task.Path -notlike $name) { continue }
$taskDefinition = $task.Definition
$actionCount = 0
foreach ($action in $taskDefinition.Actions) {
$actionCount += 1
$output = new-object PSObject
# PROPERTY: ComputerName
$output | add-member NoteProperty ComputerName $computerName
# PROPERTY: ServiceVersion
$output | add-member NoteProperty ServiceVersion $serviceVersion
# PROPERTY: TaskName
if ($vistaOrNewer) {
$output | add-member NoteProperty TaskName $task.Path
} else {
$output | add-member NoteProperty TaskName $task.Name
}
#PROPERTY: Enabled
$output | add-member NoteProperty Enabled ([Boolean] $task.Enabled)
# PROPERTY: ActionNumber
$output | add-member NoteProperty ActionNumber $actionCount
# PROPERTIES: ActionType and Action
# Old platforms return null for the Type property
if ((-not $action.Type) -or ($action.Type -eq 0)) {
$output | add-member NoteProperty ActionType $ACTION_TYPE[0]
$output | add-member NoteProperty Action "$($action.Path) $($action.Arguments)"
} else {
$output | add-member NoteProperty ActionType $ACTION_TYPE[$action.Type]
$output | add-member NoteProperty Action $NULL
}
# PROPERTY: LastRunTime
$output | add-member NoteProperty LastRunTime (get-OLEdate $task.LastRunTime)
# PROPERTY: LastResult
if ($task.LastTaskResult) {
# If negative, convert to DWORD (UInt32)
if ($task.LastTaskResult -lt 0) {
$lastTaskResult = "0x{0:X}" -f [UInt32] ($task.LastTaskResult + [Math]::Pow(2, 32))
} else {
$lastTaskResult = "0x{0:X}" -f $task.LastTaskResult
}
} else {
$lastTaskResult = $NULL # fix bug in v1.0-1.1 (should output $NULL)
}
$output | add-member NoteProperty LastResult $lastTaskResult
# PROPERTY: NextRunTime
$output | add-member NoteProperty NextRunTime (get-OLEdate $task.NextRunTime)
# PROPERTY: State
if ($task.State) {
$taskState = $TASK_STATE[$task.State]
}
$output | add-member NoteProperty State $taskState
$regInfo = $taskDefinition.RegistrationInfo
# PROPERTY: Author
$output | add-member NoteProperty Author $regInfo.Author
# The RegistrationInfo object's Date property, if set, is a string
if ($regInfo.Date) {
$creationDate = [DateTime]::Parse($regInfo.Date)
}
$output | add-member NoteProperty Created $creationDate
# PROPERTY: RunAs
$principal = $taskDefinition.Principal
$output | add-member NoteProperty RunAs $principal.UserId
# PROPERTY: Elevated
if ($vistaOrNewer) {
if ($principal.RunLevel -eq 1) { $elevated = $TRUE } else { $elevated = $FALSE }
}
$output | add-member NoteProperty Elevated $elevated
# Output the object
$output
}
}
}
}
}
process {
if ($PIPELINEINPUT) {
get-scheduledtask2 $_
}
else {
$ComputerName | foreach-object {
get-scheduledtask2 $_
}
}
}
Example:
Save the PowerShell script as Get-ScheduledTask.ps1
Invoke-Command -ComputerName NewDC01 -FilePath C:\Users\Testing.CONTOSO\Desktop\Get-ScheduledTask.ps1 | Out-GridView

The second PowerShell script will enumerate all the services on a machine with the path as well.
function Find-Service
{
param
(
$Name = '*',
$DisplayName = '*',
$Started
)
$pattern = '^.*\.exe\b'
$Name = $Name.Replace('*','%')
$DisplayName = $DisplayName.Replace('*','%')
Get-WmiObject -Class Win32_Service -Filter "Name like '$Name' and DisplayName like '$DisplayName'"|
ForEach-Object {
if ($_.PathName -match $pattern)
{
$Path = $matches[0].Trim('"')
$file = Get-Item -Path $Path
$rv = $_ | Select-Object -Property Name, DisplayName, isMicrosoft, Started, StartMode, Description, CompanyName, ProductName, FileDescription, ServiceType, ExitCode, InstallDate, DesktopInteract, ErrorControl, ExecutablePath, PathName
$rv.CompanyName = $file.VersionInfo.CompanyName
$rv.ProductName = $file.VersionInfo.ProductName
$rv.FileDescription = $file.VersionInfo.FileDescription
$rv.ExecutablePath = $path
$rv.isMicrosoft = $file.VersionInfo.CompanyName -like '*Microsoft*'
$rv
}
else
{
Write-Warning ("Service {0} has no EXE attached. PathName='{1}'" -f $_.PathName)
}
}
}
Find-Service
Example:
Save this PowerShell script as Get-Services.ps1 for example.
Invoke-Command -ComputerName NewDC01 -FilePath C:\Users\Testing.CONTOSO\Desktop\Get-Services.ps1 | Out-GridView

- Promote new server to a Domain Controller
This step is very straight forward, which will be promoting a server to a Domain Controller. First, we have to install the Active Directory Domain Services role and the associated RSAT tools.

The second part is to promote the new server to a Domain Controller.

After the new server has been promoted to a Domain Controller. We can verify this by running the following command:
nltest /dclist:contoso.com

- Transferring FSMO Role to the new Domain Controller
We can see that DC01.contoso.com holds the FSMO roles, which was a Domain Controller that got impacted with ransomware in our example. Before we are going to transfer the FSMO roles on DC01.contoso.com to NewDC01.contoso.com – We are going to perform some AD Health Checks first.
Here is a script that can be used to check the AD Health:
$Computers = (Get-ADComputer -Filter *).count
$Workstations = (Get-ADComputer -LDAPFilter "(&(objectClass=Computer)(!operatingSystem=*server*))" -Searchbase (Get-ADDomain).distinguishedName).count
$Servers = (Get-ADComputer -LDAPFilter "(&(objectClass=Computer)(operatingSystem=*server*))" -Searchbase (Get-ADDomain).distinguishedName).count
$Users = (get-aduser -filter *).count
$domain = Get-ADDomain |FT Forest
$FSMO = netdom query FSMO
$ADForest = (Get-ADForest).ForestMode
$ADDomain = (Get-ADDomain).DomainMode
$ADVer = Get-ADObject (Get-ADRootDSE).schemaNamingContext -property objectVersion | Select objectVersion
$ADNUM = $ADVer -replace "@{objectVersion=","" -replace "}",""
If ($ADNum -eq '88') {$srv = 'Windows Server 2019'}
ElseIf ($ADNum -eq '87') {$srv = 'Windows Server 2016'}
ElseIf ($ADNum -eq '69') {$srv = 'Windows Server 2012 R2'}
ElseIf ($ADNum -eq '56') {$srv = 'Windows Server 2012'}
ElseIf ($ADNum -eq '47') {$srv = 'Windows Server 2008 R2'}
ElseIf ($ADNum -eq '44') {$srv = 'Windows Server 2008'}
ElseIf ($ADNum -eq '31') {$srv = 'Windows Server 2003 R2'}
ElseIf ($ADNum -eq '30') {$srv = 'Windows Server 2003'}
Write-Host "For this Domain there are;"
Write-Host "Computers = "$Computers -ForegroundColor Cyan
Write-Host "Workstions = "$Workstations -ForegroundColor Cyan
Write-Host "Servers = "$Servers -ForegroundColor Cyan
Write-Host "Users = "$Users -ForegroundColor Cyan
Write-host ""
Write-host "Active Directory Info" -ForegroundColor Yellow
Write-Host "Active Directory Forest Mode = "$ADForest -ForegroundColor Cyan
Write-Host "Active Directory Domain Mode = "$ADDomain -ForegroundColor Cyan
Write-Host "Active Directory Schema Version is $ADNum which corresponds to $Srv" -ForegroundColor Cyan
Write-Host ""
Write-Host "FSMO Role Owners" -ForegroundColor Cyan
$FSMO
Write-Host "Active Directory Health Check" -ForegroundColor Yellow
Write-Host ""
#####################################Get ALL DC Servers#################################
$getForest = [system.directoryservices.activedirectory.Forest]::GetCurrentForest()
$DCServers = $getForest.domains | ForEach-Object {$_.DomainControllers} | ForEach-Object {$_.Name}
$timeout = "60"
foreach ($DC in $DCServers){
$Identity = $DC
################Ping Test######
if ( Test-Connection -ComputerName $DC -Count 1 -ErrorAction SilentlyContinue ) {
Write-Host $DC `t $DC `t Ping Success -ForegroundColor Green
##############Netlogon Service Status################
$serviceStatus = start-job -scriptblock {get-service -ComputerName $($args[0]) -Name "Netlogon" -ErrorAction SilentlyContinue} -ArgumentList $DC
wait-job $serviceStatus -timeout $timeout
if($serviceStatus.state -like "Running")
{
Write-Host $DC `t Netlogon Service TimeOut -ForegroundColor Yellow
stop-job $serviceStatus
}
else
{
$serviceStatus1 = Receive-job $serviceStatus
if ($serviceStatus1.status -eq "Running") {
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Green
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
else
{
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Red
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
}
}
##############NTDS Service Status################
$serviceStatus = start-job -scriptblock {get-service -ComputerName $($args[0]) -Name "NTDS" -ErrorAction SilentlyContinue} -ArgumentList $DC
wait-job $serviceStatus -timeout $timeout
if($serviceStatus.state -like "Running")
{
Write-Host $DC `t NTDS Service TimeOut -ForegroundColor Yellow
stop-job $serviceStatus
}
else
{
$serviceStatus1 = Receive-job $serviceStatus
if ($serviceStatus1.status -eq "Running") {
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Green
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
else
{
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Red
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
}
##############DNS Service Status################
$serviceStatus = start-job -scriptblock {get-service -ComputerName $($args[0]) -Name "DNS" -ErrorAction SilentlyContinue} -ArgumentList $DC
wait-job $serviceStatus -timeout $timeout
if($serviceStatus.state -like "Running")
{
Write-Host $DC `t DNS Server Service TimeOut -ForegroundColor Yellow
stop-job $serviceStatus
}
else
{
$serviceStatus1 = Receive-job $serviceStatus
if ($serviceStatus1.status -eq "Running") {
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Green
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
else
{
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Red
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
}
####################Netlogons status##################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:netlogons /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Netlogons Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test NetLogons"))
{
Write-Host $DC `t Netlogons Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Netlogons Test Failed -ForegroundColor Red
}
}
####################Replications status#################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:Replications /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Replications Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test Replications"))
{
Write-Host $DC `t Replications Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Replications Test Failed -ForegroundColor Red
}
}
####################Services status#####################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:Services /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Services Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test Services"))
{
Write-Host $DC `t Services Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Services Test Failed -ForegroundColor Red
}
}
####################Advertising status##################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:Advertising /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Advertising Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test Advertising"))
{
Write-Host $DC `t Advertising Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Advertising Test Failed -ForegroundColor Red
}
}
####################FSMOCheck status##################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:FSMOCheck /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t FSMOCheck Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test FsmoCheck"))
{
Write-Host $DC `t FSMOCheck Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t FSMOCheck Test Failed -ForegroundColor Red
}
}
Write-Host ""
}
In this example, we can see that DC01.contoso.com looks good. Yes, there are some timeouts. However, we are luckily that there are no serious issues.

However, we can see on NewDC01.contoso.com has some issues. This is a great example to wait and fix this first before we are transferring the FSMO roles.

Let’s say we have troubleshooted the issue and were able to resolve it. Everything works fine now on NewDC01.contoso.com

We can now transfer the FSMO roles to our new Domain Controller.
Move-ADDirectoryServerOperationMasterRole -Identity "NewDC01" -OperationMasterRole DomainNamingMaster,PDCEmulator,RIDMaster,SchemaMaster,InfrastructureMaster -Verbose

To verify whether this has been succeeded. We can now run the following command:
netdom query FSMO

- Decommission old Domain Controller
Since we have now the new Domain Controller that holds all the FSMO roles. We can now decommission the old Domain Controller. Make sure that the encrypted Domain Controllers are isolated from the network. Here we have an encrypted Domain Controller called ‘DC01’, which we need to do metadata cleanup from.
- Metadata cleanup
Remove the computer account within AD and delete the NTDS setting object.
Get-ADComputer -Identity "DC01" | Remove-ADObject -Recursive

Open Active Directory Sites and Services
Delete the object that is associated with the old Domain Controller that was decomissioned.

- Ensure Tier-0 servers are fully patched
We are going to setup a Log Analytics workspace and use Azure Automation to keep our Tier-0 systems updated. Go to the Azure Portal and type in ‘Automation Accounts’ and create a new account. This is an example of keeping systems updated, but other solutions are fine as well.

Go to ‘Update Management’ and enable Log Analytics.

After we click on ‘Enable’, we will be creating a new Log Analytics workspace.

Install the Windows Agent on the Tier-0 servers. In this example, we will be installing the Windows Agent on the NewDC01.contoso.com

During the installation, make sure to select ‘Connect the agent to Azure Log Analytics’

We can now enable ‘Update Management’ on the NewDC01.contoso.com and see the machine here. This allows us now to schedule an update deployment policy, and so on.

When a server is onboarded to Azure Automation. We can also query it in Log Analytics, since we need to create a Log Analytics workspace in the first place to use Azure Automation.

IMPORTANT: To end this chapter, start replacing the old Domain Controllers and decommission them. Perform metadata cleanup for the rest, and make sure that you perform the other checks first before promoting the new servers to a Domain Controller. We have to replace the old DCs, since we cannot trust them anymore.
Rebuild impacted Tier-0 servers
This section will cover two examples of rebuilding servers that should be treated as a ‘Tier-0’. This will include ADCS and AAD Connect server.
- Rebuilding ADCS server
At this example, we have an ADCS server that was hit with ransomware. It is important to understand whether we should rebuild an entire ADCS server or not. If there was any evidence that lead to traces of ADCS being abused, such as the CA private key being extracted. The entire server should be rebuilt. However, if this is not the case. And the attacker just encrypted the server with ransomware. We could export the current ADCS configuration and import it back again in a new server. Take the proper risk assessment and define what’s best for business.

Use the following PowerShell script to take a backup of ADCS:
## PowerShell Script to Backup a Windows Server Certificate Authority (CA) ##
<#
Overview: PowerShell Function that takes a backup of a Certification Authority (CA) database files and Cert Authority 'Root' CA certificate', along with the CA configuration settings registry key
Usage Examples:
Backup-CertificationAuthority -path "C:\Backup\CA" -type "Full" -Password "YourPassword" -BackupKey -KeepLog -Force
Backup-CertificationAuthority -path "C:\Backup\CA" -type "Incremental" -Password "YourPassword" -BackupKey -KeepLog -Force
Resources:
https://blog.ahasayen.com/pki-recovery-plan
https://blog.ahasayen.com/wp-content/uploads/2013/10/CABackup.zip
#>
function Backup-CertificationAuthority {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[IO.DirectoryInfo]$Path,
[ValidateSet("Full","Incremental")]
[string]$Type = "Full",
[string]$Password,
[switch]$BackupKey,
[switch]$KeepLog,
[switch]$Extended,
[switch]$Force
)
if ($PSBoundParameters.Verbose) {$VerbosePreference = "continue"}
if ($PSBoundParameters.Debug) {
$Host.PrivateData.DebugForegroundColor = "Cyan"
$DebugPreference = "continue"
}
#region Backup of CA configuration settings registry key
$strExportRegKey = "HKLM\System\CurrentControlSet\Services\CertSVc\Configuration\"
$strExportPath = $Path
$strExportFileName = "CARegistryConfiguration_$(get-date -f ddMMyyyy).reg"
reg export $strExportRegKey $strExportPath\$strExportFileName
#endregion
#region Defining low-level APIs
$cadmsignature = @"
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern bool CertSrvIsServerOnline(
string pwszServerName,
ref bool pfServerOnline
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupPrepare(
string pwszServerName,
uint grbitJet,
uint dwBackupFlags,
ref IntPtr phbc
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupGetDatabaseNames(
IntPtr hbc,
ref IntPtr ppwszzAttachmentInformation,
ref uint pcbSize
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupGetBackupLogs(
IntPtr hbc,
ref IntPtr ppwszzBackupLogFiles,
ref uint pcbSize
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupGetDynamicFileList(
IntPtr hbc,
ref IntPtr ppwszzFileList,
ref uint pcbSize
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupOpenFile(
IntPtr hbc,
string pwszAttachmentName,
int cbReadHintSize,
ref Int64 pliFileSize
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupRead(
IntPtr hbc,
IntPtr pvBuffer,
int cbBuffer,
ref int pcbRead
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupClose(
IntPtr hbc
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupTruncateLogs(
IntPtr hbc
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupEnd(
IntPtr phbc
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvBackupFree(
IntPtr pv
);
[DllImport("Certadm.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern int CertSrvRestoreGetDatabaseLocations(
IntPtr hbc,
ref IntPtr ppwszzDatabaseLocationList,
ref uint pcbSize
);
"@
#endregion
#region add defined types
try {Add-Type -MemberDefinition $cadmsignature -Namespace PKI -Name CertAdm}
catch {break}
#endregion
#region Path checking
if (Test-Path $Path) {
if (Test-Path $Path\DataBase) {
if ($Force) {
try {
Remove-Item $Path\DataBase -Recurse -Force -ErrorAction Stop
$BackupDir = New-Item -Name DataBase -ItemType directory -Path $Path -Force -ErrorAction Stop
} catch {
Write-Error -Category InvalidOperation -ErrorId "InvalidOperationDeleteException" `
-ErrorAction Stop -Message $Error[0].Exception
}
} else {
Write-Error -Category ResourceExists -ErrorId "ResourceExistsException" `
-ErrorAction Stop -Message "The path '$Path\DataBase' already exist."
}
} else {
$BackupDir = New-Item -Name DataBase -ItemType directory -Path $Path -Force -ErrorAction Stop
}
} else {
try {$BackupDir = New-Item -Name DataBase -ItemType directory -Path $Path -Force -ErrorAction Stop}
catch {
Write-Error -Category ObjectNotFound -ErrorId "PathNotFoundException" `
-ErrorAction Stop -Message "Cannot create object in '$Path'"
}
}
#endregion
#region helper functions
function Split-BackupPath ([Byte[]]$Bytes) {
$SB = New-Object System.Text.StringBuilder
$bytes1 = $bytes | ForEach-Object {"{0:X2}" -f $_}
for ($n = 0; $n -lt $bytes1.count; $n = $n + 2) {
[void]$SB.Append([char](Invoke-Expression 0x$(($bytes1[$n+1]) + ($bytes1[$n]))))
}
$SB.ToString().Split("`0",[StringSplitOptions]::RemoveEmptyEntries)
}
function __BackupKey ($Password) {
$CertConfig = New-Object -ComObject CertificateAuthority.Config
try {$local = $CertConfig.GetConfig(3)}
catch { }
if ($local -ne $null) {
$name = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Services\CertSvc\Configuration' -Name Active).Active
$StoreCerts = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection
$Certs = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection
$TempCerts = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection
$Store = New-Object Security.Cryptography.X509Certificates.X509Store "My", "LocalMachine"
$Store.Open("ReadOnly")
$StoreCerts = $Store.Certificates
$Store.Close()
$Certs = $StoreCerts.Find("FindBySubjectName",$name,$true)
$chain = New-Object Security.Cryptography.X509Certificates.X509Chain
$chain.ChainPolicy.RevocationMode = "NoCheck"
$Certs | ForEach-Object {
[void]$chain.Build($_)
if ($chain.ChainElements.Count -ge 1) {
for ($n = 1; $n -lt $chain.ChainElements.Count; $n++) {
[void]$TempCerts.Add($chain.ChainElements[$n].Certificate)
}
}
$chain.Reset()
}
if ($TempCerts.Count -gt 0) {
$Certs.AddRange([Security.Cryptography.X509Certificates.X509Certificate2[]]($TempCerts | Select-Object -Unique))
}
try {[IO.File]::WriteAllBytes("$Path\$Name.p12",$Certs.Export("pfx",$Password))}
finally {$StoreCerts, $Certs, $TempCerts | ForEach-Object {$_.Clear()}}
}
}
# helper function for backup routine
function __BackupRoutine ($phbc,$File,$BackupDir,$pvBuffer, $cbBuffer, $FileType) {
$n = 1
Write-Debug "Read buffer address: $pvBuffer"
$FileName = Get-Item $File -ErrorAction SilentlyContinue
$pliFileSize = 0
Write-Debug "Open current item: $file"
# open DB file. I set 0 for cbReadHintSize to allow system to automatically select proper buffer size
$hresult = [PKI.CertAdm]::CertSrvBackupOpenFile($phbc,$File,$cbBuffer,[ref]$pliFileSize)
if ($hresult -ne 0) {
$StatusObject.Status = 0x8007004
__status $StatusObject
break
}
Write-Debug "Current item size in bytes: $pliFileSize"
$BackupFile = New-Item -Name $FileName.Name -ItemType file -Path $BackupDir -Force -ErrorAction Stop
$FS = New-Object IO.FileStream $BackupFile,"append","write"
[int]$pcbRead = 0
$complete = 0
$Name = (Get-Item $File -Force -ErrorAction SilentlyContinue).Name
while (!$last) {
$n++
[int]$percent = $complete / $pliFileSize * 100
Write-Progress -Activity "Backing up database file '$name' " -CurrentOperation InnerLoop -PercentComplete $percent `
-Status "$percent% complete"
$hresult = [PKI.CertAdm]::CertSrvBackupRead($phbc,$pvBuffer,$cbBuffer,[ref]$pcbRead)
if ($hresult -ne 0) {
$StatusObject.Status = 0x800701e
__status $StatusObject
break
}
if ($FileType -eq "database") {$script:Size += $pcbRead}
Write-Debug "Reading $n portion of $pcbRead bytes"
$uBuffer = New-Object byte[] -ArgumentList $pcbRead
[Runtime.InteropServices.Marshal]::Copy($pvBuffer,$uBuffer,0,$pcbRead)
$FS.Write($uBuffer,0,$uBuffer.Length)
$complete += $pcbRead
if ($pcbRead -lt $cbBuffer) {$last = $true}
}
Write-Debug "Closing current item: $file"
$FS.Close()
$hresult = [PKI.CertAdm]::CertSrvBackupClose($phbc)
Write-Debug "Current item '$BackupFile' is closed: $(!$hresult)"
# relelase managed and unmanaged buffers
Remove-Variable uBuffer
}
function __status ($StatusObject) {
try {$StatusObject.StatusMessage = [PKI.Utils.Error]::GetMessage($StatusObject.Status)}
catch { }
Write-Verbose "Clearing resources"
$hresult = [PKI.CertAdm]::CertSrvBackupEnd($phbc)
Write-Debug "Backup sent to end state: $(!$hresult)"
$StatusObject.BackupEnd = [datetime]::Now
$StatusObject
}
#endregion
$StatusObject = New-Object psobject -Property @{
BackupType = $Type;
Status = 0;
StatusMessage = [string]::Empty;
DataBaseSize = 0;
LogFileCount = 0;
BackupStart = [datetime]::Now;
BackupEnd = [datetime]::Now
}
if ($BackupKey) {
if ($Password -eq $null -or $Password -eq [string]::Empty) {
$Password = Read-Host "Enter password"
}
__BackupKey $Password
}
$ofs = ", "
Write-Verbose "Set server name to $($Env:computername)"
$Server = $Env:COMPUTERNAME
$ServerStatus = $false
Write-Verbose "Test connection to local CA"
$hresult = [PKI.CertAdm]::CertSrvIsServerOnline($Server,[ref]$ServerStatus)
if (!$ServerStatus) {
$StatusObject.Status = 0x800706ba
__status $StatusObject
break
}
Write-Debug "Instantiate backup context handle"
[IntPtr]$phbc = [IntPtr]::Zero
Write-Debug "Retrieve backup context handle for the backup type: $type"
$hresult = switch ($Type) {
"Full" {[PKI.CertAdm]::CertSrvBackupPrepare($Server,0,1,[ref]$phbc)}
"Incremental" {[PKI.CertAdm]::CertSrvBackupPrepare($Server,0,2,[ref]$phbc)}
}
if ($hresult -ne 0) {
$StatusObject.Status = $hresult
__status $StatusObject
break
}
Write-Debug "Backup context handle is: $phbc"
$cbBuffer = 524288
$pvBuffer = [Runtime.InteropServices.Marshal]::AllocHGlobal($cbBuffer)
if ($Type -eq "Full") {
Write-Debug "Retrieve restore map"
$ppwszzDatabaseLocationList = [IntPtr]::Zero
$pcbSize = 0
$hresult = [PKI.CertAdm]::CertSrvRestoreGetDatabaseLocations($phbc,[ref]$ppwszzDatabaseLocationList,[ref]$pcbSize)
Write-Debug "Restore map handle: $ppwszzDatabaseLocationList"
Write-Debug "Restore map size in bytes: $pcbSize"
$Bytes = New-Object byte[] -ArgumentList $pcbSize
[Runtime.InteropServices.Marshal]::Copy($ppwszzDatabaseLocationList,$Bytes,0,$pcbSize)
Write-Verbose "Writing restore map to: $BackupDir\certbkxp.dat"
[IO.File]::WriteAllBytes("$BackupDir\certbkxp.dat",$Bytes)
Remove-Variable Bytes -Force
Write-Verbose "Retrieve DB file locations"
$ppwszzAttachmentInformation = [IntPtr]::Zero
$pcbSize = 0
$hresult = [PKI.CertAdm]::CertSrvBackupGetDatabaseNames($phbc,[ref]$ppwszzAttachmentInformation,[ref]$pcbSize)
Write-Debug "DB file location handle: $ppwszzAttachmentInformation"
Write-Debug "DB file location size in bytes: $pcbSize"
if ($hresult -ne 0) {
$StatusObject.Status = $hresult
__status $StatusObject
break
}
if ($pcbSize -eq 0) {
$StatusObject.Status = 0x80070012
__status $StatusObject
break
}
$Bytes = New-Object byte[] -ArgumentList $pcbSize
[Runtime.InteropServices.Marshal]::Copy($ppwszzAttachmentInformation,$Bytes,0,$pcbSize)
$DBPaths = Split-BackupPath $Bytes
Write-Verbose "Unstripped DB paths:"
$DBPaths | ForEach-Object {Write-Verbose $_}
Remove-Variable Bytes
# backup DB files
# initialize read buffer
Write-Debug "Set read buffer to: $cbBuffer bytes"
$script:Size = 0
foreach ($File in $DBPaths) {
$File = $File.Substring(1,($File.Length - 1))
Write-Verbose "Backing up file: $File"
__BackupRoutine $phbc $File $BackupDir $pvBuffer $cbBuffer "database"
}
$StatusObject.DataBaseSize = $script:Size
Remove-Variable DBPaths
} else {
Write-Verbose "Skipping CA database backup."
Write-Debug "Skipping CA database backup. Logs only"
}
# retrieve log files
$ppwszzBackupLogFiles = [IntPtr]::Zero
$pcbSize = 0
Write-Verbose "Retrieving DB log file list"
$hresult = [PKI.CertAdm]::CertSrvBackupGetBackupLogs($phbc,[ref]$ppwszzBackupLogFiles,[ref]$pcbSize)
Write-Debug "Log file location handle: $ppwszzAttachmentInformation"
Write-Debug "Log file location size in bytes: $pcbSize"
if ($hresult -ne 0) {
$StatusObject.Status = 0x80070012
__status $StatusObject
break
}
$Bytes = New-Object byte[] -ArgumentList $pcbSize
[Runtime.InteropServices.Marshal]::Copy($ppwszzBackupLogFiles,$Bytes,0,$pcbSize)
$LogPaths = Split-BackupPath $Bytes
$StatusObject.LogFileCount = $LogPaths.Length
Write-Verbose "Unstripped LOG paths:"
$LogPaths | ForEach-Object {Write-Verbose $_}
Remove-Variable Bytes
foreach ($File in $LogPaths) {
$File = $File.Substring(1,($File.Length - 1))
Write-Verbose "Backing up file: $File"
__BackupRoutine $phbc $File $BackupDir $pvBuffer $cbBuffer "log"
}
[Runtime.InteropServices.Marshal]::FreeHGlobal($pvBuffer)
Remove-Variable LogPaths
Write-Debug "Releasing read buffer"
# truncate logs
if ($Type -eq "Full" -and !$KeepLog) {
Write-Verbose "Truncating logs"
Write-Debug "Truncating logs"
$hresult = [PKI.CertAdm]::CertSrvBackupTruncateLogs($phbc)
if ($hresult -ne 0) {
$StatusObject.Status = 0x80070012
__status $StatusObject
break
}
}
# retrieve and backup dynamic files
if ($Extended) {
$Now = Get-Date -Format dd.MM.yyyy
Write-Verbose "Export CA configuration registry hive and CAPolicy.inf (if possible)."
Write-Debug "Export CA configuration registry hive and CAPolicy.inf (if possible)."
reg export "HKLM\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration" "$Path\CAConfig-$($Now.ToString()).reg" /y | Out-Null
Copy-Item $Env:windir\CAPolicy.inf -Destination $Path -Force -ErrorAction SilentlyContinue
}
__status $StatusObject
}
Example:
Save the PowerShell script as: BackupCertificateAuthority.ps1
Backup-CertificationAuthority -Path C:\Temp -BackupKey -Password P@ssw0rd -Extended

This is how the result will look like. ZIP everything up with a password and save it temporary somewhere, because we will use it later to import everything into the new server.

Let’s now decommission the ADCS server. Run the following command:
Uninstall-AdcsCertificationAuthority -Verbose

After the server has been decommissioned. We can delete the computer object in AD.
Get-ADComputer -Identity "ADCS" | Remove-ADObject -Recursive

Deploy a new Windows server and re-install ADCS. In this example, we are going to re-install an ADCS server that was an Enterprise Root CA. We need to select the box ‘Use existing private key’.

At this stage, we can now import the private key.

Here we can see our ADCS server again with the same configuration that resides on the previous server.

The final step is to import the CA registry entries.

We have now rebuilt our ADCS server, but as discussed before. Only follow this approach if there was no evidence of an attacker abusing ADCS to compromise your domain.
- Rebuilding Azure AD Connect server
At this example, we have an Azure AD Connect server that has been encrypted. This server needs to be rebuilt.

First, we need to stop the ‘ADSync’ services
sc stop ADSync
The second thing we have to do is uninstall the Azure AD Connect technology. Remote PowerShell is not supported in this case, since we do need access to the GUI.
$application = Get-WmiObject -Class Win32_Product -Filter "Name = 'Microsoft Azure AD Connect'"
$application.Uninstall()

Now open PowerShell and import the Active Directory PowerShell module. We are now going to remove the AD DS Connector account from the domain root. This account will be automatically created when Azure AD Connect is installed. Replace dc=contoso,dc=com with your domain name.
import-module activedirectory
set-location ad:
$acl = Get-Acl -Path "dc=contoso,dc=com"
foreach($acc in $acl.access )
{
$value = $acc.IdentityReference.Value
if($value -match "MSOL_")
{
$ACL.RemoveAccessRule($acc)
Set-Acl -Path "dc=contoso,dc=com" -AclObject $acl -ErrorAction Stop
Write-Host "Remove ACL Entry: $value "
}
}

Disable the old AD DS Connector account.
Disable-ADAccount -Identity MSOL_05326ab33985 -Verbose

Final step is to delete the AAD Connect computer account within AD.
Get-ADComputer -Identity "AADConnect" | Remove-ADObject -Recursive

Active Directory Security Assessment
We will be using PingCastle to perform an Active Directory Security Assessment. Yes, there are loads of other tools and scripts that can be used. Important thing to highlight is that we are going to focus on remediating the stuff as well, which is often not covered in other blog posts.
The first step is to download PingCastle: https://www.pingcastle.com/download/
The second step would be to run PingCastle.

This will generate a report with various findings, which we can start fixing.

- Check that every account requires a password
In this example, we have four accounts that have the PASSWD_NOTREQD flag enabled.

If we run the following command, we can see the results as well:
get-adobject -ldapfilter "(&(objectCategory=person)(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=32))" | Select-Object Name

This should be an easy fix, so we can turn this flag by running the following command:
$users=Get-Content C:\Users\Testing\Desktop\PasswordNotRequired.txt
ForEach ($user in $users)
{
Get-ADUser -Identity $user | Set-ADUser -PasswordNotRequired $false
write-host "user $($user) has PASSWD_NOTREQD flag removed"
}

- Check if all admin accounts require Kerberos Pre-Authentication
In this example, we are having accounts with Kerberos Pre-Authentication being disabled. The only legitimate configuration I’ve seen was for software like Oracle ERP.

We can get all the accounts with Kerberos Pre-Authentication being disabled when we run the following command:
get-adobject -ldapfilter "(&(objectCategory=person)(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=4194304))" | Select-Object Name

We can fix this problem by running the following command in PowerShell.
$users=Get-Content C:\Users\Testing\Desktop\KerberosPreAuthDisabled.txt
ForEach ($user in $users)
{
Get-ADUser -Identity $user | Set-ADAccountControl -DoesNotRequirePreAuth $False
write-host "user $($user) has Kerberos Pre-Authentication enabled"
}

- Clean members from legacy Admin groups
Legacy groups have too many permissions within AD and should not be used. Let’s start with making sure that the following groups are empty. There is no discussion, and yes. All of these groups should be empty.
Account Operators |
Backup Operators |
Server Operators |
Print Operators |
DnsAdmins |
Group Policy Creator Owners |
Remote Desktop Users |
Here we have a PowerShell one-liner that will empty this group. This requires the ActiveDirectory PowerShell module in order to run this successfully.
Get-ADGroupMember "Account Operators" | ForEach-Object {Remove-ADGroupMember "Account Operators" $_ -Confirm:$false};Get-ADGroupMember "Backup Operators" | ForEach-Object {Remove-ADGroupMember "Backup Operators" $_ -Confirm:$false};Get-ADGroupMember "Server Operators" | ForEach-Object {Remove-ADGroupMember "Server Operators" $_ -Confirm:$false};Get-ADGroupMember "Print Operators" | ForEach-Object {Remove-ADGroupMember "Print Operators" $_ -Confirm:$false};Get-ADGroupMember "DnsAdmins" | ForEach-Object {Remove-ADGroupMember "DnsAdmins" $_ -Confirm:$false};Get-ADGroupMember "Group Policy Creator Owners" | ForEach-Object {Remove-ADGroupMember "Group Policy Creator Owners" $_ -Confirm:$false};Get-ADGroupMember "Remote Desktop Users" | ForEach-Object {Remove-ADGroupMember "Remote Desktop Users" $_ -Confirm:$false}

- ANONYMOUS LOGON and Everyone in Pre-Windows 2000 Compatible Access
Ensure that ‘ANONYMOUS LOGON’ and ‘Everyone’ is removed from the Pre-Windows 2000 Compatible Access group. This one should be straightforward to fix.

- Disable the Print Spooler service on all Domain Controllers
Stop the Print Spooler service on all the Domain Controllers.

First let’s get all the Domain Controllers.
Get-ADDomainController -Filter * | Select-Object name

Instead of stopping all the services manually, we can use remote PowerShell to speed up the process. The first command will check if the Spooler service is running or not.
Invoke-Command -ComputerName (Get-Content "C:\Users\Testing\Desktop\DomainControllers.txt") -ScriptBlock {Get-Service -Displayname "*Spooler*"}

The second command will stop the Spooler service.
Invoke-Command -ComputerName (Get-Content "C:\Users\Testing\Desktop\DomainControllers.txt") -ScriptBlock {Get-Service -DisplayName "*Spooler*" | Stop-Service}

We can now see that all the services have been stopped.

- Remove dangerous AD ACLs on Domain Naming Context
Removing dangerous AD ACLs that can lead to full domain takeover. Pay close attention to the following objects:
- dc=contoso,dc=com (Domain Naming Context)
- CN=AdminSDHolder,CN=System
- CN=MicrosoftDNS,CN=System
- OU=Domain Controllers,DC=contoso,DC=com (Domain Controllers OU)

Let’s start with the Domain Naming Context. We can see that DAISY_PETERSON has Full control over this object. It is not recommended to delegate permissions on the root level, so let’s start with revoking this permission. Make sure to replace ‘dc=contoso,dc=com’ with your domain name.
import-module activedirectory
set-location ad:
$acl = Get-Acl -Path "dc=contoso,dc=com"
foreach($acc in $acl.access )
{
$value = $acc.IdentityReference.Value
if($value -match "DAISY_PETERSON")
{
$ACL.RemoveAccessRule($acc)
Set-Acl -Path "dc=contoso,dc=com" -AclObject $acl -ErrorAction Stop
Write-Host "Remove ACL Entry: $value "
}
}

The official documentation of Microsoft. See: https://learn.microsoft.com/en-us/windows/security/identity-protection/hello-for-business/hello-hybrid-cert-whfb-settings-dir-sync recommends assigning Enterprise Key Admins Read/Write msDs-KeyCredentialLink for Descendant User objects. This is a group that is used for Windows Hello for Business.

Now ensure that Enterprise Key Admins has the following permissions: Read/Write msDs-KeyCredentialLink on the domain root level.
First let’s remove Enterprise Key Admins from the Domain Naming Context.
import-module activedirectory
set-location ad:
$acl = Get-Acl -Path "DC=contoso,DC=com"
foreach($acc in $acl.access )
{
$value = $acc.IdentityReference.Value
if($value -match "Enterprise Key Admins")
{
$ACL.RemoveAccessRule($acc)
Set-Acl -Path "DC=contoso,DC=com" -AclObject $acl -ErrorAction Stop
Write-Host "Remove ACL Entry: $value "
}
}

Now let’s add Enterprise Key Admins back again with the minimum permissions that are required:
$DNC = "dc=contoso,dc=com"
$ADGroup = "Enterprise Key Admins"
Set-Location AD:
$Group = Get-ADGroup -Identity $ADGroup
$GroupSID = [System.Security.Principal.SecurityIdentifier] $Group.SID
$ACL = Get-Acl -Path $DNC
$Identity = [System.Security.Principal.IdentityReference] $GroupSID
$Users = [GUID]"bf967aba-0de6-11d0-a285-00aa003049e2"
$msdsKeyCredentialLink = [GUID]"5b47d60f-6090-40b2-9f37-2a4de88f3063"
$SetRequiredPermissions = New-Object System.DirectoryServices.ActiveDirectoryAccessRule ($Identity, "ReadProperty, WriteProperty", "Allow", $msdsKeyCredentialLink, "Descendents", $Users)
$ACL.AddAccessRule($SetRequiredPermissions)
Set-Acl -Path $DNC -AclObject $ACL

- Cleanup AdminSDHolder permissions
We can now start removing AD ACLs permissions that have been assigned on the AdminSDHolder container. Account Operators with Full control permission on the container is for example a real configuration I’ve seen.

Remove Account Operators from AdminSDHolder and other ACEs that don’t need to have delegated permissions on this container.
import-module activedirectory
set-location ad:
$acl = Get-Acl -Path "CN=AdminSDHolder,CN=System,DC=contoso,DC=com"
foreach($acc in $acl.access )
{
$value = $acc.IdentityReference.Value
if($value -match "Account Operators")
{
$ACL.RemoveAccessRule($acc)
Set-Acl -Path "CN=AdminSDHolder,CN=System,DC=contoso,DC=com" -AclObject $acl -ErrorAction Stop
Write-Host "Remove ACL Entry: $value "
}
}
Do the same as well for the other ACE that has been delegated on the AdminSDHolder container, which happens to be JAN_JENSEN.

- Deny WriteDacl from Exchange Windows Permissions on Domain Root
If you have Exchange Windows Permissions having WriteDacl on the Domain Naming Context. It means that every Exchange Admin can become indirect a Domain Admin.

To fix this, we can start putting a ‘Deny’ rule on WriteDacl for the Exchange Windows Permissions group.
$ADSI = [ADSI]"LDAP://DC=contoso,DC=com"
$NTAccount = New-Object System.Security.Principal.NTAccount("Exchange Windows Permissions")
$IdentityReference = $NTAccount.Translate([System.Security.Principal.SecurityIdentifier])
$ActiveDirectoryRights = [System.DirectoryServices.ActiveDirectoryRights] "WriteDacl"
$AccessControlType = [System.Security.AccessControl.AccessControlType] "Deny"
$Inherit = [System.DirectoryServices.ActiveDirectorySecurityInheritance] "All"
$ACE = New-Object System.DirectoryServices.ActiveDirectoryAccessRule($IdentityReference,$ActiveDirectoryRights,$AccessControlType,$Inherit)
$ADSI.psbase.ObjectSecurity.SetAccessRule($ACE)
$ADSI.psbase.commitchanges()

- Review AD ACLs on GPOs linked to Domain and Domain Controllers OU
This PowerShell script can gather all the GPOs that are linked to the domain or OUs. In this case, let’s focus on the GPOs that are linked to root level and the Domain Controllers OU.
#requires -version 5.1
#requires -module GroupPolicy,ActiveDirectory
Function Get-GPLink {
<#
.Synopsis
Get Group Policy Object links
.Description
This command will display the links to existing Group Policy objects. You can filter for enabled or disabled links. The default user domain is queried although you can specify an alternate domain and/or a specific domain controller. There is no provision for alternate credentials.
The command writes a custom object to the pipeline. There are associated custom table views you can use. See examples.
.Parameter Name
Enter a GPO name. Wildcards are allowed. This parameter has an alias of gpo.
.Parameter Server
Specify the name of a specific domain controller to query.
.Parameter Domain
Enter the name of an Active Directory domain. The default is the current user domain. Your credentials must have permission to query the domain. Specify the DNS domain name, i.e. company.com
.Parameter Enabled
Only show links that are enabled.
.Parameter Disabled
Only show links that are Disabled.
.Example
PS C:\> Get-GPLink
Target DisplayName Enabled Enforced Order
------ ----------- ------- -------- -----
dc=company,dc=pri Default Domain Policy True True 1
dc=company,dc=pri PKI AutoEnroll True False 2
ou=domain controllers,dc=company,dc=pri Default Domain Controllers Policy True False 1
ou=it,dc=company,dc=pri Demo 2 True False 1
ou=dev,dc=company,dc=pri Demo 1 True False 1
ou=dev,dc=company,dc=pri Demo 2 False False 2
ou=sales,dc=company,dc=pri Demo 1 True False 1
...
If you are running in the console, False values under Enabled will be displayed in red. Enforced values that are True will be displayed in Green.
.Example
PS C:\> Get-GPLink -Disabled
Target DisplayName Enabled Enforced Order
------ ----------- ------- -------- -----
ou=dev,dc=company,dc=pri Demo 2 False False 2
ou=foo\,bar demo,dc=company,dc=pri Gladys False False 1
Get disabled Group Policy links.
.Example
PS C:\> Get-GPLink gladys | get-gpo
DisplayName : Gladys
DomainName : Company.Pri
Owner : COMPANY\Domain Admins
Id : 7551c3d8-99fa-4bc6-85a2-bd650124f11a
GpoStatus : AllSettingsEnabled
Description :
CreationTime : 1/11/2021 2:34:37 PM
ModificationTime : 1/11/2021 2:34:38 PM
UserVersion : AD Version: 0, SysVol Version: 0
ComputerVersion : AD Version: 0, SysVol Version: 0
WmiFilter :
.Example
PS C:\> Get-GPLink | Where TargetType -eq "domain"
Target DisplayName Enabled Enforced Order
------ ----------- ------- -------- -----
dc=company,dc=pri Default Domain Policy True True 1
dc=company,dc=pri PKI AutoEnroll True True 2
Other possible TargetType values are OU and Site.
.Example
PS C:\> Get-GPLink | sort Target | Format-Table -view link
Target: dc=company,dc=pri
DisplayName Enabled Enforced Order
----------- ------- -------- -----
PKI AutoEnroll True False 2
Default Domain Policy True True 1
Target: ou=dev,dc=company,dc=pri
DisplayName Enabled Enforced Order
----------- ------- -------- -----
Demo 1 True False 1
Demo 2 False False 2
...
.Example
PS C:\> Get-GPLink | Sort TargetType | Format-Table -view targetType
TargetType: Domain
Target DisplayName Enabled Enforced Order
------ ----------- ------- -------- -----
dc=company,dc=pri PKI AutoEnroll True True 2
dc=company,dc=pri Default Domain Policy True True 1
TargetType: OU
Target DisplayName Enabled Enforced Order
------ ----------- ------- -------- -----
ou=accounting,dc=company,dc=pri Accounting-dev-test-foo True False 1
ou=sales,dc=company,dc=pri Demo 1 True False 1
...
.Example
PS C:\> Get-GPLink | Sort Name | Format-Table -view gpo
DisplayName: Default Domain Controllers Policy
Target Enabled Enforced Order
------ ------- -------- -----
ou=domain controllers,dc=company,dc=pri True False 1
DisplayName: Default Domain Policy
Target Enabled Enforced Order
------ ------- -------- -----
dc=company,dc=pri True True 1
DisplayName: Demo 1
Target Enabled Enforced Order
------ ------- -------- -----
ou=dev,dc=company,dc=pri True False 1
CN=Default-First-Site-Name,cn=Sites,CN=Config True True 2
uration,DC=Company,DC=Pri
...
.Example
PS C:\> Get-GPLink | Format-Table -GroupBy Domain -Property Link,GPO,Enabled,Enforced
Domain: Company.Pri
Link GPO Enabled Enforced
---- --- ------- --------
dc=company,dc=pri Default Domain Policy True True
dc=company,dc=pri PKI AutoEnroll True False
ou=domain controllers,dc=company,dc=pri Default Domain Controllers Policy True False
ou=it,dc=company,dc=pri Demo 2 True False
ou=dev,dc=company,dc=pri Demo 1 True False
ou=dev,dc=company,dc=pri Demo 2 False False
ou=sales,dc=company,dc=pri Demo 1 True False
ou=foo\,bar demo,dc=company,dc=pri Gladys False False
ou=foo\,bar demo,dc=company,dc=pri Demo 2 True False
.Link
Get-GPO
.Link
Set-GPLink
.Inputs
System.String
.Notes
Learn more about PowerShell: http://jdhitsolutions.com/blog/essential-powershell-resources/
#>
[cmdletbinding(DefaultParameterSetName = "All")]
[outputtype("myGPOLink")]
Param(
[parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "Enter a GPO name. Wildcards are allowed")]
[alias("gpo")]
[ValidateNotNullOrEmpty()]
[string]$Name,
[Parameter(HelpMessage = "Specify the name of a specific domain controller to query.")]
[ValidateNotNullOrEmpty()]
[string]$Server,
[Parameter(ValueFromPipelineByPropertyName)]
[ValidateNotNullOrEmpty()]
[string]$Domain,
[Parameter(ParameterSetName = "enabled")]
[switch]$Enabled,
[Parameter(ParameterSetName = "disabled")]
[switch]$Disabled
)
Begin {
Write-Verbose "Starting $($myinvocation.mycommand)"
#display some metadata information in the verbose output
Write-Verbose "Running as $($env:USERDOMAIN)\$($env:USERNAME) on $($env:Computername)"
Write-Verbose "Using PowerShell version $($psversiontable.PSVersion)"
Write-Verbose "Using ActiveDirectory module $((Get-Module ActiveDirectory).version)"
Write-Verbose "Using GroupPolicy module $((Get-Module GroupPolicy).version)"
#define a helper function to get site level GPOs
#It is easier for this task to use the Group Policy Management COM objects.
Function Get-GPSiteLink {
[cmdletbinding()]
Param (
[Parameter(Position = 0,ValueFromPipelineByPropertyName,ValueFromPipeline)]
[alias("Name")]
[string[]]$SiteName = "Default-First-Site-Name",
[Parameter(Position = 1)]
[string]$Domain,
[string]$Server
)
Begin {
Write-Verbose "Starting $($myinvocation.mycommand)"
#define the GPMC COM Objects
$gpm = New-Object -ComObject "GPMGMT.GPM"
$gpmConstants = $gpm.GetConstants()
} #Begin
Process {
$getParams = @{Current = "LoggedonUser"; ErrorAction = "Stop" }
if ($Server) {
$getParams.Add("Server", $Server)
}
if ( -Not $PSBoundParameters.ContainsKey("Domain")) {
Write-Verbose "Querying domain"
Try {
$Domain = (Get-ADDomain @getParams).DNSRoot
}
Catch {
Write-Warning "Failed to query the domain. $($_.exception.message)"
#Bail out of the function since we need this information
return
}
}
Try {
$Forest = (Get-ADForest @getParams).Name
}
Catch {
Write-Warning "Failed to query the forest. $($_.exception.message)"
#Bail out of the function since we need this information
return
}
$gpmDomain = $gpm.GetDomain($domain, $server, $gpmConstants.UseAnyDC)
foreach ($item in $siteName) {
#connect to site container
$SiteContainer = $gpm.GetSitesContainer($forest, $domain, $null, $gpmConstants.UseAnyDC)
Write-Verbose "Connected to site container on $($SiteContainer.domainController)"
#get sites
Write-Verbose "Getting $item"
$site = $SiteContainer.GetSite($item)
Write-Verbose "Found $($sites.count) site(s)"
if ($site) {
Write-Verbose "Getting site GPO links"
$links = $Site.GetGPOLinks()
if ($links) {
#add the GPO name
Write-Verbose "Found $($links.count) GPO link(s)"
foreach ($link in $links) {
[pscustomobject]@{
GpoId = $link.GPOId -replace ("{|}", "")
DisplayName = ($gpmDomain.GetGPO($link.GPOID)).DisplayName
Enabled = $link.Enabled
Enforced = $link.Enforced
Target = $link.som.path
Order = $link.somlinkorder
} #custom object
}
} #if $links
} #if $site
} #foreach site
} #process
End {
Write-Verbose "Ending $($myinvocation.MyCommand)"
} #end
} #end function
} #begin
Process {
Write-Verbose "Using these bound parameters"
$PSBoundParameters | Out-String | Write-Verbose
#use a generic list instead of an array for better performance
$targets = [System.Collections.Generic.list[string]]::new()
#use an internal $PSDefaultParameterValues instead of trying to
#create parameter hashtables for splatting
if ($Server) {
$script:PSDefaultParameterValues["Get-AD*:Server"] = $server
$script:PSDefaultParameterValues["Get-GP*:Server"] = $Server
}
if ($domain) {
$script:PSDefaultParameterValues["Get-AD*:Domain"] = $domain
$script:PSDefaultParameterValues["Get-ADDomain:Identity"] = $domain
$script:PSDefaultParameterValues["Get-GP*:Domain"] = $domain
}
Try {
Write-Verbose "Querying the domain"
$mydomain = Get-ADDomain -ErrorAction Stop
#add the DN to the list
$targets.Add($mydomain.distinguishedname)
}
Catch {
Write-Warning "Failed to get domain information. $($_.exception.message)"
#bail out if the domain can't be queried
Return
}
if ($targets) {
#get OUs
Write-Verbose "Querying organizational units"
Get-ADOrganizationalUnit -Filter * |
ForEach-Object { $targets.add($_.Distinguishedname) }
#get all the links
Write-Verbose "Getting GPO links from $($targets.count) targets"
$links = [System.Collections.Generic.list[object]]::New()
Try {
($Targets | Get-GPInheritance -ErrorAction Stop).gpolinks | ForEach-Object { $links.Add($_) }
}
Catch {
Write-Warning "Failed to get GPO inheritance. If specifying a domain, be sure to use the DNS name. $($_.exception.message)"
#bail out
return
}
Write-Verbose "Querying sites"
$getADO = @{
LDAPFilter = "(Objectclass=site)"
properties = "Name"
SearchBase = (Get-ADRootDSE).ConfigurationNamingContext
}
$sites = (Get-ADObject @getADO).name
if ($sites) {
Write-Verbose "Processing $($sites.count) site(s)"
#call the private helper function
$sites | Get-GPSiteLink | ForEach-Object { $links.add($_) }
}
#filter for Enabled or Disabled
if ($enabled) {
Write-Verbose "Filtering for Enabled policies"
$links = $links.where( { $_.enabled })
}
elseif ($Disabled) {
Write-Verbose "Filtering for Disabled policies"
$links = $links.where( { -Not $_.enabled })
}
if ($Name) {
Write-Verbose "Filtering for GPO name like $name"
#filter by GPO name using v4 filtering feature for performance
$results = $links.where({ $_.displayname -like "$name" })
}
else {
#write all the links
Write-Verbose "Displaying ALL GPO Links"
$results = $links
}
if ($results) {
#insert a custom type name so that formatting can be applied
$results.GetEnumerator().ForEach( { $_.psobject.TypeNames.insert(0, "myGPOLink") })
$results
}
else {
Write-Warning "Failed to find any GPO using a name like $Name"
}
} #if targets
} #process
End {
Write-Verbose "Ending $($myinvocation.mycommand)"
} #end
} #end function
#define custom type extensions
Update-TypeData -MemberType AliasProperty -MemberName GUID -Value GPOId -TypeName myGPOLink -Force
Update-TypeData -MemberType AliasProperty -MemberName Name -Value DisplayName -TypeName myGPOLink -Force
Update-TypeData -MemberType AliasProperty -MemberName GPO -Value DisplayName -TypeName myGPOLink -Force
Update-TypeData -MemberType AliasProperty -MemberName Link -Value Target -TypeName myGPOLink -Force
Update-TypeData -MemberType AliasProperty -MemberName Domain -Value GpoDomainName -TypeName myGPOLink -Force
Update-TypeData -MemberType ScriptProperty -MemberName TargetType -Value {
switch -regex ($this.target) {
"^((ou)|(OU)=)" { "OU" }
"^((dc)|(DC)=)" { "Domain" }
"^((cn)|(CN)=)" { "Site" }
Default { "Unknown"}
}
} -TypeName myGPOLink -Force
Example:
Save this PowerShell script as Get-GPLink.ps1
Import-Module .\Get-GPLink.ps1
Get-GPLink | Out-GridView
Now we can see all the GPOs that are linked within the domain.

Let’s now view who can modify the settings of the GPOs that are linked to the domain and DCs.
$gpos = Get-GPO -All
$info = foreach ($gpo in $gpos)
{
Get-GPPermissions -Guid $gpo.Id -All | Select-Object `
@{n='GPOName';e={$gpo.DisplayName}},
@{n='AccountName';e={$_.Trustee.Name}},
@{n='AccountType';e={$_.Trustee.SidType.ToString()}},
@{n='Permissions';e={$_.Permission}}
}
$info | Out-GridView
As example over here, we can see that Account Operators has the permissions to edit the settings of a GPO that is linked to the Domain Controllers OU. This is a dangerous ACE that should be removed.

- Review User Right Assignments on GPOs linked to Domain Controllers
Review all the User Right Assignments of GPOs that are linked to Domain Controllers.

Focus on the following rights, but not limited to:
- Allow log on locally
- Allow log on through Remote Desktop Services
- Back up files and directories
- Restore files and directories
- Load and unload device drivers
- Enable computer and user accounts to be trusted for delegation
- Debug Programs
- Shutdown the system
- Take ownership of files or other objects
- Log on as a batch job
- Log on as a service
- Act as part of the operating system
Example:
If you look very closely, we can see here that ‘Authenticated Users’ are allowed to log on locally to Domain Controllers. This should never be the case, which is why we should remove it.

- Kerberoastable accounts with adminCount set to 1
Review all the accounts that have the adminCount set to 1 but do have a SPN configured. Make sure to clean up the SPNs when they are not needed anymore.
$as = [adsisearcher]"(&(objectClass=user)(servicePrincipalName=*)(!(samaccountname=krbtgt)(adminCount=1)))"
$as.PropertiesToLoad.Add('samaccountname')
$as.PropertiesToLoad.Add('lastLogon')
$as.PropertiesToLoad.Add('pwdLastSet')
$as.PropertiesToLoad.Add('servicePrincipalName')
$as.FindAll() | ForEach-Object {
$props = @{ 'samaccountname' = ($_.properties.item('samaccountname') | Out-String).Trim()
'pwdLastSet' =
([datetime]::FromFiletime(($_.properties.item('pwdLastSet') | Out-String).Trim()))
'lastLogon' =
([datetime]::FromFiletime(($_.properties.item('lastLogon') | Out-String).Trim())) }
New-Object psObject -Property $props
} | Out-GridView
A quick win is to look at human accounts that have a SPN configured. This should never be the case, since SPNs are only intended for accounts that are running as a service.

The second example is to review whether a SPN that was set on a service account still has a mapping. In this example, we are going to run the following command to view the SPN that is attached to svc_SQL.
setspn -L svc_SQL
I can see the SPN ‘MSSQL\SQLServer2012.contoso.com’ being attached to it. Now the second question would be. Do we still use SQLServer2012 or was the server decommissioned? If the server is gone, we can start deleting the SPN.

In order to delete the SPN, we can run the following command:
setspn -D MSSQL/SQLServer2012.contoso.com svc_SQL

- Ensure Schema Admins is empty
Ensure that the Schema Admins group is empty.
Get-ADGroupMember "Schema Admins" | ForEach-Object {Remove-ADGroupMember "Schema Admins" $_ -Confirm:$false}

- Reduce the amount of Domain Admins and Enterprise Admins
Let’s come to a standard. It always ‘depends’ on how many Domain Admins each organization should have. However, let’s try to have a maximum of 5. In a single domain, single forest scenario. The Enterprise Admins group should be empty.
- Re-create new accounts for all Domain Admins and Enterprise Admins
After a ransomware attack where attackers have been able to get Domain Admin credentials. It is recommended to create brand new accounts for all the members that are Domain Admins or Enterprise Admins. Here we have 3 new accounts.

- Enable ‘Account is sensitive and cannot be delegated’ for Tier-0 admins
Enable this checkbox on your Tier-0 admins, which are very likely your Domain Admins.

- Reset the KRBTGT account twice
After a Domain Admin or equivalent compromise. It is recommended to rotate the KRBTGT account twice to avoid having potential Golden Tickets lingering around. We need to reset the password twice to remove the current and previous password hash.
Run the following command to check when the KRBTGT was last rotated.
Get-ADUser krbtgt -Properties Created,PasswordLastSet,msDS-KeyVersionNumber

Before resetting the password of the KRBTGT account. Let’s verify the AD Replication health status.
$Computers = (Get-ADComputer -Filter *).count
$Workstations = (Get-ADComputer -LDAPFilter "(&(objectClass=Computer)(!operatingSystem=*server*))" -Searchbase (Get-ADDomain).distinguishedName).count
$Servers = (Get-ADComputer -LDAPFilter "(&(objectClass=Computer)(operatingSystem=*server*))" -Searchbase (Get-ADDomain).distinguishedName).count
$Users = (get-aduser -filter *).count
$domain = Get-ADDomain |FT Forest
$FSMO = netdom query FSMO
$ADForest = (Get-ADForest).ForestMode
$ADDomain = (Get-ADDomain).DomainMode
$ADVer = Get-ADObject (Get-ADRootDSE).schemaNamingContext -property objectVersion | Select objectVersion
$ADNUM = $ADVer -replace "@{objectVersion=","" -replace "}",""
If ($ADNum -eq '88') {$srv = 'Windows Server 2019'}
ElseIf ($ADNum -eq '87') {$srv = 'Windows Server 2016'}
ElseIf ($ADNum -eq '69') {$srv = 'Windows Server 2012 R2'}
ElseIf ($ADNum -eq '56') {$srv = 'Windows Server 2012'}
ElseIf ($ADNum -eq '47') {$srv = 'Windows Server 2008 R2'}
ElseIf ($ADNum -eq '44') {$srv = 'Windows Server 2008'}
ElseIf ($ADNum -eq '31') {$srv = 'Windows Server 2003 R2'}
ElseIf ($ADNum -eq '30') {$srv = 'Windows Server 2003'}
Write-Host "For this Domain there are;"
Write-Host "Computers = "$Computers -ForegroundColor Cyan
Write-Host "Workstions = "$Workstations -ForegroundColor Cyan
Write-Host "Servers = "$Servers -ForegroundColor Cyan
Write-Host "Users = "$Users -ForegroundColor Cyan
Write-host ""
Write-host "Active Directory Info" -ForegroundColor Yellow
Write-Host "Active Directory Forest Mode = "$ADForest -ForegroundColor Cyan
Write-Host "Active Directory Domain Mode = "$ADDomain -ForegroundColor Cyan
Write-Host "Active Directory Schema Version is $ADNum which corresponds to $Srv" -ForegroundColor Cyan
Write-Host ""
Write-Host "FSMO Role Owners" -ForegroundColor Cyan
$FSMO
Write-Host "Active Directory Health Check" -ForegroundColor Yellow
Write-Host ""
#####################################Get ALL DC Servers#################################
$getForest = [system.directoryservices.activedirectory.Forest]::GetCurrentForest()
$DCServers = $getForest.domains | ForEach-Object {$_.DomainControllers} | ForEach-Object {$_.Name}
$timeout = "60"
foreach ($DC in $DCServers){
$Identity = $DC
################Ping Test######
if ( Test-Connection -ComputerName $DC -Count 1 -ErrorAction SilentlyContinue ) {
Write-Host $DC `t $DC `t Ping Success -ForegroundColor Green
##############Netlogon Service Status################
$serviceStatus = start-job -scriptblock {get-service -ComputerName $($args[0]) -Name "Netlogon" -ErrorAction SilentlyContinue} -ArgumentList $DC
wait-job $serviceStatus -timeout $timeout
if($serviceStatus.state -like "Running")
{
Write-Host $DC `t Netlogon Service TimeOut -ForegroundColor Yellow
stop-job $serviceStatus
}
else
{
$serviceStatus1 = Receive-job $serviceStatus
if ($serviceStatus1.status -eq "Running") {
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Green
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
else
{
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Red
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
}
}
##############NTDS Service Status################
$serviceStatus = start-job -scriptblock {get-service -ComputerName $($args[0]) -Name "NTDS" -ErrorAction SilentlyContinue} -ArgumentList $DC
wait-job $serviceStatus -timeout $timeout
if($serviceStatus.state -like "Running")
{
Write-Host $DC `t NTDS Service TimeOut -ForegroundColor Yellow
stop-job $serviceStatus
}
else
{
$serviceStatus1 = Receive-job $serviceStatus
if ($serviceStatus1.status -eq "Running") {
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Green
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
else
{
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Red
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
}
##############DNS Service Status################
$serviceStatus = start-job -scriptblock {get-service -ComputerName $($args[0]) -Name "DNS" -ErrorAction SilentlyContinue} -ArgumentList $DC
wait-job $serviceStatus -timeout $timeout
if($serviceStatus.state -like "Running")
{
Write-Host $DC `t DNS Server Service TimeOut -ForegroundColor Yellow
stop-job $serviceStatus
}
else
{
$serviceStatus1 = Receive-job $serviceStatus
if ($serviceStatus1.status -eq "Running") {
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Green
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
else
{
Write-Host $DC `t $serviceStatus1.name `t $serviceStatus1.status -ForegroundColor Red
$svcName = $serviceStatus1.name
$svcState = $serviceStatus1.status
}
}
####################Netlogons status##################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:netlogons /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Netlogons Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test NetLogons"))
{
Write-Host $DC `t Netlogons Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Netlogons Test Failed -ForegroundColor Red
}
}
####################Replications status#################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:Replications /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Replications Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test Replications"))
{
Write-Host $DC `t Replications Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Replications Test Failed -ForegroundColor Red
}
}
####################Services status#####################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:Services /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Services Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test Services"))
{
Write-Host $DC `t Services Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Services Test Failed -ForegroundColor Red
}
}
####################Advertising status##################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:Advertising /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t Advertising Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test Advertising"))
{
Write-Host $DC `t Advertising Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t Advertising Test Failed -ForegroundColor Red
}
}
####################FSMOCheck status##################
add-type -AssemblyName microsoft.visualbasic
$cmp = "microsoft.visualbasic.strings" -as [type]
$sysvol = start-job -scriptblock {dcdiag /test:FSMOCheck /s:$($args[0])} -ArgumentList $DC
wait-job $sysvol -timeout $timeout
if($sysvol.state -like "Running")
{
Write-Host $DC `t FSMOCheck Test TimeOut -ForegroundColor Yellow
stop-job $sysvol
}
else
{
$sysvol1 = Receive-job $sysvol
if($cmp::instr($sysvol1, "passed test FsmoCheck"))
{
Write-Host $DC `t FSMOCheck Test passed -ForegroundColor Green
}
else
{
Write-Host $DC `t FSMOCheck Test Failed -ForegroundColor Red
}
}
Write-Host ""
}
We can see that AD Replication looks good, so we can proceed further. In most cases, you always at least one DC that doesn’t replicate well. Try to get this fixed first before resetting the password for the first time.

Now reset the password of the KRBTGT once and wait 10 hours before doing the second reset. Every time that you reset the password of the KRBTGT account. The msDs-KeyVersionNumber attribute will go up one number. In the first screenshot, we saw that it was set to 2. Now we can see that it went up to 4.

- Ensure all the ‘compromised’ accounts have been disabled
Let’s assume that your Incident Response team noticed that 3 accounts were compromised. Make sure that these accounts have been disabled. A quick way to check this is to run the following PowerShell script:
$users=Get-Content C:\Users\Testing\Desktop\Accounts.txt
ForEach ($user in $users)
{
Get-ADUser -Identity $user | select samaccountname,enabled
}
At the example, we can see that these accounts are still enabled.

Make sure that these accounts will be disabled.
$users=Get-Content C:\Users\Testing\Desktop\Accounts.txt
ForEach ($user in $users)
{
Disable-ADAccount -Identity $user
write-host "user $($user) has been disabled"
}
Here we can see that the accounts are getting disabled.

Mitigating Lateral Movement
This section will cover the AD Tier Model and deploying LAPS. We will be focusing on Tier-0 only. Tier-0 is the highest level and includes administrative accounts and groups, domain controllers, and systems that have direct or indirect administrative control of an Active Directory Forest.
First, we will be creating the ‘Tier-0’ structure.
# This requires the RSAT Module being installed
# The command below uses the New-ADOrganizationUnit cmdlet to create a root OU called Tier-0
New-ADOrganizationalUnit -Path 'DC=contoso,DC=com' -Name 'Tier-0' -Verbose
# The commands below use the New-ADOrganizationUnit cmdlet to create six new sub
New-ADOrganizationalUnit -Path 'OU=Tier-0,DC=contoso,DC=com' -Name 'Tier-0-Staging' -Verbose
New-ADOrganizationalUnit -Path 'OU=Tier-0,DC=contoso,DC=com' -Name 'Tier-0-Accounts' -Verbose
New-ADOrganizationalUnit -Path 'OU=Tier-0,DC=contoso,DC=com' -Name 'Tier-0-PAW' -Verbose
New-ADOrganizationalUnit -Path 'OU=Tier-0,DC=contoso,DC=com' -Name 'Tier-0-Groups' -Verbose
New-ADOrganizationalUnit -Path 'OU=Tier-0,DC=contoso,DC=com' -Name 'Tier-0-Service-Accounts' -Verbose
New-ADOrganizationalUnit -Path 'OU=Tier-0,DC=contoso,DC=com' -Name 'Tier-0-Servers' -Verbose

If you already claim to have some form of ‘Tier’ Model in place. Run this PowerShell script from Jeff Hicks to gather all the OU structure within the domain.
Save this PowerShell script as Get-DomainTree.ps1
Replace ‘DC=contoso,DC=com’ with your domain name.
Function Get-DSTree {
[cmdletbinding()]
[OutputType("string")]
Param(
[ADSI]$ADSPath="LDAP://DC=contoso,DC=com",
[int]$Indent=0
)
[string]$leader=" "
[int]$pad = $leader.length+$indent
$searcher = New-Object directoryservices.directorysearcher
$searcher.pagesize=100
#get containers and OUs
#$searcher.Filter = "(|(objectclass=container)(objectclass=organizationalUnit))"
#get only OUs
$Searcher.filter = "(&(objectclass=organizationalUnit))"
$searcher.searchScope="OneLevel"
$searcher.searchRoot = $ADSPath
[void]$searcher.PropertiesToLoad.Add("DistinguishedName")
$searcher.FindAll() | ForEach-Object {
$branch = "{0}{1}" -f ($leader.Padleft($pad,$leader),$_.properties.distinguishedname[0])
$branch
Get-DSTree -ADSPath $_.path -indent ($pad+2)
}
}
Run the following command:
Import-Module .\Get-DSTree.ps1
Get-DSTree
Here we can see an OU structure that forms ‘Tier-0’ in this case.

The second step is to identify what ‘Tier-0’ is. This is for each organization different. Try to see Tier-0 like. If this server or account is getting compromised. Can the attacker compromise the environment whether that’s On-Premises or Cloud?
Let’s start with examples of what is considered as a Tier-0:
Legacy groups like Account Operators, Backup Operators, DnsAdmins, Group Policy Creator Owners, and so on. All of these groups should be empty, which will reduce your Tier-0.
This means that it will be very likely your Domain Admins, Enterprise Admins, and Administrators. The second example will be service accounts that need to run as a service on a Tier-0 server. Those kinds of accounts may often require local admin rights to function properly.
When it comes down to Tier-0 servers. You can think of servers like Azure AD Connect for example or you may manage Domain Controllers with SCCM. Other well-known Tier-0 servers are ADFS, ADCS, Backup servers, AV Management Consoles (i.e., McAfee ePo, Kaspersky), and so on.
- Add all your Tier-0 accounts to the ‘Tier-0-Accounts’ OU

- Create AD group for Tier-0 service accounts
You may have service accounts that are running as a service on Tier-0 servers but are not necessary a Domain Admin for example. In this case, create a Tier-0 group for service accounts in the ‘Tier-0-Groups’.
Add all the service accounts that are running as a service on your Tier-0 to this group.

- Add all your Tier-0 service accounts to the Tier-0 service accounts OU
In this example, I have a service account called ‘SVC_PKI’ that runs as a service on an ADCS server. ADCS server is a Tier-0, so the ‘SVC_PKI’ should also become a Tier-0. Add this account to the ‘Tier-0-Service-Accounts’ OU.

- Add Tier-0 computer objects to the Tier-0-Servers OU
When joining a server to a domain, it will create a computer object that is associated with a server. Add the computer object that is a ‘Tier-0’ to the ‘Tier-0-Servers’ OU.

- Deploy a Privileged Access Workstation and add it to the Tier-0-PAW OU
Ensure that all your Tier-0 admins have a second physical laptop that will be used as a Privileged Access Workstation or also known as a PAW. This is a hardened workstation that will be used to manage AD and AAD.

- Create Tier-0 Deny logon GPO to lower Tiers
Open Group Policy Management Console and create a new GPO with the name ‘Tier-0-Deny-Logon-Rights’

Now edit the GPO and go to ‘User Right Assignments’ and configure the following:
- Deny log on locally: Domain Admins, Enterprise Admins, Schema Admins, Account Operators, Backup Operators, Server Operators, Print Operators, DnsAdmins, Group Policy Creator Owners, Remote Desktop User, Tier-0-Service-Accounts-Groups
- Deny log on through Remote Desktop Services: Domain Admins, Enterprise Admins, Schema Admins, Account Operators, Backup Operators, Server Operators, Print Operators, DnsAdmins, Group Policy Creator Owners, Remote Desktop Users, Tier-0-Service-Accounts-Group
- Deny log on as a service: Domain Admins, Enterprise Admins, Schema Admins, Account Operators, Backup Operators, Server Operators, Print Operators, DnsAdmins, Group Policy Creator Owners, Remote Desktop User, Tier-0-Service-Accounts-Groups
- Deny log on as a batch job: Domain Admins, Enterprise Admins, Schema Admins, Account Operators, Backup Operators, Server Operators, Print Operators, DnsAdmins, Group Policy Creator Owners, Remote Desktop User, Tier-0-Service-Accounts-Groups
If you are curious how that looks like. It looks like the following:

- Link Tier-0-Deny-Logon-Rights GPO to lower Tiers and Client workstations
The GPO that has been created with the name ‘Tier-0-Deny-Logon-Rights’. Assign this GPO now to the OU that contains servers that are Tier-1. Besides of that, assign the GPO to the OU that contains all the client workstations. Here is an example.

- Hardening of Tier-0
We will be using the Windows Security Baseline to harden Tier-0. Go to the following URL: https://www.microsoft.com/en-us/download/details.aspx?id=55319 and download the ZIP files. Since my lab contains Windows Server 2022 and the PAW is Windows 10 20H. I’m going to select the following two packages:

Let’s start with hardening our Tier-0 servers. Extract the ‘Windows Server 2022 Security Baseline.zip’ file and go to the following location: C:\Users\Testing\Downloads\Windows Server 2022 Security Baseline\Windows Server-2022-Security-Baseline-FINAL\Scripts

Run the ‘Baseline-ADImport’ script. It requires the GPMC PowerShell module.

We can now review which GPO we would like to test out and use it as hardening. It is always recommended to test this first on one server before we applying to the rest.
In this example. I have reviewed the ‘MSFT Windows Server 2022 – Domain Controller‘ GPO and it fits my needs, so I’m going to link this GPO to the Domain Controllers OU.

The second GPO that interests me is ‘MSFT Windows Server 2022 – Member Server‘, which is intended for member servers. I’m going to link this GPO to the ‘Tier-0-Servers’ OU.

The last step is to apply the hardening GPO to our Tier-0 PAWs. Extract the ZIP ‘Windows 10 20H Security Baseline‘ ZIP file. Once done, run the Baseline-ADImport.ps1 script. It is preferred to have use Windows Defender on PAWs, so it can use the ASR rules.

In this example, we are going to link the ‘MSFT Windows 10 20H2 – Computer‘ to the Tier-0-PAW OU.

- Tier-0 PAW – URL Exclusions
Only allow a set of URLs to be configured on Tier-0 PAWs. URLs that are excluded from the Firewall contain examples, such as Windows Updates, Log Analytics, perhaps EDR that needs to communicate to the Cloud, etc. Keep this limited. Here are some examples:
Windows Update:
- *.download.windowsupdate.com
- *.update.microsoft.com
- *.windowsupdate.com
- *.windowsupdate.microsoft.com
- *.download.windowsupdate.com
- *.update.microsoft.com
- *.windowsupdate.com
- *.windowsupdate.microsoft.com
- *.dl.delivery.mp.microsoft.com
- *.update.microsoft.com
- *.windowsupdate.microsoft.com
- *.update.microsoft.com
- *.windowsupdate.microsoft.com
Log Analytics:
Outbound connection to 443
- *.ods.opinsights.azure.com
- *.oms.opinsights.azure.com
- *.blob.core.windows.net
- *.azure-automation.net
Azure Portals:
- *.login.microsoftonline.com
- *.aadcdn.msftauth.net
- *.logincdn.msftauth.net
- *.login.live.com
- *.msauth.net
- *.aadcdn.microsoftonline-p.com
- *.microsoftonline-p.com
Azure Portal Framework:
- *.portal.azure.com
- *.hosting.portal.azure.net
- *.reactblade.portal.azure.net
- *.management.azure.com
- *.ext.azure.com
- *.graph.windows.net
- *.graph.microsoft.com
Account Data:
- *.account.microsoft.com
- *.bmx.azure.com
- *.subscriptionrp.trafficmanager.net
- *.signup.azure.com
General Azure Services:
- aka.ms (Microsoft short URL)
- *.asazure.windows.net (Analysis Services)
- *.azconfig.io (AzConfig Service)
- *.aad.azure.com (Azure AD)
- *.aadconnecthealth.azure.com (Azure AD)
- ad.azure.com (Azure AD)
- api.aadrm.com (Azure AD)
- api.loganalytics.io (Log Analytics Service)
- *.applicationinsights.azure.com (Application Insights Service)
- appservice.azure.com (Azure App Services)
- asazure.windows.net (Analysis Services)
- bastion.azure.com (Azure Bastion Service)
- batch.azure.com (Azure Batch Service)
- catalogapi.azure.com (Azure Marketplace)
- changeanalysis.azure.com (Change Analysis)
- cognitiveservices.azure.com (Cognitive Services)
- config.office.com (Microsoft Office)
- cosmos.azure.com (Azure Cosmos DB)
- *.database.windows.net (SQL Server)
- datalake.azure.net (Azure Data Lake Service)
- dev.azure.com (Azure DevOps)
- dev.azuresynapse.net (Azure Synapse)
- digitaltwins.azure.net (Azure Digital Twins)
- docs.microsoft.com (Azure documentation)
- elm.iga.azure.com (Azure AD)
- eventhubs.azure.net (Azure Event Hubs)
- functions.azure.com (Azure Functions)
- gallery.azure.com (Azure Marketplace)
- go.microsoft.com (Microsoft documentation placeholder)
- help.kusto.windows.net (Azure Kusto Cluster Help)
- identitygovernance.azure.com (Azure AD)
- iga.azure.com (Azure AD)
- informationprotection.azure.com (Azure AD)
- kusto.windows.net (Azure Kusto Clusters)
- learn.microsoft.com (Azure documentation)
- logic.azure.com (Logic Apps)
- marketplacedataprovider.azure.com (Azure Marketplace)
- marketplaceemail.azure.com (Azure Marketplace)
- media.azure.net (Azure Media Services)
- monitor.azure.com (Azure Monitor Service)
- mspim.azure.com (Azure AD)
- network.azure.com (Azure Network)
- purview.azure.com (Azure Purview)
- quantum.azure.com (Azure Quantum Service)
- rest.media.azure.net (Azure Media Services)
- search.azure.com (Azure Search)
- servicebus.azure.net (Azure Service Bus)
- servicebus.windows.net (Azure Service Bus)
- shell.azure.com (Azure Command Shell)
- sphere.azure.net (Azure Sphere)
- azure.status.microsoft (Azure Status)
- storage.azure.com (Azure Storage)
- storage.azure.net (Azure Storage)
- vault.azure.net (Azure Key Vault Service)
Deploy LAPS
At this section, we are going to deploy LAPS across the environment. The only recommendation is to NOT deploy LAPS on Domain Controllers. Regarding LAPS on Tier-0 servers.

Full tutorial on deploying LAPS step-by-step can be found here: https://m365internals.com/2021/05/14/how-to-roll-out-microsoft-laps-via-gpo-and-use-it-for-administration/
Create a Tier-0-LAPS Admin group and add your Tier-0 admins to it. This is the group that will be able to read the LAPS password on the Tier-0 systems.

Security Monitoring with Sentinel
Start deploying Sysmon on all Tier-0 and make sure that all the logs are flowing into your SIEM. In this example, we will be using Microsoft Sentinel to do so. Write-up on how to deploy Sysmon can be found here: https://m365internals.com/2021/05/17/how-to-deploy-sysmon-and-mma-agent-to-receive-logs-in-azure-sentinel/

References
- Manage updates and patches for your VMs: https://learn.microsoft.com/en-us/azure/automation/update-management/manage-updates-for-vm
- Tier model for partitioning administrative privileges: https://learn.microsoft.com/en-us/microsoft-identity-manager/pam/tier-model-for-partitioning-administrative-privileges
- Climbing Trees in PowerShell: https://jdhitsolutions.com/blog/active-directory/8173/climbing-trees-in-powershell/
- BackupCertificateAuthortity.ps1: https://github.com/chrisdee/Scripts/blob/master/PowerShell/Working/certificates/BackupCertificateAuthority.ps1
- PingCastle: https://www.pingcastle.com/download/
Thank you for this very detailed blogpost.
LikeLike
Thank you for this very detailed blogpost.
LikeLike
Thank you very much for this walk threw!
LikeLike
This is awesome! One question when you create the Tier-0 OU, I have read it is not advised to move DCs out of the default OU, do you take extra measures?
LikeLike
You are right, keep the DCs in the Domain Controllers OU.
LikeLike