Hunting for MFA manipulations in Entra ID tenants using KQL
Published May 29 2024 10:10 AM 39.7K Views
Microsoft

Cloud security is a top priority for many organizations, especially given that threat actors are constantly looking for ways to compromise cloud accounts and access sensitive data. One of the common, and highly effective, methods that attackers use is changing the multi-factor authentication (MFA) properties for users in compromised tenants. This can allow the attacker to satisfy MFA requirements, disable MFA for other users, or enroll new devices for MFA. Some of these changes can be hard to detect and monitor, as they are typically performed as part of standard helpdesk processes and may be lost in the noise of all the other directory activities occurring in the Microsoft Entra audit log.

 

In this blog, we will show you how to use Kusto Query Language (KQL) to parse and hunt for MFA modifications in Microsoft Entra audit logs. We will explain the different types of MFA changes that can occur, how to identify them, and how to create user-friendly outputs that can help you investigate and respond to incidents involving these techniques. We will also share some tips and best practices for hunting for MFA anomalies, such as looking for unusual patterns, locations, or devices. By the end of this blog, you will have a better understanding of how to track MFA changes in compromised tenants using KQL queries and how to improve your cloud security posture. 

 

Kusto to the rescue

Microsoft Entra audit logs record changes to MFA settings for a user. When a user's MFA details are changed, two log entries are created in the audit log. One is logged by the service “Authentication Methods” and category “UserManagement” where the activity name is descriptive (e.g., “User registered security info”) but lacks details about what alterations were made. The other entry has the activity name “Update User” that shows the modified properties. This artifact is challenging because “Update User” is a very common operation and occurs in many different situations. Using the Microsoft Entra portal here can pose challenges due to the volume of data, especially in large tenants, but KQL can help simplify this task.

 

By default, Microsoft Entra audit logs are available through the portal for 30 days, regardless of the license plan; however, getting this data via KQL requires pre-configuration. In this blog, we provide ready-to-use KQL queries for both Azure Log Analytics and Microsoft Defender 365 Advanced Hunting, allowing you to analyze and find these scenarios in your own tenant.

 

Flow.png

Figure 1: Diagram of data flow of logs related to account manipulation

 

Table 1: Comparison between Azure Log Analytics and Defender 365 Advanced Hunting

 

Azure Log Analytics

Defender 365 Advanced Hunting

Interface

Azure Portal, but can be connected to Azure Data Explorer

Defender 365 Portal

Retention

Configurable

30 days

Pre-requisite

Log Analytics Workspace

Microsoft Defender for Cloud Apps License

Cost

Minimal cost

No additional cost

Required configuration

Diagnostics settings need to be configured in Microsoft Entra ID to send Audit Logs to Log Analytics

Microsoft 365 Connector needs to be enabled in Microsoft Defender for Cloud Apps

Column containing modified properties

TargetResources

RawEventData

 

Know your data

There are 3 key different MFA properties that can be changed, all of which can be found in the “Update User” details:

 1. StrongAuthenticationMethod: The registered MFA methods for the user and the default method chosen. The methods are represented as numbers ranging from 0 to 7 as follows:

 

Table 2: Mapping Strong Authentication Methods numbers to names

Method

Name

Description

0

TwoWayVoiceMobile

Two-way voice using mobile phone

1

TwoWaySms

Two-way SMS message using mobile phone

2

TwoWayVoiceOffice

Two-way voice using office phone

3

TwoWayVoiceOtherMobile

Two-way voice using Alternative Mobile phone numbers

4

TwoWaySmsOtherMobile

Two-way SMS message using Alternative Mobile phone numbers

5

OneWaySms

One-way SMS message using mobile phone

6

PhoneAppNotification

Notification based MFA in Microsoft Authenticator mobile app. (Code and Notification)

7

PhoneAppOTP

OTP based 2FA in Microsoft Authenticator mobile app, third-party Authenticator app without push notifications, Hardware or Software OATH token which requires the user enter a code displayed in Mobile application or device. (Code only)

 

2. StrongAuthenticationUserDetails: User information for the following MFA methods:

- Phone Number

- Email

- Alternative Phone Number

- Voice Only Phone Number

 

3. StrongAuthenticationAppDetail: Information about Microsoft Authenticator App registered by the user. This property contains many fields, but we are mainly interested in the following:

