Public/Find-LS2VulnerableTemplate.ps1

function Find-LS2VulnerableTemplate {
    <#
    .SYNOPSIS
        Identifies vulnerable AD CS templates based on ESC technique definitions.

    .DESCRIPTION
        Reads ESC technique definitions from ESCDefinitions.psd1, queries the AdcsObjectStore
        for matching templates, and generates issues for problematic enrollees.

    .PARAMETER Technique
        ESC technique name to scan for (e.g., 'ESC1', 'ESC2', 'ESC3c1', 'ESC3c2')

    .EXAMPLE
        Find-LS2VulnerableTemplate -Technique ESC1
        Scans for templates vulnerable to ESC1 (misconfigured certificate templates).

    .EXAMPLE
        Find-LS2VulnerableTemplate -Technique ESC2
        Scans for templates vulnerable to ESC2 (certificate SubCA abuse).

    .EXAMPLE
        $esc1Issues = Find-LS2VulnerableTemplate -Technique ESC1 -Verbose
        Scans for ESC1 issues with verbose output, stores issues in $esc1Issues variable.

    .EXAMPLE
        Find-LS2VulnerableTemplate -Technique ESC1 -ExpandGroups
        Scans for ESC1 issues and expands group principals into individual 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
        
        Supported techniques:
        - ESC1: Certificate Request Agent abuse
        - ESC2: Misconfigured Certificate Templates
        - ESC3c1/ESC3c2: Enrollment Agent restrictions
        - ESC4a/ESC4o: Vulnerable ACLs on templates
        - ESC9: Weak Certificate Mappings

    .LINK
        Find-LS2VulnerableCA

    .LINK
        Find-LS2VulnerableObject

    .LINK
        Invoke-Locksmith2
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('ESC1', 'ESC2', 'ESC3c1', 'ESC3c2', 'ESC9', 'ESC4a', 'ESC4o')]
        [string]$Technique,
        
        [Parameter()]
        [string]$Forest,
        
        [Parameter()]
        [PSCredential]$Credential,
        
        [Parameter()]
        [switch]$ExpandGroups,
        
        [Parameter()]
        [switch]$Rescan
    )

    # 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 template issues
    if (-not $Technique) {
        Write-Verbose "No technique specified. Returning all template issues..."
        $allIssues = Get-FlattenedIssues
        $templateTechniques = @('ESC1', 'ESC2', 'ESC3c1', 'ESC3c2', 'ESC9', 'ESC4a', 'ESC4o')
        $templateIssues = $allIssues | Where-Object { $_.Technique -in $templateTechniques }
        
        if ($ExpandGroups) {
            $templateIssues | ForEach-Object { Expand-IssueByGroup $_ }
        } else {
            $templateIssues
        }
        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 templates, then filter by conditions
    $allTemplates = $script:AdcsObjectStore.Values | Where-Object { $_.IsCertificateTemplate() }
    
    $vulnerableTemplates = @(foreach ($template in $allTemplates) {
        $matchesAllConditions = $true
        
        foreach ($condition in $config.Conditions) {
            $propertyValue = $template.($condition.Property)
            if ($propertyValue -ne $condition.Value) {
                $matchesAllConditions = $false
                break
            }
        }
        
        if ($matchesAllConditions) {
            $template
        }
    })

    Write-Verbose "Found $($vulnerableTemplates.Count) template(s) with $technique-vulnerable configuration"

    $issueCount = 0

    # ESC4a: ACE-based template editor detection
    if ($Technique -eq 'ESC4a') {
        foreach ($template in $vulnerableTemplates) {
            Write-Verbose " Checking editors on template: $($template.Name)"

            # Get problematic editors based on config
            $problematicEditors = @()
            foreach ($editorProperty in $config.EditorProperties) {
                $problematicEditors += @($template.$editorProperty)
            }
            $problematicEditors = @($problematicEditors | Select-Object -Unique)

            if ($problematicEditors.Count -eq 0) {
                Write-Verbose " No problematic editors found"
                continue
            }

            Write-Verbose " Found $($problematicEditors.Count) problematic editor(s)"

            # Check ObjectSecurity for ACE details
            if (-not $template.ObjectSecurity) {
                Write-Verbose " No ObjectSecurity available for template: $($template.Name)"
                continue
            }

            # For each problematic editor, find their ACE and create an issue
            foreach ($editorSid in $problematicEditors) {
                # Find the ACE for this SID
                $ace = $template.ObjectSecurity.Access | Where-Object {
                    $aceSid = ($_.IdentityReference | Convert-IdentityReferenceToSid).Value
                    $aceSid -eq $editorSid
                } | Select-Object -First 1

                if (-not $ace) {
                    Write-Verbose " Could not find ACE for SID: $editorSid"
                    continue
                }

                Write-Verbose " VULNERABLE: $($ace.IdentityReference) ($editorSid) has write rights"

                # Get domain/forest name from DN
                $forestName = if ($template.distinguishedName -match 'DC=([^,]+)') {
                    $template.distinguishedName -replace '^.*?DC=(.*)$', '$1' -replace ',DC=', '.'
                } else {
                    'Unknown'
                }

                # Join templates if they're arrays, then expand variables
                $issueTemplate = if ($config.IssueTemplate -is [array]) {
                    $config.IssueTemplate -join ''
                } else {
                    $config.IssueTemplate
                }
                
                $fixTemplate = if ($config.FixTemplate -is [array]) {
                    $config.FixTemplate -join "`n"
                } else {
                    $config.FixTemplate
                }
                
                $revertTemplate = if ($config.RevertTemplate -is [array]) {
                    $config.RevertTemplate -join "`n"
                } else {
                    $config.RevertTemplate
                }

                # Expand template variables in Issue, Fix, and Revert strings
                $issueText = $issueTemplate `
                    -replace '\$\(IdentityReference\)', $ace.IdentityReference `
                    -replace '\$\(TemplateName\)', $template.Name `
                    -replace '\$\(ActiveDirectoryRights\)', $ace.ActiveDirectoryRights
                
                $fixScript = $fixTemplate `
                    -replace '\$\(DistinguishedName\)', $template.distinguishedName `
                    -replace '\$\(IdentityReference\)', $ace.IdentityReference
                
                $revertScript = $revertTemplate `
                    -replace '\$\(DistinguishedName\)', $template.distinguishedName

                # Create LS2Issue object
                $issue = [LS2Issue]@{
                    Technique             = $technique
                    Forest                = $forestName
                    Name                  = $template.Name
                    DistinguishedName     = $template.distinguishedName
                    IdentityReference     = $ace.IdentityReference
                    IdentityReferenceSID  = $editorSid
                    ActiveDirectoryRights = $ace.ActiveDirectoryRights
                    Enabled               = $template.Enabled
                    EnabledOn             = $template.EnabledOn
                    Issue                 = $issueText
                    Fix                   = $fixScript
                    Revert                = $revertScript
                }

                # Store in IssueStore
                $dn = $template.distinguishedName
                if (-not $script:IssueStore) {
                    $script:IssueStore = @{}
                }
                if (-not $script:IssueStore.ContainsKey($dn)) {
                    $script:IssueStore[$dn] = @{}
                }
                if (-not $script:IssueStore[$dn].ContainsKey($Technique)) {
                    $script:IssueStore[$dn][$Technique] = @()
                }
                
                # Only add to store if not a duplicate
                if (-not (Test-IssueExists -Issue $issue -DistinguishedName $dn -Technique $Technique)) {
                    $script:IssueStore[$dn][$Technique] += $issue
                    $issueCount++
                }

                # Always output to pipeline
                if ($ExpandGroups) {
                    Expand-IssueByGroup -Issue $issue
                } else {
                    $issue
                }
            }
        }
        Write-Verbose "$Technique scan complete. Found $issueCount issue(s)."
        return
    }

    # ESC4o: Ownership-based detection (no enrollee checking needed)
    if ($Technique -eq 'ESC4o') {
        foreach ($template in $vulnerableTemplates) {
            $templateName = if ($template.displayName) { $template.displayName } else { $template.Name }
            $owner = if ($template.Owner) { $template.Owner } else { 'Unknown' }

            Write-Verbose " Checking template: $templateName"

            # Create issue using template expansion
            $issueText = ($config.IssueTemplate -join '') `
                -replace '\$\(TemplateName\)', $templateName `
                -replace '\$\(Owner\)', $owner

            $fixScript = ($config.FixTemplate -join "`n") `
                -replace '\$\(DistinguishedName\)', $template.distinguishedName

            $revertScript = ($config.RevertTemplate -join "`n") `
                -replace '\$\(DistinguishedName\)', $template.distinguishedName `
                -replace '\$\(OriginalOwner\)', $owner

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

            # Store in IssueStore
            $dn = $template.distinguishedName
            if (-not $script:IssueStore) {
                $script:IssueStore = @{}
            }
            if (-not $script:IssueStore.ContainsKey($dn)) {
                $script:IssueStore[$dn] = @{}
            }
            if (-not $script:IssueStore[$dn].ContainsKey($Technique)) {
                $script:IssueStore[$dn][$Technique] = @()
            }
            
            # Only add to store if not a duplicate
            if (-not (Test-IssueExists -Issue $issue -DistinguishedName $dn -Technique $Technique)) {
                $script:IssueStore[$dn][$Technique] += $issue
                $issueCount++
            }

            # Always output to pipeline
            if ($ExpandGroups) {
                Expand-IssueByGroup -Issue $issue
            } else {
                $issue
            }
        }
        Write-Verbose "$Technique scan complete. Found $issueCount issue(s)."
        return
    }

    # Standard enrollee-based detection for other ESC techniques
    foreach ($template in $vulnerableTemplates) {
        Write-Verbose " Checking enrollees on template: $($template.Name)"

        # Get problematic enrollees based on config
        $problematicEnrollees = @()
        foreach ($enrolleeProperty in $config.EnrolleeProperties) {
            $problematicEnrollees += @($template.$enrolleeProperty)
        }
        $problematicEnrollees = @($problematicEnrollees | Select-Object -Unique)

        if ($problematicEnrollees.Count -eq 0) {
            Write-Verbose " No problematic enrollees found"
            continue
        }

        Write-Verbose " Found $($problematicEnrollees.Count) problematic enrollee(s)"

        # Check ObjectSecurity for ACE details
        if (-not $template.ObjectSecurity) {
            Write-Verbose " No ObjectSecurity available for template: $($template.Name)"
            continue
        }

        # For each problematic enrollee, find their ACE and create an issue
        foreach ($enrolleeSid in $problematicEnrollees) {
            # Find the ACE for this SID
            $ace = $template.ObjectSecurity.Access | Where-Object {
                $aceSid = ($_.IdentityReference | Convert-IdentityReferenceToSid).Value
                $aceSid -eq $enrolleeSid
            } | Select-Object -First 1

            if (-not $ace) {
                Write-Verbose " Could not find ACE for SID: $enrolleeSid"
                continue
            }

            Write-Verbose " VULNERABLE: $($ace.IdentityReference) ($enrolleeSid) has enrollment rights"

            # Get domain/forest name from DN
            $forestName = if ($template.distinguishedName -match 'DC=([^,]+)') {
                $template.distinguishedName -replace '^.*?DC=(.*)$', '$1' -replace ',DC=', '.'
            } else {
                'Unknown'
            }

            # Join templates if they're arrays, then expand variables
            $issueTemplate = if ($config.IssueTemplate -is [array]) {
                $config.IssueTemplate -join ''
            } else {
                $config.IssueTemplate
            }
            
            $fixTemplate = if ($config.FixTemplate -is [array]) {
                $config.FixTemplate -join "`n"
            } else {
                $config.FixTemplate
            }
            
            $revertTemplate = if ($config.RevertTemplate -is [array]) {
                $config.RevertTemplate -join "`n"
            } else {
                $config.RevertTemplate
            }

            # Expand template variables in Issue, Fix, and Revert strings
            $issueText = $issueTemplate `
                -replace '\$\(IdentityReference\)', $ace.IdentityReference `
                -replace '\$\(TemplateName\)', $template.Name
            
            $fixScript = $fixTemplate `
                -replace '\$\(DistinguishedName\)', $template.distinguishedName
            
            $revertScript = $revertTemplate `
                -replace '\$\(DistinguishedName\)', $template.distinguishedName

            # Create LS2Issue object
            $issue = [LS2Issue]@{
                Technique             = $technique
                Forest                = $forestName
                Name                  = $template.Name
                DistinguishedName     = $template.distinguishedName
                IdentityReference     = $ace.IdentityReference
                IdentityReferenceSID  = $enrolleeSid
                ActiveDirectoryRights = $ace.ActiveDirectoryRights
                Enabled               = $template.Enabled
                EnabledOn             = $template.EnabledOn
                Issue                 = $issueText
                Fix                   = $fixScript
                Revert                = $revertScript
            }

            # Initialize IssueStore structure if needed
            $dn = $template.distinguishedName
            if (-not $script:IssueStore) {
                $script:IssueStore = @{}
            }
            if (-not $script:IssueStore.ContainsKey($dn)) {
                $script:IssueStore[$dn] = @{}
            }
            if (-not $script:IssueStore[$dn].ContainsKey($technique)) {
                $script:IssueStore[$dn][$technique] = @()
            }
            
            # Only add to store if not a duplicate
            if (-not (Test-IssueExists -Issue $issue -DistinguishedName $dn -Technique $technique)) {
                $script:IssueStore[$dn][$technique] += $issue
                $issueCount++
            }

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

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