Return to overview
5 min read

Sneaky2FA: Use This KQL Query to Stay Ahead of the Emerging Threat

5 min read
January 28, 2025
By: Bas van der Berg
SNEAKY2FA protecting against the threat
By: Bas van der Berg
28 January 2025

At Eye Security, we constantly seek out new threats and detection methods to protect our customers. In the past months, we have observed a rising trend in Business Email Compromises (BECs). In this instance, threat actors use Attacker-in-the-Middle (AITM) phishing kits that lure users, via links in email messages, to phishing pages. These phishing pages are pixel-for-pixel the same as the regular Microsoft login pages. However, the users' interaction is proxied to a server controlled by the threat actor. This allows the threat actor to grab not only the username and password, but also the session token, which, in turn, bypasses multi-factor authentication (MFA). Read on and find out how to stay protected.

We protect customers not only through prevention but also by detecting and responding to suspicious signins. In order to do this at scale, we employ Microsoft Sentinel for our Microsoft 365 customers. This approach allows for tight integration with the Microsoft ecosystem, along with the flexibility to create custom rules and threat hunt at scale.

Sneaky2FA: new threat exploits vulnerabilities in Microsoft 365 accounts 

We recently became aware of a new Attacker-in-the-Middle (AITM) phishing kit aptly named Sneaky2FA. We quickly developed a Sentinel KQL query and performed a retroactive threat hunt at scale, searching through hundreds of Sentinel workspaces in less time it takes to grab a good cup of coffee. We did, in fact, come across Business Email Compromises (BECs) that can now be attributed to Sneaky2FA. However, all of these BECs were detected by the myriad of other detection rules that we deploy to customer Sentinel workspaces. One of these detection rules was for an AITM phishing kit named W3LL. Sources such as Sekoia suggest that Sneaky2FA is derived from W3LL.

In our opinion, the interesting thing about Sneaky2FA is the way it rotates through user agents during the signin process when a user is phished. Sneaky2FA uses a set of five common and slightly outdated yet distinct user agents. Here is an example:
 

Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0

 
Detecting the presence of one of these user agents can be an effective indicator. This, however, often results in high false positive rates and may lead to alert fatigue in the SOC. At the same time, the behaviour itself creates a detection opportunity. Because we like our SOC analysts and don't like false positives, we set out to build a generic query to detect this behaviour with high fidelity.
 

Developing a KQL query to detect suspicious device shifts during signin

We wrote a generic KQL query that detects, as Sekoia dubs it, "an impossible device shift" during signin. This query can be deployed in Sentinel workspaces as a scheduled search with a short lookback window. We are gladly sharing it with the community. Our in-house version adds a few KQL tricks to further reduce the already low false positive rate. Furthermore, the jaccardIndex parameter can be tuned to weed out false positives due to browser version updates.

Here is the KQL query in all its glory:

 // Eye Security 2025
let suspiciousSignins = SigninLogs
| where DeviceDetail.deviceId == ''
| summarize userAgents = make_set(UserAgent) by CorrelationId
| where array_length(userAgents) > 2;
let correlationIds = suspiciousSignins
| mv-expand i = range(0, array_length(userAgents) - 2)
| mv-expand j = range(i + 1, array_length(userAgents) - 1)
| extend userAgent1 = tostring(userAgents[toint(i)])
| extend userAgent2 = tostring(userAgents[toint(j)])
| extend array1 = to_utf8(userAgent1)
| extend array2 = to_utf8(userAgent2)
| extend jaccardIndex = jaccard_index(array1, array2)
| where jaccardIndex < 0.8
| summarize by CorrelationId;
SigninLogs
| where ResultType == 0
| where CorrelationId in (correlationIds)

 

Breakdown of the query 

Let's break the query down. First, the query takes the SigninLogs where there's no Entra-registered device:

let suspiciousSignins = SigninLogs
| where DeviceDetail.deviceId == ''

 

We do this because a threat actor cannot have an Entra registered or joined device during the initial compromise. This reduces the false positive rate tremendously.

Then, the query takes all the UserAgents associated with the same CorrelationId and only keeps the rows that have three or more User Agents:

| summarize userAgents = make_set(UserAgent) by CorrelationId
| where array_length(userAgents) > 2;

 