- Device Name: the name of the device that has Authenticator App installed on

- Device Token: a unique identifier for the device

 

Note: This information is available when the method used is PhoneAppNotification. For PhoneAppOTP, you will see DeviceName as NO_DEVICE and DeviceToken as NO_DEVICE_TOKEN, making it a popular choice for threat actors.

 

Let’s go hunting!

Now that we know there are 3 different types of MFA properties that might be modified, and each one has a different format in the "Update User" activity, we require a different query for each type. Even though the queries may seem complex, the outcome is certainly nice!

 

Note: The KQL queries provided in this article do not have any time filters. Add time filters in the query or select it in the GUI as desired.

 

1. StrongAuthenticationMethod

 

JSON structure for modified properties:

 

"modifiedProperties": [{
                "displayName": "StrongAuthenticationMethod",
                "oldValue": "[{"MethodType":3,"Default":false},{"MethodType":7,"Default":true}]",
                "newValue": "[{"MethodType":6,"Default":true},{"MethodType":7,"Default":false}]"
            }]

 

In the JSON above, we can compare the elements in the oldValue array against the newValue array to see which methods have been added or removed, and whether the Default method is different.

By performing this comparison using KQL, we can extract the Changed value, old value and new value from each log entry and generate a friendly description alongside the Timestamp, Actor, and Target. If multiple properties were changed in the same operation, a separate row will be displayed for each in the output.

 

In Advanced Hunting:

 

//Advanced Hunting query to parse modified StrongAuthenticationMethod

let AuthenticationMethods = dynamic(["TwoWayVoiceMobile","TwoWaySms","TwoWayVoiceOffice","TwoWayVoiceOtherMobile","TwoWaySmsOtherMobile","OneWaySms","PhoneAppNotification","PhoneAppOTP"]);
let AuthenticationMethodChanges = CloudAppEvents
| where ActionType == "Update user." and RawEventData contains "StrongAuthenticationMethod"
| extend Target = tostring(RawEventData.ObjectId)
| extend Actor = tostring(RawEventData.UserId)
| mv-expand ModifiedProperties = parse_json(RawEventData.ModifiedProperties)
| where ModifiedProperties.Name == "StrongAuthenticationMethod"
| project Timestamp,Actor,Target,ModifiedProperties,RawEventData,ReportId;
let OldValues = AuthenticationMethodChanges
| extend OldValue = parse_json(tostring(ModifiedProperties.OldValue))
| mv-apply OldValue on (extend Old_MethodType=tostring(OldValue.MethodType),Old_Default=tostring(OldValue.Default) | sort by Old_MethodType);
let NewValues = AuthenticationMethodChanges
| extend NewValue = parse_json(tostring(ModifiedProperties.NewValue))
| mv-apply NewValue on (extend New_MethodType=tostring(NewValue.MethodType),New_Default=tostring(NewValue.Default) | sort by New_MethodType);
let RemovedMethods = AuthenticationMethodChanges
| join kind=inner OldValues on ReportId
| join kind=leftouter  NewValues  on ReportId,$left.Old_MethodType==$right.New_MethodType
| project Timestamp,ReportId,ModifiedProperties,Actor,Target,Old_MethodType,New_MethodType
| where Old_MethodType != New_MethodType
| extend Action = strcat("Removed (" , AuthenticationMethods[toint(Old_MethodType)], ") from Authentication Methods.")
| extend ChangedValue = "Method Removed";
let AddedMethods = AuthenticationMethodChanges
| join kind=inner NewValues on ReportId
| join kind=leftouter  OldValues  on ReportId,$left.New_MethodType==$right.Old_MethodType
| project Timestamp,ReportId,ModifiedProperties,Actor,Target,Old_MethodType,New_MethodType
| where Old_MethodType != New_MethodType
| extend Action = strcat("Added (" , AuthenticationMethods[toint(New_MethodType)], ") as Authentication Method.") 
| extend ChangedValue = "Method Added";
let DefaultMethodChanges = AuthenticationMethodChanges
| join kind=inner OldValues on ReportId
| join kind=inner NewValues on ReportId
| where Old_Default != New_Default and Old_MethodType == New_MethodType and New_Default == "true"
| join kind=inner OldValues on ReportId | where Old_Default1 == "true" and Old_MethodType1 != New_MethodType | extend Old_MethodType = Old_MethodType1
| extend Action = strcat("Default Authentication Method was changed to (" , AuthenticationMethods[toint(New_MethodType)], ").")
| extend ChangedValue = "Default Method";
union RemovedMethods,AddedMethods,DefaultMethodChanges
| project Timestamp,Action,Actor,Target,ChangedValue,OldValue=case(isempty(Old_MethodType), "",strcat(Old_MethodType,": ", AuthenticationMethods[toint(Old_MethodType)])),NewValue=case(isempty( New_MethodType),"", strcat(New_MethodType,": ", AuthenticationMethods[toint(New_MethodType)]))
| distinct *

 

 

 

