Skip to content

Hunt 004 — Credential Theft

Objective: Identify credential harvesting activity including LSASS access, password spraying, Kerberoasting, DCSync, and credential dumping tool execution before stolen credentials are used for lateral movement or escalation.

Frequency: Weekly

Prerequisites:

  • SecurityEvent table receiving EventID 4624, 4625, 4648, 4688, 4769, 4776 from all Windows hosts
  • EventID 4688 (process creation) enabled with command-line logging via GPO/Intune — this hunt is significantly degraded without it
  • PowerShell Script Block Logging (EventID 4104) enabled
  • SignInLogs from Entra ID
  • Cortex XDR behavioral alerts forwarded to CortexXDR_CL (if CEF forwarding is configured)

Process name watchlist (dump these immediately if seen in 4688 logs):

  • mimikatz.exe, mimilib.dll
  • procdump.exe, procdump64.exe
  • lsadump.exe
  • wce.exe (Windows Credential Editor)
  • fgdump.exe
  • pwdump.exe, pwdump7.exe
  • gsecdump.exe
  • safetykatz.exe
  • rubeus.exe
  • kekeo.exe
  • sharpdpapi.exe
  • nanodump.exe
  • handlekatz.exe
  • crackmapexec.exe, cme.exe
  • secretsdump.py (as script block or child process of python)

Queries

Q1 — LSASS Access via Process Creation (4688 — Direct Dump Attempt)

When 4688 is enabled with command-line logging, this query catches tools that directly target lsass.exe via command-line arguments or that are known dumping utilities.

kql
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4688
| where CommandLine has_any ("lsass", "procdump", "mimikatz", "sekurlsa",
                             "logonpasswords", "lsadump", "wce.exe",
                             "fgdump", "pwdump", "nanodump", "handlekatz",
                             "safetykatz", "rubeus", "kekeo", "sharpdpapi")
    or NewProcessName has_any ("mimikatz", "procdump", "wce", "fgdump",
                               "pwdump", "gsecdump", "nanodump", "rubeus",
                               "safetykatz", "crackmapexec", "handlekatz")
| extend Actor = SubjectAccount
| extend ProcessName = tostring(split(NewProcessName, "\\")[-1])
| project TimeGenerated, Computer, Actor, ProcessName, NewProcessName,
          CommandLine, ParentProcessName
| order by TimeGenerated desc

Look for: Any match in this table is a confirmed hit with no false positive threshold — these process names should never appear in this environment. Pay attention to ParentProcessName: explorer.exe spawning procdump is manual (interactive attacker); cmd.exe or powershell.exe spawning it is scripted. Also look for unusual parent processes (Word, Excel, outlook — document-based execution).


Q2 — LSASS Handle Open via Process Access (Sysmon Event 10 if Available / Cortex)

If Cortex XDR is forwarding behavioral alerts to CortexXDR_CL, this catches LSASS access at the handle level — more reliable than process names alone since attackers rename tools.

kql
CortexXDR_CL
| where TimeGenerated > ago(7d)
| where alert_name_s has_any ("LSASS", "credential", "dump", "sekurlsa",
                               "mimikatz", "procdump")
    or description_s has_any ("lsass.exe", "credential dump", "memory dump")
| extend Severity = alert_severity_s
| extend AffectedHost = endpoint_name_s
| extend Actor = actor_process_image_name_s
| project TimeGenerated, AffectedHost, Severity, alert_name_s,
          description_s, Actor, actor_process_command_line_s
| order by TimeGenerated desc

Look for: Any LSASS-related Cortex behavioral alert. Cortex should be catching these at the kernel level. If this table is consistently empty but Q1 shows hits, that's a Cortex bypass attempt worth investigating separately.


Q3 — Password Spray Detection — Failed Auth Volume Then Success (SignInLogs)

Password spray: many failures against many accounts, then one success. This query finds accounts that had 5+ failures in a 10-minute window followed by a success within 30 minutes — the spray-then-win pattern.

kql
let SprayFailures = SignInLogs
| where TimeGenerated > ago(7d)
| where ResultType != 0
| where ResultType in (50126, 50053, 50055, 50056, 50064, 50076, 50105, 50158)
    // bad password, locked out, expired, MFA required, etc.
| summarize
    FailCount = count(),
    FailedAccounts = dcount(UserPrincipalName),
    IPs = make_set(IPAddress, 5),
    FirstFail = min(TimeGenerated),
    LastFail = max(TimeGenerated)
    by IPAddress, bin(TimeGenerated, 10m)
| where FailedAccounts >= 5;   // same IP, 5+ distinct accounts failing in 10 min
let SpraySuccess = SignInLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| project SuccessTime = TimeGenerated, UserPrincipalName, IPAddress;
SprayFailures
| join kind=inner (SpraySuccess) on IPAddress
| where SuccessTime between (FirstFail .. (LastFail + 30m))
| project FirstFail, LastFail, SuccessTime, IPAddress, IPs, FailedAccounts,
          FailCount, UserPrincipalName
