From Shadows to Signals: Hunting Pass-the-Hash Attacks.
Overview
In a lot of threat hunting scenarios it can be very difficult to determine if an activity is attributable to legitimate user input or the result of an attacker’s actions. This is particularly relevant when talking about Pass-the-Hash attacks.

In our Defending Enterprises training, the topic of Pass-the-Hash has been a bit elusive.
A lot of public resources seem to concentrate of the detection of specific tooling rather than actions and their resulting fingerprints, aka indicator over behaviour.
We’re aiming to tackle that in this post, or at least push some ideas and detection logic into the public domain based around anomaly detection.
We’ll be looking into NTLM network-based authentication and it will be very difficult for us to identify legitimate NTLM traffic from illegitimate NTLM traffic. However, when we start to correlate data from multiple log sources, we may start to see anomalies between logon and account activity that could highlight malicious activity within our environments.
Environment
We’re using Microsoft’s Kusto Query Language (KQL) in the code snippets, but the detection logic can be lifted and shifted into any platform and language of choice.
The setup also doesn’t assume that we have access to any vendor specific Anti-Malware products or specialist telemetry as the aim is to help organisations on any budget to have a chance of detecting these attacks.
That being said we will need data from the following common EventID:
In fact, EventID 4624 along with “4768: A Kerberos authentication ticket (TGT) was requested” and “4769: A Kerberos service ticket was requested” (the latter two of which are not required for this logic), are probably the three most useful events you can have access to when attempting to detect a large number of credential based attacks.
For the upcoming IOC we’ll need to be able to resolve IP addresses to DNS names and vice-versa. We don’t need to get into the nitty-gritty of this as “Event ID DNS” is a topic we’ve tackled in a previous post so we’ll tread lightly on the detail here. Needless to say some data will need to be enriched and if you have access to the Heartbeat table or you have DNS logs available, you’re good to go.
Detection Logic
In a nutshell we’re going to initially be looking for anomalies between some type of interactive authentication event (step 3 below), where we should discover an account is logged onto, and uses the client for some purpose (illegitimate or malicious, it doesn’t really matter to us at this time). We then look for NTLM network authentication events (step 4 below) originating from this same host, but authenticate to the desired endpoint using a different account (step 5 below). This is our trigger.
- Are these accounts permitted to use this endpoint?
- Why is that happening?
- Does it occur regularly, is it one-off behaviour, or is this some form of legitimate service/administrative activity?
The overarching detection logic looks like this:
- Define a LookBack period i.e. how may days’ worth of data should be reviewed. In our query this is set at 7 days.
- Correlate hostname and IP addresses using Heartbeat/DNS logs (essentially using event data as a means to perform DNS resolution)
- Identify authentication events based on the following three LogonTypes:
- Identify NTLM network-based authentication attempts (LogonType 3)
- Join the interactive/unlock/RDP authentication event datasets to the NTLM network authentication event datasets, and filter;
- For events that occurred within 24 hours of each other
- Where the interactive/unlock/RDP account does not match the NTLM network authentication account
- The last matching entry of the interactive/unlock/RDP and associated NTLM network authentication events
- Investigate!
As you can imagine, this logic is useful on clients where a single user is assigned access, but noise can become unbearable when applying this same logic on servers. Use with caution.
KQL
The associated KQL for this logic is shown below.
// Period to run query against
let QueryLookBack = 7d;
let IpHostNameLookup =
(
Heartbeat
| where TimeGenerated >= ago(QueryLookBack)
// Expand dynamic array into multiple records and convert to string representation
| mv-expand ComputerPrivateIPs
| extend ComputerPrivateIPs = tostring(ComputerPrivateIPs)
// Deduplicate results based on IP and hostname
| distinct IpAddress = ComputerPrivateIPs, Computer
);
let LogonEvents =
(
SecurityEvent
| where TimeGenerated >= ago(QueryLookBack)
// Event ID 4624 (an account was successfully logged on) is used
| where EventID == 4624
// Looking for just interactive, unlock & remoteinteractive (RDP) logons - https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4624
| where LogonType in(2,7,10)
// Extract domain value (position 0)
| extend Domain = tostring(split(Account, strcat("\\",TargetUserName))[0])
// Not interested in any of these ‘default’ values
| where Domain !in ("Window Manager","Font Driver Host")
| project AuthenticatedAccount = Account, Computer, UserLogonType = LogonType, LogonEventTime = TimeGenerated
);
let NtlmNetworkAuthEvents =
(
SecurityEvent
| where TimeGenerated >= ago(QueryLookBack)
| where EventID == 4624
// Interested in just network auth logon types - https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4624
| where LogonType == 3
// Looking for just NTLM based events
| where AuthenticationPackageName == "NTLM" and LogonProcessName has "NtLmSsp"
// Only interested in user events and filtering out anonymous type events
| where AccountType == "User" and TargetUserName !~ "anonymous logon"
// Only interested in private IPv4 based traffic (optional)
| where ipv4_is_private(IpAddress) == true
| project NtlmAuthEventTime = TimeGenerated, TargetAccount = Account, AccountType, TargetHost=Computer, NtlmLogonType = LogonType, IpAddress
);
let TargetLookup =
(
IpHostNameLookup
// Inner join used on NtlmNetworkAuthEvents dataset, results based on matching IpAddress field
| join kind=inner
(
NtlmNetworkAuthEvents
) on IpAddress
| project NtlmAuthEventTime, IpAddress, SourceHost = Computer, TargetHost, TargetAccount, AccountType, NtlmLogonType
);
LogonEvents
// Inner join used on TargetLookup dataset, results based on matching Computer field
| join kind=inner TargetLookup on $left.Computer == $right.SourceHost
// Logon authentication request must occur within a 24 hour period of a NTLM authentication event
| where (NtlmAuthEventTime - LogonEventTime) between (0h..24h)
// Only include accounts where the initial logon (interactive, RDP etc.) differs from the account used in NTLM network authentication events
| where AuthenticatedAccount != TargetAccount
// Return only the last/latest logon event based on AuthenticatedAccount, SourceHost, TargetAccount, TargetHost, NtlmAuthEventTime
| summarize arg_max(LogonEventTime, *) by AuthenticatedAccount, SourceHost, TargetAccount, TargetHost, NtlmAuthEventTime
// Return only the last/latest ntlm network auth event based on TargetAccount, TargetHost, AuthenticatedAccount, SourceHost, LogonEventTime
| summarize arg_max(NtlmAuthEventTime, *) by TargetAccount, TargetHost, AuthenticatedAccount, SourceHost, LogonEventTime
| project LogonEventTime, UserLogonType, AuthenticatedAccount, SourceHost, NtlmAuthEventTime, TargetAccount, TargetHost, NtlmLogonType
| sort by LogonEventTime, NtlmAuthEventTime
Example Data
For this example we spun up the Defending Enterprises LAB and ran several PtH attacks using various tooling (impacket, netexec, pth-winexe), results are shown below.

