adPEAS v2 Episode 2: Under the Hood - Anatomy of a Scan
What happens when adPEAS scans an Active Directory? From authentication and LDAP queries to context-dependent severity ratings and caching -- a look under the hood.
What Actually Happens After You Hit Enter?
Episode 1 showed how to launch adPEAS. Invoke-adPEAS -Domain contoso.com — Enter — and then a whole lot happens. But what exactly? This episode covers what goes on under the hood when adPEAS performs a scan.
Don’t worry, this won’t get too technical. But having a basic understanding of the internal workflow is enormously helpful for troubleshooting when something doesn’t work as expected. And that will happen — Active Directory wouldn’t be Active Directory if something unexpected didn’t come up eventually.
Phase 1: The Connection
Everything starts with authentication. Depending on the parameters provided, adPEAS chooses the appropriate path:
+--------------------------------------------------------------------+
| Connect-adPEAS |
| (The Club's Bouncer) |
+---------------------------------+----------------------------------+
|
+--------------+-------------+-------------+--------------+
| | | | |
v v v v v
+----------+ +----------+ +-----------+ +------------+ +---------+
| Windows | | Kerberos | | NTLM | | Simple | | Ticket |
| Auth | | Native | | Imperson. | | Bind | | Import |
| (Current | | (TGT->TGS| | (runas | | (Username | | (Kirbi/ |
| User) | | ->PTT) | | /netonly) | | +Password) | | Ccache) |
+----------+ +----------+ +-----------+ +------------+ +---------+
Fallback with credentials: Kerberos -> NTLM Impersonation -> SimpleBind
The most common case is probably Windows Auth — logging into the domain with the current account. That’s straightforward: adPEAS uses the Windows credential cache of the current session.
Things get more interesting with explicit credentials. In that case, adPEAS first tries native Kerberos:
- Get a TGT — Request a Ticket Granting Ticket from the KDC
- Get a TGS — Use the TGT to request a Service Ticket for LDAP
- PTT — Import the ticket into the Windows session (Pass-the-Ticket)
- Connect — Establish the LDAP connection, Kerberos is used automatically
Why all this effort? Because Kerberos is the standard in enterprise environments. Many DCs have SimpleBind disabled or only allow it over LDAPS. With Kerberos, you’re on the safe side.
If Kerberos doesn’t work — port 88 blocked, DNS issues, etc. — adPEAS automatically falls back to NTLM Impersonation (supports LDAP Signing). If that also fails, SimpleBind is attempted. A warning is displayed, but the scan continues regardless. (Note: PTT does not require admin privileges — adPEAS uses LsaConnectUntrusted.)
Phase 2: The LDAPContext Object
Once the connection is established, adPEAS gathers basic information about the domain. This is stored in $Script:LDAPContext — the notebook that adPEAS uses throughout the entire scan:
$Script:LDAPContext = @{
Domain = "contoso.com"
DomainDN = "DC=contoso,DC=com"
DomainSID = "S-1-5-21-3623811015-..."
Server = "DC01.contoso.com"
DefaultNamingContext = "DC=contoso,DC=com"
ConfigurationDN = "CN=Configuration,DC=contoso,DC=com"
SchemaNamingContext = "CN=Schema,CN=Configuration,DC=contoso,DC=com"
RootDomainNamingContext = "DC=contoso,DC=com"
# ... and quite a bit more (Protocol, Port, AuthMethod, etc.)
}
Why does this matter? Because these values are needed everywhere. When a check module wants to know which SIDs belong to “Domain Admins,” it needs to know <DomainSID>-512. For ACL analysis, the Schema DN is needed to look up attribute GUIDs. And so on.
The LDAPContext object is adPEAS’s memory for the current session. Once populated, it’s shared across all modules.
Phase 3: The LDAP Engine
Now it gets technically interesting. At the heart of adPEAS sits Invoke-LDAPSearch — a single function that every LDAP query flows through. Whether it’s users, computers, or ACLs — everything goes through this channel:
# A typical call looks like this (simplified):
$result = Invoke-LDAPSearch -Filter "(objectClass=user)" -Properties "sAMAccountName","memberOf"
Why a centralized function instead of direct LDAP calls scattered throughout the code?
Error handling in one place: If the connection drops or a timeout occurs, it only needs to be handled in one spot. Every module benefits automatically.
Paging: LDAP has a limit on how many results are returned per request. In large domains with thousands of objects, paging is required — meaning multiple requests are made and the results are stitched together. Invoke-LDAPSearch handles this automatically.
Logging: With -Verbose enabled, every single LDAP filter that gets executed is displayed. Very helpful for debugging.
# With Verbose:
Invoke-adPEAS -Domain "contoso.com" -Verbose
# Output (truncated):
# VERBOSE: [Invoke-LDAPSearch] Filter: (objectClass=user)
# VERBOSE: [Invoke-LDAPSearch] Returned 1523 results
# VERBOSE: [Invoke-LDAPSearch] Filter: (objectClass=group)
# VERBOSE: [Invoke-LDAPSearch] Returned 342 results
Phase 4: The Check Modules
Now comes the actual scan. adPEAS has over 40 check modules that are executed sequentially (or in groups). Each module is responsible for a specific aspect of AD security.
The workflow is always the same:
+------------------------------------------------------------+
| Check Module |
| (e.g. Get-KerberoastableAccounts) |
+-----------------------------+------------------------------+
|
+---------------------+---------------------+
v v v
+------------+ +----------+ +------------+
| 1. Ensure | | 2. Query | | 3. Analyze |
| Connection | | Data | | & Return |
+------------+ +----------+ +------------+
Step 1: Ensure Connection
Every check module starts with the same code:
if (-not (Ensure-LDAPConnection @PSBoundParameters)) {
return $null
}
This verifies whether a valid session exists. If not, one is automatically established (when credentials were provided for a standalone call) or the check is skipped.
Step 2: Query Data
Now the module retrieves the required data via the Get-Domain* functions from the core modules:
# Example: Finding Kerberoastable accounts
$users = Get-DomainUser -LDAPFilter "(servicePrincipalName=*)"
Step 3: Analyze and Return
The retrieved data is analyzed and enriched with severity information:
foreach ($user in $users) {
# Determine severity
$user | Add-Member -NotePropertyName '_adPEASObjectType' -NotePropertyValue 'Kerberoastable' -Force
# Pass to reporting pipeline
Show-Object -Object $user
}
The Severity System
Every finding that adPEAS discovers gets a severity rating. This sounds simple at first, but it’s not — the rating is context-dependent.
The base categories:
| Severity | Symbol | Color | Meaning |
|---|---|---|---|
| Finding | [!] | Red | Security issue, action required |
| Hint | [+] | Yellow | Interesting, should be investigated |
| Note | [*] | Green | Information, no risk |
| Secure | [#] | Red on Yellow | Good configuration |
What makes this special is the context-dependent rating. Example: “Password Never Expires”:
# For a regular user: Hint (yellow)
# Interesting, but not a major risk
# For a Domain Admin: Finding (red)
# This is a real risk!
# For a gMSA (Managed Service Account): Note (green)
# This is normal and expected for gMSAs
Technically, this works through the Get-AttributeSeverity function, which evaluates multiple factors:
- Which attribute? — Some attributes are inherently more critical than others
- What value? — Some values are more problematic than others
- What context? — Is the account privileged? Which groups? What type?
# Simplified example:
function Get-AttributeSeverity {
param($Attribute, $Value, $Object)
# Check if the account is privileged
$isPrivileged = Test-IsPrivileged -Object $Object
if ($Attribute -eq 'PasswordNeverExpires' -and $Value -eq $true) {
if ($isPrivileged) {
return "Finding" # Red for Domain Admins
}
return "Hint" # Yellow for regular users
}
# ... further logic
}
SID-Based Checks
An important aspect that shouldn’t be overlooked: adPEAS performs all permission checks based on SIDs, not names.
Why? Because Active Directory is used internationally. The group “Domain Admins” is called “Domänen-Admins” on a German DC and something entirely different in other languages. Name-based checks would fail on localized systems. That’s why adPEAS checks the SID:
# WRONG - Only works on English systems:
if ($group.Name -eq "Domain Admins") { ... }
# RIGHT - Works everywhere:
if ($group.SID -match "-512$") { ... } # Domain Admins always have SID *-512
Phase 5: Reporting
At the end of a scan (or in parallel), all findings are collected and formatted. Internally, adPEAS uses a Unified RenderModel Pipeline: when a check calls Show-Object $user, Get-RenderModel builds a renderer-agnostic data model — severity classification, attribute ordering, and transformer logic are determined once. From there, Render-ConsoleObject (ANSI colors) and Render-HtmlObject (HTML with tooltips) each produce their respective output. Details on this in Episode 5: Output & Reports.
Depending on the selected output options:
Console Output (Default)
Results are displayed directly in the console — color-coded and structured. Ideal for quick checks when you need to see at a glance what’s going on.
HTML Report
Using -Outputfile, an interactive HTML report is generated with:
- Security Score (overall rating)
- Finding Cards (expandable details)
- Tooltips with explanations and remediation steps
- Search and filter functionality
- Dark mode
The Caching System
Caching deserves its own section — though it’s important to understand what adPEAS caches and what it doesn’t.
What is NOT cached: LDAP query results. Every call to Get-DomainUser, Get-DomainComputer, or Get-DomainGroup goes fresh to the DC. If two different check modules both call Get-DomainUser -LDAPFilter "(servicePrincipalName=*)", two separate LDAP queries are actually made. This is intentional — the checks always work with current data.
What IS cached: The expensive side operations that occur during analysis. First and foremost, SID-to-name resolution. ConvertFrom-SID has its own cache:
$Script:SIDResolutionCache = @{
'S-1-5-21-3623811015-...-512' = 'CONTOSO\Domain Admins'
'S-1-5-21-3623811015-...-519' = 'CONTOSO\Enterprise Admins'
# ... populated during the scan
}
In large domains with thousands of ACLs, without caching adPEAS would have to resolve the same SID hundreds of times. With caching, it happens once and the result is stored.
Beyond the SID cache, there are several others:
- Global Catalog Connection (
$Script:GCConnection) — When adPEAS encounters SIDs from foreign domains in the forest during trust analysis or ACL checks,ConvertFrom-SIDautomatically establishes a connection to the Global Catalog (port 3268, or 3269 for LDAPS). This connection is cached for the entire session and cleaned up duringDisconnect-adPEAS. If the GC is unreachable, adPEAS falls back to a RID-based format (e.g.,FOREIGN\DomainAdmins (RID:512)). - Privileged-Check Cache — Remembers whether a SID belongs to a privileged group. Since the privilege check resolves recursive group memberships, this saves a significant number of queries.
- Group-Membership Cache — Stores recursive group memberships resolved via the LDAP Matching Rule filter.
- DNS Cache — Hostnames to IP addresses, so DNS isn’t queried on every operation.
The result: significantly faster scans, less load on the DC — and still always current data for the actual LDAP queries.
Troubleshooting: What to Do When Things Get Stuck
To wrap up, here are some tips in case the scan doesn’t run as expected:
“No LDAP connection available”
The connection to the DC isn’t possible. Things to check:
- Is port 389 (LDAP) or 636 (LDAPS) reachable?
- Is the domain name correct?
- Are the credentials valid?
# Quick test:
Test-NetConnection dc01.contoso.com -Port 389
“The server is not operational”
A classic. The DC isn’t responding or DNS resolution isn’t working.
# Check DNS:
Resolve-DnsName contoso.com
Resolve-DnsName _ldap._tcp.dc._msdcs.contoso.com -Type SRV
Kerberos isn’t working
If native Kerberos auth doesn’t work, adPEAS first tries NTLM Impersonation. If that also fails, SimpleBind is attempted. Possible reasons for failure:
- Port 88 (Kerberos) is blocked
- Restrictive security policies (LSA access restricted)
- DNS issues with KDC resolution
Use -ForceSimpleBind to skip Kerberos and NTLM, or -ForceNTLM to force NTLM Impersonation:
# SimpleBind - send credentials directly to the DC
Connect-adPEAS -Domain "contoso.com" -Credential $cred -ForceSimpleBind
# NTLM Impersonation - similar to "runas /netonly", preserves existing Kerberos tickets
Connect-adPEAS -Domain "contoso.com" -Credential $cred -ForceNTLM
The scan is slow
With very large domains (100k+ objects), a scan can take a while. Some measures:
- Run only specific modules:
-Module Domain,Accounts - OPSEC mode (fewer queries):
-OPSEC - Specify a DC instead of auto-discovery:
-Server DC01.contoso.com
Summary
Here’s a recap of the phases:
- Connection — Authentication and session setup
- Context — Gathering domain information
- Checks — Modules perform their analyses
- Severity — Context-dependent rating of findings
- Reporting — Formatting and output
Understanding these phases helps with troubleshooting and with properly interpreting the results.
In the upcoming episodes, we’ll dive deep into authentication. We’ll look at how adPEAS implements Kerberos natively, what Pass-the-Hash and Pass-the-Key mean, and how certificate-based authentication works via PKINIT.
← Episode 1: Introduction | Episode 3: Authentication — coming soon
About the Author
Related Articles
adPEAS v2 Blog Series: Active Directory Security Analysis with adPEAS
Introducing adPEAS v2 — a complete rewrite of the PowerShell-based Active Directory analysis tool with native Kerberos support, zero dependencies, and over 40 security checks.