| order by FirstFail desc

Look for: An IP that fails against 5+ distinct accounts in 10 minutes and then has a successful sign-in within 30 minutes. The IP spraying and the success IP may differ if the attacker is using a proxy for the spray phase — also run this with a join on UserPrincipalName rather than IPAddress if you want to catch that variation.


Q4 — Distributed Password Spray (Single Account, Many IPs Failing)

The inverse of Q3 — attacker distributes the spray across many IPs (botnet) to avoid per-IP lockout thresholds. This looks for a single account receiving failures from 5+ distinct IPs in a short window.

kql
SignInLogs
| where TimeGenerated > ago(7d)
| where ResultType in (50126, 50053)   // bad password and locked out specifically
| summarize
    DistinctIPs = dcount(IPAddress),
    IPs = make_set(IPAddress, 20),
    Countries = make_set(tostring(LocationDetails.countryOrRegion), 10),
    FailCount = count(),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by UserPrincipalName, bin(TimeGenerated, 30m)
| where DistinctIPs >= 5
| project TimeWindow = TimeGenerated, UserPrincipalName, DistinctIPs,
          IPs, Countries, FailCount, FirstSeen, LastSeen
| order by DistinctIPs desc

Look for: A single user account receiving bad-password errors from 5+ distinct IPs, especially from multiple countries. A user with a misconfigured app retrying from one IP is benign. 5+ distinct IPs in 30 minutes against one account is a distributed spray. 10+ IPs is a confirmed hit regardless of success.


Q5 — Kerberoasting Indicators (4769 — RC4 Service Ticket Requests in Volume)

Kerberoasting requests service tickets for service accounts using RC4 encryption (0x17), then cracks them offline. The signal is high-volume RC4 ticket requests from a single IP in a short window.

kql
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4769
| where TicketEncryptionType == "0x17"   // RC4 — AES would be 0x12 or 0x11
| where Status == "0x0"   // success
| where ServiceName !endswith "$"   // exclude computer accounts (machine tickets are normal)
    and ServiceName != "krbtgt"
| summarize
    ServiceCount = dcount(ServiceName),
    Services = make_set(ServiceName, 30),
    TicketCount = count(),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by Account, IpAddress, bin(TimeGenerated, 30m)
| where ServiceCount >= 3 or TicketCount >= 10
| project TimeWindow = TimeGenerated, Account, IpAddress, ServiceCount,
          Services, TicketCount, FirstSeen, LastSeen
| order by ServiceCount desc

Look for: A single IP/account requesting RC4 tickets for 3+ distinct services in 30 minutes, or 10+ tickets total. Normal operations request AES tickets. A Kerberoasting run with Rubeus will generate dozens of RC4 requests in seconds. Even 3 RC4 service tickets from a workstation IP in one window is worth reviewing the associated account.


Q6 — DCSync Detection (4662 — DS-Replication-Get-Changes on Non-DC Sources)

DCSync replicates the domain database (including all password hashes) from a non-domain-controller source. EventID 4662 with the replication access right from a non-DC source is the indicator.

kql
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4662
| where ObjectType contains "domainDNS"
| where AccessMask == "0x100" or Properties has "Replicating Directory Changes"
    or Properties has "1131f6aa-9c07-11d1-f79f-00c04fc2dcd2"   // DS-Replication-Get-Changes
    or Properties has "1131f6ad-9c07-11d1-f79f-00c04fc2dcd2"   // DS-Replication-Get-Changes-All
| extend Actor = SubjectAccount
| extend ActorHost = toupper(tostring(split(Computer, ".")[0]))
// Filter out known DCs — add your DC hostnames here
| where ActorHost !in ("ACTDIRAZP01", "ACTDIRAZP02")
| project TimeGenerated, Actor, ActorHost, Computer, AccessMask, Properties, ObjectName
| order by TimeGenerated desc

Look for: Any replication request from a non-DC host. This table should be completely empty every run. A single row from a workstation or member server means an attacker has executed DCSync (Mimikatz lsadump::dcsync or Impacket secretsdump.py). This is a CRITICAL incident — every credential in the domain is potentially compromised.

Note: Update the ActorHost !in list with your actual domain controller hostnames. If you don't know them, pull from a SecurityEvent | where EventID == 4672 | where AccountName endswith "$" baseline to find DC computer accounts.


Q7 — PowerShell Credential Harvesting Script Blocks (4104)

PowerShell Script Block Logging captures the decoded content of all script blocks. This query searches for credential-related functions that indicate in-memory harvesting.

