Type to search 66 articles.

    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

    • Active Directory
    • PowerShell
    • Security assessment

    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