In Azure Log Analytics:

 

//Azure Log Analytics query to parse modified StrongAuthenticationMethod

let AuthenticationMethods = dynamic(["TwoWayVoiceMobile","TwoWaySms","TwoWayVoiceOffice","TwoWayVoiceOtherMobile","TwoWaySmsOtherMobile","OneWaySms","PhoneAppNotification","PhoneAppOTP"]);
let AuthenticationMethodChanges = AuditLogs
| where OperationName == "Update user" and TargetResources contains "StrongAuthenticationMethod"
| extend Target = tostring(TargetResources[0].userPrincipalName)
| extend Actor = case(isempty(parse_json(InitiatedBy.user).userPrincipalName),tostring(parse_json(InitiatedBy.app).displayName) ,tostring(parse_json(InitiatedBy.user).userPrincipalName))
| mvexpand ModifiedProperties = parse_json(TargetResources[0].modifiedProperties)
| where ModifiedProperties.displayName ==  "StrongAuthenticationMethod"
| project TimeGenerated,Actor,Target,TargetResources,ModifiedProperties,Id;
let OldValues = AuthenticationMethodChanges
| extend OldValue = parse_json(tostring(ModifiedProperties.oldValue))
| mv-apply OldValue on (extend Old_MethodType=tostring(OldValue.MethodType),Old_Default=tostring(OldValue.Default) | sort by Old_MethodType);
let NewValues = AuthenticationMethodChanges
| extend NewValue = parse_json(tostring(ModifiedProperties.newValue))
| mv-apply NewValue on (extend New_MethodType=tostring(NewValue.MethodType),New_Default=tostring(NewValue.Default) | sort by New_MethodType);
let RemovedMethods = AuthenticationMethodChanges
| join kind=inner OldValues on Id
| join kind=leftouter  NewValues  on Id,$left.Old_MethodType==$right.New_MethodType
| project TimeGenerated,Id,ModifiedProperties,Actor,Target,Old_MethodType,New_MethodType
| where Old_MethodType != New_MethodType
| extend Action = strcat("Removed (" , AuthenticationMethods[toint(Old_MethodType)], ") from Authentication Methods.")
| extend ChangedValue = "Method Removed";
let AddedMethods = AuthenticationMethodChanges
| join kind=inner NewValues on Id
| join kind=leftouter  OldValues  on Id,$left.New_MethodType==$right.Old_MethodType
| project TimeGenerated,Id,ModifiedProperties,Actor,Target,Old_MethodType,New_MethodType
| where Old_MethodType != New_MethodType
| extend Action = strcat("Added (" , AuthenticationMethods[toint(New_MethodType)], ") as Authentication Method.") 
| extend ChangedValue = "Method Added";
let DefaultMethodChanges = AuthenticationMethodChanges
| join kind=inner OldValues on Id
| join kind=inner NewValues on Id
| where Old_Default != New_Default and Old_MethodType == New_MethodType and New_Default == "true"
| join kind=inner OldValues on Id | where Old_Default1 == "true" and Old_MethodType1 != New_MethodType | extend Old_MethodType = Old_MethodType1
| extend Action = strcat("Default Authentication Method was changed to (" , AuthenticationMethods[toint(New_MethodType)], ").")
| extend ChangedValue = "Default Method";
union RemovedMethods,AddedMethods,DefaultMethodChanges
| project TimeGenerated,Action,Actor,Target,ChangedValue,OldValue=case(isempty(Old_MethodType), "",strcat(Old_MethodType,": ", AuthenticationMethods[toint(Old_MethodType)])),NewValue=case(isempty( New_MethodType),"", strcat(New_MethodType,": ", AuthenticationMethods[toint(New_MethodType)]))
| distinct *

 

