---
title: "Kerberoasting Triage: Most Service Tickets Are a Waste of Your Time"
description: "Requesting every SPN in the domain is easy. Knowing which twelve tickets are worth cracking — and which ones will burn a week of GPU time for nothing — is the actual skill."
date: 2025-02-13
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/kerberoasting-triage/
---

## Kerberoasting in 60 seconds

Kerberoasting is one of the first things anyone runs on an internal assessment, and it is
almost always taught the same way: request every service ticket you can, dump the hashes,
point a cracker at them, wait. That works. It is also how you end up with four hundred
tickets, a week of GPU time, and nothing to show for it.

The interesting part of Kerberoasting is not the request. It is the triage.

## What the attack actually depends on

A service ticket is encrypted with the password hash of the account the SPN is registered
to. Any authenticated domain user can request one for any SPN. That is not a
misconfiguration — it is how Kerberos works. The vulnerability is entirely in the
password behind the account.

Which means the question is never "can I roast this?" It is always "is the account behind
this SPN likely to have a password a cracker can reach?"

Three attributes decide that, and you can read all three before you crack anything.

## The three signals that matter

**Account type.** A machine account SPN is not worth requesting. Machine account passwords
are 120 characters of random UTF-16, rotated every thirty days by default. You will not
crack one. In practice, filtering to user accounts alone removes the overwhelming majority
of SPNs in a mature domain.

**Password age.** `pwdLastSet` is readable by any domain user, and it is the single most
predictive field. An account whose password was set years ago was almost certainly set by
a human, once, during a deployment — and never touched since. An account rotated last month
is under some kind of management. The old ones are where the weak passwords live.

**Encryption type.** If the returned ticket is RC4 (`etype 23`), you are cracking an NTLM
hash and the throughput is enormous. If it is AES (`etype 17`/`18`), you are cracking
through 4096 iterations of PBKDF2 and your effective rate collapses by orders of magnitude.
Same password, wildly different economics. An account that still returns RC4 in a
current-generation domain is telling you something about how long it has been sitting there.

## Reading it before you crack it

The point is that all three are queryable up front. A single LDAP pass gives you enough to
rank every candidate before a cracker starts:

```powershell
Get-ADUser -Filter { ServicePrincipalName -like "*" } `
           -Properties ServicePrincipalName, pwdLastSet, msDS-SupportedEncryptionTypes, adminCount |
  Select-Object SamAccountName,
                @{ n = "PwdAge"; e = { (Get-Date) - [datetime]::FromFileTime($_.pwdLastSet) } },
                @{ n = "EncTypes"; e = { $_.'msDS-SupportedEncryptionTypes' } },
                adminCount |
  Sort-Object PwdAge -Descending
