Vedran Dojčinović
← All posts

Detecting Kerberoasting with Wazuh

The rule is six lines of XML. The part that actually mattered was a query I ran before writing any of it.

I wanted a Wazuh rule that caught Kerberoasting. Writing it was the easy part. Figuring out whether it would actually work in my environment was the part that taught me something, and that’s really what this post is about.

But before the rule, let’s cover what the attack is, what an attacker walks away with, and how you’d spot it by hand. Then the rule makes a lot more sense.

What Kerberoasting actually is

In Active Directory, service accounts have an SPN — a Service Principal Name — that ties them to a service like a database or a web app. When any user wants to use that service, they ask the domain controller for a service ticket. The DC hands one back, and it’s encrypted with the service account’s password hash.

The catch: any domain user can request a ticket for any SPN. No special rights. You don’t need access to the service, you don’t need to be an admin, you just ask. And the DC gives you a ticket encrypted with that account’s hash.

So an attacker with a single low-privilege account asks for tickets for every SPN they can find, and now they’re holding a pile of hashes.

MITRE tracks this as T1558.003.

What you get out of it

The tickets are the prize, because you crack them offline.

That’s the dangerous part. Once the attacker has the ticket, everything happens on their own machine — Hashcat, John, whatever — completely off the network. The domain controller never sees a single guess. No failed logons, no lockouts, nothing to brute-force on the wire.

This is exactly why service-account password strength matters so much. Length, complexity, randomness — they directly increase the time an attacker needs to crack that hash offline. A weak one falls in hours. A genuinely strong one might never fall at all, which turns a successful roast into a dead end.

And a weak one? Cracked in hours, and now the attacker has valid credentials for that account — often something with more access than the account they started with. They got there without tripping anything, because the only moment that ever touched the network was one ordinary-looking ticket request hours earlier.

That request is the only shot you get at catching this. So that’s where detection has to live.

How you’d catch it by hand

Every service-ticket request logs event 4769 — “A Kerberos service ticket was requested.” So the raw material is there. The problem is volume: 4769 fires constantly. Open a file share, hit a database, authenticate to almost anything, and there’s a 4769. You can’t just alert on the event or you’ll get buried in minutes.

So if you were hunting this manually, you’d pull all the 4769s and start looking for something that separates the attack from the mountain of legitimate requests. The tell is the encryption type.

Roasting tools ask for the ticket with RC4 — that’s ticketEncryptionType = 0x17 — and they do it on purpose. RC4 tickets crack far faster offline than AES ones (0x12). A modern AD, left alone, negotiates AES. So an RC4 request should stand out.

Should. That’s the word I nearly tripped over — but I’ll get to that.

Manually, you’d filter 4769 down to encryption type 0x17, throw out the machine accounts (their names end in $ and they request tickets nonstop), and look at what’s left. If your environment is healthy, what’s left should be almost nothing — and anything there is worth a look.

That manual filter is basically the rule. So let’s write it.

Starting the rule

The first draft kind of writes itself. Match 4769, match 0x17, drop machine accounts:

<rule id="100600" level="12">
<if_group>windows_security</if_group>
<field name="win.system.eventID">^4769$</field>
<field name="win.eventdata.ticketEncryptionType">^0x17$</field>
<field name="win.eventdata.targetUserName" negate="yes" type="pcre2">\$$</field>
<description>Possible Kerberoasting: RC4 (0x17) service ticket for SPN $(win.eventdata.serviceName) requested by $(win.eventdata.targetUserName)</description>
<mitre>
<id>T1558.003</id>
</mitre>
</rule>

Line by line: match the event, match RC4, and that negated targetUserName at the end (\$$) is what kills the computer-account noise — those names end in $ and they’d flood the rule otherwise.

Looks finished. It isn’t. Because this whole rule leans on one assumption I haven’t checked yet.

Check the baseline first — or the rule is useless

The assumption is that RC4 is rare where I’m running this.

If it’s not — if the domain hands out RC4 as normal, everyday behavior — then this rule doesn’t detect Kerberoasting. It detects everything. It fires a few hundred times an hour and somebody mutes it by lunch. And a muted rule is worse than no rule, because now people are trained to ignore the exact alert you wanted them to see.

Older domains are where this really bites. Anything that’s dragged legacy systems along, or never enforced AES, throws RC4 around all day as completely normal traffic. You cannot guess which situation you’re in. You have to go look.

So before turning anything on, I went into Discover and pulled the spread of ticketEncryptionType across recent 4769s. One question: with nobody attacking anything, how often does 0x17 actually show up?