If we run the above queries, we get example output as below. In the output below, we can see a few examples of users who have had their MFA settings changed, who performed the change, and the old/new comparison, giving us areas to focus our attention on.

 

StrongAuthenticationMethods.png

Figure 2: Example output from running the StrongAuthenticationMethods parsing query

 

 

2. StrongAuthenticationUserDetails

 

JSON structure for modified properties:

 

    "ModifiedProperties": [{
            "Name": "StrongAuthenticationUserDetails",
            "NewValue": "[{"PhoneNumber": "+962 78XXXXX92","AlternativePhoneNumber": null,"Email": " contoso@contoso.com","VoiceOnlyPhoneNumber": null}]",
            "OldValue": "[{"PhoneNumber": "+962 78XXXXX92","AlternativePhoneNumber": null,"Email": null,"VoiceOnlyPhoneNumber": null}]"
        }]

 

Again, we are interested in comparing values in OldValue and NewValue to see what details were changed, deleted, or updated. In the above example, we can see that Email was (null) in OldValue and (contoso@contoso.com) in NewValue, which means an email address was added to MFA details for this user.

 

In Advanced Hunting:

 

//Advanced Hunting query to parse modified StrongAuthenticationUserDetails

CloudAppEvents
| where ActionType == "Update user." and RawEventData contains "StrongAuthenticationUserDetails"
| extend Target = RawEventData.ObjectId
| extend Actor = RawEventData.UserId
| extend reportId= RawEventData.ReportId
| mvexpand ModifiedProperties = parse_json(RawEventData.ModifiedProperties)
| where ModifiedProperties.Name == "StrongAuthenticationUserDetails"
| extend NewValue = parse_json(replace_string(replace_string(tostring(ModifiedProperties.NewValue),"[",""),"]",""))
| extend OldValue = parse_json(replace_string(replace_string(tostring(ModifiedProperties.OldValue),"[",""),"]",""))
| mv-expand NewValue
| mv-expand OldValue
| where (tostring( bag_keys(OldValue)) == tostring(bag_keys(NewValue))) or (isempty(OldValue) and tostring(NewValue) !contains ":null") or (isempty(NewValue) and tostring(OldValue) !contains ":null") 
| extend ChangedValue = tostring(bag_keys(NewValue)[0])
| extend OldValue = tostring(parse_json(OldValue)[ChangedValue])
| extend NewValue = tostring(parse_json(NewValue)[ChangedValue])
| extend OldValue = case(ChangedValue == "PhoneNumber" or ChangedValue == "AlternativePhoneNumber", replace_strings(OldValue,dynamic([' ','(',')']), dynamic(['','',''])), OldValue )
| extend NewValue = case(ChangedValue == "PhoneNumber" or ChangedValue == "AlternativePhoneNumber", replace_strings(NewValue,dynamic([' ','(',')']), dynamic(['','',''])), NewValue )
| where tostring(OldValue) != tostring(NewValue)
| extend Action = case(isempty(OldValue), strcat("Added new ",ChangedValue, " to Strong Authentication."),isempty(NewValue),strcat("Removed existing ",ChangedValue, " from Strong Authentication."),strcat("Changed ",ChangedValue," in Strong Authentication."))
| project Timestamp,Action,Actor,Target,ChangedValue,OldValue,NewValue

 

 

In Azure Log Analytics:

 

//Azure Log Analytics query to parse modified StrongAuthenticationUserDetails

