Private/Expand-SafeUsers.ps1

function Expand-SafeUsers {
    <#
        .SYNOPSIS
        Expands the SafeUsers pattern by recursively enumerating members of well-known privileged security groups.
 
        .DESCRIPTION
        This function takes a base SafeUsers pattern and expands it by recursively enumerating the members of
        well-known privileged security groups across all domains in the forest. It builds a comprehensive
        SafeUsers regex pattern that includes both the base well-known SIDs and all individual members
        of privileged groups.
 
        .PARAMETER BaseSafeUsers
        The base SafeUsers regex pattern containing well-known SIDs for privileged groups.
        If not provided, uses the standard Locksmith pattern.
 
        .PARAMETER AdcsObjects
        Array of DirectoryEntry objects from Get-AdcsObjects. Used to determine forest and domain context.
        If not provided, the function will attempt to discover the forest automatically.
 
        .INPUTS
        System.DirectoryServices.DirectoryEntry[]
 
        .OUTPUTS
        System.String
        Returns an expanded SafeUsers regex pattern that includes both base SIDs and recursively enumerated group members.
 
        .EXAMPLE
        $AdcsObjects = Get-AdcsObjects
        $ExpandedSafeUsers = Expand-SafeUsers -AdcsObjects $AdcsObjects
         
        Expands SafeUsers to include all members of privileged groups across the forest.
 
        .EXAMPLE
        $BaseSafeUsers = '-512$|-519$|-544$|-18$|-517$|-500$|-516$|-521$|-498$|-9$|-526$|-527$|S-1-5-10'
        $ExpandedSafeUsers = Expand-SafeUsers -BaseSafeUsers $BaseSafeUsers -AdcsObjects $AdcsObjects
         
        Uses a custom base SafeUsers pattern and expands it with group memberships.
 
        .EXAMPLE
        # Use the expanded SafeUsers with other ESCalator functions
        $ExpandedSafeUsers = Expand-SafeUsers -AdcsObjects $AdcsObjects
        $ESC4Issues = Find-ESC4Issue -AdcsObjects $AdcsObjects -SafeOwners $ExpandedSafeUsers
 
        .NOTES
        This function performs Active Directory queries using DirectoryEntry objects and may take some time to complete in large environments.
        Does not require the ActiveDirectory PowerShell module - uses pure DirectoryEntry approach for maximum compatibility.
 
        .LINK
        https://posts.specterops.io/certified-pre-owned-d95910965cd2
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [string]$BaseSafeUsers = '-512$|-519$|-544$|-18$|-517$|-500$|-516$|-521$|-498$|-9$|-526$|-527$|S-1-5-10',
        
        [Parameter(ValueFromPipeline)]
        [System.DirectoryServices.DirectoryEntry[]]$AdcsObjects
    )


    begin {
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd hh:mm:ss')] Starting $($MyInvocation.MyCommand) on $env:COMPUTERNAME..."
        
        # Initialize the SafeUsers with the base pattern
        $SafeUsers = $BaseSafeUsers
        Write-Verbose "Base SafeUsers pattern: $BaseSafeUsers"
        
        # Initialize collections for tracking
        $ProcessedSIDs = @{}
        $ErrorSIDs = @{}
        
        # Get forest context
        $Forest = $null
        $ForestDomains = @()
        
        try {
            if ($AdcsObjects -and $AdcsObjects.Count -gt 0) {
                # Extract forest information from AdcsObjects
                $sampleObject = $AdcsObjects[0]
                $sampleDN = if ($sampleObject.distinguishedName.Value) { 
                    $sampleObject.distinguishedName.Value 
                } elseif ($sampleObject.distinguishedName) { 
                    $sampleObject.distinguishedName 
                } else { 
                    $null 
                }
                
                if ($sampleDN) {
                    # Extract domain components from DN
                    $dcComponents = ($sampleDN -split ',DC=' | Where-Object { $_ -match '^DC=' -or $_ -notmatch '=' })
                    if ($dcComponents.Count -gt 1) {
                        $forestRoot = ($dcComponents[1..($dcComponents.Count - 1)] -join '.').Replace('DC=', '')
                        Write-Verbose "Detected forest root from AdcsObjects: $forestRoot"
                    }
                }
            }
            
            # Get forest information using DirectoryEntry
            try {
                $rootDSE = New-Object System.DirectoryServices.DirectoryEntry("LDAP://RootDSE")
                $forestRootNC = $rootDSE.rootDomainNamingContext.Value
                $configNC = $rootDSE.configurationNamingContext.Value
                
                if ($forestRootNC) {
                    $forestName = ($forestRootNC -replace 'DC=', '' -replace ',', '.')
                    Write-Verbose "Found forest using DirectoryEntry: $forestName"
                    
                    # Create a mock forest object for compatibility
                    $Forest = [PSCustomObject]@{
                        Name = $forestName
                        RootDomain = $forestName
                        RootDomainNC = $forestRootNC
                    }
                    
                    # Enumerate domains from partitions container
                    if ($configNC) {
                        $partitionsContainer = New-Object System.DirectoryServices.DirectoryEntry("LDAP://CN=Partitions,$configNC")
                        $searcher = New-Object System.DirectoryServices.DirectorySearcher($partitionsContainer)
                        $searcher.Filter = "(&(objectClass=crossRef)(systemFlags=3))"
                        $searcher.PropertiesToLoad.AddRange(@("dnsRoot"))
                        
                        $domains = $searcher.FindAll()
                        foreach ($domain in $domains) {
                            if ($domain.Properties["dnsRoot"]) {
                                $ForestDomains += $domain.Properties["dnsRoot"][0]
                            }
                        }
                        $domains.Dispose()
                        $searcher.Dispose()
                        $partitionsContainer.Dispose()
                    }
                }
                $rootDSE.Dispose()
            } catch {
                Write-Warning "Failed to get forest information via DirectoryEntry: $($_.Exception.Message)"
            }
            
            if (-not $ForestDomains -or $ForestDomains.Count -eq 0) {
                Write-Warning "Could not determine forest domains. SafeUsers expansion will be limited to base pattern."
                return $SafeUsers
            }
            
        } catch {
            Write-Warning "Failed to determine forest context: $($_.Exception.Message)"
            return $SafeUsers
        }
    }

    process {
        Write-Verbose "Expanding SafeUsers across $($ForestDomains.Count) domains..."
        
        # Helper function to get group members recursively using DirectoryEntry
        function Get-GroupMembersRecursive {
            param(
                [string]$GroupSID,
                [string]$DomainName,
                [hashtable]$ProcessedGroups = @{},
                [int]$MaxDepth = 10,
                [int]$CurrentDepth = 0
            )
            
            if ($CurrentDepth -ge $MaxDepth) {
                Write-Verbose "Maximum recursion depth reached for group $GroupSID"
                return @()
            }
            
            if ($ProcessedGroups.ContainsKey($GroupSID)) {
                Write-Verbose "Group $GroupSID already processed, skipping to avoid circular reference"
                return @()
            }
            
            $ProcessedGroups[$GroupSID] = $true
            $members = @()
            
            try {
                Write-Verbose "Getting members for group SID: $GroupSID (depth: $CurrentDepth)"
                
                # Try to bind to the group using SID
                $groupEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://<SID=$GroupSID>")
                
                if ($groupEntry.Properties["member"]) {
                    foreach ($memberDN in $groupEntry.Properties["member"]) {
                        try {
                            $memberEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$memberDN")
                            
                            if ($memberEntry.Properties["objectSid"]) {
                                $memberSidBytes = $memberEntry.Properties["objectSid"][0]
                                $memberSidObj = New-Object System.Security.Principal.SecurityIdentifier($memberSidBytes, 0)
                                $memberSID = $memberSidObj.Value
                                
                                # Check if this is a group that needs recursive expansion
                                $objectClass = $memberEntry.Properties["objectClass"]
                                if ($objectClass -contains "group") {
                                    Write-Verbose "Found nested group: $memberSID, expanding recursively"
                                    $nestedMembers = Get-GroupMembersRecursive -GroupSID $memberSID -DomainName $DomainName -ProcessedGroups $ProcessedGroups -MaxDepth $MaxDepth -CurrentDepth ($CurrentDepth + 1)
                                    $members += $nestedMembers
                                } else {
                                    # This is a user or computer, add it directly
                                    $members += $memberSID
                                    Write-Verbose "Added member: $memberSID"
                                }
                            }
                            $memberEntry.Dispose()
                        } catch {
                            Write-Verbose "Failed to process member $memberDN : $($_.Exception.Message)"
                        }
                    }
                }
                $groupEntry.Dispose()
            } catch {
                Write-Verbose "Failed to process group $GroupSID : $($_.Exception.Message)"
            }
            
            return $members
        }
        
        # Get Enterprise Admins SID and expand members
        if ($Forest -and $Forest.RootDomainNC) {
            try {
                # Get the domain SID of the root domain
                $rootDomainEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$($Forest.RootDomainNC)")
                if ($rootDomainEntry.Properties["objectSid"]) {
                    $rootDomainSidBytes = $rootDomainEntry.Properties["objectSid"][0]
                    $rootDomainSidObj = New-Object System.Security.Principal.SecurityIdentifier($rootDomainSidBytes, 0)
                    $rootDomainSID = $rootDomainSidObj.Value
                    
                    $EnterpriseAdminsSID = $rootDomainSID + '-519'
                    Write-Verbose "Enterprise Admins SID: $EnterpriseAdminsSID"
                    
                    # Get Enterprise Admins members recursively
                    $eaMembers = Get-GroupMembersRecursive -GroupSID $EnterpriseAdminsSID -DomainName $Forest.RootDomain
                    foreach ($memberSID in $eaMembers) {
                        if (-not $ProcessedSIDs.ContainsKey($memberSID)) {
                            $SafeUsers += '|' + $memberSID
                            $ProcessedSIDs[$memberSID] = $true
                            Write-Verbose "Added Enterprise Admin member: $memberSID"
                        }
                    }
                }
                $rootDomainEntry.Dispose()
            } catch {
                Write-Verbose "Failed to process Enterprise Admins: $($_.Exception.Message)"
            }
        }

        # Process each domain
        foreach ($DomainName in $ForestDomains) {
            Write-Verbose "Processing domain: $DomainName"
            
            try {
                # Get domain SID using DirectoryEntry
                $domainDN = "DC=" + ($DomainName -replace '\.', ',DC=')
                $domainEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domainDN")
                
                $DomainSID = $null
                if ($domainEntry.Properties["objectSid"]) {
                    $domainSidBytes = $domainEntry.Properties["objectSid"][0]
                    $domainSidObj = New-Object System.Security.Principal.SecurityIdentifier($domainSidBytes, 0)
                    $DomainSID = $domainSidObj.Value
                    Write-Verbose "Domain SID for $DomainName : $DomainSID"
                }
                $domainEntry.Dispose()
                
                if (-not $DomainSID) {
                    Write-Warning "Could not determine domain SID for $DomainName, skipping domain"
                    continue
                }

                # Define safe group RIDs and SIDs to process
                $SafeGroupRIDs = @('-517', '-512')  # Cert Publishers, Domain Admins
                $SafeGroupSIDs = @('S-1-5-32-544')  # Local Administrators
                
                # Add domain-specific groups
                foreach ($rid in $SafeGroupRIDs) {
                    $SafeGroupSIDs += $DomainSID + $rid
                }
                
                # Process each safe group
                foreach ($groupSID in $SafeGroupSIDs) {
                    Write-Verbose "Processing group SID: $groupSID"
                    
                    try {
                        # Get group members recursively using DirectoryEntry
                        $groupMembers = Get-GroupMembersRecursive -GroupSID $groupSID -DomainName $DomainName
                        
                        # Add all members to SafeUsers
                        foreach ($memberSID in $groupMembers) {
                            if (-not $ProcessedSIDs.ContainsKey($memberSID)) {
                                $SafeUsers += '|' + $memberSID
                                $ProcessedSIDs[$memberSID] = $true
                                Write-Verbose "Added group member: $memberSID"
                            }
                        }
                        
                    } catch {
                        Write-Verbose "Failed to process group $groupSID in domain $DomainName : $($_.Exception.Message)"
                        $ErrorSIDs[$groupSID] = $_.Exception.Message
                    }
                }
                
            } catch {
                Write-Warning "Failed to process domain $DomainName : $($_.Exception.Message)"
            }
        }
    }

    end {
        # Clean up the SafeUsers pattern
        $SafeUsers = $SafeUsers.Replace('||', '|')
        
        # Remove any leading/trailing pipe characters
        $SafeUsers = $SafeUsers.Trim('|')
        
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd hh:mm:ss')] Completed $($MyInvocation.MyCommand)"
        Write-Verbose "Final SafeUsers pattern length: $($SafeUsers.Length) characters"
        Write-Verbose "Processed $($ProcessedSIDs.Count) individual SIDs"
        
        if ($ErrorSIDs.Count -gt 0) {
            Write-Verbose "Encountered errors processing $($ErrorSIDs.Count) groups"
        }
        
        return $SafeUsers
    }
}