16 min read

Detecting non-privileged Windows Hello abuse

Detecting non-privileged Windows Hello abuse
A Severed Hand

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 below needs to be in place.

Remote Desktop sign-in with Windows Hello for Business
Learn how to configure Remote Desktop (RDP) sign-in with Windows Hello for Business.

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.

DEF CON 32: Abusing Windows Hello Without a Severed Hand (https://dirkjanm.io/assets/raw/Abusing%20Windows%20Hello%20Without%20a%20Severed%20Hand_v3.pdf)

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:

EDR Telemetry Project File Opened in Sysmon VS MDE (https://www.edr-telemetry.com/windows)

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.

DEF CON 32: Abusing Windows Hello Without a Severed Hand (https://dirkjanm.io/assets/raw/Abusing%20Windows%20Hello%20Without%20a%20Severed%20Hand_v3.pdf)
RoadTools hellopoc.ps1 (https://github.com/dirkjanm/ROADtools/blob/master/winhello_assertion/hellopoc.ps1)

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:

DeviceEvents
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
| where InitiatingProcessAccountName !~ "system" and InitiatingProcessVersionInfoOriginalFileName =~ "powershell.exe"
| extend WdacPolicyName = parse_json(AdditionalFields)["PolicyName"]
| 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.

DeviceEvents
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
| 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:

let cli_tools = dynamic(["powershell", "python"]);
// Get suspicious ncrypt.dll usage via WDAC audit policy
DeviceEvents
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
| invoke FileProfile(InitiatingProcessSHA1, 1000)
| where (
    // Flag CLI tools
    InitiatingProcessFileName has_any (cli_tools) or 
    // Flag unknown processes
    GlobalPrevalence < 250
)
| 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:

// Get all possible nonce requests
let nonce_requests = (
    DeviceNetworkEvents
    | where Timestamp > ago(30d)
    | where ActionType startswith "ConnectionSuccess"
    | where RemoteUrl =~ "login.microsoftonline.com"
    | project-rename NonceTimestamp = Timestamp
);
// Get suspicious ncrypt.dll usage via WDAC audit policy
DeviceEvents
| where Timestamp > ago(30d)
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
// Check if the same initiating process is doing a nonce request
| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
// Only flag when nonce was request 10min before of after ncrypt usage
| where Timestamp between (todatetime(NonceTimestamp - 10m) .. todatetime(NonceTimestamp + 10m))
| 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:

RoadTools hellopoc.ps1 (https://github.com/dirkjanm/ROADtools/blob/master/winhello_assertion/hellopoc.ps1)
let cli_tools = dynamic(["powershell", "python"]);
// Get all possible nonce requests
let nonce_requests = (
    DeviceNetworkEvents
    | where Timestamp > ago(1h)
    | where ActionType startswith "ConnectionSuccess"
    | where RemoteUrl =~ "login.microsoftonline.com"
    | project-rename NonceTimestamp = Timestamp
);
// Get suspicious ncrypt.dll usage via WDAC audit policy
DeviceEvents
| where Timestamp > ago(1h)
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
// Check if the same initiating process is doing a nonce request
| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
// Only flag when nonce was request 10min before of after ncrypt usage
| where Timestamp between (todatetime(NonceTimestamp - 10m) .. todatetime(NonceTimestamp + 10m))
| invoke FileProfile(InitiatingProcessSHA1, 1000)
| where (
    // Flag CLI tools
    InitiatingProcessFileName has_any (cli_tools) or 
    // Flag unknown processes
    GlobalPrevalence < 250
)

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.

DEF CON 32: Abusing Windows Hello Without a Severed Hand (https://dirkjanm.io/assets/raw/Abusing%20Windows%20Hello%20Without%20a%20Severed%20Hand_v3.pdf)
DEF CON 32: Abusing Windows Hello Without a Severed Hand (https://dirkjanm.io/assets/raw/Abusing%20Windows%20Hello%20Without%20a%20Severed%20Hand_v3.pdf)

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
let historic_rdp_devices = toscalar(
    DeviceNetworkEvents
    | where Timestamp > ago (30d)
    | where ActionType == "ConnectionSuccess"
    | where RemotePort == 3389
    | summarize make_set(DeviceId)
);
DeviceNetworkEvents
| where Timestamp > ago(1h)
| where ActionType == "ConnectionSuccess"
| where RemotePort == 3389
| where DeviceId !in (historic_rdp_devices)
let no_tpm_devices = (
    ExposureGraphNodes
    // Get device nodes with their inventory ID
    | where NodeLabel == "device"
    | mv-expand EntityIds
    | where EntityIds.type == "DeviceInventoryId"
    // Get interesting properties
    | extend OnboardingStatus = tostring(parse_json(NodeProperties)["rawData"]["onboardingStatus"]),
        TpmSupported = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["supported"]),
        TpmEnabled = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["enabled"]),
        TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"]),
        DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"]),
        DeviceId = tostring(EntityIds.id)
    // Search for distinct devices
    | distinct DeviceId, DeviceName, OnboardingStatus, TpmSupported, TpmEnabled, TpmActivated
    // Get Unmanaged devices and device not supporting a TPM
    | where OnboardingStatus != "Onboarded" or (TpmSupported != "true" and TpmActivated != "true" and TpmEnabled != "true")
    | extend TpmSupported = iff(TpmSupported == "", "unknown", TpmSupported),
        TpmActivated = iff(TpmActivated == "", "unknown", TpmActivated),
        TpmEnabled = iff(TpmEnabled == "", "unknown", TpmEnabled)
);
let no_tpm_device_info = (
    DeviceNetworkInfo
    | where Timestamp > ago(7d)
    // Get latest network info for each device ID
    | summarize arg_max(Timestamp, *) by DeviceId
    | mv-expand todynamic(IPAddresses)
    | extend IPAddress = tostring(IPAddresses.IPAddress)
    // Find no TPM devices and join with their network information
    | join kind=inner no_tpm_devices on DeviceId
    | project DeviceId, DeviceName, MacAddress, IPAddress, OnboardingStatus, TpmActivated, TpmEnabled, TpmSupported
);
DeviceNetworkEvents
// Search for RDP connections to non-tpm devices
| where Timestamp > ago(1h)
| where ActionType == "ConnectionSuccess"
| where RemotePort == 3389
// Exclude MDI RDP Connections (known for NNR)
| where InitiatingProcessFileName !~ "microsoft.tri.sensor.exe"
| join kind=inner no_tpm_device_info on $left.RemoteIP == $right.IPAddress
| project-rename RemoteDeviceId = DeviceId1, RemoteDeviceName = DeviceName1, RemoteMacAddress = MacAddress, RemoteDeviceOnboardingStatus = OnboardingStatus, RemoteDeviceTpmActivated = TpmActivated, RemoteDeviceTpmEnabled = TpmEnabled, RemoteDeviceTpmSupported = TpmSupported
| 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:

🛡️
let time_lookback = 1h;
let no_tpm_devices = (
    ExposureGraphNodes
    // Get device nodes with their inventory ID
    | where NodeLabel == "device"
    | mv-expand EntityIds
    | where EntityIds.type == "DeviceInventoryId"
    // Get interesting properties
    | extend OnboardingStatus = tostring(parse_json(NodeProperties)["rawData"]["onboardingStatus"]),
        TpmSupported = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["supported"]),
        TpmEnabled = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["enabled"]),
        TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"]),
        DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"]),
        DeviceId = tostring(EntityIds.id)
    // Search for distinct devices
    | distinct DeviceId, DeviceName, OnboardingStatus, TpmSupported, TpmEnabled, TpmActivated
    // Get Unmanaged devices and device not supporting a TPM
    | where OnboardingStatus != "Onboarded" or (TpmSupported != "true" and TpmActivated != "true" and TpmEnabled != "true")
    | extend TpmSupported = iff(TpmSupported == "", "unknown", TpmSupported),
        TpmActivated = iff(TpmActivated == "", "unknown", TpmActivated),
        TpmEnabled = iff(TpmEnabled == "", "unknown", TpmEnabled)
);
let no_tpm_device_info = (
    DeviceNetworkInfo
    | where Timestamp > ago(7d)
    // Get latest network info for each device ID
    | summarize arg_max(Timestamp, *) by DeviceId
    | mv-expand todynamic(IPAddresses)
    | extend IPAddress = tostring(IPAddresses.IPAddress)
    // Find no TPM devices and join with their network information
    | join kind=inner no_tpm_devices on DeviceId
    | project DeviceId, DeviceName, MacAddress, IPAddress, OnboardingStatus, TpmActivated, TpmEnabled, TpmSupported
);
let dangerous_rdp_sessions = (
    DeviceNetworkEvents
    | where Timestamp > ago(time_lookback)
    // Exclude MDI RDP Connections (known for NNR)
    | where InitiatingProcessFileName !~ "microsoft.tri.sensor.exe"
    // Search for RDP connections to non-tpm devices
    | where ActionType == "ConnectionSuccess"
    | where RemotePort == 3389
    | join kind=inner no_tpm_device_info on $left.RemoteIP == $right.IPAddress
    | project-rename RemoteDeviceId = DeviceId1, 
        RdpRemoteDeviceName = DeviceName1, 
        RdpRemoteMacAddress = MacAddress, 
        RdpRemoteDeviceOnboardingStatus = OnboardingStatus, 
        RdpRemoteDeviceTpmActivated = TpmActivated, 
        RdpRemoteDeviceTpmEnabled = TpmEnabled, 
        RdpRemoteDeviceTpmSupported = TpmSupported,
        RdpTimeGenerated = Timestamp,
        RdpInitiatingProcessFileName = InitiatingProcessFileName
    | project-away IPAddress
);
// Get all possible nonce requests
let nonce_requests = (
    DeviceNetworkEvents
    | where Timestamp > ago(time_lookback)
    | where ActionType == "ConnectionSuccess"
    | where RemoteUrl =~ "login.microsoftonline.com"
    | project-rename NonceRequestTimestamp = Timestamp
);
// Get suspicious ncrypt.dll usage via WDAC audit policy
DeviceEvents
| where Timestamp > ago(time_lookback)
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
// Check if the same initiating process is doing a nonce request
| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
// Only flag when nonce was request 10min before of after ncrypt usage
| where Timestamp between (todatetime(NonceRequestTimestamp - 10m) .. todatetime(NonceRequestTimestamp + 10m))
// Check if the same device is doing RDP Connections
| join kind=inner dangerous_rdp_sessions on DeviceId
// Whitelist known good processes
| where InitiatingProcessFileName !in ("backgroundtaskhost.exe","svchost.exe")
// Project interesting columns
| extend WdacPolicyName = parse_json(AdditionalFields)["PolicyName"]
| project Timestamp, DeviceName, ActionType, FileName, InitiatingProcessSHA1, InitiatingProcessFileName, 
    InitiatingProcessId, InitiatingProcessAccountName, InitiatingProcessParentFileName, WdacPolicyName, InitiatingProcessRemoteSessionDeviceName, InitiatingProcessRemoteSessionIP,
    NonceRequestTimestamp, RdpTimeGenerated, RdpInitiatingProcessFileName, RdpRemoteDeviceName, RdpRemoteMacAddress, RdpRemoteDeviceOnboardingStatus,
    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)
let time_lookback = 30d;
let admin_users = toscalar(
    IdentityInfo
    | where Timestamp > ago(7d)
    | where CriticalityLevel != "" or AccountDisplayName contains "Admin"
    | summarize make_set(AccountDisplayName)
);
let devices_with_admin_accounts = (
    ExposureGraphEdges
    // Get edges where source is a device and destination is a admin user
    | where SourceNodeLabel == "device" and TargetNodeLabel == "user"
    | where TargetNodeName in (admin_users)
    // Check which devices have the credentials of the admin user
    | make-graph SourceNodeId --> TargetNodeId with ExposureGraphNodes on NodeId
    | graph-match (SourceNode)-[hasCredentialsOf]->(TargetNode)
        project IncomingNodeName = SourceNode.NodeName, OutgoingNodeName = TargetNode.NodeName, CriticalityLevel = TargetNode.NodeProperties.rawData.criticalityLevel.criticalityLevel, CriticalityRuleNames = TargetNode.NodeProperties.rawData.criticalityLevel.ruleNames
    | summarize make_set(IncomingNodeName)
);
let no_tpm_devices = (
    ExposureGraphNodes
    // Get device nodes with their inventory ID
    | where NodeLabel == "device"
    | mv-expand EntityIds
    | where EntityIds.type == "DeviceInventoryId"
    // Get interesting properties
    | extend OnboardingStatus = tostring(parse_json(NodeProperties)["rawData"]["onboardingStatus"]),
        TpmSupported = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["supported"]),
        TpmEnabled = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["enabled"]),
        TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"]),
        DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"]),
        DeviceId = tostring(EntityIds.id)
    // Search for distinct devices
    | distinct DeviceId, DeviceName, OnboardingStatus, TpmSupported, TpmEnabled, TpmActivated
    // Get Unmanaged devices and device not supporting a TPM
    | where OnboardingStatus != "Onboarded" or (TpmSupported != "true" and TpmActivated != "true" and TpmEnabled != "true")
    | extend TpmSupported = iff(TpmSupported == "", "unknown", TpmSupported),
        TpmActivated = iff(TpmActivated == "", "unknown", TpmActivated),
        TpmEnabled = iff(TpmEnabled == "", "unknown", TpmEnabled)
);
let no_tpm_device_info = (
    DeviceNetworkInfo
    | where Timestamp > ago(7d)
    // Get latest network info for each device ID
    | summarize arg_max(Timestamp, *) by DeviceId
    | mv-expand todynamic(IPAddresses)
    | extend IPAddress = tostring(IPAddresses.IPAddress)
    // Find no TPM devices and join with their network information
    | join kind=inner no_tpm_devices on DeviceId
    | project DeviceId, DeviceName, MacAddress, IPAddress, OnboardingStatus, TpmActivated, TpmEnabled, TpmSupported
);
let dangerous_rdp_sessions = (
    DeviceNetworkEvents
    | where Timestamp > ago(time_lookback)
    // Only flag admin devices
    | where DeviceName in (devices_with_admin_accounts)
    // Exclude MDI RDP Connections (known for NNR)
    | where InitiatingProcessFileName !~ "microsoft.tri.sensor.exe"
    // Search for RDP connections to non-tpm devices
    | where ActionType == "ConnectionSuccess"
    | where RemotePort == 3389
    | join kind=inner no_tpm_device_info on $left.RemoteIP == $right.IPAddress
    | project-rename RemoteDeviceId = DeviceId1, 
        RdpRemoteDeviceName = DeviceName1, 
        RdpRemoteMacAddress = MacAddress, 
        RdpRemoteDeviceOnboardingStatus = OnboardingStatus, 
        RdpRemoteDeviceTpmActivated = TpmActivated, 
        RdpRemoteDeviceTpmEnabled = TpmEnabled, 
        RdpRemoteDeviceTpmSupported = TpmSupported,
        RdpTimeGenerated = Timestamp,
        RdpInitiatingProcessFileName = InitiatingProcessFileName
    | project-away IPAddress
);
// Get all possible nonce requests
let nonce_requests = (
    DeviceNetworkEvents
    | where Timestamp > ago(time_lookback)
    | where ActionType == "ConnectionSuccess"
    | where RemoteUrl =~ "login.microsoftonline.com"
    | project-rename NonceRequestTimestamp = Timestamp
);
// Get suspicious ncrypt.dll usage via WDAC audit policy
DeviceEvents
| where Timestamp > ago(time_lookback)
// Only flag admin devices
| where DeviceName in (devices_with_admin_accounts)
| where ActionType startswith "AppControl" and FileName =~ "ncrypt.dll"
// Check if the same initiating process is doing a nonce request
| join kind=inner nonce_requests on InitiatingProcessId, DeviceId
// Only flag when nonce was request 10min before of after ncrypt usage
| where Timestamp between (todatetime(NonceRequestTimestamp - 10m) .. todatetime(NonceRequestTimestamp + 10m))
// Check if the same device is doing RDP Connections
| join kind=inner dangerous_rdp_sessions on DeviceId
// Whitelist known good processes
| where InitiatingProcessFileName !in ("backgroundtaskhost.exe","svchost.exe")
// Project interesting columns
| extend WdacPolicyName = parse_json(AdditionalFields)["PolicyName"]
| project Timestamp, DeviceName, ActionType, FileName, InitiatingProcessSHA1, InitiatingProcessFileName, 
    InitiatingProcessId, InitiatingProcessAccountName, InitiatingProcessParentFileName, WdacPolicyName, InitiatingProcessRemoteSessionDeviceName, InitiatingProcessRemoteSessionIP,
    NonceRequestTimestamp, RdpTimeGenerated, RdpInitiatingProcessFileName, RdpRemoteDeviceName, RdpRemoteMacAddress, RdpRemoteDeviceOnboardingStatus,
    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:

