Kerberos Delegation Diagnostic Tool
Overview
While troubleshooting an issue with Power BI Gateway connecting to a SQL instance, I came across the “Microsoft Kerberos Configuration Manager for SQL Server” tool. The diagnostics sounded perfect for the situation I was in, but unfortunately did not work in my scenario. This PowerShell script was created to help diagnose the fun layers that can come up when integrating service accounts and delegated authentication in Active Directory.
Invoke-KerberosDiagnostic.ps1 runs nine diagnostic sections in sequence, covering every layer of the delegation stack. Results are color-coded in the console and optionally exported to a self-contained HTML report with collapsible sections.

Key Features
- Nine diagnostic sections covering SPNs, RBCD, account flags, CIS/GPO policy risks, gateway service status, DNS, network connectivity, time synchronization, and live Kerberos event analysis
- AD module with ADSI fallback — no RSAT requirement; runs zero-dependency on any domain-joined machine
- Server-side event filtering — XPath-scoped 4769 queries over WinRM to minimize data transfer from the DC
- Delegation chain detection — identifies S4U2Self and S4U2Proxy events to confirm the full RBCD protocol flow is executing
- Self-contained HTML report — collapsible sections, status-coded rows, pass/warn/fail badge summary in the header
- Diagnostic profiles —
PowerBiSql(full suite),SqlOnly, orGeneralto reduce noise for non-PBI scenarios - Prioritized remediation — failures listed before warnings in the console summary, each with a specific remediation command
Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
SourceServer | No | — | FQDN of the server initiating delegation (alias: GatewayServer) |
TargetServer | Yes | — | FQDN of the target server (alias: SqlServer) |
SourceAccount | No | — | SAM account name of the source service or computer account (alias: GatewayGmsa) |
TargetAccount | Yes | — | SAM account name of the target service or computer account (alias: SqlGmsa) |
DomainController | Yes | — | FQDN of a Domain Controller for AD queries and event log access |
TestUserUpn | No | — | UPN of a test user; account flags are checked for this account |
ServiceClass | No | MSSQLSvc | SPN service class for validation |
TargetPort | No | 1433 | TCP port for connectivity check |
Profile | No | PowerBiSql | Diagnostic profile: PowerBiSql, SqlOnly, or General |
ExportHtml | No | — | Generate a self-contained HTML report in the current directory |
Usage
# Full Power BI Gateway → SQL Server diagnostic with HTML report
.\Invoke-KerberosDiagnostic.ps1 `
-SourceServer "pbigateway.corp.com" `
-TargetServer "sql01.corp.com" `
-SourceAccount "svc_pbigateway" `
-TargetAccount "svc_sql" `
-DomainController "dc01.corp.com" `
-TestUserUpn "analyst@corp.com" `
-ExportHtml
# SQL Server only (no PBI Gateway checks)
.\Invoke-KerberosDiagnostic.ps1 `
-TargetServer "sql01.corp.com" `
-TargetAccount "svc_sql" `
-DomainController "dc01.corp.com" `
-Profile SqlOnly
# Generic Kerberos delegation (HTTP service)
.\Invoke-KerberosDiagnostic.ps1 `
-SourceServer "web01.corp.com" `
-TargetServer "api01.corp.com" `
-SourceAccount "svc_web" `
-TargetAccount "svc_api" `
-DomainController "dc01.corp.com" `
-Profile General `
-ServiceClass "HTTP" `
-TargetPort 443
Diagnostic Sections
1. SPN Validation
Queries AD for Service Principal Names across all relevant accounts — source service account, source computer, target service account, and target computer. Detects SPNs incorrectly registered on a computer account instead of a service account (a common misconfiguration that silently breaks delegation) and performs a cross-account duplicate SPN scan.
2. RBCD Configuration
Reads msDS-AllowedToActOnBehalfOfOtherIdentity on the target account and parses the embedded security descriptor to extract the DACL. Resolves each ACE’s SID to an NTAccount name and confirms the source account appears in the allowed principals list. Handles both the ActiveDirectorySecurity type returned by the AD module and the raw byte types returned by ADSI.
3. Account Flags
Inspects userAccountControl and related attributes on source, target, and test user accounts:
- NotDelegated flag (UAC
0x100000) — fails if set; this bit silently blocks all delegation - TrustedToAuthForDelegation — reports protocol transition status on the source account
- Encryption types (
msDS-SupportedEncryptionTypes) — warns if AES128/AES256 not enabled - Protected Users group — fails for service accounts, warns for user accounts
4. CIS / GPO Risks
Checks local machine policy that can silently break Kerberos without any AD misconfiguration:
- Credential Guard — queries
Win32_DeviceGuardfor active security services - Kerberos encryption policy — reads
HKLM:\...\Kerberos\Parameters\SupportedEncryptionTypes - FAST / Kerberos Armoring — checks
RequireFastandKdcFlagRequiredregistry values - Delegation privileges (requires elevation) — exports local security policy via
seceditand checks forSeEnableDelegationPrivilegeandSeDelegateSessionUserImpersonatePrivilege
5. Gateway Service
PowerBiSql profile only. Checks that the Power BI Enterprise Gateway service (PBIEgwService) is installed and running.
6. DNS Resolution
- FQDN quality — warns if the target is a short name; Kerberos SPN matching requires FQDNs
- Forward lookup — resolves the target FQDN to an A or AAAA record
- Reverse PTR lookup — resolves the IP back and compares against the target FQDN; a mismatch can break Kerberos strict name checking
7. Network Connectivity
Opens an async TCP connection to TargetServer:TargetPort with a 2-second timeout.
8. Time Synchronization
Runs w32tm /stripchart against the Domain Controller and parses the reported clock skew. Fails if skew exceeds Kerberos’s 5-minute tolerance window.
9. Kerberos Events
Queries TGS ticket events (Event ID 4769) from the DC’s Security log over WinRM using a server-side XPath filter scoped to the last 30 minutes and the relevant account names. Parses event XML to detect:
- Failed tickets — maps hex status codes to Kerberos error names (
KDC_ERR_POLICY,KDC_ERR_ETYPE_NOTSUPP, and others) - Forwardable flag — checks bit
0x40000000on successful tickets; delegation requires forwardable tickets - Delegation chain — detects S4U2Self (service ticket to self) and S4U2Proxy (transited services present) to confirm the full RBCD protocol flow executed
AD Query Layer
The script uses a dual-path strategy so it runs on any domain-joined machine regardless of whether RSAT is installed:
- Primary:
Get-ADComputer,Get-ADServiceAccount,Get-ADUserfrom the ActiveDirectory module - Fallback: Pure ADSI /
[adsisearcher]queries with RFC 4515 LDAP filter escaping
A canonical property name map normalizes ADSI’s all-lowercase attribute names to their correct AD casing. Invoke-AdQuery auto-detects account type (computer, msaAccount, user, or auto) and tries each in order.
Output
Console
Color-coded [PASS] / [WARN] / [FAIL] per check with section banners. A prioritized remediation summary at the end lists failures first, then warnings, each with a specific remediation command.
HTML Report (-ExportHtml)
Self-contained single-file HTML saved as KerberosDiagnostic_<hostname>_<timestamp>.html:
- Header with source/target/DC/user context and pass/warn/fail badge counts
- Collapsible
<details>sections — sections containing failures or warnings auto-expand - Status-coded table rows (green/yellow/red) per check
Requirements
| Requirement | Notes |
|---|---|
| PowerShell 5.1+ or 7+ | Tested on both Windows PowerShell and PowerShell 7 |
| Domain-joined machine | Required for ADSI fallback context |
| Network access to DC | WinRM for event log queries; UDP/TCP for w32tm |
| Administrator (optional) | Required for secedit delegation privilege checks |
| RSAT AD module (optional) | Falls back to ADSI automatically if unavailable |
Invoke-KerberosDiagnostic.ps1
Save as Invoke-KerberosDiagnostic.ps1:
<#
.SYNOPSIS
General-purpose Kerberos delegation diagnostic tool.
.DESCRIPTION
Invoke-KerberosDiagnostic performs a comprehensive diagnostic of Kerberos
delegation (including RBCD and Constrained Delegation) between a source
service and a target service.
The script supports different profiles (PowerBISql, SqlOnly, General) to
tailor the checks to specific scenarios.
Key checks include:
1. Account health and SPN configuration
2. RBCD (msDS-AllowedToActOnBehalfOfOtherIdentity) settings
3. Account flags (NotDelegated, TrustedToAuth, Encryption Types)
4. CIS / Group Policy risk checks (Credential Guard, FAST)
5. Profile-specific service checks (e.g., Power BI Gateway)
6. DNS resolution (Forward and Reverse)
7. Network connectivity (Port checks)
8. Time synchronization (Clock skew)
9. Event log analysis on the Domain Controller
.PARAMETER SourceServer
FQDN of the server initiating the delegation request.
Alias: GatewayServer.
.PARAMETER TargetServer
FQDN of the target server.
Alias: SqlServer.
.PARAMETER SourceAccount
SAM account name of the service account or computer account for the source.
Alias: GatewayGmsa.
.PARAMETER TargetAccount
SAM account name of the service account or computer account for the target.
Alias: SqlGmsa.
.PARAMETER DomainController
FQDN of a Domain Controller to query for events and AD lookups.
.PARAMETER TestUserUpn
UPN of a test user. Account flags (NotDelegated, Protected Users membership) are checked for
this account. Full delegation simulation (live Kerberos ticket request) is not performed.
.PARAMETER ServiceClass
The service class for SPN checks (default: MSSQLSvc).
.PARAMETER TargetPort
The TCP port to check connectivity on (default: 1433).
.PARAMETER Profile
The diagnostic profile to use: PowerBiSql, SqlOnly, or General.
- PowerBiSql: Full suite including Power BI Gateway checks.
- SqlOnly: Standard SQL Kerberos checks.
- General: Generic Kerberos delegation checks.
.PARAMETER ExportHtml
Generates an HTML report.
.EXAMPLE
.\Invoke-KerberosDiagnostic.ps1 `
-SourceServer "web01.corp.com" `
-TargetServer "sql01.corp.com" `
-SourceAccount "svc_web" `
-TargetAccount "svc_sql" `
-DomainController "dc01.corp.com" `
-TestUserUpn "user@corp.com" `
-Profile General `
-ServiceClass "HTTP" `
-TargetPort 80
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[Alias("GatewayServer")]
[string]$SourceServer,
[Parameter(Mandatory = $true)]
[Alias("SqlServer")]
[string]$TargetServer,
[Parameter(Mandatory = $false)]
[Alias("GatewayGmsa")]
[string]$SourceAccount,
[Parameter(Mandatory = $true)]
[Alias("SqlGmsa")]
[string]$TargetAccount,
[Parameter(Mandatory = $true)]
[string]$DomainController,
[Parameter(Mandatory = $false)]
[string]$TestUserUpn,
[Parameter(Mandatory = $false)]
[string]$ServiceClass = "MSSQLSvc",
[Parameter(Mandatory = $false)]
[int]$TargetPort = 1433,
[Parameter(Mandatory = $false)]
[ValidateSet("PowerBiSql", "SqlOnly", "General")]
[string]$Profile = "PowerBiSql",
[Parameter(Mandatory = $false)]
[switch]$ExportHtml
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
# =========================================================================
# GLOBAL STATE
# =========================================================================
$script:AllResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$script:UseAdModule = $false
$script:IsAdmin = $false
# Derive short computer names
$SourceComputer = if ($SourceServer) { ($SourceServer -split '\.')[0] } else { $null }
$TargetComputer = ($TargetServer -split '\.')[0]
# Normalize account names - we keep them as provided but handle $ logic in queries
$SourceAccount = if ($SourceAccount) { $SourceAccount.Trim() } else { $null }
$TargetAccount = $TargetAccount.Trim()
# Canonical AD property name mapping (ADSI returns all-lowercase)
$script:AdPropertyMap = @{
'serviceprincipalname' = 'servicePrincipalName'
'msds-allowedtoactonbehalfofotheridentity' = 'msDS-AllowedToActOnBehalfOfOtherIdentity'
'useraccountcontrol' = 'userAccountControl'
'msds-supportedencryptiontypes' = 'msDS-SupportedEncryptionTypes'
'memberof' = 'memberOf'
'distinguishedname' = 'distinguishedName'
'samaccountname' = 'sAMAccountName'
}
function ConvertTo-LdapFilterValue {
param([string]$Value)
# RFC 4515 - escape special LDAP filter characters
$Value = $Value.Replace('\', '\5c')
$Value = $Value.Replace('*', '\2a')
$Value = $Value.Replace('(', '\28')
$Value = $Value.Replace(')', '\29')
$Value = $Value.Replace([char]0, '\00')
return $Value
}
# Kerberos TGS status code map (Event ID 4769)
$script:KerbStatusCodes = @{
'0x0' = 'Success'
'0x6' = 'KDC_ERR_C_PRINCIPAL_UNKNOWN'
'0xc' = 'KDC_ERR_POLICY'
'0xd' = 'KDC_ERR_BADOPTION'
'0xe' = 'KDC_ERR_ETYPE_NOTSUPP'
'0x12' = 'KDC_ERR_CLIENT_REVOKED'
'0x17' = 'KDC_ERR_KEY_EXPIRED'
'0x1b' = 'KDC_ERR_MUST_USE_USER2USER'
'0x20' = 'KDC_ERR_SUMTYPE_NOSUPP'
}
# =========================================================================
# OUTPUT HELPERS
# =========================================================================
function New-DiagResult {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string]$Section,
[Parameter(Mandatory = $true)] [string]$Check,
[Parameter(Mandatory = $true)] [ValidateSet('Pass', 'Warn', 'Fail')] [string]$Status,
[Parameter(Mandatory = $true)] [string]$Detail,
[Parameter(Mandatory = $false)] [string]$Remediation = ''
)
$result = [PSCustomObject]@{
Section = $Section
Check = $Check
Status = $Status
Detail = $Detail
Remediation = $Remediation
}
$script:AllResults.Add($result)
switch ($Status) {
'Pass' { Write-Host " [PASS] " -ForegroundColor Green -NoNewline }
'Warn' { Write-Host " [WARN] " -ForegroundColor Yellow -NoNewline }
'Fail' { Write-Host " [FAIL] " -ForegroundColor Red -NoNewline }
}
Write-Host "$Check - $Detail"
return $result
}
function Write-SectionBanner {
param([string]$Title)
Write-Host ""
Write-Host ('=' * 60) -ForegroundColor Cyan
Write-Host " $Title" -ForegroundColor Cyan
Write-Host ('=' * 60) -ForegroundColor Cyan
}
function Write-Summary {
$passCount = @($script:AllResults | Where-Object { $_.Status -eq 'Pass' }).Count
$warnCount = @($script:AllResults | Where-Object { $_.Status -eq 'Warn' }).Count
$failCount = @($script:AllResults | Where-Object { $_.Status -eq 'Fail' }).Count
Write-Host ""
Write-Host ('=' * 60) -ForegroundColor Cyan
Write-Host " SUMMARY" -ForegroundColor Cyan
Write-Host ('=' * 60) -ForegroundColor Cyan
Write-Host ""
$summaryLine = ' Results: {0} Pass | {1} Warn | {2} Fail' -f $passCount, $warnCount, $failCount
if ($failCount -gt 0) { Write-Host $summaryLine -ForegroundColor Red }
elseif ($warnCount -gt 0) { Write-Host $summaryLine -ForegroundColor Yellow }
else { Write-Host $summaryLine -ForegroundColor Green }
Write-Host ""
$actionable = @($script:AllResults | Where-Object { $_.Status -in @('Warn', 'Fail') })
if ($actionable.Count -eq 0) {
Write-Host " All checks passed. No remediation required." -ForegroundColor Green
Write-Host ""
return
}
Write-Host " Remediation Summary:" -ForegroundColor White
Write-Host (' ' + ('-' * 56)) -ForegroundColor Gray
$sorted = $actionable | Sort-Object @{ Expression = { switch ($_.Status) { 'Fail' { 0 }; 'Warn' { 1 } } } }
foreach ($item in $sorted) {
$color = if ($item.Status -eq 'Fail') { 'Red' } else { 'Yellow' }
Write-Host " [$($item.Status.ToUpper())] " -ForegroundColor $color -NoNewline
Write-Host "$($item.Section) > $($item.Check)" -ForegroundColor White
if ($item.Remediation) { Write-Host " -> $($item.Remediation)" -ForegroundColor Gray }
}
Write-Host ""
}
# (Export-HtmlReport is largely the same, but updated for generic terms)
function Export-HtmlReport {
[CmdletBinding()]
param()
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$hostname = $env:COMPUTERNAME
$filePath = Join-Path -Path (Get-Location) -ChildPath ('KerberosDiagnostic_{0}_{1}.html' -f $hostname, $timestamp)
$passCount = @($script:AllResults | Where-Object { $_.Status -eq 'Pass' }).Count
$warnCount = @($script:AllResults | Where-Object { $_.Status -eq 'Warn' }).Count
$failCount = @($script:AllResults | Where-Object { $_.Status -eq 'Fail' }).Count
$reportTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$css = @'
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f5f5f5; color: #333; line-height: 1.5; }
.header { background: #1a1a2e; color: #eee; padding: 24px 32px; }
.header h1 { font-size: 1.6em; margin-bottom: 8px; }
.header .meta { font-size: 0.9em; color: #aaa; margin-bottom: 4px; }
.badges { margin-top: 12px; display: flex; gap: 12px; }
.badge { padding: 6px 16px; border-radius: 4px; font-weight: 600; font-size: 0.95em; }
.badge-pass { background: #27ae60; color: #fff; }
.badge-warn { background: #f39c12; color: #fff; }
.badge-fail { background: #e74c3c; color: #fff; }
.content { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
details { background: #fff; border-radius: 6px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
summary { padding: 12px 16px; cursor: pointer; font-weight: 600; font-size: 1.05em; list-style: none; }
summary::before { margin-right: 8px; }
.section-body { padding: 0 16px 16px; }
table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
th { background: #2c3e50; color: #fff; padding: 8px 12px; text-align: left; }
td { padding: 8px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
tr.pass { background: #eafaf1; }
tr.warn { background: #fef9e7; }
tr.fail { background: #fdedec; }
.remediation-panel { position: sticky; bottom: 0; background: #fff; border-top: 3px solid #2c3e50; padding: 16px 32px; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); margin-top: 24px; }
</style>
'@
$headerHtml = [System.Text.StringBuilder]::new()
[void]$headerHtml.AppendLine('<div class="header">')
[void]$headerHtml.AppendLine(' <h1>Kerberos Delegation Diagnostic Report</h1>')
[void]$headerHtml.AppendLine((' <div class="meta">Source: {0}  |  Target: {1}  |  Generated: {2}</div>' -f [System.Net.WebUtility]::HtmlEncode($SourceServer), [System.Net.WebUtility]::HtmlEncode($TargetServer), $reportTime))
[void]$headerHtml.AppendLine((' <div class="meta">Source Account: {0}  |  Target Account: {1}  |  DC: {2}  |  User: {3}</div>' -f [System.Net.WebUtility]::HtmlEncode($SourceAccount), [System.Net.WebUtility]::HtmlEncode($TargetAccount), [System.Net.WebUtility]::HtmlEncode($DomainController), [System.Net.WebUtility]::HtmlEncode($TestUserUpn)))
[void]$headerHtml.AppendLine(' <div class="badges">')
[void]$headerHtml.AppendLine((' <span class="badge badge-pass">{0} Pass</span>' -f $passCount))
[void]$headerHtml.AppendLine((' <span class="badge badge-warn">{0} Warn</span>' -f $warnCount))
[void]$headerHtml.AppendLine((' <span class="badge badge-fail">{0} Fail</span>' -f $failCount))
[void]$headerHtml.AppendLine(' </div>')
[void]$headerHtml.AppendLine('</div>')
# Collapsible sections
$sections = $script:AllResults | Group-Object -Property Section
$sectionHtml = [System.Text.StringBuilder]::new()
foreach ($section in $sections) {
$hasFailOrWarn = @($section.Group | Where-Object { $_.Status -in @('Fail', 'Warn') }).Count -gt 0
$openAttr = if ($hasFailOrWarn) { ' open' } else { '' }
$hasFail = @($section.Group | Where-Object { $_.Status -eq 'Fail' }).Count -gt 0
$hasWarn = @($section.Group | Where-Object { $_.Status -eq 'Warn' }).Count -gt 0
$icon = if ($hasFail) { '❌' } elseif ($hasWarn) { '⚠' } else { '✅' }
[void]$sectionHtml.AppendLine(('<details{0}>' -f $openAttr))
[void]$sectionHtml.AppendLine((' <summary>{0} {1}</summary>' -f $icon, [System.Net.WebUtility]::HtmlEncode($section.Name)))
[void]$sectionHtml.AppendLine(' <div class="section-body">')
[void]$sectionHtml.AppendLine(' <table><tr><th>Status</th><th>Check</th><th>Detail</th></tr>')
foreach ($result in $section.Group) {
[void]$sectionHtml.AppendLine((' <tr class="{0}"><td>{1}</td><td>{2}</td><td>{3}</td></tr>' -f $result.Status.ToLower(), $result.Status.ToUpper(), [System.Net.WebUtility]::HtmlEncode($result.Check), [System.Net.WebUtility]::HtmlEncode($result.Detail)))
}
[void]$sectionHtml.AppendLine(' </table>')
[void]$sectionHtml.AppendLine(' </div>')
[void]$sectionHtml.AppendLine('</details>')
}
$html = @"
<!DOCTYPE html>
<html>
<head><title>Kerberos Diagnostic - $reportTime</title>$css</head>
<body>
$($headerHtml.ToString())
<div class="content">$($sectionHtml.ToString())</div>
</body>
</html>
"@
$html | Out-File -FilePath $filePath -Encoding UTF8
Write-Host "`n HTML report saved to: $filePath" -ForegroundColor Green
}
# =========================================================================
# AD QUERY HELPERS
# =========================================================================
function Initialize-AdAccess {
$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object System.Security.Principal.WindowsPrincipal($currentIdentity)
$script:IsAdmin = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $script:IsAdmin) {
Write-Host " WARNING: Not running as Administrator." -ForegroundColor Yellow
}
try { Import-Module ActiveDirectory -ErrorAction Stop; $script:UseAdModule = $true }
catch { $script:UseAdModule = $false; Write-Host " AD module not available - using ADSI fallback" -ForegroundColor Gray }
}
function Invoke-AdQuery {
param([string]$Identity, [string[]]$Properties = @('*'), [string]$ObjectType = 'auto')
if ($script:UseAdModule) {
try {
switch ($ObjectType) {
'computer' { return Get-ADComputer -Identity $Identity -Properties $Properties -ErrorAction Stop }
'msaAccount' { return Get-ADServiceAccount -Identity $Identity -Properties $Properties -ErrorAction Stop }
'user' { return Get-ADUser -Identity $Identity -Properties $Properties -ErrorAction Stop }
'auto' {
$obj = Get-ADServiceAccount -Identity $Identity -Properties $Properties -ErrorAction SilentlyContinue
if ($obj) { return $obj }
$obj = Get-ADComputer -Identity $Identity -Properties $Properties -ErrorAction SilentlyContinue
if ($obj) { return $obj }
return Get-ADUser -Identity $Identity -Properties $Properties -ErrorAction SilentlyContinue
}
}
} catch { Write-Verbose "AD module query failed for '$Identity': $($_.Exception.Message)" }
}
return Invoke-AdQueryAdsi -Identity $Identity -Properties $Properties -ObjectType $ObjectType
}
function Invoke-AdQueryAdsi {
param([string]$Identity, [string[]]$Properties = @('*'), [string]$ObjectType)
$searcher = $null
try {
$searcher = [adsisearcher]::new()
$samName = ConvertTo-LdapFilterValue ($Identity.TrimEnd('$'))
$escapedIdentity = ConvertTo-LdapFilterValue $Identity
switch ($ObjectType) {
'computer' { $searcher.Filter = "(&(objectCategory=computer)(sAMAccountName=$samName`$))" }
'user' { $searcher.Filter = if ($Identity -match '@') { "(&(objectCategory=person)(objectClass=user)(userPrincipalName=$escapedIdentity))" } else { "(&(objectCategory=person)(objectClass=user)(sAMAccountName=$escapedIdentity))" } }
'msaAccount' { $searcher.Filter = "(&(|(objectClass=msDS-GroupManagedServiceAccount)(objectClass=msDS-ManagedServiceAccount))(sAMAccountName=$samName`$))" }
'auto' { $searcher.Filter = if ($Identity -match '@') { "(|(userPrincipalName=$escapedIdentity)(sAMAccountName=$escapedIdentity))" } else { "(|(sAMAccountName=$escapedIdentity)(sAMAccountName=$samName`$))" } }
}
$searchResult = $searcher.FindOne()
if ($null -eq $searchResult) { return $null }
$obj = [ordered]@{}
foreach ($propName in $searchResult.Properties.PropertyNames) {
$values = $searchResult.Properties[$propName]
$canonicalName = if ($script:AdPropertyMap.ContainsKey($propName)) { $script:AdPropertyMap[$propName] } else { $propName }
$obj[$canonicalName] = if ($values.Count -eq 1) { $values[0] } else { @($values) }
}
return [PSCustomObject]$obj
} catch { return $null }
finally { if ($searcher) { $searcher.Dispose() } }
}
# =========================================================================
# DIAGNOSTIC SECTIONS
# =========================================================================
function Test-SpnValidation {
$sectionName = 'SPN Validation'
Write-SectionBanner $sectionName
$accounts = @(
@{ Label = "Target Account ($TargetAccount)"; Identity = $TargetAccount; Type = 'auto' },
@{ Label = "Target Computer ($TargetComputer)"; Identity = $TargetComputer; Type = 'computer' }
)
if ($SourceAccount) { $accounts += @{ Label = "Source Account ($SourceAccount)"; Identity = $SourceAccount; Type = 'auto' } }
if ($SourceComputer) { $accounts += @{ Label = "Source Computer ($SourceComputer)"; Identity = $SourceComputer; Type = 'computer' } }
$spnMap = @{}
foreach ($acct in $accounts) {
$obj = Invoke-AdQuery -Identity $acct.Identity -Properties @('servicePrincipalName') -ObjectType $acct.Type
if ($null -eq $obj) {
New-DiagResult -Section $sectionName -Check "SPNs on $($acct.Label)" -Status 'Fail' -Detail "Could not query account."
continue
}
$spns = $obj.servicePrincipalName
if ($null -eq $spns) {
New-DiagResult -Section $sectionName -Check "SPNs on $($acct.Label)" -Status 'Warn' -Detail "No SPNs registered."
} else {
$spns = @($spns)
New-DiagResult -Section $sectionName -Check "SPNs on $($acct.Label)" -Status 'Pass' -Detail "Found $($spns.Count) SPN(s)."
foreach ($spn in $spns) {
$s = $spn.ToLower()
if (-not $spnMap.ContainsKey($s)) { $spnMap[$s] = [System.Collections.Generic.List[string]]::new() }
$spnMap[$s].Add($acct.Label)
}
if ($acct.Type -eq 'computer') {
$bad = $spns | Where-Object { $_ -match "^$([regex]::Escape($ServiceClass))/" }
if ($bad) { New-DiagResult -Section $sectionName -Check "$ServiceClass SPNs on $($acct.Label)" -Status 'Fail' -Detail "Found on computer (should be on service account)." }
}
}
}
# Check for duplicate SPNs across accounts
$duplicates = $spnMap.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 }
if ($duplicates) {
foreach ($dup in $duplicates) {
$owners = $dup.Value -join ', '
New-DiagResult -Section $sectionName -Check "Duplicate SPN" -Status 'Fail' -Detail "SPN '$($dup.Key)' registered on multiple accounts: $owners" -Remediation "Remove duplicate SPN from all but the correct account using setspn -D."
}
} else {
New-DiagResult -Section $sectionName -Check "Duplicate SPN scan" -Status 'Pass' -Detail "No duplicate SPNs found across queried accounts."
}
}
function Test-RbcdConfiguration {
$sectionName = 'RBCD Configuration'
Write-SectionBanner $sectionName
$targetObj = Invoke-AdQuery -Identity $TargetAccount -Properties @('msDS-AllowedToActOnBehalfOfOtherIdentity')
if ($null -eq $targetObj) {
New-DiagResult -Section $sectionName -Check "Query Target" -Status 'Fail' -Detail "Could not query $TargetAccount." -Remediation "Verify the account name and your permissions."
return
}
$rbcdRaw = $targetObj.'msDS-AllowedToActOnBehalfOfOtherIdentity'
if ($null -eq $rbcdRaw) {
New-DiagResult -Section $sectionName -Check "RBCD on Target" -Status 'Fail' -Detail "Attribute not set on $TargetAccount." -Remediation "`$src = Get-ADComputer '<source_account>'; Set-ADComputer '$TargetAccount' -PrincipalsAllowedToDelegateToAccount `$src"
return
}
New-DiagResult -Section $sectionName -Check "RBCD on Target" -Status 'Pass' -Detail "Attribute is present."
# Parse security descriptor to extract allowed principals
try {
$rawSd = $null
if ($rbcdRaw -is [System.DirectoryServices.ActiveDirectorySecurity]) {
# AD module returns ActiveDirectorySecurity - get binary form, then parse
$bytes = $rbcdRaw.GetSecurityDescriptorBinaryForm()
$rawSd = New-Object System.Security.AccessControl.RawSecurityDescriptor($bytes, 0)
} else {
# ADSI returns byte[] or __ComObject (COM variant wrapping a byte array) — both cast via [byte[]]
try {
$bytes = [byte[]]$rbcdRaw
$rawSd = New-Object System.Security.AccessControl.RawSecurityDescriptor($bytes, 0)
} catch { }
}
if ($null -eq $rawSd -or $null -eq $rawSd.DiscretionaryAcl) {
New-DiagResult -Section $sectionName -Check "RBCD Principals" -Status 'Warn' -Detail "Could not read DACL from security descriptor."
return
}
$principals = @()
foreach ($ace in $rawSd.DiscretionaryAcl) {
$sid = $ace.SecurityIdentifier
try {
$account = $sid.Translate([System.Security.Principal.NTAccount])
$principals += $account.Value
} catch {
$principals += $sid.Value
}
}
if ($principals.Count -eq 0) {
New-DiagResult -Section $sectionName -Check "RBCD Principals" -Status 'Warn' -Detail "Security descriptor has no access control entries."
return
}
New-DiagResult -Section $sectionName -Check "RBCD Principals" -Status 'Pass' -Detail "Allowed: $($principals -join ', ')"
# Check if source account is in the RBCD list
if ($SourceAccount) {
$sourceFound = $false
$sourceNorm = $SourceAccount.TrimEnd('$').ToLower()
foreach ($p in $principals) {
$shortName = (($p -split '\\')[-1]).TrimEnd('$').ToLower()
if ($shortName -eq $sourceNorm) { $sourceFound = $true; break }
}
if ($sourceFound) {
New-DiagResult -Section $sectionName -Check "Source in RBCD" -Status 'Pass' -Detail "$SourceAccount is allowed to delegate to $TargetAccount."
} else {
New-DiagResult -Section $sectionName -Check "Source in RBCD" -Status 'Fail' -Detail "$SourceAccount not found in RBCD allowed list." -Remediation "`$src = Get-ADComputer '$SourceAccount'; Set-ADComputer '$TargetAccount' -PrincipalsAllowedToDelegateToAccount `$src"
}
}
} catch {
New-DiagResult -Section $sectionName -Check "RBCD Principals" -Status 'Warn' -Detail "Could not parse security descriptor: $($_.Exception.Message)"
}
}
function Test-AccountFlags {
$sectionName = 'Account Flags'
Write-SectionBanner $sectionName
$checkAccts = @(@{ Label = "Target ($TargetAccount)"; Identity = $TargetAccount })
if ($SourceAccount) { $checkAccts += @{ Label = "Source ($SourceAccount)"; Identity = $SourceAccount } }
if ($TestUserUpn) { $checkAccts += @{ Label = "User ($TestUserUpn)"; Identity = $TestUserUpn } }
foreach ($acct in $checkAccts) {
$obj = Invoke-AdQuery -Identity $acct.Identity -Properties @('userAccountControl', 'msDS-SupportedEncryptionTypes', 'memberOf')
if ($null -eq $obj) { continue }
$uac = [int]($obj.userAccountControl)
$notDelegated = ($uac -band 0x100000) -ne 0
$status = if ($notDelegated) { 'Fail' } else { 'Pass' }
New-DiagResult -Section $sectionName -Check "NotDelegated on $($acct.Label)" -Status $status -Detail "Flag is $(if($notDelegated){'SET'}else{'not set'})."
if ($acct.Label -match 'Source') {
$trustedToAuth = ($uac -band 0x1000000) -ne 0
New-DiagResult -Section $sectionName -Check "Protocol Transition on $($acct.Label)" -Status 'Pass' -Detail "TrustedToAuthForDelegation: $trustedToAuth"
}
# Encryption type validation
$encTypes = $obj.'msDS-SupportedEncryptionTypes'
if ($null -ne $encTypes) {
$encTypes = [int]$encTypes
$flags = @()
if ($encTypes -band 0x1) { $flags += 'DES-CBC-CRC' }
if ($encTypes -band 0x2) { $flags += 'DES-CBC-MD5' }
if ($encTypes -band 0x4) { $flags += 'RC4-HMAC' }
if ($encTypes -band 0x8) { $flags += 'AES128-CTS' }
if ($encTypes -band 0x10) { $flags += 'AES256-CTS' }
$hasAes = ($encTypes -band 0x18) -ne 0
$status = if ($hasAes) { 'Pass' } else { 'Warn' }
$remediation = if (-not $hasAes) { "Enable AES encryption types on this account." } else { '' }
New-DiagResult -Section $sectionName -Check "Encryption on $($acct.Label)" -Status $status -Detail "Types: $($flags -join ', ')" -Remediation $remediation
}
# Protected Users group membership
$memberOf = $obj.memberOf
if ($null -ne $memberOf) {
$memberOf = @($memberOf)
$inProtectedUsers = $memberOf | Where-Object { $_ -match 'CN=Protected Users' }
if ($inProtectedUsers) {
# Service accounts in Protected Users = Fail (breaks delegation)
# Test users in Protected Users = Warn (expected in some environments but blocks delegation)
$puStatus = if ($acct.Label -match 'User') { 'Warn' } else { 'Fail' }
New-DiagResult -Section $sectionName -Check "Protected Users on $($acct.Label)" -Status $puStatus -Detail "Member of Protected Users group." -Remediation "Protected Users membership prevents delegation. Remove if delegation is required."
}
}
}
}
function Test-CisGpoRisks {
$sectionName = 'CIS / GPO Risks'
Write-SectionBanner $sectionName
# Check 1: Credential Guard
try {
$dg = Get-CimInstance -ClassName Win32_DeviceGuard -Namespace root\Microsoft\Windows\DeviceGuard -ErrorAction Stop
$cgRunning = ($dg.SecurityServicesRunning -contains 1)
if ($cgRunning) {
New-DiagResult -Section $sectionName -Check "Credential Guard" -Status 'Warn' -Detail "Credential Guard is enabled." -Remediation "Verify delegation works with Credential Guard active."
} else {
New-DiagResult -Section $sectionName -Check "Credential Guard" -Status 'Pass' -Detail "Credential Guard is not running."
}
} catch {
New-DiagResult -Section $sectionName -Check "Credential Guard" -Status 'Warn' -Detail "Could not query DeviceGuard status: $($_.Exception.Message)"
}
# Check 2: Kerberos encryption types registry
$kerbRegPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Kerberos\Parameters'
try {
$regEncTypes = Get-ItemProperty -Path $kerbRegPath -Name 'SupportedEncryptionTypes' -ErrorAction Stop
$val = [int]$regEncTypes.SupportedEncryptionTypes
$flags = @()
if ($val -band 0x1) { $flags += 'DES-CBC-CRC' }
if ($val -band 0x2) { $flags += 'DES-CBC-MD5' }
if ($val -band 0x4) { $flags += 'RC4-HMAC' }
if ($val -band 0x8) { $flags += 'AES128-CTS' }
if ($val -band 0x10) { $flags += 'AES256-CTS' }
$hasAes = ($val -band 0x18) -ne 0
if ($hasAes) {
New-DiagResult -Section $sectionName -Check "Kerberos Encryption Policy" -Status 'Pass' -Detail "Policy types: $($flags -join ', ')"
} else {
New-DiagResult -Section $sectionName -Check "Kerberos Encryption Policy" -Status 'Warn' -Detail "Policy restricts to: $($flags -join ', ')" -Remediation "AES not included in policy encryption types."
}
} catch {
New-DiagResult -Section $sectionName -Check "Kerberos Encryption Policy" -Status 'Pass' -Detail "No policy override — Windows defaults (all types) in effect."
}
# Check 3: FAST / Kerberos Armoring
try {
$regFast = Get-ItemProperty -Path $kerbRegPath -ErrorAction SilentlyContinue
$requireFast = if ($null -ne $regFast -and (Get-Member -InputObject $regFast -Name 'RequireFast' -ErrorAction SilentlyContinue)) { $regFast.RequireFast } else { 0 }
$kdcFlag = if ($null -ne $regFast -and (Get-Member -InputObject $regFast -Name 'KdcFlagRequired' -ErrorAction SilentlyContinue)) { $regFast.KdcFlagRequired } else { 0 }
if ($requireFast -eq 1 -or $kdcFlag -eq 1) {
New-DiagResult -Section $sectionName -Check "FAST / Armoring" -Status 'Warn' -Detail "RequireFast=$requireFast, KdcFlagRequired=$kdcFlag" -Remediation "Kerberos FAST/armoring required — can cause delegation failures if KDC or client does not support it."
} else {
New-DiagResult -Section $sectionName -Check "FAST / Armoring" -Status 'Pass' -Detail "FAST/armoring not enforced."
}
} catch {
New-DiagResult -Section $sectionName -Check "FAST / Armoring" -Status 'Pass' -Detail "No FAST/armoring policy configured."
}
# Check 4: secedit delegation privileges (requires elevation)
if ($script:IsAdmin) {
$tmpFile = $null
try {
$tmpFile = [System.IO.Path]::GetTempFileName()
& secedit /export /cfg $tmpFile /quiet 2>&1 | Out-Null
$content = Get-Content $tmpFile -Raw -ErrorAction Stop
# Check delegation-specific privileges (SeImpersonatePrivilege omitted — assigned to NT AUTHORITY\SERVICE by default, always passes)
$privChecks = @(
@{ Name = 'SeEnableDelegationPrivilege'; Label = 'Enable Delegation'; Desc = 'Required to configure delegation settings on AD accounts.' }
@{ Name = 'SeDelegateSessionUserImpersonatePrivilege'; Label = 'Session Impersonation'; Desc = 'Required to impersonate another user in the same session.' }
)
foreach ($priv in $privChecks) {
if ($content -match "$($priv.Name)\s*=\s*(.+)") {
New-DiagResult -Section $sectionName -Check "$($priv.Label)" -Status 'Pass' -Detail "$($priv.Name) assigned to: $($Matches[1].Trim())"
} else {
New-DiagResult -Section $sectionName -Check "$($priv.Label)" -Status 'Warn' -Detail "$($priv.Name) not found in local policy." -Remediation $priv.Desc
}
}
} catch {
New-DiagResult -Section $sectionName -Check "Delegation Privileges" -Status 'Warn' -Detail "secedit export failed: $($_.Exception.Message)"
} finally {
if ($tmpFile -and (Test-Path $tmpFile)) { Remove-Item $tmpFile -Force -ErrorAction SilentlyContinue }
}
} else {
New-DiagResult -Section $sectionName -Check "Delegation Privileges" -Status 'Warn' -Detail "Skipped — requires elevation." -Remediation "Run as Administrator to check delegation privileges via secedit."
}
}
function Test-Connectivity {
param([string]$Server, [int]$Port)
$sectionName = 'Network Connectivity'
Write-SectionBanner $sectionName
$tcp = $null
$connect = $null
try {
$tcp = New-Object System.Net.Sockets.TcpClient
$connect = $tcp.BeginConnect($Server, $Port, $null, $null)
if ($connect.AsyncWaitHandle.WaitOne(2000, $false)) {
$tcp.EndConnect($connect)
New-DiagResult -Section $sectionName -Check "Port $Port connectivity" -Status 'Pass' -Detail "Connected to $Server."
} else { New-DiagResult -Section $sectionName -Check "Port $Port connectivity" -Status 'Fail' -Detail "Timeout connecting to $Server." }
} catch { New-DiagResult -Section $sectionName -Check "Port $Port connectivity" -Status 'Fail' -Detail "Error: $($_.Exception.Message)" }
finally {
if ($connect -and $connect.AsyncWaitHandle) { $connect.AsyncWaitHandle.Dispose() }
if ($tcp) { $tcp.Close() }
}
}
function Test-ClockSkew {
param([string]$DC)
$sectionName = 'Time Sync'
Write-SectionBanner $sectionName
try {
$output = & w32tm /stripchart /computer:$DC /samples:1 /dataonly 2>&1
# w32tm output last data line format: "HH:MM:SS, +/-N.NNNNNNNs"
$dataLine = ($output | Where-Object { $_ -match '[+-]\d+\.\d+s' } | Select-Object -Last 1)
if ($dataLine -match '([+-]?\d+\.\d+)s') {
$skewSeconds = [Math]::Abs([double]::Parse($Matches[1], [System.Globalization.CultureInfo]::InvariantCulture))
$skewMinutes = $skewSeconds / 60
$status = if ($skewMinutes -lt 5) { 'Pass' } else { 'Fail' }
$remediation = if ($skewMinutes -ge 5) { "Synchronize time: w32tm /resync /force" } else { '' }
New-DiagResult -Section $sectionName -Check "Clock skew vs DC" -Status $status -Detail ("Skew: {0:N2}s ({1:N2} min)." -f $skewSeconds, $skewMinutes) -Remediation $remediation
} else {
New-DiagResult -Section $sectionName -Check "Clock skew vs DC" -Status 'Warn' -Detail "Could not parse w32tm output." -Remediation "Run manually: w32tm /stripchart /computer:$DC /samples:1"
}
} catch {
New-DiagResult -Section $sectionName -Check "Clock skew vs DC" -Status 'Warn' -Detail "w32tm failed: $($_.Exception.Message)" -Remediation "Run manually: w32tm /stripchart /computer:$DC /samples:1"
}
}
function Test-DnsResolution {
$sectionName = 'DNS'
Write-SectionBanner $sectionName
# Check 1: FQDN quality
if ($TargetServer -notmatch '\.') {
New-DiagResult -Section $sectionName -Check "FQDN Quality" -Status 'Warn' -Detail "Target '$TargetServer' is a short name." -Remediation "Kerberos requires FQDN for proper SPN matching. Use the fully qualified domain name."
}
# Check 2: Forward lookup (A and AAAA)
$forwardIp = $null
try {
$res = Resolve-DnsName -Name $TargetServer -ErrorAction Stop
$forwardIp = ($res | Where-Object { $_.QueryType -in @('A', 'AAAA') } | Select-Object -First 1).IPAddress
if ($forwardIp) {
New-DiagResult -Section $sectionName -Check "Forward Lookup" -Status 'Pass' -Detail "Resolved $TargetServer to $forwardIp."
} else {
New-DiagResult -Section $sectionName -Check "Forward Lookup" -Status 'Warn' -Detail "Resolved $TargetServer but no A record found."
}
} catch {
New-DiagResult -Section $sectionName -Check "Forward Lookup" -Status 'Fail' -Detail "Failed to resolve $TargetServer." -Remediation "Verify DNS configuration and that $TargetServer has an A record."
}
# Check 3: Reverse PTR lookup
if ($forwardIp) {
try {
$ptr = Resolve-DnsName -Name $forwardIp -Type PTR -ErrorAction Stop
$ptrName = ($ptr | Where-Object { $_.QueryType -eq 'PTR' } | Select-Object -First 1).NameHost
if ($ptrName) {
$ptrNorm = $ptrName.TrimEnd('.')
$targetNorm = $TargetServer.TrimEnd('.')
if ($ptrNorm -eq $targetNorm) {
New-DiagResult -Section $sectionName -Check "Reverse PTR Lookup" -Status 'Pass' -Detail "PTR for $forwardIp resolves to $ptrNorm."
} else {
New-DiagResult -Section $sectionName -Check "Reverse PTR Lookup" -Status 'Warn' -Detail "PTR mismatch: $ptrNorm vs $targetNorm." -Remediation "Kerberos strict name checking may fail when PTR does not match the target FQDN."
}
} else {
New-DiagResult -Section $sectionName -Check "Reverse PTR Lookup" -Status 'Warn' -Detail "No PTR record for $forwardIp." -Remediation "Reverse lookup failures can break Kerberos in some configurations."
}
} catch {
New-DiagResult -Section $sectionName -Check "Reverse PTR Lookup" -Status 'Warn' -Detail "No PTR record for $forwardIp." -Remediation "Reverse lookup failures can break Kerberos in some configurations."
}
}
}
function Test-GatewayService {
$sectionName = 'Gateway Service'
Write-SectionBanner $sectionName
$svc = Get-Service -Name 'PBIEgwService' -ErrorAction SilentlyContinue
if ($null -eq $svc) { New-DiagResult -Section $sectionName -Check "Service" -Status 'Fail' -Detail "Not found."; return }
$status = if ($svc.Status -eq 'Running') { 'Pass' } else { 'Fail' }
New-DiagResult -Section $sectionName -Check "Service status" -Status $status -Detail "Status: $($svc.Status)"
}
function Test-KerberosEvents {
$sectionName = 'Kerberos Events'
Write-SectionBanner $sectionName
# Build server-side XPath filter to avoid pulling all 4769 events over WinRM
$sourceNorm = if ($SourceAccount) { $SourceAccount.TrimEnd('$').ToLower() } else { $null }
$targetNorm = $TargetAccount.TrimEnd('$').ToLower()
$startTimeUtc = (Get-Date).AddMinutes(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z')
# Build account filter conditions for XPath — XML-escape values to handle sAMAccountName special chars
$acctFilters = @()
$targetVariants = @($TargetAccount, ($TargetAccount.TrimEnd('$')), ($TargetAccount.TrimEnd('$') + '$'))
if ($SourceAccount) {
$sourceVariants = @($SourceAccount, ($SourceAccount.TrimEnd('$')), ($SourceAccount.TrimEnd('$') + '$'))
} else { $sourceVariants = @() }
foreach ($v in ($targetVariants + $sourceVariants) | Select-Object -Unique) {
$vXml = [System.Security.SecurityElement]::Escape($v)
$acctFilters += "(Data[@Name='TargetUserName']='$vXml')"
$acctFilters += "(Data[@Name='ServiceName']='$vXml')"
}
$acctXPath = $acctFilters -join ' or '
$filterXml = @"
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[(EventID=4769) and TimeCreated[@SystemTime>='$startTimeUtc']]]
and
*[EventData[$acctXPath]]
</Select>
</Query>
</QueryList>
"@
$allEvents = $null
try {
# Get filtered events (server-side)
$allEvents = Get-WinEvent -ComputerName $DomainController -FilterXml $filterXml -ErrorAction Stop
} catch {
$msg = $_.Exception.Message
if ($msg -match 'Access is denied' -or $msg -match 'access denied' -or $msg -match 'UnauthorizedAccess') {
New-DiagResult -Section $sectionName -Check "DC Event Log Access" -Status 'Warn' -Detail "Access denied to DC event log." -Remediation "Run on the DC: Get-WinEvent -FilterHashtable @{LogName='Security';Id=4769;StartTime=(Get-Date).AddMinutes(-30)}"
return
} elseif ($msg -match 'No events were found') {
# Distinguish: no matching events vs no 4769 events at all on this DC
$anyKerbEvents = $false
try {
Get-WinEvent -ComputerName $DomainController -FilterHashtable @{ LogName='Security'; Id=4769; StartTime=(Get-Date).AddMinutes(-30) } -MaxEvents 1 -ErrorAction Stop | Out-Null
$anyKerbEvents = $true
} catch { }
$detail = if ($anyKerbEvents) { "4769 events exist on DC but none match the specified accounts in the last 30 minutes." } else { "No 4769 (TGS-REQ) events at all on the DC in the last 30 minutes." }
New-DiagResult -Section $sectionName -Check "Event Count" -Status 'Warn' -Detail $detail -Remediation "Reproduce the delegation issue and re-run."
return
} else {
New-DiagResult -Section $sectionName -Check "DC Event Log Access" -Status 'Warn' -Detail "Could not query DC events: $msg" -Remediation "Run on the DC: Get-WinEvent -FilterHashtable @{LogName='Security';Id=4769;StartTime=(Get-Date).AddMinutes(-30)}"
return
}
}
if ($null -eq $allEvents -or @($allEvents).Count -eq 0) {
New-DiagResult -Section $sectionName -Check "Event Count" -Status 'Warn' -Detail "No matching 4769 events in the last 30 minutes." -Remediation "Reproduce the delegation issue and re-run this diagnostic."
return
}
# Parse filtered events
$filtered = @()
foreach ($evt in @($allEvents)) {
$xml = [xml]$evt.ToXml()
$data = @{}
foreach ($d in $xml.Event.EventData.Data) {
$data[$d.Name] = $d.'#text'
}
$filtered += [PSCustomObject]@{
TimeCreated = $evt.TimeCreated
TargetUserName = $data['TargetUserName']
ServiceName = $data['ServiceName']
Status = $data['Status']
TicketOptions = $data['TicketOptions']
TransitedServices = $data['TransitedServices']
}
}
# Check 1: Event count (events are already server-side filtered to matching accounts)
$filteredCount = $filtered.Count
New-DiagResult -Section $sectionName -Check "Event Count" -Status 'Pass' -Detail "Analyzed $filteredCount matching 4769 events (last 30 min, server-side filtered)."
# Check 2: Failed tickets
$failedEvents = @($filtered | Where-Object { $_.Status -and $_.Status -ne '0x0' })
if ($failedEvents.Count -gt 0) {
foreach ($fe in $failedEvents) {
$statusHex = $fe.Status.ToLower()
$statusName = if ($script:KerbStatusCodes.ContainsKey($statusHex)) { $script:KerbStatusCodes[$statusHex] } else { "Unknown status code $($fe.Status)" }
New-DiagResult -Section $sectionName -Check "Failed Ticket" -Status 'Fail' -Detail "Service=$($fe.ServiceName), User=$($fe.TargetUserName), Status=$($fe.Status) ($statusName)" -Remediation "Investigate Kerberos error $statusName for this service ticket request."
}
}
# Check 3: Forwardable flag on successful tickets
$successEvents = @($filtered | Where-Object { $_.Status -eq '0x0' -and $_.TicketOptions })
$nonForwardable = @($successEvents | Where-Object {
$opts = [Convert]::ToUInt32(($_.TicketOptions -replace '^0x',''), 16)
($opts -band 0x40000000) -eq 0
})
if ($nonForwardable.Count -gt 0) {
New-DiagResult -Section $sectionName -Check "Forwardable Flag" -Status 'Warn' -Detail "$($nonForwardable.Count) successful ticket(s) missing forwardable flag." -Remediation "Delegation requires forwardable tickets — check account and policy settings."
} elseif ($successEvents.Count -gt 0) {
New-DiagResult -Section $sectionName -Check "Forwardable Flag" -Status 'Pass' -Detail "All $($successEvents.Count) successful ticket(s) are forwardable."
}
# Check 4: Delegation chain summary
$hasS4U2Self = $false
$hasS4U2Proxy = $false
foreach ($fe in $filtered) {
if ($sourceNorm -and ($fe.ServiceName -replace '\$$', '').ToLower() -eq $sourceNorm) { $hasS4U2Self = $true }
if ($fe.TransitedServices -and $fe.TransitedServices.Trim().Length -gt 0) { $hasS4U2Proxy = $true }
}
if ($hasS4U2Self -and $hasS4U2Proxy) {
New-DiagResult -Section $sectionName -Check "Delegation Chain" -Status 'Pass' -Detail "Delegation chain complete: S4U2Self and S4U2Proxy observed."
} elseif ($hasS4U2Self) {
New-DiagResult -Section $sectionName -Check "Delegation Chain" -Status 'Warn' -Detail "S4U2Self detected but S4U2Proxy not observed." -Remediation "Delegation may be failing at the proxy step — check RBCD configuration and account flags."
} else {
New-DiagResult -Section $sectionName -Check "Delegation Chain" -Status 'Warn' -Detail "No delegation activity (S4U2Self/S4U2Proxy) observed." -Remediation "Verify the delegation is being attempted — reproduce the issue and re-run."
}
}
# =========================================================================
# MAIN
# =========================================================================
Write-Host "`n Kerberos Diagnostic Tool v2.2`n" -ForegroundColor Cyan
Initialize-AdAccess
Test-SpnValidation
Test-RbcdConfiguration
Test-AccountFlags
Test-CisGpoRisks
if ($Profile -eq 'PowerBiSql') { Test-GatewayService }
Test-DnsResolution
Test-Connectivity -Server $TargetServer -Port $TargetPort
Test-ClockSkew -DC $DomainController
Test-KerberosEvents
Write-Summary
if ($ExportHtml) { Export-HtmlReport }