Microsoft describes the CorrelationId as follows:

Sign-in logs contain several unique identifiers that provide further insight into the sign-in attempt. Correlation ID: The correlation ID groups sign-ins from the same sign-in session. The value is based on parameters passed by a client, so Microsoft Entra ID can't guarantee its accuracy.

We indeed observe outliers with respect to the CorrelationId. For instance, in some cases, the same CorrelationId is attached to multiple cloud identies, which should not happen. We have yet to uncover the cause of this discrepancy. Further, this part of the query also gets results where Chrome/130 is updated to Chrome/131. We need to filter these out to prevent false positives. This is what the next part of the query does.

Using the Jaccard index KQL function

The first part of the query yields an array of UserAgents per CorrelationId. We take this information and transform it so it can be fed to a KQL function called jaccard_index. This Jaccard index can be used to quantify the similarity of two sets.

The query does this by generating all pair-wise combinations of the User Agents, first generating two helper columns:

let correlationIds = suspiciousSignins
| mv-expand i = range(0, array_length(userAgents) - 2)
| mv-expand j = range(i + 1, array_length(userAgents) - 1)


Then, it grabs the corresponding user agents from the array userAgents:

| extend userAgent1 = tostring(userAgents[toint(i)])
| extend userAgent2 = tostring(userAgents[toint(j)])

 

When shown to a colleague, his response was "I would have written this for i / for j loop differently in a SQL-like language; it works fine, but it feels like someone who wants to write C in a KQL query". Guilty as charged!

This yields two strings, but the jaccard_index function operates on sets, so we transform the strings to an array of byte values using KQL's to_utf8:

// create inputs for jaccard_index function
| extend array1 = to_utf8(userAgent1)
| extend array2 = to_utf8(userAgent2)

 

Finally, we are able to calculate the Jaccard index and filter out the values that are too similar (e.g. Chrome/130 and Chrome/131), giving us a list of correlationIds that we can cross-reference with the SigninLogs:

| extend jaccardIndex = jaccard_index(array1, array2)
| where jaccardIndex < 0.8 // cut-off
| summarize by CorrelationId; // leave only suspicious correlationIds

The cut-off point of 0.8 is determined by taking the user agents that Sneaky2FA uses and calculating the corresponding Jaccard indices:

let userAgents = dynamic([
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0 OS/10.0.22635",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0"]
);
print dummy = 0
| mv-expand i = range(0, array_length(userAgents) - 2)
| mv-expand j = range(i + 1, array_length(userAgents) - 1)
| extend userAgent1 = tostring(userAgents[toint(i)])
| extend userAgent2 = tostring(userAgents[toint(j)])
| extend array1 = to_utf8(userAgent1)
| extend array2 = to_utf8(userAgent2)
| extend jaccardIndex = jaccard_index(array1, array2)
| project userAgent1, userAgent2, jaccardIndex

 

results of the queryImage 1. Sneaky 2FA: user agent Jaccard indices

Based on these Jaccard indices, we took 0.8 as an appropriate threshold value.

Sneaky 2FA: putting it all together

The final piece of the puzzle is taking the suspicious correlationIds and cross-referencing these with the SigninLogs where the authentication succeeded:

...
SigninLogs
| where ResultType == 0 // authentication succeeded
| where CorrelationId in (correlationIds)


This query yields a surprising fidelity already, but we've tuned it in-house to further reduce the false positive rate using knowledge that we've picked up from various internal and external research.

Here, you can see it in full swing, where it correctly identifies a Sneaky2FA login. Notice that the query correctly detects the suspicious Firefox/115 user agent without having to hardcode it in the query.

sneaky 2FA query results

Image 2. Sneaky 2FA: displaying the query results

Conclusion

We hope this KQL query helps companies in the constant struggle against AITM phishing, especially with emerging threats such as Sneaky2FA.

To help companies in the fight against login spoofing pages, we have developed the Eye Anti-Spoofing Tool (EAST). This is our advanced cybersecurity tool especially designed for combating Microsoft login spoofing. EAST can be used a separate measure to alert users when they are entering their credentials on certain phishing pages. 

If you'd like to know how Eye Security can help you protect your company, reach out to us using the form below:

Let's talk

Curious to know how we can help?

Get in touch
GET IN TOUCH
Share this article.