Session Changes — 2025-05-15¶
All changes made during the debugging and memory-hygiene session. Target runtime: PowerShell 7.5.4 / .NET 9 on Windows Server.
Category 1: PS 7.5.4 Fully-Qualified Type Names¶
Problem: PS 7.5.4 uses lazy compilation for dot-sourced files — function
bodies aren't compiled until first invocation. By that point the
using namespace System.DirectoryServices.Protocols directive's scope has
been lost, causing TypeNotFound errors for short type names like
[SearchRequest], [SearchScope], etc.
Fix: Replaced every short SDP type name with its fully-qualified form
and removed the now-unnecessary using namespace directives.
scripts/lib/Phase1-DiscoveryAndMaps.ps1¶
| # | Short Name | Fully-Qualified Name |
|---|---|---|
| 1 | [LdapDirectoryIdentifier] |
[System.DirectoryServices.Protocols.LdapDirectoryIdentifier] |
| 2 | [LdapConnection] |
[System.DirectoryServices.Protocols.LdapConnection] |
| 3 | [AuthType]::Negotiate |
[System.DirectoryServices.Protocols.AuthType]::Negotiate |
| 4 | [SearchRequest] (in Read-LdapEntry) |
[System.DirectoryServices.Protocols.SearchRequest] |
| 5 | [SearchScope]::Subtree (in Read-LdapEntry) |
[System.DirectoryServices.Protocols.SearchScope]::Subtree |
| 6 | [PageResultRequestControl] |
[System.DirectoryServices.Protocols.PageResultRequestControl] |
| 7 | [SearchResponse] |
[System.DirectoryServices.Protocols.SearchResponse] |
| 8 | [PageResultResponseControl] |
[System.DirectoryServices.Protocols.PageResultResponseControl] |
| 9 | [SearchRequest] (in Get-ADNamingContext) |
[System.DirectoryServices.Protocols.SearchRequest] |
| 10 | [SearchScope]::Base (in Get-ADNamingContext) |
[System.DirectoryServices.Protocols.SearchScope]::Base |
| 11 | [SearchResponse] (in Get-ADNamingContext) |
[System.DirectoryServices.Protocols.SearchResponse] |
| 12 | [SearchRequest] (in New-ADExtendedRightsMap) |
[System.DirectoryServices.Protocols.SearchRequest] |
| 13 | [SearchScope] (in New-ADSchemaGuidMap) |
[System.DirectoryServices.Protocols.SearchScope] |
| 14 | [SearchRequest] (in New-ADSchemaGuidMap) |
[System.DirectoryServices.Protocols.SearchRequest] |
- Removed:
using namespace System.DirectoryServices.Protocols(top of file) - Kept:
using namespace System.Collections.Generic
scripts/lib/Phase2-Enumeration.ps1¶
| # | Short Name | Fully-Qualified Name |
|---|---|---|
| 1 | [SecurityDescriptorFlagControl] |
[System.DirectoryServices.Protocols.SecurityDescriptorFlagControl] |
| 2 | [SecurityMasks] |
[System.DirectoryServices.Protocols.SecurityMasks] |
| 3 | [DirectoryControl] (array type) |
[System.DirectoryServices.Protocols.DirectoryControl] |
- Removed:
using namespace System.DirectoryServices.Protocols
scripts/lib/Phase4-TrusteeResolution.ps1¶
| # | Short Name | Fully-Qualified Name |
|---|---|---|
| 1 | [SearchScope]::Base |
[System.DirectoryServices.Protocols.SearchScope]::Base |
- Removed:
using namespace System.DirectoryServices.Protocols
Category 2: LDAP Connection Fixes¶
All changes in scripts/lib/Phase1-DiscoveryAndMaps.ps1, function
Connect-AdLdap.
2a. LdapDirectoryIdentifier connectionless parameter¶
Problem: connectionless: $true creates a UDP-based LDAP connection.
Active Directory requires TCP for paged searches, extended controls, and
binary attribute retrieval.
Fix: Changed connectionless from $true to $false in the
[LdapDirectoryIdentifier]::new() call.
2b. Missing ProtocolVersion = 3¶
Problem: The default protocol version is 2. LDAPv3 is required for paged result controls and security descriptor controls used by Phase 2.
Fix: Added $connection.SessionOptions.ProtocolVersion = 3 after
LdapConnection construction.
2c. Referral chasing causing paging failures¶
Problem: Default referral chasing (All) followed subordinate referrals
to DomainDnsZones and ForestDnsZones application partitions. This
inflated page-1 results (returned 5000 entries instead of the expected
~1000) and corrupted the paging continuation cookie, causing page-2 to
fail with an LdapException.
Fix: Added
$connection.SessionOptions.ReferralChasing = [System.DirectoryServices.Protocols.ReferralChasingOptions]::None
to Connect-AdLdap.
Category 3: SearchRequest Constructor / Attribute Fixes¶
All changes in scripts/lib/Phase1-DiscoveryAndMaps.ps1.
3a. SearchRequest attributes double-wrapping¶
Problem: Passing [string[]] directly to the SearchRequest constructor
caused PowerShell to wrap the array in another array (the constructor's
params string[] overload). This resulted in malformed attribute lists.
Fix: Changed to $null for the constructor's attributes parameter, then
used $request.Attributes.AddRange($attrs) to add attributes after
construction. Applied in both Read-LdapEntry and Get-ADNamingContext.
3b. Attribute value unwrapping in Read-LdapEntry¶
Problem: Attribute values retrieved from SearchResultEntry.Attributes
were not being unwrapped from their DirectoryAttribute containers
correctly, producing unexpected types downstream.
Fix: Added explicit [string[]] and [object[]] casts on direct
assignments inside the if/else branches of the attribute extraction loop
in Read-LdapEntry.
Category 4: Runtime Assembly Loading¶
scripts/Invoke-ADPermissionAnalysis-Core.ps1¶
Problem: using assembly System.DirectoryServices.Protocols at the top
of a script does not work reliably on PS 7.5.4 — the assembly may not be
loaded before dot-sourced lib files reference its types.
Fix: Added Add-Type -AssemblyName System.DirectoryServices.Protocols
before the dot-source block that loads Phase 1–6 lib files. This ensures
the SDP types are available at runtime when the lib functions are first
invoked.
Category 5: Memory Hygiene — Mid-Enumeration Drain¶
Problem: The Phase 2→3 pipeline accumulated ALL submitted runspace work
items in $handles without draining any until enumeration finished. Then
Receive-RunspaceHandle collected ALL parsed ACE records into a single
$aceRecords list. For large domains this caused 8+ GB memory consumption
and process kills.
5a. Removed Item from runspace handle (Phase3-AceParsing.ps1)¶
What: Removed Item = $Item from the [PSCustomObject] returned by
Submit-RunspaceWorkItem.
Why: Every handle was pinning a reference to its input batch (250
objects with binary nTSecurityDescriptor blobs). This data was never read
after submission — Receive-RunspaceHandle does not access $h.Item —
but the reference prevented GC from collecting the batch data after the
runspace finished processing it.
Updated the function's docstring to reflect the new output shape
({PowerShell, Handle, Index, Metadata} instead of
{PowerShell, Handle, Item, Index, Metadata}).
5b. Pre-initialized $aceRecords and tracking variables (Core.ps1)¶
What: Moved $aceRecords = [List[PSObject]]::new() before the
enumeration loop. Added $totalBatches = 0 counter and
$drainThreshold = [Math]::Max($ThreadCount * 2, 8).
Why: Allows incremental collection of parsed ACE records during enumeration instead of a single bulk allocation after. The threshold caps how many in-flight PowerShell pipelines exist simultaneously.
5c. Mid-enumeration drain loop (Core.ps1)¶
What: After each batch submit, when $handles.Count -ge $drainThreshold,
scans $handles backwards for completed handles
($h.Handle.IsCompleted). For each completed handle: calls EndInvoke,
appends results to $aceRecords, logs errors to $script:ErrorBag,
disposes the PowerShell pipeline, and removes the handle from the list.
Why: Caps the number of live [powershell] instances to roughly
2× ThreadCount. Frees pipeline memory and internal buffers as runspaces
complete, rather than holding hundreds of disposed-but-referenced pipeline
objects until the end.
5d. Final drain replacement (Core.ps1)¶
What: Replaced the single $aceRecords = Receive-RunspaceHandle ...
call with a conditional block: if $handles.Count -gt 0, calls
Receive-RunspaceHandle for remaining in-flight handles and appends
results to the existing $aceRecords list, then clears $handles.
Why: After mid-enumeration drains, only a few handles remain in-flight.
The final drain collects these stragglers and appends to the already-
populated $aceRecords rather than creating a new list.
5e. Fixed batchCount in Phase 3 end log (Core.ps1)¶
What: Changed batchCount = $handles.Count to
batchCount = $totalBatches in the Phase 3 PhaseEnd log event.
Why: After draining, $handles is empty — the count would always be 0.
$totalBatches tracks the true number of batches submitted.
5f. Enriched progress logging (Core.ps1)¶
What: Added pendingDrains = $handles.Count and
acesCollected = $aceRecords.Count to the Phase 2 progress log data.
Updated the Write-Progress CurrentOperation string to include pending
handle count and ACE count.
Why: Provides visibility into memory pressure during long-running enumeration passes.