Forum Discussion
Hunting for MFA manipulations in Entra ID tenants using KQL
The following article, Hunting for MFA manipulations in Entra ID tenants using KQL proved to be an invaluable resource in my search for an automated way to notify users of MFA modifications.
I've adapted the KQL query to function within Defender Advanced Hunting or Azure Entra, my objective is to establish an alert that directly E-Mails the affected user, informing them of the MFA change and advising them to contact security if they did not initiate it.
While the query runs correctly under Defender Advanced Hunting, I'm currently unable to create a workable custom alert because no "ReportId" is being captured. Despite consulting with Copilot, Gemini, CDW Support, and Microsoft Support, no workable solution has been achieved.
Any insight would be greatly appreciated - Thank You!
//Advanced Hunting query to parse modified:
//StrongAuthenticationUserDetails (SAUD)
//StrongAuthenticationMethod (SAM)
let SearchWindow = 1h;
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
| 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
| 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";
let AuthenticationMethodReport = 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)]));
let AuthenticationDetailsChanges = CloudAppEvents
| where ActionType == "Update user." and RawEventData contains "StrongAuthenticationUserDetails"
| extend Target = tostring(RawEventData.ObjectId)
| extend Actor = tostring(RawEventData.UserId)
| extend ReportId= tostring(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."));
union AuthenticationMethodReport, AuthenticationDetailsChanges
| extend AccountUpn = Target
| where Timestamp > ago(SearchWindow)
//| summarize count() by Timestamp, Action, Actor, Target, ChangedValue, OldValue, NewValue, ReportId, AccountDisplayName, AccountId, AccountUpn
| summarize arg_max(Timestamp, *) by Action
| project Timestamp, Action, Actor, Target, ChangedValue, OldValue, NewValue, ReportId, AccountDisplayName, AccountId, AccountUpn
| sort by Timestamp desc
2 Replies
- HeyNikoCopper Contributor
Thanks for the suggestion. I attempted to join the tables as you outlined but was unable to extract the recordid. I am continuing to work on this.
On a related note, I find it interesting that an account's MFA settings can be modified without generating an alert for the user.
- jbmartin6Iron Contributor
To get around the 'recordId' problem I have just done a join on some table on username and 'take 1' just to hook in some record with a recordId value. This allows you to proceed. The system needs a recordId to populate things in the alert view, so it can affect how the alert is displayed. In at least one case I had to join to a recordid that didn't have much to do with the alert, so our playbook has to specify 'do not look at that part of the alert' and run a specific query instead.