AuditLogs
| where OperationName == "Update user" and TargetResources contains "StrongAuthenticationUserDetails"
| extend Target = TargetResources[0].userPrincipalName
| extend Actor = parse_json(InitiatedBy.user).userPrincipalName
| mv-expand   ModifiedProperties = parse_json(TargetResources[0].modifiedProperties)
| where ModifiedProperties.displayName == "StrongAuthenticationUserDetails"
| extend NewValue = parse_json(replace_string(replace_string(tostring(ModifiedProperties.newValue),"[",""),"]",""))
| extend OldValue = parse_json(replace_string(replace_string(tostring(ModifiedProperties.oldValue),"[",""),"]",""))
| mv-expand NewValue
| mv-expand OldValue
| where (tostring(bag_keys(OldValue)) == tostring(bag_keys(NewValue))) or (isempty(OldValue) and tostring(NewValue) !contains ":null") or (isempty(NewValue) and tostring(OldValue) !contains ":null") 
| extend ChangedValue = tostring(bag_keys(NewValue)[0])
| extend OldValue = tostring(parse_json(OldValue)[ChangedValue])
| extend NewValue = tostring(parse_json(NewValue)[ChangedValue])
| extend OldValue = case(ChangedValue == "PhoneNumber" or ChangedValue == "AlternativePhoneNumber", replace_strings(OldValue,dynamic([' ','(',')']), dynamic(['','',''])), OldValue )
| extend NewValue = case(ChangedValue == "PhoneNumber" or ChangedValue == "AlternativePhoneNumber", replace_strings(NewValue,dynamic([' ','(',')']), dynamic(['','',''])), NewValue )
| where tostring(OldValue) != tostring(NewValue)
| extend Action = case(isempty(OldValue), strcat("Added new ",ChangedValue, " to Strong Authentication."),isempty(NewValue),strcat("Removed existing ",ChangedValue, " from Strong Authentication."),strcat("Changed ",ChangedValue," in Strong Authentication."))
| project TimeGenerated,Action,Actor,Target,ChangedValue,OldValue,NewValue

 

After running the above queries, we get the output below. Here we can see phone numbers and emails being added/modified which may or may not be expected or desired.

 

StrongAuthenticationUserDetails-1.png

Figure 3: Example output from running the StrongAuthenticationUserDetails parsing query

 

Further analysis:

To hunt for anomalies, we can extend our query to look for MFA user details that have been added to multiple users by adding the following lines (for Log Analytics queries, replace Timestamp with TimeGenerated):

 

| where isnotempty(NewValue)
| summarize min(Timestamp),max(Timestamp),make_set(Target) by NewValue
| extend UserCount = array_length(set_Target)
| where UserCount > 1

 

The output looks like this:

StrongAuthenticationUserDetails-2.png

 

Here we can see that the phone number (+14424XXX657) has been added as MFA phone number to 3 different users between 2024-04-12 10:24:09 and 2024-04-17 11:24:09 and the email address (Evil@hellomail.net) has been added as MFA Email for 2 different users between 2024-04-12 10:24:09 and 2024-04-17 11:24:09.

 

We can also monitor users who switch their phone number to a different country code than their previous one. We can achieve this by adding the following lines to the original KQL query, which checks if the first 3 characters of the new value are different from the old value (This may not give the desired results for US and Canada country codes):

 

| where (ChangedValue == "PhoneNumber" or ChangedValue == "AlternativePhoneNumber") and isnotempty(OldValue) and isnotempty(NewValue)
| where substring(OldValue,0,2) != substring(NewValue,0,2)

 

 

3. StrongAuthenticationAppDetail

 

JSON structure for modified properties:

 

   "ModifiedProperties": [{
            "Name": "StrongAuthenticationPhoneAppDetail",
            "NewValue": "[ { "DeviceName": "Samsung", "DeviceToken": "cH1BCUm_XXXXXXXXXXXXXX_F5VYZx3-xxPibuYVCL9xxxxdVR", "DeviceTag": "SoftwareTokenActivated", "PhoneAppVersion": "6.2401.0119", "OathTokenTimeDrift": 0, "DeviceId": "00000000-0000-0000-0000-000000000000", "Id": "384c3a59-XXXX-XXXX-XXXX-XXXXXXXX166d ", "TimeInterval": 0, "AuthenticationType": 3, "NotificationType": 4, "LastAuthenticatedTimestamp": "2024-XX-XXT09:20:16.4364195Z ", "AuthenticatorFlavor": null, "HashFunction": null, "TenantDeviceId": null, "SecuredPartitionId": 0, "SecuredKeyId": 0 }, { "DeviceName": "iPhone", "DeviceToken": "apns2-e947c2a3b41XXXXXXXXXXXXXXXXXXXXXXXXXXXXa1d3930", "DeviceTag": "SoftwareTokenActivated", "PhoneAppVersion": "6.8.7", "OathTokenTimeDrift": 0, "DeviceId": "00000000-0000-0000-0000-000000000000", "Id": "8da1XXXX-XXXX-XXXX-XXXX-XXXXXXa6028", "TimeInterval": 0, "AuthenticationType": 3, "NotificationType": 2, "LastAuthenticatedTimestamp": "2024-XX-XXT11:XX:XX.5184213Z", "AuthenticatorFlavor": null, "HashFunction": null, "TenantDeviceId": null, "SecuredPartitionId": 0, "SecuredKeyId": 0 }]",
            "OldValue": "[ { "DeviceName": "Samsung", "DeviceToken": " cH1BCUm_XXXXXXXXXXXXXX_F5VYZx3-xxPibuYVCL9xxxxdVR", "DeviceTag": "SoftwareTokenActivated", "PhoneAppVersion": "6.2401.0119", "OathTokenTimeDrift": 0, "DeviceId": "00000000-0000-0000-0000-000000000000", "Id": "384c3a59-XXXX-XXXX-XXXX-XXXXXXXX166d", "TimeInterval": 0, "AuthenticationType": 3, "NotificationType": 4, "LastAuthenticatedTimestamp": "2024-XX-XXT09:20:16.4364195Z", "AuthenticatorFlavor": null, "HashFunction": null, "TenantDeviceId": null, "SecuredPartitionId": 0, "SecuredKeyId": 0 }]"
      }]

 