🛡️
// Get the Sign-in logs we want to query
let base = materialize(
    SigninLogs
    | where TimeGenerated > ago(1d)
);
// Get all the WHfB signins by looking at the authentication method and incomming token
let whfb = (
    base
    // Get WHfB signins
    | mv-expand todynamic(AuthenticationDetails)
    | where AuthenticationDetails.authenticationMethod == "Windows Hello for Business"
    | where IncomingTokenType == "primaryRefreshToken"
    | extend DeviceID = tostring(DeviceDetail.deviceId), AuthenticationDateTime = todatetime(AuthenticationDetails.authenticationStepDateTime)
    // Remove empty Session and Device IDs
    | where SessionId != "" and DeviceID != ""
);
// Save the time frame for each WHfB PRT token
// We use the SessionID to identify a specific PRT token since the SessionID changes when a new refresh token is being used
let prt_timeframes = (
    whfb
    // Summarize the first and last PRT usage per device, by using the Session ID
    | summarize TimeMin = arg_min(AuthenticationDateTime,*), TimeMax=arg_max(AuthenticationDateTime,*) by DeviceID, SessionId
    | project DeviceID, SessionId, TimeMin, TimeMax
);
// Save all the Session IDs for the logins that came from a WHfB authentication method
let whfb_sessions = toscalar(
    whfb
    | summarize make_set(SessionId)
);
base
| mv-expand todynamic(AuthenticationDetails)
| extend DeviceID = tostring(DeviceDetail.deviceId), AuthenticationDateTime = todatetime(AuthenticationDetails.authenticationStepDateTime)
// Get all signins related to a WHfB Session
| where SessionId in (whfb_sessions)
// Join the access token requests comming from a WHfB session with all the PRT tokens used in the past for each device
| join kind=inner prt_timeframes on DeviceID
| extend CurrentSessionID = SessionId, OtherSessionID = SessionId1, OtherSessionTimeMin = TimeMin, OtherSessionTimeMax = TimeMax, DeviceName = tostring(DeviceDetail.displayName)
// Get logins where the current SessionID is not the same as another one
| where CurrentSessionID != OtherSessionID
// Check if the new Session ID is seen while other Session IDs are still active (only check first login of the current Session ID)
| summarize arg_min(AuthenticationDateTime, *) by DeviceID, CurrentSessionID
| where AuthenticationDateTime between (OtherSessionTimeMin .. OtherSessionTimeMax)
// 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)
| where AppDisplayName != "Windows Sign In"
| 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.