Microsoft Sentinel 101

Learning Microsoft Sentinel, one KQL error at a time

Enrich hunting with data from MS Graph and Azure AD — 4th Jul 2021

Enrich hunting with data from MS Graph and Azure AD

This post was an idea that came about from a post on the Sentinel tech community here, from a contributor that asked how can we match a query with group membership data from Azure AD. The AuditLogs table will show changes to Azure AD groups, but that isn’t especially useful, we need to make sure our query is matched against a current list of users whenever the alert fires.

There a few ways I can think of to achieve this, but the one I like is to use a Logic App to query Azure AD every so often, then take the data and ingest it into a custom log, for this example lets use HighRiskUsers_CL as the custom log.

Let’s build our Logic App – if you want to use the Azure AD connector then you will need an account with a role to read group memberships (Global Reader would suffice), if you want to poll the MS Graph directly then you will need a Azure AD App Registration will equivalent MS Graph access (directory.read.all would do it but may be too much depending on your stance).

I find with Logic Apps there are a thousand ways to do everything, for this I have just configured a test array with my known high risk AAD Group ID’s, you could obviously look up the MS Graph based on name and fill this array in dynamically of course, but for this example we will just list them. Set your recurrence to however often you want to update the table

Then we are going to use a couple of for each loops to iterate through each group and its members to build a small JSON payload to then send to Sentinel using the Azure Log Analytics Data Collector

The first time you write to a new table it can take up to 20 minutes, but once done you will see your table filled in with the data taken from Azure AD.

Now when you write your hunting queries you can join between your query and members of your groups. The custom table we have created is log data so just make sure you query it with the same time frame that it is updated on, so if you update it daily, use the last 24 hours of logs. Here you can see with a test query looking for a 50158 ResultType

let Alert=
SigninLogs
| where UserPrincipalName contains "username"
| where ResultType == "50158"
| take 1;
let HighRiskUser=
HighRiskUsers_CL
| where TimeGenerated > ago(24h)
| extend UserPrincipalName = UserPrincipalName_s
| project TimeGenerated, UserPrincipalName, AADObjectID_g
;
Alert
| join kind=inner HighRiskUser on UserPrincipalName
| project TimeGenerated, ResultType, UserPrincipalName

And we see an alert for a user that was in the HighRiskUsers_CL that has been populated for us

You could also do this is via a Sentinel watchlist, but the Logic App doesn’t currently allow entries to be removed

Azure AD, Duo and Azure Sentinel — 3rd Jul 2021

Azure AD, Duo and Azure Sentinel

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.