Hunting for Risky Rules in Office 365

Using the Microsoft Graph API with Python to hunt down malicious inbox rules in Office365 mailboxes

Hunting for Risky Rules in Office 365

When an attacker compromises an Office 365 mailbox, one of the most common activities that we see is new inbox rules being created - therefore finding these rules is a good way to identify compromised accounts and mailboxes.

One obvious way to achieve this is to feed Office365 audit logs into a SIEM or use MCAS - however there are a couple of disadvantages to relying solely on this approach - Firstly it will only detect new compromises so if a rule already exists when the logging is enabled then it won't be detected. Secondly, MCAS requires a more expensive license that a lot of customers currently have assigned which makes it a less attractive approach for smaller companies that might also not have a SIEM.

Detecting pre-existing compromise when onboarding an environment is critical, particularly for MSSPs or companies making acquisitions

I've written scripts to find inbox rules before and export them to a spreadsheet, using the Get-InboxRule Powershell cmdlet but the poor performance and authentication requirements meant this never felt like a robust solution.


The code is available here:

Building the tool

While reading about the Microsoft Graph API recently I found that it can be used to list inbox rules so I set about testing it out.

Microsoft's Graph Explorer web application is a great way to start playing with the API - it handles the authentication stuff for you so you can focus on the API endpoints you're actually interested in.

Once I'd determined that the 2 endpoints I was most interested in were /users to list all accounts and /users/{userid}/mailFolders/inbox/messagerules to list the rules, I then moved across to Postman to work out the authentication flow.

Moving from the graph explorer I needed to create my own API credentials. This is done through the Azure AD App Registrations blade, where the required Graph API permissions can be granted

Once I was happy with the API and authentication it was time to start actually writing some code, helpfully postman can generate snippets for Python Requests which got things moving quickly.

One thing I was keen to do was automate the identification of rules that were likely to be suspicious rather than relying on an analyst to read through the exported data.

The presence of a particular type of condition or action can be indicative of malicious behaviour such as a ForwardTo action but often a more definitive assessment can be made by examining the particular value, for instance moving an email to the RSS Feeds folder is virtually never something an actual user will do but is a common tactic for an attacker to hide emails.

To each of these factors I added a risk score - these are likely to need tweaking to match each particular environment

riskFactor(ruleElement="condition", subType="subjectContains", risk=5)
riskFactor(ruleElement="condition", subType="bodyContains", risk=15)
riskFactor(ruleElement="condition", subType="subjectContains", regex="(financ|bank|swift|transaction)", risk=50)
riskFactor(ruleElement="condition", subType="subjectContains", regex="(hack|breach|compromise|phishing)", risk=50)

riskFactor(ruleElement="action", subType="moveToFolder", risk=2)
riskFactor(ruleElement="action", subType="moveToFolder", value="RSS Feeds", risk=70)
riskFactor(ruleElement="action", subType="moveToFolder", regex="(deleted|junk|spam)", risk=40)

riskFactor(ruleElement="action", subType="forwardTo", risk=25)
riskFactor(ruleElement="action", subType="redirectTo", risk=35)
riskFactor(ruleElement="action", subType="forwardAsAttachmentTo", risk=35)
riskFactor(ruleElement="action", subType="forwardTo", regex="g(oogle)?", risk=25)

During the testing I found that folders are returned as IDs rather than names so I needed to call out to another endpoint of the graph API

def getFolderName(userID, folderID):
    token = getAuth()
    url = f"{userID}/mailFolders/{folderID}"
    response = requests.get(url, headers={"Authorization": "Bearer " + token}).json()
    return response["displayName"]

Unfortunately, looking up every folder name referenced in a rule on every iteration of the loop made the script significantly slower. I considered implementing my own caching for folder names but since I'm using the Requests library to look them up, I just used the Requests-Cache method of patching the requests module and storing the cache in memory.

I was keen to provide output that shows the riskiest rules at a glance and allows an analyst to make a quick judgement on how likely a rule is to be malicious. To this end I used Jinja2 to render an HTML report. This would clearly benefit from some better design/formatting going forward!

I'm hoping to add more similar scripts to my py365 repository over the coming months and the next threat I want to tackle is malicious apps granted permissions on an environment which is an increasing threat