Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Classes/LS2AdcsObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class LS2AdcsObject {
[Nullable[int]]$AuditFilter
[object[]]$DisableExtensionList
[Nullable[bool]]$SecurityExtensionDisabled
[object[]]$WebEnrollmentEndpoints

# Schema class name for easy type checking
[string]$SchemaClassName
Expand Down
2 changes: 1 addition & 1 deletion Locksmith2.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Description='An AD CS toolkit for AD Admins, Defensive Security Professionals, and Filthy Red Teamers'
FunctionsToExport=@('*')
GUID='e32f7d0d-2b10-4db2-b776-a193958e3d69'
ModuleVersion='2026.5.101055'
ModuleVersion='2026.5.121255'
PowerShellVersion='5.1'
PrivateData=@{
PSData=@{
Expand Down
41 changes: 41 additions & 0 deletions Private/Data/ESCDefinitions.ps1
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# MAINTENANCE: When adding a new ESC entry here, you MUST also:
# 1. Add the technique name to the [ValidateSet(...)] in the appropriate Find-LS2Vulnerable* function:
# - Template techniques -> Public/Find-LS2VulnerableTemplate.ps1
# - CA techniques -> Public/Find-LS2VulnerableCA.ps1
# - Object techniques -> Public/Find-LS2VulnerableObject.ps1
# 2. Add the technique name to the matching $*Techniques array in Private/Initialize/Initialize-LS2Scan.ps1
# 3. Add the technique name to the $techniques array in Public/Invoke-Locksmith2.ps1
# 4. Add an elseif detection branch in the appropriate Find-LS2Vulnerable* function
$script:ESCDefinitions = data {
@{
ESC1 = @{
Expand Down Expand Up @@ -623,5 +631,38 @@ $script:ESCDefinitions = data {
"`$Object.CommitChanges()"
)
}

ESC8 = @{
# ESC8: NTLM Relay to AD CS HTTP Endpoints
Technique = 'ESC8'

# EndpointBased signals Find-LS2VulnerableCA to use the per-endpoint branch
EndpointBased = $true

# Issue text is built dynamically per endpoint in Find-LS2VulnerableCA.
# These templates are used as fallback / documentation only.
IssueTemplate = @(
"The web enrollment endpoint at `$(URL) is vulnerable to NTLM relay attacks.`n`n"
"An attacker who can intercept network traffic (e.g., via responder, mitm6, or similar) "
"can relay NTLM authentication to this endpoint and obtain a certificate on behalf of the "
"intercepted account.`n`n"
"More info:`n"
" - https://posts.specterops.io/certified-pre-owned-d95910965cd2"
)

FixTemplate = @(
"# ESC8 Fix: require HTTPS with EPA and disable NTLM where possible.`n"
"# 1. Enable EPA (Extended Protection for Authentication) on IIS.`n"
"# 2. Disable NTLM authentication on the web enrollment site and use Kerberos only.`n"
"# 3. If HTTP is enabled, redirect all traffic to HTTPS.`n"
"# Reference: https://support.microsoft.com/kb/5005413"
)

RevertTemplate = @(
"# ESC8 Revert: re-enable NTLM or HTTP as required by your environment.`n"
"# Review IIS authentication settings on the CA host.`n"
"# Reference: https://learn.microsoft.com/en-us/windows-server/networking/core-network-guide/cncg/server-certs/configure-server-certificate-autoenrollment"
)
}
}
}
143 changes: 143 additions & 0 deletions Private/Get/Get-WebEnrollmentEndpointStatus.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
function Get-WebEnrollmentEndpointStatus {
<#
.SYNOPSIS
Probes a single web enrollment URL and returns its authentication posture.

.DESCRIPTION
Sends anonymous and (for HTTPS) Negotiate HTTP requests to the given URL using
System.Net.Http.HttpClient. Returns $null when the endpoint does not respond.
For responding endpoints, returns a PSCustomObject describing whether NTLM is
offered and whether Extended Protection for Authentication (EPA) is not required.

Probe 1 (anonymous GET):
- Determines whether the endpoint exists.
- Reads the WWW-Authenticate header to detect NTLM.
- HTTP endpoints stop here; NtlmOffered and EpaNotRequired are both $null.

Probe 2 (Negotiate GET, HTTPS only):
- Uses HttpClientHandler with DefaultNetworkCredentials and Negotiate.
- A 200 response indicates channel binding (EPA) was not required.
- A 401/403 response indicates EPA may be enforced (conservative $false).

.PARAMETER Url
The full URL to probe (e.g., 'http://ca1.contoso.com/certsrv/').

.OUTPUTS
PSCustomObject with properties URL, NtlmOffered, EpaNotRequired.
Returns $null when the endpoint is unreachable or times out.

.NOTES
Intentionally has no unit tests - HttpClient cannot be mocked in Pester.
Use integration tests (Get-WebEnrollmentEndpointStatus.Integration.Tests.ps1)
against a live AD CS environment.
SSL certificate validation is intentionally disabled to probe self-signed certs.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory)]
[string]$Url
)

$isHttps = $Url -match '^https://'

# --- Probe 1: anonymous GET ---
$anonHandler = $null
$anonClient = $null
try {
$anonHandler = [System.Net.Http.HttpClientHandler]::new()
$anonHandler.AllowAutoRedirect = $false
$anonHandler.UseDefaultCredentials = $false

if ($isHttps) {
try {
# .NET 4.6.1+ / Core: disable SSL cert validation for self-signed certs
$anonHandler.ServerCertificateCustomValidationCallback = {
param($sender, $cert, $chain, $sslPolicyErrors)
return $true
}
} catch {
# PS5.1 fallback - ServicePointManager is process-wide but necessary
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
}
}

$anonClient = [System.Net.Http.HttpClient]::new($anonHandler)
$anonClient.Timeout = [System.TimeSpan]::FromSeconds(10)

$anonResponse = $anonClient.GetAsync($Url).GetAwaiter().GetResult()

if ($isHttps) {
# Detect NTLM in WWW-Authenticate
$wwwAuth = ''
if ($anonResponse.Headers.Contains('WWW-Authenticate')) {
$wwwAuth = $anonResponse.Headers.GetValues('WWW-Authenticate') -join ', '
}
$ntlmOffered = $wwwAuth -match '(?i)\bNTLM\b'
} else {
# HTTP: always exists, no auth probing
return [PSCustomObject]@{
URL = $Url
NtlmOffered = $null
EpaNotRequired = $null
}
}
} catch [System.Net.Http.HttpRequestException] {
Write-Verbose "Get-WebEnrollmentEndpointStatus: connection failed for $Url - $($_.Exception.Message)"
return $null
} catch [System.OperationCanceledException] {
Write-Verbose "Get-WebEnrollmentEndpointStatus: timeout for $Url"
return $null
} catch {
Write-Verbose "Get-WebEnrollmentEndpointStatus: unexpected error for $Url - $($_.Exception.Message)"
return $null
} finally {
if ($null -ne $anonClient) { $anonClient.Dispose() }
if ($null -ne $anonHandler) { $anonHandler.Dispose() }
}

# --- Probe 2 (HTTPS only): Negotiate auth to detect EPA ---
$authHandler = $null
$authClient = $null
$epaNotRequired = $false
try {
$authHandler = [System.Net.Http.HttpClientHandler]::new()
$authHandler.AllowAutoRedirect = $false

try {
$authHandler.ServerCertificateCustomValidationCallback = {
param($sender, $cert, $chain, $sslPolicyErrors)
return $true
}
} catch {
# ServicePointManager already set above - no-op
}

$credentialCache = [System.Net.CredentialCache]::new()
$credentialCache.Add([System.Uri]::new($Url), 'Negotiate', [System.Net.CredentialCache]::DefaultNetworkCredentials)
$authHandler.Credentials = $credentialCache
$authHandler.PreAuthenticate = $false

$authClient = [System.Net.Http.HttpClient]::new($authHandler)
$authClient.Timeout = [System.TimeSpan]::FromSeconds(10)

$authResponse = $authClient.GetAsync($Url).GetAwaiter().GetResult()
$statusCode = [int]$authResponse.StatusCode

# 200 = auth succeeded without EPA -- not required
# 401/403 = server rejected -- EPA may be enforced (conservative)
$epaNotRequired = ($statusCode -eq 200)
} catch {
Write-Verbose "Get-WebEnrollmentEndpointStatus: Negotiate probe failed for $Url - $($_.Exception.Message)"
$epaNotRequired = $false
} finally {
if ($null -ne $authClient) { $authClient.Dispose() }
if ($null -ne $authHandler) { $authHandler.Dispose() }
}

return [PSCustomObject]@{
URL = $Url
NtlmOffered = [bool]$ntlmOffered
EpaNotRequired = [bool]$epaNotRequired
}
}
1 change: 1 addition & 0 deletions Private/Initialize/Initialize-AdcsObjectStore.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function Initialize-AdcsObjectStore {
Set-CADisableExtensionList |
Set-CAAdministrator |
Set-CACertificateManager |
Set-CAWebEnrollmentEndpoints |
Set-DangerousCAAdministrator |
Set-LowPrivilegeCAAdministrator |
Set-DangerousCACertificateManager |
Expand Down
2 changes: 1 addition & 1 deletion Private/Initialize/Initialize-LS2Scan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ function Initialize-LS2Scan {

# Scan all CA techniques
Write-Verbose "Scanning certification authorities..."
$caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC11', 'ESC16')
$caTechniques = @('ESC6', 'ESC7a', 'ESC7m', 'ESC8', 'ESC11', 'ESC16')
foreach ($tech in $caTechniques) {
Find-LS2VulnerableCA -Technique $tech | Out-Null
}
Expand Down
94 changes: 94 additions & 0 deletions Private/Set/Set-CAWebEnrollmentEndpoints.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
function Set-CAWebEnrollmentEndpoints {
<#
.SYNOPSIS
Probes web enrollment endpoints on each CA and stores results on the CA object.

.DESCRIPTION
For each pKIEnrollmentService (CA) object, constructs candidate URLs from the known
web enrollment paths and the CA's dNSHostName, then probes each URL using
Get-WebEnrollmentEndpointStatus. Responding endpoints are collected into the
WebEnrollmentEndpoints property on the CA object.

Paths probed (both http:// and https://):
certsrv/
{CAName}_CES_Kerberos/service.svc
{CAName}_CES_Kerberos/service.svc/CES
ADPolicyProvider_CEP_Kerberos/service.svc
certsrv/mscep/

.PARAMETER AdcsObject
One or more LS2AdcsObject instances. Non-CA objects are passed through unchanged.

.INPUTS
LS2AdcsObject[]

.OUTPUTS
LS2AdcsObject[]

.EXAMPLE
$CAs | Set-CAWebEnrollmentEndpoints

.NOTES
Requires Get-WebEnrollmentEndpointStatus (Private/Get).
CAs without dNSHostName are passed through without probing.
Probe errors on individual URLs are suppressed; remaining URLs still probed.
#>
[CmdletBinding()]
[OutputType([LS2AdcsObject[]])]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[LS2AdcsObject[]]$AdcsObject
)

begin {
Write-Verbose 'Set-CAWebEnrollmentEndpoints: probing web enrollment endpoints...'
}

process {
foreach ($object in $AdcsObject) {
if (-not $object.IsCertificationAuthority()) {
$object
continue
}

$hostName = $object.dNSHostName
if ([string]::IsNullOrEmpty($hostName)) {
Write-Verbose "Set-CAWebEnrollmentEndpoints: skipping $($object.cn) - no dNSHostName"
$object
continue
}

$caName = $object.cn
$paths = @(
'certsrv/',
"${caName}_CES_Kerberos/service.svc",
"${caName}_CES_Kerberos/service.svc/CES",
'ADPolicyProvider_CEP_Kerberos/service.svc',
'certsrv/mscep/'
)

$endpoints = [System.Collections.Generic.List[object]]::new()

foreach ($path in $paths) {
foreach ($scheme in @('http', 'https')) {
$url = "${scheme}://${hostName}/${path}"
try {
$status = Get-WebEnrollmentEndpointStatus -Url $url
if ($null -ne $status) {
$endpoints.Add($status)
}
} catch {
Write-Verbose "Set-CAWebEnrollmentEndpoints: probe error for $url - $($_.Exception.Message)"
}
}
}

$object.WebEnrollmentEndpoints = $endpoints.ToArray()
$object
}
}

end {
Write-Verbose 'Set-CAWebEnrollmentEndpoints: done.'
}
}
Binary file modified Private/Test/Test-IsUtf8.ps1
Binary file not shown.
Loading
Loading