Public/Find-LS2VulnerableObject.ps1

function Find-LS2VulnerableObject {
    <#
    .SYNOPSIS
        Identifies vulnerable AD CS infrastructure objects (containers, computer accounts).

    .DESCRIPTION
        Scans AD CS infrastructure objects for ESC5 vulnerabilities related to ownership.
        
        ESC5: Vulnerable PKI Object Access Control
        - Containers with non-standard owners (can be modified to create vulnerable templates/CAs)
        - Computer objects hosting CAs with non-standard owners
        - Other PKI infrastructure objects with non-standard owners
        
        This function complements Find-LS2VulnerableTemplate (templates) and Find-LS2VulnerableCA (CAs)
        by focusing on the supporting infrastructure objects.

    .PARAMETER Technique
        ESC technique name to scan for. Currently supports 'ESC5'.

    .EXAMPLE
        Find-LS2VulnerableObject -Technique ESC5o
        Checks for AD CS infrastructure objects with non-standard owners.

    .EXAMPLE
        Find-LS2VulnerableObject -Technique ESC5a
        Checks for AD CS objects with dangerous editors (write permissions).

    .EXAMPLE
        $issues = Find-LS2VulnerableObject -Technique ESC5o -Verbose
        Stores ESC5o issues in $issues variable with verbose output.

    .EXAMPLE
        Find-LS2VulnerableObject -Technique ESC5a -ExpandGroups
        Checks for dangerous write permissions and expands group principals into per-member issues.

    .OUTPUTS
        LS2Issue
        LS2Issue objects for each vulnerability found.

    .NOTES
        Author: Jake Hildreth (@jakehildreth)
        Module: Locksmith2
        Requires: PowerShell 5.1+
        
        Requires script-scope variables set by Invoke-Locksmith2:
        - $script:AdcsObjectStore: Cache of AD CS objects
        - $script:PrincipalStore: Cache of resolved principals
        - $script:StandardOwners: List of acceptable owner SIDs
        
        Supported techniques:
        - ESC5a: Dangerous editors with write access to PKI objects
        - ESC5o: Non-standard ownership of PKI infrastructure objects

    .LINK
        Find-LS2VulnerableCA

    .LINK
        Find-LS2VulnerableTemplate

    .LINK
        Invoke-Locksmith2
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('ESC5a', 'ESC5o')]
        [string]$Technique,
        
        [Parameter()]
        [string]$Forest,
        
        [Parameter()]
        [PSCredential]$Credential,
        
        [Parameter()]
        [switch]$ExpandGroups,
        
        [Parameter()]
        [switch]$Rescan
    )

    #requires -Version 5.1

    # Ensure stores are initialized and populated
    $initParams = @{}
    if ($PSBoundParameters.ContainsKey('Forest')) { $initParams['Forest'] = $Forest }
    if ($PSBoundParameters.ContainsKey('Credential')) { $initParams['Credential'] = $Credential }
    if ($Rescan) { $initParams['Rescan'] = $true }
    
    if (-not (Initialize-LS2Scan @initParams)) {
        return
    }

    # If no technique specified, return all object issues
    if (-not $Technique) {
        Write-Verbose "No technique specified. Returning all object issues..."
        $allIssues = Get-FlattenedIssues
        $objectTechniques = @('ESC5a', 'ESC5o')
        $objectIssues = $allIssues | Where-Object { $_.Technique -in $objectTechniques }
        
        if ($ExpandGroups) {
            $objectIssues | ForEach-Object { Expand-IssueByGroup $_ }
        } else {
            $objectIssues
        }
        return
    }

    # Load all ESC definitions
    $definitionsPath = Join-Path $PSScriptRoot '..\Private\Data\ESCDefinitions.psd1'
    $allDefinitions = Import-PowerShellDataFile -Path $definitionsPath
    $config = $allDefinitions[$Technique]

    Write-Verbose "Scanning for $Technique using definitions from $definitionsPath"

    # Query AdcsObjectStore for infrastructure objects and CAs (exclude templates only)
    $allObjects = $script:AdcsObjectStore.Values | Where-Object { 
        $_.objectClass -notcontains 'pKICertificateTemplate'
    }
    
    # Handle ESC5a special logic (check EditorProperties)
    if ($config.EditorProperties) {
        Write-Verbose "ESC5a: Checking EditorProperties for vulnerable objects"
        
        $issueCount = 0
        
        foreach ($object in $allObjects) {
            $objectName = if ($object.displayName) { 
                $object.displayName 
            } elseif ($object.name) { 
                $object.name 
            } elseif ($object.cn) {
                $object.cn
            } else { 
                'Unknown Object' 
            }
            
            Write-Verbose " Checking object: $objectName"
            
            # Check each editor property
            foreach ($editorProperty in $config.EditorProperties) {
                $editors = $object.$editorProperty
                
                if (-not $editors -or $editors.Count -eq 0) {
                    continue
                }
                
                Write-Verbose " Found $($editors.Count) editor(s) in $editorProperty"
                
                # Create an issue for each problematic editor
                foreach ($editor in $editors) {
                    $issueCount++
                    
                    # Get corresponding names property
                    $namesProperty = $editorProperty + 'Names'
                    $editorNames = $object.$namesProperty
                    $editorDisplayName = if ($editorNames) {
                        ($editorNames | Where-Object { $_ -like "*$editor*" })[0]
                    } else {
                        $editor
                    }
                    
                    # Get object type for issue description
                    $objectType = if ($object.objectClass -contains 'container') {
                        'Container'
                    } elseif ($object.objectClass -contains 'certificationAuthority') {
                        'Certification Authority Container'
                    } elseif ($object.objectClass -contains 'pKIEnrollmentService') {
                        'Certification Authority'
                    } elseif ($object.objectClass -contains 'computer') {
                        'Computer Account'
                    } else {
                        'PKI Object'
                    }
                    
                    # Determine what rights were granted (simplified for now)
                    $activeDirectoryRights = 'Write/Modify'
                    
                    # Expand issue template with variables
                    $issueText = ($config.IssueTemplate -join '') `
                        -replace '\$\(ObjectName\)', $objectName `
                        -replace '\$\(ObjectType\)', $objectType `
                        -replace '\$\(IdentityReference\)', $editorDisplayName `
                        -replace '\$\(ActiveDirectoryRights\)', $activeDirectoryRights
                    
                    # Expand fix script template with variables
                    $fixScript = ($config.FixTemplate -join "`n") `
                        -replace '\$\(DistinguishedName\)', $object.distinguishedName `
                        -replace '\$\(IdentityReference\)', $editor
                    
                    # Expand revert script template with variables
                    $revertScript = ($config.RevertTemplate -join "`n") `
                        -replace '\$\(DistinguishedName\)', $object.distinguishedName
                    
                    # Create issue object
                    $issue = [LS2Issue]::new(@{
                        Technique             = $Technique
                        Forest                = $script:ForestContext.RootDomain
                        Name                  = $objectName
                        DistinguishedName     = $object.distinguishedName
                        IdentityReference     = $editorDisplayName
                        IdentityReferenceSID  = $editor
                        ActiveDirectoryRights = $activeDirectoryRights
                        Issue                 = $issueText
                        Fix                   = $fixScript
                        Revert                = $revertScript
                    })
                    
                    # Add issue to IssueStore
                    if (-not $script:IssueStore) {
                        $script:IssueStore = @{}
                    }
                    if (-not $script:IssueStore.ContainsKey($object.distinguishedName)) {
                        $script:IssueStore[$object.distinguishedName] = @{}
                    }
                    
                    if (-not $script:IssueStore[$object.distinguishedName].ContainsKey($Technique)) {
                        $script:IssueStore[$object.distinguishedName][$Technique] = @()
                    }
                    
                    # Only add to store if not a duplicate
                    if (-not (Test-IssueExists -Issue $issue -DistinguishedName $object.distinguishedName -Technique $Technique)) {
                        $script:IssueStore[$object.distinguishedName][$Technique] += $issue
                        Write-Verbose " VULNERABLE: $editorDisplayName has write access"
                    }
                    
                    # Always output to pipeline
                    if ($ExpandGroups) {
                        Expand-IssueByGroup -Issue $issue
                    } else {
                        $issue
                    }
                }
            }
        }
        
        Write-Verbose "$Technique scan complete. Found $issueCount issue(s)."
        return
    }
    
    # Filter objects by conditions (for non-ESC5a techniques like ESC5o)
    $vulnerableObjects = @(foreach ($object in $allObjects) {
        $matchesAllConditions = $true
        
        foreach ($condition in $config.Conditions) {
            $propertyValue = $object.($condition.Property)
            
            $matches = switch ($condition.Operator) {
                'eq' { $propertyValue -eq $condition.Value }
                'ne' { $propertyValue -ne $condition.Value }
                'gt' { $propertyValue -gt $condition.Value }
                'lt' { $propertyValue -lt $condition.Value }
                'contains' { $propertyValue -contains $condition.Value }
                default { $false }
            }
            
            if (-not $matches) {
                $matchesAllConditions = $false
                break
            }
        }
        
        if ($matchesAllConditions) {
            $object
        }
    })
    
    Write-Verbose "Found $($vulnerableObjects.Count) object(s) to check (CAs and infrastructure)"

    $issueCount = 0

    # Process vulnerable objects
    foreach ($object in $vulnerableObjects) {
        $objectName = if ($object.displayName) { 
            $object.displayName 
        } elseif ($object.name) { 
            $object.name 
        } elseif ($object.cn) {
            $object.cn
        } else { 
            'Unknown Object' 
        }
        
        $owner = if ($object.Owner) { $object.Owner } else { 'Unknown' }
        
        Write-Verbose " Checking object: $objectName (owned by $owner)"
        
        $issueCount++
        
        # Get object type for issue description
        $objectType = if ($object.objectClass -contains 'container') {
            'Container'
        } elseif ($object.objectClass -contains 'certificationAuthority') {
            'Certification Authority Container'
        } elseif ($object.objectClass -contains 'pKIEnrollmentService') {
            'Certification Authority'
        } elseif ($object.objectClass -contains 'computer') {
            'Computer Account'
        } else {
            'PKI Object'
        }

        # Expand issue template with variables
        $issueText = ($config.IssueTemplate -join '') `
            -replace '\$\(ObjectName\)', $objectName `
            -replace '\$\(ObjectType\)', $objectType `
            -replace '\$\(Owner\)', $owner

        # Expand fix script template with variables
        $fixScript = ($config.FixTemplate -join "`n") `
            -replace '\$\(DistinguishedName\)', $object.distinguishedName

        # Expand revert script template with variables
        $revertScript = ($config.RevertTemplate -join "`n") `
            -replace '\$\(DistinguishedName\)', $object.distinguishedName `
            -replace '\$\(OriginalOwner\)', $owner

        # Create issue object
        $issue = [LS2Issue]::new(@{
            Technique          = $Technique
            Forest             = $script:ForestContext.RootDomain
            Name               = $objectName
            DistinguishedName  = $object.distinguishedName
            Owner              = $owner
            HasNonStandardOwner = $true
            Issue              = $issueText
            Fix                = $fixScript
            Revert             = $revertScript
        })

        # Add issue to IssueStore
        if (-not $script:IssueStore) {
            $script:IssueStore = @{}
        }
        if (-not $script:IssueStore.ContainsKey($object.distinguishedName)) {
            $script:IssueStore[$object.distinguishedName] = @{}
        }
        
        if (-not $script:IssueStore[$object.distinguishedName].ContainsKey($Technique)) {
            $script:IssueStore[$object.distinguishedName][$Technique] = @()
        }
        
        # Only add to store if not a duplicate
        if (-not (Test-IssueExists -Issue $issue -DistinguishedName $object.distinguishedName -Technique $Technique)) {
            $script:IssueStore[$object.distinguishedName][$Technique] += $issue
            Write-Verbose " VULNERABLE: $objectType '$objectName' owned by $owner"
        }

        # Always output to pipeline
        if ($ExpandGroups) {
            Expand-IssueByGroup -Issue $issue
        } else {
            $issue
        }
    }

    Write-Verbose "$Technique scan complete. Found $issueCount issue(s)."
}