Hunting for suspicious external forwards in Office365
By Gianni Castaldi
In today’s blog post we will learn to hunt for external forwards with the Office 365 audit logs. I got inspired, back in May by an old friend @rikvduijn when he tweeted about some forwarding detections he was building. He also wrote a great blog post about the technical bits and pieces. The KQL which will build will check for all office activity for external forwards, and filters out the internal domains. We will get those by looking at the domains from the mailbox logins.
Todays KQL will be built in 8 steps:
- Get all the office activity
- Get all the sign-ins to correlate display names
- Get all the domains from the mailbox logins
- Get all the New-InboxRule and Set-InboxRule rules
- Get all the Set-Mailbox rules
- Get all the New-TransportRule and Set-TransportRule rules
- Union the rules
- Remove all the internal domains
Let’s start with step 1.
- Get all the office activity
For performance reasons, the first step will be to store all the office activity in AllOfficeActivity. For demo purposes, We have set the Lookback period to 90 days. If you want to use this query in an Azure Sentinel analytics rule, you could use 1 hour or 1 day.
let Lookback = ago(90d);
let AllOfficeActivity =
OfficeActivity
| where TimeGenerated > Lookback
| extend Parsed=parse_json(Parameters)
;
AllOfficeActivity
- Get all the sign-ins to correlate display names
In this step, we will get all distinct UserPrincipalName and UserDisplayName combinations. We will use this later on to convert
UserDisplayNames to UserPrincipalNames.
let Lookback = ago(90d);
let Signins =
SigninLogs
| where TimeGenerated > Lookback
| distinct UserDisplayName, UserPrincipalName
;
Signins
- Get all the domains from the mailbox logins
In this step we will get all the MailboxLogin operations and split the MailboxOwnerUPN to get all the domains. We will use these to exclude all internal forwards.
let Domains =
AllOfficeActivity
| where Operation == "MailboxLogin"
| extend OwnDomains = tostring(split(MailboxOwnerUPN, "@")[1])
| distinct OwnDomains
;
Domains
- Get all the New-InboxRule and Set-InboxRule rules
In steps 1 till 3 we have set up the basics so we can start looking for the client-side rules: New-InboxRule and Set-InboxRule. Bear in mind that this does not work for rules created in the fat clients. But we will return on fat clients later. For now, our query will return the following 2 values:
- TimeGenerated
- Operation
And parse the following 5:
- InitiatedBy
- ForwardSource
- ForwardDestination
- RuleName
- IPAddress
- Port
We will use the case operator because the position of the values can change when the rule is constructed differently. We will use the parse operator with regex, to parse the IP and the port number. We also will do an if else statement in the form of the iif operator, to merge 2 different columns.
let RuleTypes = dynamic([ "ForwardTo" , "ForwardAsAttachmentTo", "RedirectTo"]);
let SetInboxRules =
AllOfficeActivity
| where Operation in ("New-InboxRule", "Set-InboxRule")
| where Parsed has_any(RuleTypes)
| extend InitiatedBy = UserId
| extend ForwardSource = UserId
| extend ForwardDestination = case(
Parsed[0].Name in(RuleTypes) and isnotempty(Parsed[0].Value), Parsed[0].Value
,Parsed[2].Name in(RuleTypes) and isnotempty(Parsed[2].Value), Parsed[2].Value
,Parsed[3].Name in(RuleTypes) and isnotempty(Parsed[3].Value), Parsed[3].Value
, "")
| extend RuleName = case(
Parsed[3].Name == "Name" and isnotempty(Parsed[3].Value), Parsed[3].Value
,Parsed[4].Name == "Name" and isnotempty(Parsed[4].Value), Parsed[4].Value
, "")
| extend ClientIP = iif(isnotempty(ClientIP), ClientIP, ClientIP_)
| parse kind=regex ClientIP with "[[]" IPAddress1 "]:" Port1
| parse kind=regex ClientIP with IPAddress2 ":" Port2
| extend IPAddress = iif(isnotempty(IPAddress1), IPAddress1, IPAddress2)
| extend Port = iif(isnotempty(Port1), Port1, Port2)
;
SetInboxRules
- Get all the Set-Mailbox rules
After the inbox rules the next step is the Set-Mailbox rules. In this step, it is possible that the KQL does not return an E-mail address but a display name. That is why we will join the Signins.
let SetMailbox =
AllOfficeActivity
| where Operation == "Set-Mailbox"
| where Parsed has "ForwardingSmtpAddress"
| extend InitiatedBy = UserId
| extend Identity = case(
Parsed[0].Name == "Identity" and isnotempty(Parsed[0].Value), Parsed[0].Value
, "")
| join kind=leftouter Signins on $left.Identity == $right.UserDisplayName
| extend ForwardSource = iff(
Identity contains "@", Identity, UserPrincipalName
)
| extend ForwardDestination = case(
Parsed[2].Name == "ForwardingSmtpAddress" and isnotempty(Parsed[2].Value), split(Parsed[2].Value,":")[1]
, "")
| project-rename RuleName=OfficeObjectId
| extend ClientIP = iif(isnotempty(ClientIP), ClientIP, ClientIP_)
| parse kind=regex ClientIP with "[[]" IPAddress1 "]:" Port1
| parse kind=regex ClientIP with IPAddress2 ":" Port2
| extend IPAddress = iif(isnotempty(IPAddress1), IPAddress1, IPAddress2)
| extend Port = iif(isnotempty(Port1), Port1, Port2)
;
SetMailbox
- Get all the New-TransportRule and Set-TransportRule rules
In third set of rules we will get the transport rules. This step does not have any new operators.
let TransportRule =
AllOfficeActivity
| where Operation in ("New-TransportRule","Set-TransportRule")
| extend InitiatedBy = UserId
| extend ForwardSource = case(
Parsed[0].Name == "SentTo" and isnotempty(Parsed[0].Value), Parsed[0].Value
, "")
| extend ForwardDestination = case(
Parsed[1].Name == "RedirectMessageTo" and isnotempty(Parsed[1].Value), Parsed[1].Value
, "")
| extend RuleName = case(
Parsed[0].Name == "Name" and isnotempty(Parsed[0].Value), Parsed[0].Value
,Parsed[2].Name == "Name" and isnotempty(Parsed[2].Value), Parsed[2].Value
, "")
| extend ClientIP = iif(isnotempty(ClientIP), ClientIP, ClientIP_)
| parse kind=regex ClientIP with "[[]" IPAddress1 "]:" Port1
| parse kind=regex ClientIP with IPAddress2 ":" Port2
| extend IPAddress = iif(isnotempty(IPAddress1), IPAddress1, IPAddress2)
| extend Port = iif(isnotempty(Port1), Port1, Port2)
;
TransportRule
- Union the rules
In this step, we will union all the rules and project the items we need.
SetInboxRules
| union SetMailbox, TransportRule
| project TimeGenerated, Operation, InitiatedBy, IPAddress, Port, ForwardSource, ForwardDestination, RuleName
- Remove all the internal domains
The last step for our KQL is removing al the forwards to internal domains because these are protected by corporate controls. To do this we will filter for all forward destinations that contain “@”, then we will split these if they contain multiple values. When we have a single E-mail address per line we will split the E-mail addresses at the @ sign to show the forward domain. The last step will be to do a left anti join on Domains to filter out all internal domains.
| where ForwardDestination contains "@"
| mv-expand split(ForwardDestination, ";")
| mv-expand split(ForwardDestination, ", ")
| extend ForwardDomain = tostring(split(ForwardDestination,"@",1)[0])
| join kind=leftanti Domains on $left.ForwardDomain == $right.OwnDomains
Lucky for our demo tenant there are no external E-mail forwards.
After completing our query we will upload it to GitHub so that others can use it. You can find it here.
But what about the fat client rules? Since Azure Sentinel does not show a lot of information about these rule creations, we will create a detection to see if the user has used the IP address before, and the location history of the user and the distinct users from that IP address. You can find the query here.
So to recap this blog post, we now have 2 KQLs. One to view external forwards and the second to correlate external forwards with IP address usage. I would recommend using both in Azure Sentinel analytics rules.
Thanks for reading and if you have any questions or ideas for a blog post let me know.