Two ways it could go:

  • Mostly 0x12, RC4 basically absent → RC4 is a real anomaly, the rule is clean, ship it.
  • Plenty of legit 0x17 → the domain uses RC4 normally, the rule will flood, and I need a different approach entirely.

Mine came back AES-only for normal service tickets — RC4 just wasn’t there in legitimate traffic. That’s what told me the rule was safe to deploy. Not because I assumed it. Because I checked.

And if it had gone the other way, I’d have dropped the encryption-type idea and gone after volume instead — one non-machine user pulling tickets for a bunch of different SPNs in a short window. That’s what a roasting tool looks like when it enumerates every SPN it can find, and it works no matter what encryption gets used. Same logic as catching a password spray by “one source, lots of targets”: you stop caring about the single event and start caring about the shape of the whole thing.

Checking your own baseline

I’d strongly suggest doing the same before you deploy anything — and you don’t even need the SIEM for it. You can pull it straight from the DC with PowerShell. Grab a few thousand 4769s and group them by encryption type:

Terminal window
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4769} -MaxEvents 5000 |
ForEach-Object {
$xml = [xml]$_.ToXml()
$etype = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TicketEncryptionType' }).'#text'
[PSCustomObject]@{ EncryptionType = $etype }
} |
Group-Object EncryptionType | Sort-Object Count -Descending | Select-Object Count, Name

The values you care about:

  • 0x12 — AES256 (what a healthy modern domain should be handing out)
  • 0x11 — AES128
  • 0x17 — RC4-HMAC (what roasting tools ask for)
  • 0x18 — RC4-HMAC-EXP (legacy, shouldn’t really be around)

If 0x17 is basically zero next to 0x12, RC4 is a genuine anomaly and the rule is clean. If it’s everywhere, your domain uses RC4 as normal traffic and the encryption-type rule is going to flood you.

If you land somewhere in between — some RC4, but not a flood — it’s worth seeing which accounts are actually pulling those RC4 tickets before you decide. A couple of known legacy services you can whitelist is a very different situation from RC4 scattered across half the domain:

Terminal window
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4769} -MaxEvents 5000 |
ForEach-Object {
$xml = [xml]$_.ToXml()
$etype = ($xml.Event.EventData.Data | Where-Object {$_.Name -eq 'TicketEncryptionType'}).'#text'
$svc = ($xml.Event.EventData.Data | Where-Object {$_.Name -eq 'ServiceName'}).'#text'
[PSCustomObject]@{ Encryption=$etype; Service=$svc }
} |
Where-Object { $_.Encryption -eq '0x17' -and $_.Service -notlike '*$' } |
Group-Object Service | Sort-Object Count -Descending | Select-Object Count, Name

That second one filters down to RC4 requests for non-machine accounts and shows you exactly who they’re for.

And if you want to understand why an account is negotiating RC4 in the first place, that’s a configuration thing — the msDS-SupportedEncryptionTypes attribute. Accounts sitting at 0 or null never got explicitly moved to AES, so they quietly allow RC4 fallback. Those are both your roasting targets and your false-positive sources, which is a useful thing to know about your own service accounts:

Terminal window
Get-ADUser -Filter {ServicePrincipalName -like "*"} -Properties msDS-SupportedEncryptionTypes, ServicePrincipalName |
Select-Object Name, msDS-SupportedEncryptionTypes, ServicePrincipalName

Firing it for real

A rule you’ve never fired isn’t a rule yet, it’s a guess. So I ran it end to end — made a throwaway service account with an SPN, then roasted it from an attack box with creds I already had. The tool requested the ticket with RC4, the DC logged the 4769 with 0x17, and rule 100600 fired at level 12. Clean, nothing false around it.

One heads-up if you reproduce this. Some newer tooling will ask for AES on an AES-only domain, and then your 0x17 rule sees nothing. If your test doesn’t fire, check what encryption type actually landed in the 4769 before assuming the rule’s broken — it might be doing exactly what you told it. Which is, again, why that volume-based fallback is worth keeping around.

The bit that actually matters

The rule is six lines. The valuable part was the query I ran before I wrote any of them.

There’s a way detection engineering goes wrong where you grab a rule off a blog — this one included — drop it in, and figure it works because the XML is valid and whoever wrote it seemed to know what they were doing. But whether a rule works depends on your baseline, and that’s the one thing a copied rule can’t bring with it. The RC4 assumption is invisible right up until it drowns you.

So check whether the thing you’re calling an anomaly is actually anomalous for you. It’s a five-minute query. It’s also the whole difference between a rule that quietly catches someone and one that got switched off before it ever did anything.


Field names and syntax here are from a specific Wazuh version — always confirm against a real event in your own setup with wazuh-logtest before trusting any rule, including mine.