kql
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4104   // PowerShell Script Block Logging
| where ScriptBlockText has_any (
    "sekurlsa", "logonpasswords", "lsadump", "dcsync",
    "Invoke-Mimikatz", "Get-Credential", "ConvertTo-SecureString",
    "Net.NetworkCredential", "DPAPI", "CryptUnprotectData",
    "vault::list", "token::elevate", "privilege::debug",
    "Invoke-Kerberoast", "Get-DomainSPNTicket", "Request-SPNTicket",
    "Invoke-DCSync", "Get-DomainUser -SPN"
    )
| extend Actor = AccountName
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, Actor, ScriptBlockText
| order by TimeGenerated desc

Look for: Any match. Invoke-Mimikatz, Invoke-Kerberoast, and Get-DomainUser -SPN are PowerSploit/PowerView functions that should never appear in legitimate activity. ConvertTo-SecureString with a hardcoded string (visible in the script block) is a credential in code — also a hit but a different kind (accidental exposure vs. active attack).


Q8 — NTLM Authentication Failures to the Domain (4776) — Spray Against Domain

EventID 4776 is generated on domain controllers when NTLM authentication is attempted. High volumes from single sources indicate NTLM spray (different from Kerberos spray in Q3/Q4).

kql
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4776
| where Status != "0x0"   // failures only
| where TargetAccount !startswith "svc-"
    and TargetAccount !startswith "SYSTEM"
| summarize
    FailCount = count(),
    DistinctAccounts = dcount(TargetAccount),
    Accounts = make_set(TargetAccount, 20),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by Workstation, bin(TimeGenerated, 10m)
| where FailCount >= 10 or DistinctAccounts >= 5
| project TimeWindow = TimeGenerated, Workstation, FailCount,
          DistinctAccounts, Accounts, FirstSeen, LastSeen
| order by DistinctAccounts desc

Look for: A single workstation generating 10+ NTLM failures in 10 minutes, or 5+ distinct accounts failing from one source. Legitimate NTLM failures happen (bad cached credentials, expired passwords) but they come from one workstation for one account, not one workstation for 5+ accounts. If Workstation is a server, not a workstation, the source is likely a lateral movement tool already on the network.


Triage Guidance

Expected / Benign:

  • Cortex XDR performing its own LSASS access for EDR functionality — will appear in Cortex telemetry as a trusted process; the Cortex process name will be the parent, not a dump utility
  • Procdump run by Rory explicitly for crash dump investigation of a non-LSASS process — verify the target process in the command line
  • 4769 RC4 tickets from legacy applications that pre-date AES Kerberos support — identify the source IP and map it to an application; RC4 from a workstation IP is suspicious, from a server running a legacy app is borderline
  • 4625/4776 failures during account password reset — single account, from known helpdesk IP

Confirmed Hits (escalate immediately):

  • Any process from the watchlist appearing in 4688 logs — zero false positives
  • DCSync (Q6) with any non-DC source — CRITICAL, all credentials assumed compromised
  • Kerberoasting burst (3+ RC4 service tickets in 30 minutes from one IP)
  • PowerShell script block containing Invoke-Mimikatz or Invoke-Kerberoast
  • Spray-then-success pattern (Q3) where success account has admin rights

Borderline (investigate, don't escalate yet):

  • Single RC4 4769 ticket from a workstation — check if the workstation has a legacy app
  • 4776 failures from a server — identify what's running on that server before escalating
  • Get-Credential in a 4104 script block — could be a legitimate admin script; check the full script block for context
  • Cortex XDR alert for LSASS-adjacent activity — review the full alert chain in the Cortex console before escalating

Response

If you find a confirmed hit:

  1. Assume all credentials on the affected host are compromised — not just the one used in the dump. LSASS contains every credential that has been used on that machine since last reboot.
  2. Quarantine the host immediately — Cortex XDR network quarantine. Do not reboot (destroys in-memory forensic evidence) until memory is captured.
  3. Rotate affected credentials in this order:
    • Service accounts with SPNs (Kerberoasting targets first)
    • Any account that logged into the compromised host
    • Domain admin accounts (all of them if DCSync was confirmed)
    • Local admin accounts on all hosts (if LSASS dump was confirmed — they share hashes via LAPS or legacy config)
  4. For DCSync — treat as domain compromise:
    • Reset krbtgt password twice (invalidates all Kerberos tickets including Golden Tickets)
    • Reset all privileged account passwords
    • Notify Arctic Wolf immediately — this requires a full domain forensics sweep
  5. Open a CRITICAL incident in SecOps with source_system = PROD.
  6. Follow kill-chain-incident-response.md from the Credential Theft containment phase.
  7. Evidence preservation: Export SecurityEvent 4688/4104/4769 logs for the affected host for the prior 7 days before any remediation changes the evidence set.

Internal use only — Cirius Group