RSAT isn’t always available and importing PowerView raises flags. This post covers native PowerShell techniques for enumerating Active Directory — users, groups, SPNs, and trust relationships — using only built-in .NET classes and LDAP queries.

Why Native AD Enumeration

The standard toolchain for AD enumeration is well-known: BloodHound, PowerView, SharpHound. That’s also why these tools are signatures in most EDRs. When you need to be quiet, native .NET LDAP queries through System.DirectoryServices are far less likely to trigger.

None of this is novel technique. The goal is a clean, minimal reference for common queries.

Connecting to LDAP

The DirectoryEntry and DirectorySearcher classes are in the GAC on every Windows machine. No imports needed.

# Connect to domain using current user's credentials
function Get-LDAPConnection {
    param([string]$Domain = $env:USERDNSDOMAIN)

    $dn = "DC=" + ($Domain -replace "\.", ",DC=")
    $entry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$dn")
    $searcher = New-Object System.DirectoryServices.DirectorySearcher($entry)
    $searcher.PageSize = 1000
    $searcher.SizeLimit = 0

    return $searcher
}

Enumerating Domain Users

function Get-DomainUsers {
    param(
        [string]$Domain      = $env:USERDNSDOMAIN,
        [switch]$Enabled,
        [switch]$AdminsOnly
    )

    $searcher = Get-LDAPConnection -Domain $Domain
    $searcher.Filter = "(&(samAccountType=805306368))"

    if ($Enabled)    { $searcher.Filter = "(&(samAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" }
    if ($AdminsOnly) { $searcher.Filter = "(&(samAccountType=805306368)(adminCount=1))" }

    $searcher.PropertiesToLoad.AddRange(@(
        "samAccountName", "displayName", "mail",
        "userAccountControl", "lastLogonTimestamp",
        "memberOf", "description"
    ))

    $results = $searcher.FindAll()

    foreach ($result in $results) {
        $props = $result.Properties
        [pscustomobject]@{
            Username    = $props["samaccountname"][0]
            DisplayName = if ($props["displayname"].Count)  { $props["displayname"][0]  } else { "" }
            Email       = if ($props["mail"].Count)         { $props["mail"][0]         } else { "" }
            Description = if ($props["description"].Count)  { $props["description"][0]  } else { "" }
            UAC         = if ($props["useraccountcontrol"].Count) { $props["useraccountcontrol"][0] } else { 0 }
            LastLogon   = if ($props["lastlogontimestamp"].Count) {
                [datetime]::FromFileTime($props["lastlogontimestamp"][0])
            } else { $null }
        }
    }
}

Usage:

# All enabled users
Get-DomainUsers -Enabled | Format-Table Username, DisplayName, LastLogon

# Users with adminCount=1 (directly or indirectly in privileged groups)
Get-DomainUsers -AdminsOnly | Select-Object Username, Description

Enumerating Service Principal Names

SPNs are the first step toward Kerberoasting. Any authenticated user can query them.

function Get-Kerberoastable {
    param([string]$Domain = $env:USERDNSDOMAIN)

    $searcher = Get-LDAPConnection -Domain $Domain
    # Users with SPNs that aren't krbtgt or machine accounts
    $searcher.Filter = "(&(samAccountType=805306368)(servicePrincipalName=*)(!(samAccountName=krbtgt)))"
    $searcher.PropertiesToLoad.AddRange(@("samAccountName", "servicePrincipalName", "memberOf", "pwdLastSet"))

    $searcher.FindAll() | ForEach-Object {
        $props = $_.Properties
        $props["serviceprincipalname"] | ForEach-Object {
            [pscustomobject]@{
                Username   = $props["samaccountname"][0]
                SPN        = $_
                PwdLastSet = if ($props["pwdlastset"].Count) {
                    [datetime]::FromFileTime($props["pwdlastset"][0])
                } else { $null }
            }
        }
    }
}
Get-Kerberoastable | Sort-Object PwdLastSet | Format-Table -AutoSize

Enumerating Group Membership

