Adversary hunting would be a lot easier if we were always looking for a single event that we knew was malicious, but unfortunately that isn’t always the case. Often when hunting for threats, a combination of events over a certain time period may be added cause for concern, or events happening at certain times of the day are more suspicious to you. Take for example a user setting up a mail forward in Outlook, that may not be inherently suspicious on its own but if it happened not too long after an abnormal sign on, then that would certainly increase the severity. Perhaps particular administrative actions outside of normal business hours would be an indicator of compromise.
Azure Sentinel and KQL have an array of really great operators to help you manipulate and tune your queries to leverage time as an added resource when hunting. We can use logic such as hunting for activities before and after a particular event, look for actions only after an event, or even calculate the time between particular events, and use that as a signal. Some of the operators worth getting familiar with are – ago, between and timespan.
I always try to remember this graphic when writing queries, Azure Sentinel/Log Analytics is highly optimized for log data, so the quicker you can filter down to the time period you care about, the faster your results will be. Searching all your data for a particular event, then filtering down to the last day will be significantly slower than filtering your data to the last day, then finding your particular event.
We often forget in Sentinel/KQL that we can apply where causes to time in the same way we would any other data, such as usernames, event ids, error codes or any other string data. You have probably written a thousand queries that start with something similar to this –
Sometable | where TimeGenerated > ago (2h)
But you can filter your time data even before writing the rest of your queries, maybe you want to look at 7 days of data, but only between midnight and 4am. So first take 7 days of data, then slice out the 4 hours you care about.
Sometable | where TimeGenerated > ago (7d) | where hourofday( TimeGenerated ) between (0 .. 3)
If you want to exclude instead of include particular hours then you can use !between.
Perhaps you are interested in admin staff who have activated Azure AD PIM roles after hours, using KQL we can leverage the hourofday function to query only between particular hours. Remember that by default Sentinel will query on UTC time, so extend a column first to create a time zone that makes sense to you. The below query will find any PIM activations that aren’t between 5am and 8pm in UTC+5.
AuditLogs | extend LocalTime=TimeGenerated+5h | where hourofday( LocalTime) !between (5 .. 19) | where OperationName == "Add member to role completed (PIM activation)"
If we take our example from the start of the post, we can detect when a user is flagged for a suspicious logon, in this case via Azure AD Identity Protection, and then within two hours created a mail forward in Office 365. This behaviour is often seen by attackers hoping to exfiltrate data or maintain a foothold in your environment.
SecurityAlert | where ProviderName == "IPC" | project AlertTime=TimeGenerated, CompromisedEntity | join kind=inner ( OfficeActivity | extend ForwardTime=TimeGenerated ) on $left.CompromisedEntity == $right.UserId | where Operation == "Set-Mailbox" | where Parameters contains "DeliverToMailboxAndForward" | extend TimeDelta = abs(ForwardTime - TimeGenerated) | where TimeDelta < 2h | project AlertTime, ForwardTime, CompromisedEntity
For this query we take the time the alert was generated, rename it to AlertTime, and the userprincipalname of the compromised entity, join it to our OfficeActivity table looking for mail forward creation events. Then finally we use the abs operator to calculate the time between the forward creation and the identity protection alert and only flag when it is less than 2 hours. There are many ways to create forwards in Outlook (such as via mailbox rules), this is just showing one particular method, but the example is more to drive the use of time as a detection method than being all encompassing.
We can also use a particular event as a starting point, then retrieve data from either side of that event. Say a user triggers an ‘unfamiliar sign-in properties’ event. We can use the time of that alert as an anchor point, and retrieve the 60 minutes of sign in data either side of the alert to give us some really great context. We do this by using a combination of the between and timespan operators
SecurityAlert | where AlertName == "Unfamiliar sign-in properties" | project AlertTime=TimeGenerated, UserPrincipalName=CompromisedEntity | join kind=inner ( SigninLogs ) on UserPrincipalName | where TimeGenerated between ((AlertTime-timespan(60m)).. (AlertTime+timespan(60m))) | project UserPrincipalName, SigninTime=TimeGenerated, AlertTime, AppDisplayName, ResultType, UserAgent, IPAddress, Location
We can see both the events prior to and those after the alert time.
You can use these time operators with much more detailed hunting too, if you use the anomaly detection operators, you can tune your detections to only parts of the day. Taking the example from that post, maybe we are interested in particular failed sign in activities, but only in non regular working hours.
let starttime = 7d; let timeframe = 1h; let resultcodes = dynamic(["50126","53003","50105"]); let outlierusers= SigninLogs | where TimeGenerated > ago(starttime) | where hourofday( TimeGenerated) !between (6 .. 18) | where ResultType in (resultcodes) | project TimeGenerated, UserPrincipalName, ResultType, AppDisplayName, Location | order by TimeGenerated | summarize Events=count()by UserPrincipalName, bin(TimeGenerated, timeframe) | summarize EventCount=make_list(Events),TimeGenerated=make_list(TimeGenerated) by UserPrincipalName | extend outliers=series_decompose_anomalies(EventCount) | mv-expand TimeGenerated, EventCount, outliers | where outliers == 1 | distinct UserPrincipalName; SigninLogs | where TimeGenerated > ago(starttime) | where UserPrincipalName in (outlierusers) | where ResultType != 0 | summarize LogonCount=count() by UserPrincipalName, bin(TimeGenerated, timeframe) | render timechart