```

`adminCount = 1` is the fourth column worth watching. It means the account sits — or once
sat — in a protected group. A privileged account with a five-year-old password and RC4
enabled is not one candidate among four hundred. It is the engagement.

<figure class="diagram">
<svg viewBox="0 0 700 282" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kerberoasting candidate triage funnel. All directory SPNs are reduced first to user-owned SPNs, then ranked by password age, encryption type, and privilege context. The output is a small high-value cracking queue, while machine accounts, recently rotated passwords, AES-only accounts, and gMSAs are deprioritized.">
<defs>
<marker id="kt-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
</defs>
<text x="0" y="18" class="dg-muted">TRIAGE FUNNEL — REDUCE BEFORE YOU REQUEST OR CRACK</text>
<rect x="0" y="34" width="150" height="62" rx="8" class="dg-box" />
<text x="14" y="56" class="dg-label">all SPNs</text>
<text x="14" y="76" class="dg-mono">hundreds of rows</text>
<text x="14" y="90" class="dg-muted">raw inventory</text>
<line x1="152" y1="65" x2="184" y2="65" class="dg-line-accent" marker-end="url(#kt-a)" />
<rect x="188" y="34" width="150" height="62" rx="8" class="dg-box" />
<text x="202" y="56" class="dg-label">user accounts</text>
<text x="202" y="76" class="dg-accent">DROP MACHINES</text>
<text x="202" y="90" class="dg-muted">human-set secrets</text>
<line x1="340" y1="65" x2="372" y2="65" class="dg-line-accent" marker-end="url(#kt-a)" />
<rect x="376" y="34" width="150" height="62" rx="8" class="dg-box" />
<text x="390" y="56" class="dg-label">score context</text>
<text x="390" y="76" class="dg-mono">age · etype · role</text>
<text x="390" y="90" class="dg-muted">cost versus value</text>
<line x1="528" y1="65" x2="560" y2="65" class="dg-line-accent" marker-end="url(#kt-a)" />
<rect x="564" y="34" width="136" height="62" rx="8" class="dg-box-accent" />
<text x="578" y="56" class="dg-label">cracking queue</text>
<text x="578" y="76" class="dg-crit">TOP CANDIDATES</text>
<text x="578" y="90" class="dg-muted">bounded + ranked</text>
<line x1="0" y1="124" x2="700" y2="124" class="dg-line dg-dash" />
<text x="0" y="150" class="dg-muted">PRIORITY MAP — NO SINGLE ATTRIBUTE IS A FINDING</text>
<rect x="0" y="166" width="216" height="78" rx="8" class="dg-box-accent" />
<text x="14" y="188" class="dg-crit">HIGH PRIORITY</text>
<text x="14" y="208" class="dg-mono dg-compact">old password + RC4 + privilege</text>
<text x="14" y="228" class="dg-muted">test first · bounded budget</text>
<rect x="242" y="166" width="216" height="78" rx="8" class="dg-box" />
<text x="256" y="188" class="dg-accent">REVIEW</text>
<text x="256" y="208" class="dg-mono dg-compact">old password + AES + value</text>
<text x="256" y="228" class="dg-muted">economics decide</text>
<rect x="484" y="166" width="216" height="78" rx="8" class="dg-box" />
<text x="498" y="188" class="dg-muted">DEPRIORITISE</text>
<text x="498" y="208" class="dg-mono dg-compact">machine · gMSA · recent</text>
<text x="498" y="228" class="dg-muted">record control · skip</text>
</svg>
<figcaption>A useful Kerberoasting workflow is a queueing problem, not a ticket-volume contest. Account type removes impossible candidates; password age and encryption type estimate cost; privilege and service ownership estimate value.</figcaption>
</figure>

## What this looks like in the lab

To show the encryption-type effect without touching anything real, two accounts in a lab
domain, same password, one forced to RC4 and one to AES-256:

```
$ hashcat -m 13100 rc4.ticket wordlist.txt --show
   Speed.#1.........:  1214.7 MH/s

$ hashcat -m 19700 aes.ticket wordlist.txt --show
   Speed.#1.........:     412.1 kH/s
```

Roughly a 3000x difference on the same hardware, for the same password. A candidate list
that ignores encryption type is not a prioritised list — it is an arbitrary one.

## Evidence matrix

| Signal | What it proves | Negative control | Defender verification |
| --- | --- | --- | --- |
| User-owned SPN with a materially old `pwdLastSet` value | A human-managed service secret has likely outlived its intended rotation cycle | Compare with a recently rotated service identity and a machine account | Inventory user-owned SPNs, password age, owner, and documented rotation dependency |
| Returned ticket uses RC4 (`etype 23`) | The account still permits the cheapest offline cracking path | Request a ticket for an AES-only lab account and confirm `etype 17` or `18` | Review `msDS-SupportedEncryptionTypes` and KDC ticket events for RC4 issuance |
| Privileged group history or `adminCount = 1` | Compromise would carry disproportionate directory impact | Repeat prioritisation after removing privilege context; the candidate should fall in rank | Resolve current and historical group membership instead of treating `adminCount` as proof by itself |
| A bounded wordlist test recovers the lab or approved candidate secret | The password policy, not Kerberos, made the ticket exploitable | Run the same budget against a gMSA or long random control and expect no recovery | Move supported services to gMSA, rotate exceptions, and confirm subsequent tickets use AES |

## The pattern I keep seeing

Across internal assessments, the accounts that fall are consistently the same shape: a
service account created during an initial deployment, given an SPN, handed a password that
someone needed to type into a config file once, and then left alone because rotating it
means restarting something nobody wants to restart. It is never the account that looks
important. It is the one that stopped being anyone's responsibility.

That is also why this finding is hard to close properly. Rotating the password fixes one
account. What actually fixes the class is moving the service to a Group Managed Service
Account, where the password is 240 bytes of machine-generated randomness, rotated
automatically — at which point the SPN stops being an attack path at all rather than
becoming a slightly better one.

## What to hand the defenders

A report that says "Kerberoasting is possible" is not actionable, because Kerberoasting is
always possible. The finding worth writing is the specific ranked list: these accounts, with
these password ages, still negotiating RC4, one of them privileged. Then the remediation is
concrete — gMSA for the ones that can move, forced rotation with a real length requirement
for the ones that cannot, and AES-only encryption types on all of them.

The triage is the deliverable. The cracked hash just proves it.