It analyzes klist output to identify Kerberos tickets with unusually long lifetimes as well as tickets where the Kdc Called field is empty.
It detects forged or abnormal Kerberos tickets by inspecting the local ticket cache and, optionally, session-specific caches.
Golden Tickets often stand out due to their unusually long lifetimes and/or an empty Kdc Called field, since the TGT is generated offline rather than issued by a legitimate KDC.
Within the artifact, you can configure a ticket lifetime threshold (for example, 1 year or more).
Setting this value to 0 disables filtering and returns all ticket information.
Additionally, there is an optional filter for FlagEmptyKdcCalled, which only triggers when FlagEmptyKdcCalled = true.
klist and parses ALL tickets (#0/#1/#2/…)klist sessions then tries klist -li 0x<LogonId> for interactive user sessions
name: Windows.Kerberos.GoldenTicketTriage
author: Deniz Ciftci
description: |
It analyzes klist output to identify Kerberos tickets with unusually long lifetimes as well as tickets where the Kdc Called field is empty.
It detects forged or abnormal Kerberos tickets by inspecting the local ticket cache and, optionally, session-specific caches.
Golden Tickets often stand out due to their unusually long lifetimes and/or an empty Kdc Called field, since the TGT is generated offline rather than issued by a legitimate KDC.
Within the artifact, you can configure a ticket lifetime threshold (for example, 1 year or more).
Setting this value to 0 disables filtering and returns all ticket information.
Additionally, there is an optional filter for FlagEmptyKdcCalled, which only triggers when FlagEmptyKdcCalled = true.
- CacheTickets:
- Runs `klist` and parses ALL tickets (#0/#1/#2/...)
- SessionTickets:
- Runs `klist sessions` then tries `klist -li 0x<LogonId>` for interactive user sessions
- Sessions that error (1314 / klist failed) are silently skippe
- TicketType:
- TGT if Server starts with "krbtgt/"
- TGS otherwise
- Filtering:
- MaxLifetimeYears = 0 -> no filtering (returns all tickets)
- Otherwise returns only suspicious tickets:
* Long lifetime >= MaxLifetimeYears
* Empty "Kdc Called:" (only if FlagEmptyKdcCalled=true)
type: CLIENT
parameters:
- name: MaxLifetimeYears
type: int
default: 1
description: Set 0 to return all tickets.
- name: FlagEmptyKdcCalled
type: bool
default: true
description: Flag empty "Kdc Called:".
required_permissions:
- EXECVE
sources:
- precondition:
SELECT OS FROM info() WHERE OS = 'windows'
name: CacheTickets
query: |
LET PS = '$ErrorActionPreference="SilentlyContinue";' +
'function gf($b,$pat){$m=[regex]::Match($b,$pat);if($m.Success){$m.Groups[1].Value.Trim()}else{$null}};' +
'$txt=(& klist 2>&1 | Out-String);' +
'$logon="0x0"; if($txt -match "Current LogonId is 0:0x([0-9a-fA-F]+)"){ $logon=("0x"+$Matches[1]) };' +
'$out=@();' +
'$ms=[regex]::Matches($txt,"(?ms)^#(?<Idx>\d+)>\\s*(?<Block>.*?)(?=^#\\d+>|\\z)");' +
'foreach($m in $ms){' +
'$idx=[int]$m.Groups["Idx"].Value;' +
'$b=$m.Groups["Block"].Value;' +
'$client=gf $b "(?m)^\\s*Client:\\s*(.+?)\\s*$";' +
'$server=gf $b "(?m)^\\s*Server:\\s*(.+?)\\s*$";' +
'$enctype=gf $b "(?m)^\\s*KerbTicket Encryption Type:\\s*(.+?)\\s*$";' +
'$flags=gf $b "(?m)^\\s*Ticket Flags\\s*(.+?)\\s*$";' +
'$st=gf $b "(?m)^\\s*Start Time:\\s*(.+?)\\s*$";' +
'$et=gf $b "(?m)^\\s*End Time:\\s*(.+?)\\s*$";' +
'$rt=gf $b "(?m)^\\s*Renew Time:\\s*(.+?)\\s*$";' +
'$kdc=gf $b "(?m)^\\s*Kdc Called:\\s*(.*?)\\s*$";' +
'$days=$null; $hours=$null; $life=$null; $sy=$null; $ey=$null;' +
'try{' +
'$stc=($st -replace "\\s*\\(local\\)\\s*","").Trim();' +
'$etc=($et -replace "\\s*\\(local\\)\\s*","").Trim();' +
'$sd=[datetime]::Parse($stc);' +
'$ed=[datetime]::Parse($etc);' +
'$ts=($ed-$sd);' +
'$days=[int]([math]::Floor($ts.TotalDays));' +
'$hours=[int]([math]::Floor($ts.TotalHours));' +
'$life = if($ts.TotalDays -ge 1){ "{0}d {1}h" -f $days, $ts.Hours } else { "{0}h {1}m" -f $ts.Hours, $ts.Minutes };' +
'}catch{}' +
'try{' +
'$sy=[int]([regex]::Match($st,"(\\d{4})").Groups[1].Value);' +
'$ey=[int]([regex]::Match($et,"(\\d{4})").Groups[1].Value);' +
'}catch{}' +
'$tt= if($server -match "^(?i)krbtgt/"){ "TGT" } else { "TGS" };' +
'$out += [pscustomobject]@{' +
'Source="CACHE";LogonIdHex=$logon;TicketType=$tt;TicketIndex=$idx;' +
'Client=$client;Server=$server;EncType=$enctype;TicketFlags=$flags;' +
'StartTime=$st;EndTime=$et;RenewTime=$rt;KdcCalled=$kdc;' +
'LifetimeDays=$days;LifetimeHours=$hours;Lifetime=$life;' +
'LifetimeYears=if($sy -ne $null -and $ey -ne $null){ ($ey-$sy) } else { $null }' +
'};' +
'};' +
'$out | ConvertTo-Json -Compress'
LET R = SELECT Stdout, Stderr, ExitStatus
FROM execve(argv=['powershell.exe','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command', PS])
LET J = SELECT row
FROM foreach(row=R, query={
SELECT row
FROM foreach(row=parse_json(data=Stdout), query={ SELECT row FROM scope() })
})
SELECT
row.Source AS Source,
row.LogonIdHex AS LogonIdHex,
row.TicketType AS TicketType,
row.TicketIndex AS TicketIndex,
row.Client AS Client,
row.Server AS Server,
row.EncType AS EncType,
row.TicketFlags AS TicketFlags,
row.StartTime AS StartTime,
row.EndTime AS EndTime,
row.RenewTime AS RenewTime,
row.KdcCalled AS KdcCalled,
row.LifetimeYears AS LifetimeYears,
row.LifetimeDays AS LifetimeDays,
row.LifetimeHours AS LifetimeHours,
row.Lifetime AS Lifetime,
((MaxLifetimeYears > 0) AND (row.LifetimeYears >= MaxLifetimeYears)) AS Flag_LongLifetime,
(FlagEmptyKdcCalled AND (row.KdcCalled = NULL OR row.KdcCalled =~ '^\\s*$')) AS Flag_EmptyKdcCalled,
(
((MaxLifetimeYears > 0) AND (row.LifetimeYears >= MaxLifetimeYears))
OR (FlagEmptyKdcCalled AND (row.KdcCalled = NULL OR row.KdcCalled =~ '^\\s*$'))
) AS Suspicious
FROM J
WHERE (MaxLifetimeYears = 0)
OR (
((MaxLifetimeYears > 0) AND (row.LifetimeYears >= MaxLifetimeYears))
OR (FlagEmptyKdcCalled AND (row.KdcCalled = NULL OR row.KdcCalled =~ '^\\s*$'))
)
- precondition:
SELECT OS FROM info() WHERE OS = 'windows'
name: SessionTickets
query: |
LET PS = '$ErrorActionPreference="SilentlyContinue";' +
'function gf($b,$pat){$m=[regex]::Match($b,$pat);if($m.Success){$m.Groups[1].Value.Trim()}else{$null}};' +
'$sess=(& klist sessions 2>&1 | Out-String);' +
'$out=@();' +
'$lines=$sess -split "`r?`n";' +
'foreach($ln in $lines){' +
'if($ln -notmatch "0:0x"){ continue };' +
'if($ln -notmatch "Interactive"){ continue };' +
'if($ln -notmatch "0:0x([0-9a-fA-F]+)"){ continue };' +
'$luid=$Matches[1];' +
'$user=""; if($ln -match "0:0x[0-9a-fA-F]+\\s+(.+?)\\s+\\S+:\\S+"){ $user=$Matches[1].Trim() };' +
'if($user -notmatch "^[^\\\\]+\\\\[^\\\\]+$"){ continue };' +
'if($user -match "\\$$"){ continue };' +
'if($user -match "^NT AUTHORITY\\\\"){ continue };' +
'if($user -match "^Window Manager\\\\"){ continue };' +
'if($user -match "^Font Driver Host\\\\"){ continue };' +
'$txt=(& cmd.exe /c ("klist -li 0x"+$luid) 2>&1 | Out-String);' +
'if($txt -notmatch "Cached Tickets"){ continue };' +
'if($txt -match "Error calling API"){ continue };' +
'if($txt -match "klist failed"){ continue };' +
'$logon=("0x"+$luid);' +
'$ms=[regex]::Matches($txt,"(?ms)^#(?<Idx>\d+)>\\s*(?<Block>.*?)(?=^#\\d+>|\\z)");' +
'foreach($m in $ms){' +
'$idx=[int]$m.Groups["Idx"].Value;' +
'$b=$m.Groups["Block"].Value;' +
'$client=gf $b "(?m)^\\s*Client:\\s*(.+?)\\s*$";' +
'$server=gf $b "(?m)^\\s*Server:\\s*(.+?)\\s*$";' +
'$enctype=gf $b "(?m)^\\s*KerbTicket Encryption Type:\\s*(.+?)\\s*$";' +
'$flags=gf $b "(?m)^\\s*Ticket Flags\\s*(.+?)\\s*$";' +
'$st=gf $b "(?m)^\\s*Start Time:\\s*(.+?)\\s*$";' +
'$et=gf $b "(?m)^\\s*End Time:\\s*(.+?)\\s*$";' +
'$rt=gf $b "(?m)^\\s*Renew Time:\\s*(.+?)\\s*$";' +
'$kdc=gf $b "(?m)^\\s*Kdc Called:\\s*(.*?)\\s*$";' +
'$days=$null; $hours=$null; $life=$null; $sy=$null; $ey=$null;' +
'try{' +
'$stc=($st -replace "\\s*\\(local\\)\\s*","").Trim();' +
'$etc=($et -replace "\\s*\\(local\\)\\s*","").Trim();' +
'$sd=[datetime]::Parse($stc);' +
'$ed=[datetime]::Parse($etc);' +
'$ts=($ed-$sd);' +
'$days=[int]([math]::Floor($ts.TotalDays));' +
'$hours=[int]([math]::Floor($ts.TotalHours));' +
'$life = if($ts.TotalDays -ge 1){ "{0}d {1}h" -f $days, $ts.Hours } else { "{0}h {1}m" -f $ts.Hours, $ts.Minutes };' +
'}catch{}' +
'try{' +
'$sy=[int]([regex]::Match($st,"(\\d{4})").Groups[1].Value);' +
'$ey=[int]([regex]::Match($et,"(\\d{4})").Groups[1].Value);' +
'}catch{}' +
'$tt= if($server -match "^(?i)krbtgt/"){ "TGT" } else { "TGS" };' +
'$out += [pscustomobject]@{' +
'Source="SESSIONS";LogonIdHex=$logon;TicketType=$tt;TicketIndex=$idx;' +
'Client=$client;Server=$server;EncType=$enctype;TicketFlags=$flags;' +
'StartTime=$st;EndTime=$et;RenewTime=$rt;KdcCalled=$kdc;' +
'LifetimeDays=$days;LifetimeHours=$hours;Lifetime=$life;' +
'LifetimeYears=if($sy -ne $null -and $ey -ne $null){ ($ey-$sy) } else { $null }' +
'};' +
'};' +
'};' +
'$out | ConvertTo-Json -Compress'
LET R = SELECT Stdout, Stderr, ExitStatus
FROM execve(argv=['powershell.exe','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command', PS])
LET J = SELECT row
FROM foreach(row=R, query={
SELECT row
FROM foreach(row=parse_json(data=Stdout), query={ SELECT row FROM scope() })
})
SELECT
row.Source AS Source,
row.LogonIdHex AS LogonIdHex,
row.TicketType AS TicketType,
row.TicketIndex AS TicketIndex,
row.Client AS Client,
row.Server AS Server,
row.EncType AS EncType,
row.TicketFlags AS TicketFlags,
row.StartTime AS StartTime,
row.EndTime AS EndTime,
row.RenewTime AS RenewTime,
row.KdcCalled AS KdcCalled,
row.LifetimeYears AS LifetimeYears,
row.LifetimeDays AS LifetimeDays,
row.LifetimeHours AS LifetimeHours,
row.Lifetime AS Lifetime,
((MaxLifetimeYears > 0) AND (row.LifetimeYears >= MaxLifetimeYears)) AS Flag_LongLifetime,
(FlagEmptyKdcCalled AND (row.KdcCalled = NULL OR row.KdcCalled =~ '^\\s*$')) AS Flag_EmptyKdcCalled,
(
((MaxLifetimeYears > 0) AND (row.LifetimeYears >= MaxLifetimeYears))
OR (FlagEmptyKdcCalled AND (row.KdcCalled = NULL OR row.KdcCalled =~ '^\\s*$'))
) AS Suspicious
FROM J
WHERE (MaxLifetimeYears = 0)
OR (
((MaxLifetimeYears > 0) AND (row.LifetimeYears >= MaxLifetimeYears))
OR (FlagEmptyKdcCalled AND (row.KdcCalled = NULL OR row.KdcCalled =~ '^\\s*$'))
)