function Get-GroupMembers {
    param(
        [Parameter(Mandatory)]
        [string]$GroupName,
        [string]$Domain = $env:USERDNSDOMAIN,
        [switch]$Recurse
    )

    $searcher = Get-LDAPConnection -Domain $Domain
    $searcher.Filter = "(&(objectClass=group)(samAccountName=$GroupName))"
    $searcher.PropertiesToLoad.AddRange(@("member", "distinguishedName"))

    $group = $searcher.FindOne()
    if (-not $group) {
        Write-Warning "Group '$GroupName' not found"
        return
    }

    $members = $group.Properties["member"]
    $output  = @()

    foreach ($memberDN in $members) {
        $memberSearcher = Get-LDAPConnection -Domain $Domain
        $memberSearcher.Filter = "(distinguishedName=$memberDN)"
        $memberSearcher.PropertiesToLoad.AddRange(@("samAccountName", "objectClass", "distinguishedName"))

        $memberObj = $memberSearcher.FindOne()
        if (-not $memberObj) { continue }

        $mp = $memberObj.Properties
        $objType = $mp["objectclass"] | Select-Object -Last 1

        $entry = [pscustomobject]@{
            Name  = $mp["samaccountname"][0]
            Type  = $objType
            DN    = $mp["distinguishedname"][0]
        }
        $output += $entry

        # Recursively expand nested groups
        if ($Recurse -and $objType -eq "group") {
            $output += Get-GroupMembers -GroupName $mp["samaccountname"][0] -Domain $Domain -Recurse
        }
    }

    $output | Sort-Object Type, Name -Unique
}
# Direct members of Domain Admins
Get-GroupMembers -GroupName "Domain Admins"

# All members including nested groups
Get-GroupMembers -GroupName "Domain Admins" -Recurse | Format-Table Name, Type

Domain Trust Enumeration

function Get-DomainTrusts {
    param([string]$Domain = $env:USERDNSDOMAIN)

    $searcher = Get-LDAPConnection -Domain $Domain
    $searcher.SearchRoot.Path = "LDAP://CN=System," + (
        "DC=" + ($Domain -replace "\.", ",DC=")
    )
    $searcher.Filter = "(objectClass=trustedDomain)"
    $searcher.PropertiesToLoad.AddRange(@(
        "name", "trustDirection", "trustType", "trustAttributes"
    ))

    $directionMap = @{ 1 = "Inbound"; 2 = "Outbound"; 3 = "Bidirectional" }
    $typeMap      = @{ 1 = "Downlevel"; 2 = "Uplevel"; 3 = "MIT"; 4 = "DCE" }

    $searcher.FindAll() | ForEach-Object {
        $p = $_.Properties
        [pscustomobject]@{
            TrustedDomain = $p["name"][0]
            Direction     = $directionMap[[int]$p["trustdirection"][0]]
            Type          = $typeMap[[int]$p["trusttype"][0]]
            Attributes    = $p["trustattributes"][0]
        }
    }
}

Putting It All Together — Quick Recon Script

$domain = $env:USERDNSDOMAIN
$ts     = Get-Date -Format "yyyyMMdd_HHmmss"
$outDir = "$env:TEMP\adrecon_$ts"
New-Item -ItemType Directory -Path $outDir -Force | Out-Null

Write-Host "[*] Domain: $domain"
Write-Host "[*] Output: $outDir"

# Admins
Get-DomainUsers -AdminsOnly |
    Export-Csv "$outDir\admin_users.csv" -NoTypeInformation
Write-Host "[+] Admin users written"

# Kerberoastable accounts
Get-Kerberoastable |
    Export-Csv "$outDir\kerberoastable.csv" -NoTypeInformation
Write-Host "[+] Kerberoastable accounts written"

# DA members
Get-GroupMembers -GroupName "Domain Admins" -Recurse |
    Export-Csv "$outDir\domain_admins.csv" -NoTypeInformation
Write-Host "[+] Domain Admin members written"

# Trusts
Get-DomainTrusts |
    Export-Csv "$outDir\trusts.csv" -NoTypeInformation
Write-Host "[+] Trust relationships written"

Write-Host "`n[+] Done. Review files in $outDir"

Opsec Notes

  • These queries go over LDAP (port 389) or LDAPS (636) to a domain controller. They look like normal Windows traffic but generate event IDs 4662 (directory service access) on DCs with advanced auditing enabled.
  • PageSize = 1000 issues multiple requests for large directories. If you need to be stealthier, reduce page size and add Start-Sleep between pages.
  • The .NET classes are loaded in-process in your PowerShell runspace — no child processes, no files on disk.

None of this replaces BloodHound for visualizing the full attack path graph. But for a quick, quiet check of the basics, native LDAP queries keep the noise down.