
Detecting non-privileged Windows Hello abuse
Introduction
I recently followed a live session of Dirk-Jan Mollema and Ceri Coburn on how Windows Hello for Business can be abused as a non-privileged user. I was very intrigued by the concept of the attack they demonstrated, which is why a spend a couple of days thinking of ways how we can counter this attack with detective controls as blue teamers.
Before diving into the controls, let’s first do a recap on what the attack scenario is all about. Interesting to note is that I flagged each detection rule and hunting query I created with the below banner throughout this blogpost:
Detection / Hunting rule
The attack scenario
A presentation about the attack scenario can be found on the website of Drik-Jan here. Since it has a lot of technical depth, I highly recommend to go over the slide deck first, and try to understand the specific technical procedures and theories of the attack.
To give a brief overview, this attack is all about how Windows Hello for Business works and how it can be abused by a non-privileged user. Here, an attacker performs a RDP connection from the victim device with TPM to a non-TPM protected device using Windows Hello for Business, in order to expose the Windows Hello for Business keys on the non-TPM protected device. With these keys and the assertion that can be generated on the victim device in user context without prompting for any credentials, the attacker can request an Entra ID PRT token on the non-TPM device using RoadTools. Once the attacker has the PRT, he can request access tokens for all different kind of applications with device state and phishing resistant MFA claims in the tokens, evading most of the strongest conditional access policies.
A very important nuance I want to mention here, is that this attack only works when performing an RDP session to a non TPM protected device using Windows Hello for Business credentials. Before a user can do this, specific configuration as mentioned here needs to be in place.
Detecting the procedures
To structure this blogpost a bit, I tried to break the attack down in a couple of procedures:
- Retrieving the assertion from victim device
- RDP session to non TPM protected device
- Request PRT on new device using the assertion
Retrieving the assertion from victim device
The first procedure I want to talk about is retrieving the assertion from the victim device. This assertion is the key element needed for the complete attack chain to start, since it is needed to request new PRT tokens. This means that if we can detect malicious use if this procedure, we have a very solid starting point in detecting the attack chain early on.
In the RoadTools GithHub repository, a PowerShell script can be found called ‘hellopoc.ps1’. This script can be used to generate the assertion needed to eventually request a PRT token, which in the background reads the Microsoft Passport Key Storage Provider from the victim device. Reading this Key Storage Provider is done by the script using the ncrypt DLL to complete the assertion generation, and is therefore an interesting procedure to look at since it is expected that only the NgcCtnrSvc Service is reading these files.
Suspicious file reads
The main challenge here is how we can detect when a suspicious process is reading the files in the Ngc folder. When you use Microsoft Defender for Endpoint, you probably know that MDE does not log file access events in their telemetry. This means that we need to look at other sources to get our file access events, for which we mainly use Sysmon when we need telemetry data not present in MDE. Unfortunately, when we look at the EDR Telemetry project, we can see that we cannot even use Sysmon for that:
The only way to do this is to deploy a file access audit policy in Windows, and log the files being opened via Windows Event ID 4663. Although it might be an interesting approach to look at, I was wondering if there was a more easy way to detect this.
Suspicious use of ncrypt.dll
In one of my previous blogposts I already talked about how we can use WDAC in audit mode to ingest missing DeviceImageLoad events in Defender XDR Advanced Hunting. Since suspicious readings of files in the Ngc folder was a bit challenging with the Windows Events, I was thinking if we can use WDAC again in this scenario. Since WDAC does not log files being accessed either, I had to rethink a bit where we can detect this procedure. This is where I suddenly remember that the hellopoc.ps1 script is actually using ncrypt.dll to read the files in the Ngc folder, which makes ncrypt.dll the ideal DLL to place a WDAC auditing policy on.
How you can create and deploy such a WDAC Audit Policy is something I explain in the blogpost I mentioned earlier. If you want to reuse the policy I created, you can find it here:
After creating the WDAC Audit policy and running the hellopoc.ps1 script, I immediately found events of processes using ncrypt.dll for which one of them was the PowerShell process:
1DeviceEvents
2| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
3| where InitiatingProcessAccountName !~ "system" and InitiatingProcessVersionInfoOriginalFileName =~ "powershell.exe"
4| extend WdacPolicyName = parse_json(AdditionalFields)["PolicyName"]
5| project DeviceName, ActionType, FileName, InitiatingProcessFileName, WdacPolicyName
This is a great start, since we can now try to create a detection that tries to find suspicious ncrypt.dll usage. The first research I did was trying to find which processes are using the ncrypt.dll, which seems to be a couple of processes both under system and user context.
1DeviceEvents
2| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
3| distinct InitiatingProcessFileName, InitiatingProcessAccountName
PowerShell as initiating process is something that stood out in all the tests I did, which is why it is a good idea to flag PowerShell importing the ncrypt.dll. But since there are a couple of other Command Line tools that can be used for importing the Ncrypt DLL, I did not want to limit the query to PowerShell alone, while not make the query over-complex. Next to flagging certain command-line-tools, I would recommend to flag global unknown processes as well:
Detection 1 - Suspicious ncrypt.dll usage by CLI tool or unknown process (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Entra%20ID/DetectSuspiciousNcryptUsageByCliToolOrUnknownProcess.md)
1let cli_tools = dynamic(["powershell", "python"]);
2// Get suspicious ncrypt.dll usage via WDAC audit policy
3DeviceEvents
4| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
5| invoke FileProfile(InitiatingProcessSHA1, 1000)
6| where (
7 // Flag CLI tools
8 InitiatingProcessFileName has_any (cli_tools) or
9 // Flag unknown processes
10 GlobalPrevalence < 250
11)
12| sort by TimeGenerated desc
Another interesting way of detecting this behavior, is by searching for processes that are requesting a nonce as well, since this nonce is needed to eventually create the assertion. A nonce request is being send to login.microsoftonline.com, for which a lot of connections happen of course. When investigating this principle on a large environment, we learned that a lot of processes are calling ncrypt.dll and requesting a nonce:
1// Get all possible nonce requests
2let nonce_requests = (
3 DeviceNetworkEvents
4 | where Timestamp > ago(30d)
5 | where ActionType startswith "ConnectionSuccess"
6 | where RemoteUrl =~ "login.microsoftonline.com"
7 | project-rename NonceTimestamp = Timestamp
8);
9// Get suspicious ncrypt.dll usage via WDAC audit policy
10DeviceEvents
11| where Timestamp > ago(30d)
12| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
13// Check if the same initiating process is doing a nonce request
14| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
15// Only flag when nonce was request 10min before of after ncrypt usage
16| where Timestamp between (todatetime(NonceTimestamp - 10m) .. todatetime(NonceTimestamp + 10m))
17| summarize count() by InitiatingProcessFileName
Because of this, you will need to build some kind of white or black listing mechanism in the detection rule to minimize False Positive detections. One example is by combining it with the previous rule:
Detection 2 - Suspicious ncrypt.dll usage by process requesting Entra ID Nonce (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Entra%20ID/DetectSuspiciousNcryptUsageByCliToolOrUnknownProcessWithNonce.md)
1let cli_tools = dynamic(["powershell", "python"]);
2// Get all possible nonce requests
3let nonce_requests = (
4 DeviceNetworkEvents
5 | where Timestamp > ago(1h)
6 | where ActionType startswith "ConnectionSuccess"
7 | where RemoteUrl =~ "login.microsoftonline.com"
8 | project-rename NonceTimestamp = Timestamp
9);
10// Get suspicious ncrypt.dll usage via WDAC audit policy
11DeviceEvents
12| where Timestamp > ago(1h)
13| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
14// Check if the same initiating process is doing a nonce request
15| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
16// Only flag when nonce was request 10min before of after ncrypt usage
17| where Timestamp between (todatetime(NonceTimestamp - 10m) .. todatetime(NonceTimestamp + 10m))
18| invoke FileProfile(InitiatingProcessSHA1, 1000)
19| where (
20 // Flag CLI tools
21 InitiatingProcessFileName has_any (cli_tools) or
22 // Flag unknown processes
23 GlobalPrevalence < 250
24)
RDP Session to non-TPM protected device
Once the attacker has the assertion, they can start requesting a PRT. But before they can do that, they need the keys that are protected in the TPM of the device. As Dirk-Jan explains in the attack scenario, an attacker can RDP to a non TPM protected device using a user certificate with Windows Hello authentication, which will eventually expose the PRT that was initially protected in the TPM of the victim device.
This gives the attacker the possibility to dump the keys using mimikatz on the non TPM device, which in combination of the assertion can be used to request new PRT tokens.
In this step, building RDP sessions with untrusted hosts is the key element here, especially when you are authenticating with Windows Hello credentials. So how can we try to detect this step?
Building an RDP session to device is something that happens a lot, and defining what is abnormal behavior is something that heavily depends for each environment. Because of this, creating a detection for suspicious RDP connections that is environmentvagnostic is pretty hard. But if you are looking for one for your specific environment, you might be interested in one of the below hunting queries to base a detection on:
- Hunt for devices doing first RDP session
- Hunt for RDP sessions to unmanaged and non TPM devices
Hunt query 1 - Hunt for devices doing first RDP session (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Defender%20For%20Endpoint/HuntDevicesDoingFirstRdpSession.md)
1let historic_rdp_devices = toscalar(
2 DeviceNetworkEvents
3 | where Timestamp > ago (30d)
4 | where ActionType == "ConnectionSuccess"
5 | where RemotePort == 3389
6 | summarize make_set(DeviceId)
7);
8DeviceNetworkEvents
9| where Timestamp > ago(1h)
10| where ActionType == "ConnectionSuccess"
11| where RemotePort == 3389
12| where DeviceId !in (historic_rdp_devices)
Hunt query 2 - Hunt for RDP sessions to unmanaged and non TPM devices (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Defender%20For%20Endpoint/HuntDevicesDoingRdpToNonTpmDevice.md)
1let no_tpm_devices = (
2 ExposureGraphNodes
3 // Get device nodes with their inventory ID
4 | where NodeLabel == "device"
5 | mv-expand EntityIds
6 | where EntityIds.type == "DeviceInventoryId"
7 // Get interesting properties
8 | extend OnboardingStatus = tostring(parse_json(NodeProperties)["rawData"]["onboardingStatus"]),
9 TpmSupported = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["supported"]),
10 TpmEnabled = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["enabled"]),
11 TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"]),
12 DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"]),
13 DeviceId = tostring(EntityIds.id)
14 // Search for distinct devices
15 | distinct DeviceId, DeviceName, OnboardingStatus, TpmSupported, TpmEnabled, TpmActivated
16 // Get Unmanaged devices and device not supporting a TPM
17 | where OnboardingStatus != "Onboarded" or (TpmSupported != "true" and TpmActivated != "true" and TpmEnabled != "true")
18 | extend TpmSupported = iff(TpmSupported == "", "unknown", TpmSupported),
19 TpmActivated = iff(TpmActivated == "", "unknown", TpmActivated),
20 TpmEnabled = iff(TpmEnabled == "", "unknown", TpmEnabled)
21);
22let no_tpm_device_info = (
23 DeviceNetworkInfo
24 | where Timestamp > ago(7d)
25 // Get latest network info for each device ID
26 | summarize arg_max(Timestamp, *) by DeviceId
27 | mv-expand todynamic(IPAddresses)
28 | extend IPAddress = tostring(IPAddresses.IPAddress)
29 // Find no TPM devices and join with their network information
30 | join kind=inner no_tpm_devices on DeviceId
31 | project DeviceId, DeviceName, MacAddress, IPAddress, OnboardingStatus, TpmActivated, TpmEnabled, TpmSupported
32);
33DeviceNetworkEvents
34// Search for RDP connections to non-tpm devices
35| where Timestamp > ago(1h)
36| where ActionType == "ConnectionSuccess"
37| where RemotePort == 3389
38// Exclude MDI RDP Connections (known for NNR)
39| where InitiatingProcessFileName !~ "microsoft.tri.sensor.exe"
40| join kind=inner no_tpm_device_info on $left.RemoteIP == $right.IPAddress
41| project-rename RemoteDeviceId = DeviceId1, RemoteDeviceName = DeviceName1, RemoteMacAddress = MacAddress, RemoteDeviceOnboardingStatus = OnboardingStatus, RemoteDeviceTpmActivated = TpmActivated, RemoteDeviceTpmEnabled = TpmEnabled, RemoteDeviceTpmSupported = TpmSupported
42| project-away IPAddress
If you are looking for a more environment agnostic detection, you can use a detection that combines the suspicious use of ncrypt.dll with RDP connections being performed. This can be an extension of the ncrypt.dll detection, for which you might want to assign a higher severity. When combining these detections, we found that there are less hits compared to the detection 2 investigation we discussed earlier:
Because of this, we can remove the blacklist for specific CLI and unknown processes, and rather rely on a specific whitelist of known good processes:
Detection 3 - Suspicious ncrypt.dll usage with RDP connections to non TPM protected device (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Entra%20ID/DetectSuspiciousNcryptUsageWithSuspiciousRdpSession.md)
1let time_lookback = 1h;
2let no_tpm_devices = (
3 ExposureGraphNodes
4 // Get device nodes with their inventory ID
5 | where NodeLabel == "device"
6 | mv-expand EntityIds
7 | where EntityIds.type == "DeviceInventoryId"
8 // Get interesting properties
9 | extend OnboardingStatus = tostring(parse_json(NodeProperties)["rawData"]["onboardingStatus"]),
10 TpmSupported = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["supported"]),
11 TpmEnabled = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["enabled"]),
12 TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"]),
13 DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"]),
14 DeviceId = tostring(EntityIds.id)
15 // Search for distinct devices
16 | distinct DeviceId, DeviceName, OnboardingStatus, TpmSupported, TpmEnabled, TpmActivated
17 // Get Unmanaged devices and device not supporting a TPM
18 | where OnboardingStatus != "Onboarded" or (TpmSupported != "true" and TpmActivated != "true" and TpmEnabled != "true")
19 | extend TpmSupported = iff(TpmSupported == "", "unknown", TpmSupported),
20 TpmActivated = iff(TpmActivated == "", "unknown", TpmActivated),
21 TpmEnabled = iff(TpmEnabled == "", "unknown", TpmEnabled)
22);
23let no_tpm_device_info = (
24 DeviceNetworkInfo
25 | where Timestamp > ago(7d)
26 // Get latest network info for each device ID
27 | summarize arg_max(Timestamp, *) by DeviceId
28 | mv-expand todynamic(IPAddresses)
29 | extend IPAddress = tostring(IPAddresses.IPAddress)
30 // Find no TPM devices and join with their network information
31 | join kind=inner no_tpm_devices on DeviceId
32 | project DeviceId, DeviceName, MacAddress, IPAddress, OnboardingStatus, TpmActivated, TpmEnabled, TpmSupported
33);
34let dangerous_rdp_sessions = (
35 DeviceNetworkEvents
36 | where Timestamp > ago(time_lookback)
37 // Exclude MDI RDP Connections (known for NNR)
38 | where InitiatingProcessFileName !~ "microsoft.tri.sensor.exe"
39 // Search for RDP connections to non-tpm devices
40 | where ActionType == "ConnectionSuccess"
41 | where RemotePort == 3389
42 | join kind=inner no_tpm_device_info on $left.RemoteIP == $right.IPAddress
43 | project-rename RemoteDeviceId = DeviceId1,
44 RdpRemoteDeviceName = DeviceName1,
45 RdpRemoteMacAddress = MacAddress,
46 RdpRemoteDeviceOnboardingStatus = OnboardingStatus,
47 RdpRemoteDeviceTpmActivated = TpmActivated,
48 RdpRemoteDeviceTpmEnabled = TpmEnabled,
49 RdpRemoteDeviceTpmSupported = TpmSupported,
50 RdpTimeGenerated = Timestamp,
51 RdpInitiatingProcessFileName = InitiatingProcessFileName
52 | project-away IPAddress
53);
54// Get all possible nonce requests
55let nonce_requests = (
56 DeviceNetworkEvents
57 | where Timestamp > ago(time_lookback)
58 | where ActionType == "ConnectionSuccess"
59 | where RemoteUrl =~ "login.microsoftonline.com"
60 | project-rename NonceRequestTimestamp = Timestamp
61);
62// Get suspicious ncrypt.dll usage via WDAC audit policy
63DeviceEvents
64| where Timestamp > ago(time_lookback)
65| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
66// Check if the same initiating process is doing a nonce request
67| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
68// Only flag when nonce was request 10min before of after ncrypt usage
69| where Timestamp between (todatetime(NonceRequestTimestamp - 10m) .. todatetime(NonceRequestTimestamp + 10m))
70// Check if the same device is doing RDP Connections
71| join kind=inner dangerous_rdp_sessions on DeviceId
72// Whitelist known good processes
73| where InitiatingProcessFileName !in ("backgroundtaskhost.exe","svchost.exe")
74// Project interesting columns
75| extend WdacPolicyName = parse_json(AdditionalFields)["PolicyName"]
76| project Timestamp, DeviceName, ActionType, FileName, InitiatingProcessSHA1, InitiatingProcessFileName,
77 InitiatingProcessId, InitiatingProcessAccountName, InitiatingProcessParentFileName, WdacPolicyName, InitiatingProcessRemoteSessionDeviceName, InitiatingProcessRemoteSessionIP,
78 NonceRequestTimestamp, RdpTimeGenerated, RdpInitiatingProcessFileName, RdpRemoteDeviceName, RdpRemoteMacAddress, RdpRemoteDeviceOnboardingStatus,
79 RdpRemoteDeviceTpmActivated, RdpRemoteDeviceTpmEnabled, RdpRemoteDeviceTpmSupported
If you are only interested to flag this for devices that have credentials of highly critical administrators, you can extend the query even further wit Exposure Management:
Detection 4 - Suspicious ncrypt.dll usage on admin device with RDP connections to non TPM protected device (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Entra%20ID/DetectSuspiciousNcryptUsageWithSuspiciousAdminRdpSession.md)
1let time_lookback = 30d;
2let admin_users = toscalar(
3 IdentityInfo
4 | where Timestamp > ago(7d)
5 | where CriticalityLevel != "" or AccountDisplayName contains "Admin"
6 | summarize make_set(AccountDisplayName)
7);
8let devices_with_admin_accounts = (
9 ExposureGraphEdges
10 // Get edges where source is a device and destination is a admin user
11 | where SourceNodeLabel == "device" and TargetNodeLabel == "user"
12 | where TargetNodeName in (admin_users)
13 // Check which devices have the credentials of the admin user
14 | make-graph SourceNodeId --> TargetNodeId with ExposureGraphNodes on NodeId
15 | graph-match (SourceNode)-[hasCredentialsOf]->(TargetNode)
16 project IncomingNodeName = SourceNode.NodeName, OutgoingNodeName = TargetNode.NodeName, CriticalityLevel = TargetNode.NodeProperties.rawData.criticalityLevel.criticalityLevel, CriticalityRuleNames = TargetNode.NodeProperties.rawData.criticalityLevel.ruleNames
17 | summarize make_set(IncomingNodeName)
18);
19let no_tpm_devices = (
20 ExposureGraphNodes
21 // Get device nodes with their inventory ID
22 | where NodeLabel == "device"
23 | mv-expand EntityIds
24 | where EntityIds.type == "DeviceInventoryId"
25 // Get interesting properties
26 | extend OnboardingStatus = tostring(parse_json(NodeProperties)["rawData"]["onboardingStatus"]),
27 TpmSupported = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["supported"]),
28 TpmEnabled = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["enabled"]),
29 TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"]),
30 DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"]),
31 DeviceId = tostring(EntityIds.id)
32 // Search for distinct devices
33 | distinct DeviceId, DeviceName, OnboardingStatus, TpmSupported, TpmEnabled, TpmActivated
34 // Get Unmanaged devices and device not supporting a TPM
35 | where OnboardingStatus != "Onboarded" or (TpmSupported != "true" and TpmActivated != "true" and TpmEnabled != "true")
36 | extend TpmSupported = iff(TpmSupported == "", "unknown", TpmSupported),
37 TpmActivated = iff(TpmActivated == "", "unknown", TpmActivated),
38 TpmEnabled = iff(TpmEnabled == "", "unknown", TpmEnabled)
39);
40let no_tpm_device_info = (
41 DeviceNetworkInfo
42 | where Timestamp > ago(7d)
43 // Get latest network info for each device ID
44 | summarize arg_max(Timestamp, *) by DeviceId
45 | mv-expand todynamic(IPAddresses)
46 | extend IPAddress = tostring(IPAddresses.IPAddress)
47 // Find no TPM devices and join with their network information
48 | join kind=inner no_tpm_devices on DeviceId
49 | project DeviceId, DeviceName, MacAddress, IPAddress, OnboardingStatus, TpmActivated, TpmEnabled, TpmSupported
50);
51let dangerous_rdp_sessions = (
52 DeviceNetworkEvents
53 | where Timestamp > ago(time_lookback)
54 // Only flag admin devices
55 | where DeviceName in (devices_with_admin_accounts)
56 // Exclude MDI RDP Connections (known for NNR)
57 | where InitiatingProcessFileName !~ "microsoft.tri.sensor.exe"
58 // Search for RDP connections to non-tpm devices
59 | where ActionType == "ConnectionSuccess"
60 | where RemotePort == 3389
61 | join kind=inner no_tpm_device_info on $left.RemoteIP == $right.IPAddress
62 | project-rename RemoteDeviceId = DeviceId1,
63 RdpRemoteDeviceName = DeviceName1,
64 RdpRemoteMacAddress = MacAddress,
65 RdpRemoteDeviceOnboardingStatus = OnboardingStatus,
66 RdpRemoteDeviceTpmActivated = TpmActivated,
67 RdpRemoteDeviceTpmEnabled = TpmEnabled,
68 RdpRemoteDeviceTpmSupported = TpmSupported,
69 RdpTimeGenerated = Timestamp,
70 RdpInitiatingProcessFileName = InitiatingProcessFileName
71 | project-away IPAddress
72);
73// Get all possible nonce requests
74let nonce_requests = (
75 DeviceNetworkEvents
76 | where Timestamp > ago(time_lookback)
77 | where ActionType == "ConnectionSuccess"
78 | where RemoteUrl =~ "login.microsoftonline.com"
79 | project-rename NonceRequestTimestamp = Timestamp
80);
81// Get suspicious ncrypt.dll usage via WDAC audit policy
82DeviceEvents
83| where Timestamp > ago(time_lookback)
84// Only flag admin devices
85| where DeviceName in (devices_with_admin_accounts)
86| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
87// Check if the same initiating process is doing a nonce request
88| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
89// Only flag when nonce was request 10min before of after ncrypt usage
90| where Timestamp between (todatetime(NonceRequestTimestamp - 10m) .. todatetime(NonceRequestTimestamp + 10m))
91// Check if the same device is doing RDP Connections
92| join kind=inner dangerous_rdp_sessions on DeviceId
93// Whitelist known good processes
94| where InitiatingProcessFileName !in ("backgroundtaskhost.exe","svchost.exe")
95// Project interesting columns
96| extend WdacPolicyName = parse_json(AdditionalFields)["PolicyName"]
97| project Timestamp, DeviceName, ActionType, FileName, InitiatingProcessSHA1, InitiatingProcessFileName,
98 InitiatingProcessId, InitiatingProcessAccountName, InitiatingProcessParentFileName, WdacPolicyName, InitiatingProcessRemoteSessionDeviceName, InitiatingProcessRemoteSessionIP,
99 NonceRequestTimestamp, RdpTimeGenerated, RdpInitiatingProcessFileName, RdpRemoteDeviceName, RdpRemoteMacAddress, RdpRemoteDeviceOnboardingStatus,
100 RdpRemoteDeviceTpmActivated, RdpRemoteDeviceTpmEnabled, RdpRemoteDeviceTpmSupported
New PRT usage on non-TPM device
Trying to detect when an attacker requests a new PRT on the non-TPM device, is something that is pretty hard to do. When a PRT is requested, conditional access policies are not evaluated and no Entra ID Sign-in logs is being generated. It is only when access tokens for specific applications are being requested using the PRT, that the Conditional Access policies are being evaluated and logs in Entra ID will pop-up.
Because of this, we need to focus on trying to detect logins to applications (or access token requests in Entra ID), that were requested using a PRT token generated on the non-TPM device. The hard part here is that since the attacker requests a PRT on the non-TPM device via the exposed Windows Hello keys of the victim device, it is just like the attacker is requesting a PRT on the victim device itself. This means that the Device ID claim which is present in the PRT, will be the claim of the victim device as well.
When you think of this concept, that would mean that there will be two PRT tokens at the same time. One on the victim device, an on the non-TPM device. This should not happen in normal scenario’s for PRT tokens with the same Device ID, since only one PRT token will exist per device and a new one will only be generated when the other one is about to expire. Because of this, I was wondering if we could create a detection on finding Sign-in logs using PRT tokens with the same Device ID but a different Session IDs, being used on the same time frames. After a couple of hours of investigating this, I eventually came with the below query:
Detection 5 - Multiple Hello for Business PRT tokens being used simultaneously for one device (https://github.com/HybridBrothers/Hunting-Queries-Detection-Rules/blob/main/Entra%20ID/DetectMultipleWhfbPrtTokensUsedSimultaneouslyForOneDevice.md)
1// Get the Sign-in logs we want to query
2let base = materialize(
3 SigninLogs
4 | where TimeGenerated > ago(1d)
5);
6// Get all the WHfB signins by looking at the authentication method and incomming token
7let whfb = (
8 base
9 // Get WHfB signins
10 | mv-expand todynamic(AuthenticationDetails)
11 | where AuthenticationDetails.authenticationMethod == "Windows Hello for Business"
12 | where IncomingTokenType == "primaryRefreshToken"
13 | extend DeviceID = tostring(DeviceDetail.deviceId), AuthenticationDateTime = todatetime(AuthenticationDetails.authenticationStepDateTime)
14 // Remove empty Session and Device IDs
15 | where SessionId != "" and DeviceID != ""
16);
17// Save the time frame for each WHfB PRT token
18// We use the SessionID to identify a specific PRT token since the SessionID changes when a new refresh token is being used
19let prt_timeframes = (
20 whfb
21 // Summarize the first and last PRT usage per device, by using the Session ID
22 | summarize TimeMin = arg_min(AuthenticationDateTime,*), TimeMax=arg_max(AuthenticationDateTime,*) by DeviceID, SessionId
23 | project DeviceID, SessionId, TimeMin, TimeMax
24);
25// Save all the Session IDs for the logins that came from a WHfB authentication method
26let whfb_sessions = toscalar(
27 whfb
28 | summarize make_set(SessionId)
29);
30base
31| mv-expand todynamic(AuthenticationDetails)
32| extend DeviceID = tostring(DeviceDetail.deviceId), AuthenticationDateTime = todatetime(AuthenticationDetails.authenticationStepDateTime)
33// Get all signins related to a WHfB Session
34| where SessionId in (whfb_sessions)
35// Join the access token requests comming from a WHfB session with all the PRT tokens used in the past for each device
36| join kind=inner prt_timeframes on DeviceID
37| extend CurrentSessionID = SessionId, OtherSessionID = SessionId1, OtherSessionTimeMin = TimeMin, OtherSessionTimeMax = TimeMax, DeviceName = tostring(DeviceDetail.displayName)
38// Get logins where the current SessionID is not the same as another one
39| where CurrentSessionID != OtherSessionID
40// Check if the new Session ID is seen while other Session IDs are still active (only check first login of the current Session ID)
41| summarize arg_min(AuthenticationDateTime, *) by DeviceID, CurrentSessionID
42| where AuthenticationDateTime between (OtherSessionTimeMin .. OtherSessionTimeMax)
43// Exclude Windows Sign In as application login since attackers will use the PRT to request access tokens for other applications (they do not need to signin into Windows anymore)
44| where AppDisplayName != "Windows Sign In"
45| project AuthenticationDateTime, UserPrincipalName, DeviceID, DeviceName, CurrentSessionID, OtherSessionID, OtherSessionTimeMin, OtherSessionTimeMax, AppDisplayName, ResourceDisplayName
This query mainly has False Positives on the ‘Windows Sign In’ Application. For some reason, some Windows Sign-ins are happening with new SessionIDs, while the other SessionID is still being used for requests to other applications. I did not find any logical explanation why this sometime happens, but since an attack will request tokens for cloud applications rather then doing Windows Sign-ins once they have the PRT token, I decided to exclude the Windows Sign In application from the query.