From this it’s possible to see that;
- Kfrye authenticated to the host DE-WKS-07 on 04/09/25 at 15:10 and again on 05/09/25 at 11:53 via RDP (UserLogonType 10).
- A NTLM network authentication event originated from this same host (DE-WKS-07) on 04/09/25 at 15:33, targeting DE-WKS-06 using the account Mreynolds, roughly 23 minutes after Kfrye had established a RDP session.
- Several NTLM network authentication events originated from DE-WKS-07 on 05/09/25 targeting several hosts, but each using the account Mreynolds; *
- DE-WKS-05 at 12:11
- DE-WKS-06 at 12:31
- DE-DC-001 at 12:35
- Therefore, the account used for the NTLM network authentication event is different to the account of the currently authenticated user (in this instance Kfrye). There is your anomaly.
- Now we ask the question, why?
* These events are grouped together as shown in points 2 and 3 above, because the 24 hour period starts at the time Kfrye first RDP’d to DE-WKS-07 (04/09/25 at 15:10), so any subsequent NTLM authentication activity within 24 hours of this would be grouped. If (or in this case when) Kfrye RDP’s to the host a second time (05/09/25 11:53), the 24 period effectively “restarts”, so NTLM authentication events that would have ordinarily fallen within a day of the first connection, now get grouped in a new record.