PowerShell Active Directory Enumeration Without RSAT
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 = 1000issues multiple requests for large directories. If you need to be stealthier, reduce page size and addStart-Sleepbetween pages.- The
.NETclasses 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.