Practical engineering guidance
Auditing Active Directory with PowerShell, without writing to it
A read-only assessment that collects privileged membership, delegation, stale accounts and password policy exposure — and changes nothing, deliberately.
Series: PowerShell toolkit
The first thing I want from an unfamiliar Active Directory environment is evidence, and the second thing is confidence that collecting it changed nothing. Those two requirements shape everything below: every command here reads, none writes, and the output is a set of files somebody can review before any decision is made.
A directory assessment that modifies the directory is not an assessment.
The data model: what actually matters
Five categories, in the order I would collect and read them.
Privileged membership, including nesting. Who holds directory-wide control, directly or through a chain of groups nobody has looked at.
Delegation configuration. Accounts trusted for delegation, per Kerberos delegation.
Credential exposure. Accounts with passwords that never expire, accounts that can authenticate without Kerberos pre-authentication, accounts with reversible encryption, and accounts with very old passwords.
Stale objects. Dormant users and computers, with the attribute caveats from retiring stale Active Directory accounts.
Structural facts. Functional levels, domain controllers, trusts, password policy, and whether the Recycle Bin is enabled.
The script
<#
.SYNOPSIS
Read-only Active Directory security assessment.
.DESCRIPTION
Collects privileged membership, delegation, credential exposure, stale objects
and structural facts to CSV for review. Makes no changes to the directory.
.EXAMPLE
.\Get-ADAssessment.ps1 -OutputFolder 'C:\Assessment'
.NOTES
Output contains account names and directory structure. Treat as sensitive.
LastLogonDate is replicated but imprecise; unsuitable for windows under 30 days.
#>
#requires -Modules ActiveDirectory
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -Path $_ -PathType Container })]
[string]$OutputFolder,
[ValidateRange(30, 730)]
[int]$InactiveDays = 90
)
$ErrorActionPreference = 'Stop'
$stamp = Get-Date -Format 'yyyyMMdd'
$save = {
param($Data, $Name)
$path = Join-Path $OutputFolder "$stamp-$Name.csv"
$Data | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8
Write-Verbose "Wrote $path"
}
# --- 1. Privileged membership, including nested ------------------------------
$privilegedGroups = @(
'Domain Admins', 'Enterprise Admins', 'Schema Admins', 'Administrators',
'Account Operators', 'Backup Operators', 'Server Operators',
'Print Operators', 'Group Policy Creator Owners', 'DnsAdmins'
)
$privileged = foreach ($groupName in $privilegedGroups) {
try {
$group = Get-ADGroup -Identity $groupName -ErrorAction Stop
}
catch {
Write-Warning "Group not found or not readable: $groupName"
continue
}
# -Recursive resolves nesting, which is where the surprises live.
Get-ADGroupMember -Identity $group -Recursive -ErrorAction SilentlyContinue |
ForEach-Object {
[pscustomobject]@{
Group = $groupName
Member = $_.SamAccountName
MemberClass = $_.objectClass
DistinguishedName = $_.DistinguishedName
}
}
}
& $save $privileged 'privileged-membership'
# --- 2. Delegation -----------------------------------------------------------
$unconstrained = Get-ADObject -LDAPFilter '(userAccountControl:1.2.840.113556.1.4.803:=524288)' `
-Properties 'samAccountName', 'objectClass' |
Select-Object Name, samAccountName, objectClass, DistinguishedName
& $save $unconstrained 'delegation-unconstrained'
$constrained = Get-ADObject -LDAPFilter '(msDS-AllowedToDelegateTo=*)' `
-Properties 'msDS-AllowedToDelegateTo', 'TrustedToAuthForDelegation' |
ForEach-Object {
[pscustomobject]@{
Name = $_.Name
ProtocolTransition = $_.TrustedToAuthForDelegation
DelegatesTo = ($_.'msDS-AllowedToDelegateTo') -join '; '
DistinguishedName = $_.DistinguishedName
}
}
& $save $constrained 'delegation-constrained'
# --- 3. Credential exposure --------------------------------------------------
$credentialProps = @(
'PasswordNeverExpires', 'PasswordLastSet', 'DoesNotRequirePreAuth',
'AllowReversiblePasswordEncryption', 'ServicePrincipalName',
'AdminCount', 'Enabled', 'LastLogonDate'
)
$exposure = Get-ADUser -Filter 'Enabled -eq $true' -Properties $credentialProps |
Where-Object {
$_.PasswordNeverExpires -or
$_.DoesNotRequirePreAuth -or
$_.AllowReversiblePasswordEncryption -or
$_.ServicePrincipalName
} |
Select-Object SamAccountName, Enabled, PasswordNeverExpires, DoesNotRequirePreAuth,
AllowReversiblePasswordEncryption, AdminCount, PasswordLastSet, LastLogonDate,
@{ Name = 'HasSPN'; Expression = { [bool]$_.ServicePrincipalName } }
& $save $exposure 'credential-exposure'
# --- 4. Stale objects --------------------------------------------------------
$threshold = (Get-Date).AddDays(-$InactiveDays)
$staleUsers = Get-ADUser -Filter 'Enabled -eq $true' `
-Properties LastLogonDate, PasswordLastSet, whenCreated |
Where-Object { $_.LastLogonDate -lt $threshold -and $_.whenCreated -lt $threshold } |
Select-Object SamAccountName, LastLogonDate, PasswordLastSet, whenCreated
& $save $staleUsers 'stale-users'
$staleComputers = Get-ADComputer -Filter 'Enabled -eq $true' `
-Properties LastLogonDate, OperatingSystem, whenCreated |
Where-Object { $_.LastLogonDate -lt (Get-Date).AddDays(-180) } |
Select-Object Name, OperatingSystem, LastLogonDate, whenCreated
& $save $staleComputers 'stale-computers'
# --- 5. Structural facts -----------------------------------------------------
$forest = Get-ADForest
$domain = Get-ADDomain
$structure = [pscustomobject]@{
Forest = $forest.Name
ForestFunctionalLevel = $forest.ForestMode
DomainFunctionalLevel = $domain.DomainMode
Domains = $forest.Domains -join '; '
RecycleBinEnabled = [bool](Get-ADOptionalFeature -Filter "Name -eq 'Recycle Bin Feature'").EnabledScopes
SchemaMaster = $forest.SchemaMaster
DomainNamingMaster = $forest.DomainNamingMaster
PDCEmulator = $domain.PDCEmulator
RIDMaster = $domain.RIDMaster
InfrastructureMaster = $domain.InfrastructureMaster
}
& $save @($structure) 'structure'
& $save (Get-ADDefaultDomainPasswordPolicy) 'password-policy'
& $save (Get-ADDomainController -Filter * |
Select-Object Name, Site, IPv4Address, OperatingSystem, IsGlobalCatalog, IsReadOnly) 'domain-controllers'
& $save (Get-ADTrust -Filter * |
Select-Object Name, Direction, TrustType, SelectiveAuthentication, SIDFilteringQuarantined) 'trusts'
Write-Verbose 'Assessment complete.'
Interpreting the output
Read the files in this order, because each informs the next.
privileged-membership first. Count the members of Domain Admins and Enterprise Admins.
The number is usually larger than anyone claims. Look specifically for computer objects and
service accounts, which should almost never be there, and for members arriving through
nesting — -Recursive is what surfaces those, and they are the ones nobody knows about.
delegation-unconstrained second. Domain controllers appearing here is expected. Anything
else is a finding, and a serious one.
delegation-constrained, checking the ProtocolTransition column. True means the account
can obtain tickets for users who never authenticated to it.
credential-exposure. DoesNotRequirePreAuth is directly exploitable for offline password
attack and should be empty. AllowReversiblePasswordEncryption should be empty.
PasswordNeverExpires combined with AdminCount of 1 is a privileged account with a static
password, which is among the worst combinations in the file. An account with a service
principal name and an old password is exposed to offline ticket cracking.
stale-users and stale-computers, read with the attribute caveats in mind.
structure. RecycleBinEnabled false is a finding, because it makes every deletion
recovery harder. Functional levels below current constrain the features available, including
the authentication policies discussed in
administrative tiering.
trusts, checking SIDFilteringQuarantined and SelectiveAuthentication. A trust without
appropriate filtering is a path in from another forest.
Extension
Run it on a schedule and compare. The first run tells you the state; the differences between runs tell you what is changing, and that is considerably more useful — a new member of Domain Admins appearing between two runs is a question with a specific answer.
Add a comparison step that diffs the privileged membership file against the previous run and reports additions. That single addition turns an assessment into a detection.
What I would not add is remediation. The value of this tool is that anyone can run it on any directory without approval and without risk, and the moment it can change something, that stops being true.
Handling the output
Every file produced contains account names, group membership, organisational structure and domain controller names. That is exactly the reconnaissance an attacker would want.
- Write it to a location with restricted access.
- Do not attach it to a ticket, a vendor portal or a public issue.
- Agree retention with the directory owner and delete it afterwards.
- If you must share findings, share counts and descriptions, not the raw export.
Verification and limits
The cmdlets, the LDAP filters — including the userAccountControl bit filter for unconstrained
delegation — the attribute names and the recursive group membership behaviour were checked
against current Microsoft documentation on 20 September 2026.
The script was not executed against a directory for this article, and it should be run against
a test forest before an unfamiliar production one. Every command reads; none writes. It does
require sufficient read access, and Get-ADGroupMember -Recursive can be slow on very large
nested groups. LastLogonDate is replicated but imprecise and is unsuitable for windows under
about thirty days. Attribute availability varies with domain functional level.
References
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