Just like with our other values, the goal is to contrast the values in OldValue and NewValue, this time paying attention to DeviceName and DeviceToken to see if the Authenticator App was set up on a different device or deleted for a current device for the user. From the JSON example above, we can infer that the user already had a device (Samsung) registered for Authenticator App and added another device (iPhone).

 

In Advanced Hunting:

 

//Advanced Hunting query to parse modified StrongAuthenticationPhoneAppDetail

let DeviceChanges = CloudAppEvents
| where ActionType == "Update user." and RawEventData contains "StrongAuthenticationPhoneAppDetail"
| extend Target = tostring(RawEventData.ObjectId)
| extend Actor = tostring(RawEventData.UserId)
| mv-expand ModifiedProperties = parse_json(RawEventData.ModifiedProperties)
| where ModifiedProperties.Name == "StrongAuthenticationPhoneAppDetail"
| project Timestamp,Actor,Target,ModifiedProperties,RawEventData,ReportId;
let OldValues= DeviceChanges
| extend  OldValue = parse_json(tostring(ModifiedProperties.OldValue))
| mv-apply OldValue on (extend Old_DeviceName=tostring(OldValue.DeviceName),Old_DeviceToken=tostring(OldValue.DeviceToken) | sort by tostring(Old_DeviceToken));
let NewValues= DeviceChanges
| extend NewValue = parse_json(tostring(ModifiedProperties.NewValue))
| mv-apply NewValue on (extend New_DeviceName=tostring(NewValue.DeviceName),New_DeviceToken=tostring(NewValue.DeviceToken) | sort by tostring(New_DeviceToken));
let RemovedDevices = DeviceChanges
| join kind=inner OldValues  on ReportId
| join kind=leftouter  NewValues on ReportId,$left.Old_DeviceToken==$right.New_DeviceToken,$left.Old_DeviceName==$right.New_DeviceName
| extend Action = strcat("Removed Authenticator App Device (Name: ", Old_DeviceName , ", Token: ", Old_DeviceToken , ") from Strong Authentication");
let AddedDevices = DeviceChanges
| join kind=inner NewValues  on ReportId
| join kind=leftouter OldValues on ReportId,$left.New_DeviceToken==$right.Old_DeviceToken,$left.New_DeviceName==$right.Old_DeviceName
| extend Action = strcat("Added Authenticator App Device (Name: ", New_DeviceName , ", Token: ", New_DeviceToken , ") to Strong Authentication");
union RemovedDevices,AddedDevices
| where Old_DeviceToken != New_DeviceToken
| project Timestamp,Action,Actor,Target,Old_DeviceName,Old_DeviceToken,New_DeviceName,New_DeviceToken
| distinct *

 

 

In Azure Log Analytics:

 

//Azure Log Analytics query to parse modified StrongAuthenticationPhoneAppDetail

