When I started first using Sentinel, one of the first use cases was tracking potentially compromised accounts, given the number of tools available from Microsoft in the identity security space – Azure AD, Azure AD Identity Protection and Cloud App Security to name a few, I would guess this is pretty common. Early on I realised that there was a lot of noise in all these alerts, bought on by the combination of people working from more locations than ever, use of private VPN’s increasing and people connecting on lots of devices; mobiles, tablets, laptops, home computers etc.
If you use non-Microsoft MFA, such as Duo, Okta or Ping (or other), then you would know from experience that Azure AD handles MFA for those products differently than its own. Instead of requiring MFA in Azure AD Conditional Access, it requires a custom control be satisfied during sign on. In terms of looking for genuine compromise within the noise, it makes it harder using these products because all sign on events are seen as only requiring single factor authentication in the eyes of Azure AD, even though a MFA challenge has been successful.
While not perfect, we know that if a user completed a MFA prompt during a sign in, then there is a good chance it is legitimate, though of course a users phone could be compromised or a person socially engineered to accept the prompt.
In the case of Duo, to try make sense of all the noise first we need to ingest the Duo authentication log into Sentinel. Duo provide a log sync agent hosted on their GitHub here, you can send this to a custom table or the CommonSecurityLog in CEF format using the log forwarder. There is also a community written Azure Function in the Azure Sentinel GitHub here, though I haven’t tested it. Once you have the logs in there, you will need to write a parser to take the information you care about
MFALogs_CL
| extend MFAOutcome = extract("outcome=(.*?) duser", 1, SyslogMessage_s)
| extend UserPrincipalName = extract("duser=(.*?) cs2", 1, SyslogMessage_s)
| extend MFAMethod = extract("cs2=(.*?) cs3", 1, SyslogMessage_s)
| extend MFAApplication = extract("cs3=(.*?) ca", 1, SyslogMessage_s)
| extend MFAIPaddr = extract("src=(([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.(([0-9]{1,3})))",1,SyslogMessage_s)
So from our logs we are going to extract the outcome (success or failure), the username returned from the MFA platform, what method was used (push, phone call, security key etc), which application was accessed (in this case Azure AD via the above custom control) and the IP Address the MFA challenge response came from. That will leave you with an easy set of data to work from and match to Azure AD log events (some details removed from the screenshots).

So now we have our data from our MFA provider, how do we make sense of an impossible travel action for example and see if it’s noise or not? We can do that via the KQL join operator, which lets us run multiple queries and then join them on matching column.
MFALogs_CL
| extend MFAOutcome = extract("outcome=(.*?) duser", 1, SyslogMessage_s)
| extend UserPrincipalName = extract("duser=(.*?) cs2", 1, SyslogMessage_s)
| extend MFAMethod = extract("cs2=(.*?) cs3", 1, SyslogMessage_s)
| extend MFAApplication = extract("cs3=(.*?) ca", 1, SyslogMessage_s)
| extend MFAIPaddr = extract("src=(([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.(([0-9]{1,3})))",1,SyslogMessage_s)
| extend MFATime = TimeGenerated
| project MFATime, UserPrincipalName, MFAOutcome, MFAMethod, MFAApplication, MFAIPaddr
| where UserPrincipalName == "username@domain.com"
| sort by MFATime desc
| join kind=inner (SigninLogs
| extend SigninTime = TimeGenerated
| where UserPrincipalName == "username@domain.com"
| project SigninTime, ResultType, AppDisplayName, IPAddress, Location, UserAgent
| sort by SigninTime desc) on $left.MFAIPaddr == $right.IPAddress
| where MFATime between ((SigninTime-timespan(10min)).. (SigninTime+timespan(10min)))
So our query looks at our custom MFA log and parses out the details needed to then join to an Azure AD sign in event. We join the two queries together based on a match on IP Address, then look for any events that occur within 20 minutes (mostly to account for log delay, you could tighten this up if you wanted to suit you environment). You are left with the events that meet that criteria –

So when you get an impossible travel alert, you could use Azure Sentinel entity mapping to retrieve the userprincipalname then feed it back into this hunting query to get you some valuable info about MFA events associated, or just jump into the logs and run this manually. Often mobile devices won’t be on the same network as the logon event, so it isn’t perfect, but helpful guidance to decide the importance of the alert.
If you wanted to do more proactive hunting you could leverage the same query but look for ‘successful’ sign ins to Azure AD followed by a MFA failure. When you use non Microsoft MFA, a user will sign on using their credentials and then a ResultType of 50158 is triggered, which means ‘external security challenge not yet satisfied’, so we could hunt for logons with that ResultType and a MFA failure in the same 20 minute period. This time though, we don’t want to join based on IP Address. If a user signs into Azure AD then fails MFA from the same IP address, chances are they just can’t find their phone, so this time we join on UserPrincipalName
MFALogs_CL
| extend MFAOutcome = extract("outcome=(.*?) duser", 1, SyslogMessage_s)
| extend UserPrincipalName = extract("duser=(.*?) cs2", 1, SyslogMessage_s)
| extend MFAMethod = extract("cs2=(.*?) cs3", 1, SyslogMessage_s)
| extend MFAApplication = extract("cs3=(.*?) ca", 1, SyslogMessage_s)
| extend MFAIPaddr = extract("src=(([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.(([0-9]{1,3})))",1,SyslogMessage_s)
| extend MFATime = TimeGenerated
| project MFATime, UserPrincipalName, MFAOutcome, MFAMethod, MFAApplication, MFAIPaddr
| where MFAOutcome == "FAILURE"
| sort by MFATime desc
| join kind=inner (SigninLogs
| extend SigninTime = TimeGenerated
| where ResultType == "50158"
| project SigninTime, UserPrincipalName, ResultType, AppDisplayName, IPAddress, Location, UserAgent
| sort by SigninTime desc) on UserPrincipalName
| where MFATime between ((SigninTime-timespan(10min)).. (SigninTime+timespan(10min)))
This will then output any events where a user has triggered a 50158 event (correct username and password but then stopped by a custom control), then a MFA failure within 20 minutes. The IP address could still match here, but if it doesn’t then it is probably worth resetting their password & revoking their sessions until you can contact them.