let DeviceChanges = AuditLogs
| where OperationName == "Update user"  and TargetResources contains "StrongAuthenticationPhoneAppDetail"
| extend Target = tostring(TargetResources[0].userPrincipalName)
| extend Actor = case(isempty(parse_json(InitiatedBy.user).userPrincipalName),tostring(parse_json(InitiatedBy.app).displayName) ,tostring(parse_json(InitiatedBy.user).userPrincipalName))
| mvexpand ModifiedProperties = parse_json(TargetResources[0].modifiedProperties)
| where ModifiedProperties.displayName == "StrongAuthenticationPhoneAppDetail" 
| project TimeGenerated,Actor,Target,TargetResources,ModifiedProperties,Id;
let OldValues= DeviceChanges
| extend  OldValue = parse_json(tostring(ModifiedProperties.oldValue))
| mv-apply OldValue on (extend Old_DeviceName=tostring(OldValue.DeviceName),Old_DeviceToken=tostring(OldValue.DeviceToken) | sort by tostring(Old_DeviceToken));
let NewValues= DeviceChanges
| extend NewValue = parse_json(tostring(ModifiedProperties.newValue))
| mv-apply NewValue on (extend New_DeviceName=tostring(NewValue.DeviceName),New_DeviceToken=tostring(NewValue.DeviceToken) | sort by tostring(New_DeviceToken));
let RemovedDevices = DeviceChanges
| join kind=inner OldValues  on Id
| join kind=leftouter  NewValues on Id,$left.Old_DeviceToken==$right.New_DeviceToken,$left.Old_DeviceName==$right.New_DeviceName
| extend Action = strcat("Removed Authenticator App Device (Name: ", Old_DeviceName , ", Token: ", Old_DeviceToken , ") from Strong Authentication");
let AddedDevices = DeviceChanges
| join kind=inner NewValues  on Id
| join kind=leftouter OldValues on Id,$left.New_DeviceToken==$right.Old_DeviceToken,$left.New_DeviceName==$right.Old_DeviceName
| extend Action = strcat("Added Authenticator App Device (Name: ", New_DeviceName , ", Token: ", New_DeviceToken , ") to Strong Authentication");
union RemovedDevices,AddedDevices
| where Old_DeviceToken != New_DeviceToken
| project TimeGenerated,Action,Actor,Target,Old_DeviceName,Old_DeviceToken,New_DeviceName,New_DeviceToken
| distinct *

 

If we run the above query, we can find users who registered or removed Authenticator App on/from a device based on Device Name and Device Token.

 

StrongAuthenticationPhoneAppDetails-1.png

Figure 4: Example output from running the StrongAuthenticationAppDetails parsing query

 

Further analysis:

Now that we know which devices were added for which users, we can hunt broadly for malicious activity. One example would be finding mobile devices that are being used by multiple users for Authenticator App using Device Token field, which is unique per device. This can be achieved by appending the following lines to the query (for Log Analytics queries, replace Timestamp with TimeGenerated):

 

| where isnotempty(New_DeviceToken) and New_DeviceToken != "NO_DEVICE_TOKEN"
| summarize min(Timestamp),max(Timestamp),make_set(Target) by DeviceToken=New_DeviceToken, DeviceName=New_DeviceName
| extend UserCount = array_length(set_Target)
| where UserCount > 1

 

The output looks like this:

StrongAuthenticationPhoneAppDetails-2.png

 

It is evident that the Device Token (apns2-e947c2a3b41eae3fbd27aec9a1c2e62bxxxxxxxxxxxxx44ea5b9fee09a1d3930) has registered for Authenticator App for 3 different users between 2024-04-12 10:24:09 and 2024-04-17 11:24:09. This may indicate that a threat actor compromised these accounts and registered their device for MFA to establish persistence. Occasionally this is done legitimately by IT administrators; however, it must be said this is not a secure practice, unless both accounts belong to the same user.

 

In summary

With MFA now being widespread across the corporate world, threat actors are increasingly interested in manipulating MFA methods as part of their initial access strategy and are using token theft via Attacker-in-the-Middle scenarios, social engineering, or MFA prompt bombing to get their foot in the door. Following this initial access, Microsoft Incident Response invariably sees changes to the authentication methods on a compromised account. We trust this article has provided clarity on the architecture and various forms of MFA modifications in Microsoft Entra audit logs. These queries, whether they are utilized for threat detection or alert creation, can empower you to spot suspicious or undesirable activities relating to MFA in your organization, and take rapid action to assess and rectify possibly illegitimate scenarios.

 

Disclaimer: User Principal Names, GUIDs, Email Address, Phone Numbers and Device Tokens in this article are for demonstration purposes and do not represent real data.

 

5 Comments
Co-Authors
Version history
Last update:
‎May 29 2024 07:51 AM
Updated by: