In a world of remote working, BYOD and SaaS productivity services, it can be difficult to identify some categories of suspicious or malicious activity related to a compromised account. During a recent purple team exercise, we leveraged a tool called SnaffPoint that can be used to discover and enumerate and sensitive data in SharePoint and OneDrive Online.
Background
On their GitHub, the creators describe Snaffpoint as “a tool for pentesters who are in need of some sweetness in this world. It should help you find sensitive files available on SharePoint online and on shared OneDrive files for your company (or your customer).” (For additional details on the tool, you can review it on GitHub here.)
The tool was built as a cloud-based follow-on from the fantastic internal share and file enumeration tool called snaffler. These toolsets share the same configuration-based search functions which allow an operator to fine tune search terms and prioritise certain types of data that the red team may be looking for. As with many of these mass enumeration and discovery tools, use on actual red team engagements may be limited. However, within the confines of purple teams and penetration tests, they can act as a superb tool to highlight many potential malicious searches and surface useful files quickly across large estates. Given the cloud-based nature of most modern environments, being able to identify and track common search criteria used by attackers can be useful.
Both snaffler and snaffpoint can also be utilised by internal teams as part of regular data audits and searches across organisations datastores. Carrying out these searches often allows you to identify and remove sensitive files before they are leveraged by an attacker. These classification and data removal searches should be considered a regular part of an organisation's defensive operation.
Logging and Hunt Queries
The go-to logs in Microsoft Sentinel for Microsoft Office 365 are the OfficeActivity logs, so that was our first port of call after running the tool. The operation for search events is called “SearchQueryPerformed” so this can be used to filter down to events of interest.
OfficeActivity
| where Operation =~ "SearchQueryPerformed"
Unfortunately, in the OfficeActivity logs, you do not get the search string that was used in the query. The best you can do to overcome this is to look for users who have performed the “SearchQueryPerformed” operation and use that as a user list to look for unusual file activity such as viewing multiple documents that contain a sensitive keyword.
OfficeActivity
| where SourceFileName contains "password"
| summarize files=make_set(SourceFileName) by UserId
| extend fileCount = array_length(files)
| where fileCount > 1
| sort by fileCount desc#
The problem with the above query is that it can generate a significant number of false positive results and requires understanding of the business, how users operate and additional tuning to reduce the results to something manageable to alert on. However, it can still be used for performing hunting activity for insider or post compromise cloud repository discovery activity. You can see in the above image that the top result was indeed activity performed by SnaffPoint. However, the tool does provide the ability to customise search frequency and many other settings to perform defence evasion such as low and slow enumeration of online resources.
So where do we go from here? Next up was to investigate what logging is available in Microsoft 365 Security Tools for this tactic, technique, and procedure (TTP). The below query can produce a list of performed search queries and, to our delight, they also include the SearchQueryText field that contains the search query syntax and strings used.
CloudAppEvents
|where ActionType == "SearchQueryPerformed"
| extend SearchQueryText = tostring(RawEventData["SearchQueryText"])
| summarize Queries=count() by SearchQueryText, AccountDisplayName
The output of this query is pure noise and not useful without performing additional filtering. To do this, we expanded on this query to provide some additional filtering to only return searches performed by the purple team test account.
CloudAppEvents
| where AccountDisplayName contains "PURPLETEAMTEST" //specific to our testing should be removed or changed to the account you want to view activity for in your own use case or testing activity.
| where ActionType == "SearchQueryPerformed"
| extend SearchQueryText = tostring(RawEventData["SearchQueryText"])
| summarize Queries=count() by SearchQueryText, AccountDisplayName
| project-reorder Queries, AccountDisplayName, SearchQueryText
| sort by Queries desc
The next iteration saw us remove some of the more general / noisy search queries generated by the tool and to remove any generic or empty results.
CloudAppEvents
| where AccountDisplayName contains "byod" //specific to our testing should be removed or changed to the account you want to view activity for in your own use case or testing activity.
| where ActionType == "SearchQueryPerformed"
| extend SearchQueryText = tostring(RawEventData["SearchQueryText"])
| where isnotempty(SearchQueryText) and SearchQueryText !="*" and not (SearchQueryText startswith "Path:" and SearchQueryText has "ContentTypeId:0x01002071A0E029BE4C89B12D7191A981257B*")
| summarize Queries=count() by SearchQueryText, AccountDisplayName
| project-reorder Queries, AccountDisplayName, SearchQueryText
| sort by Queries desc
To grab a list of the search queries the tool produced, I made a distinct list of the SearchQueryText field using the following query.
let SearchQueries = CloudAppEvents
| where AccountDisplayName contains "byod" //specific to our testing should be removed or changed to the account you want to view activity for in your own use case or testing activity.
| where ActionType == "SearchQueryPerformed"
| extend SearchQueryText = tostring(RawEventData["SearchQueryText"])
| where isnotempty(SearchQueryText) and SearchQueryText !="*" and not (SearchQueryText startswith "Path:" and SearchQueryText has "ContentTypeId:0x01002071A0E029BE4C89B12D7191A981257B*")
| distinct SearchQueryText; SearchQueries
It’s possible to use this as an initial list of keywords to produce a detection analytic. The following query does this but is not exhaustive of every search query performed by SnaffPoint.
let keywords = datatable (keyword:string)[
@'NEAR(OR("user","username","login"), OR("password","pass","passw","passwd","secret","key","credential"), n=4)',@'AND(NEAR("create", OR("user", "login"), n=1), OR("identified by", "with password"))',
@'OR(mysql_connect,mysql_pconnect,mysql_change_user,pg_connect,pg_pconnect)',
@'*validationkey* OR *decryptionkey*',
@'NEAR("getConnection*", "jdbc:", n=2)',
@'AND(NOT("*SENSITIVE*DATA*DELETED*"),OR(filename:Autounattend.xml,filename:unattend.xml))',
@'OR("mysql.connector.connect","psycopg2.connect")',
@'OR("-SecureString","-AsPlainText","Net.NetworkCredential")',
@'filename:OR("running-config.cfg","startup-config.cfg","running-config","startup-config")',
@'OR("NVRAM config last updated","simple-bind authenticated encrypt","pac key","snmp-server community")',
@'filename:OR(".git-credentials")',
@'OR(filename:proftpdpasswd,filename:filezilla.xml)',
@'filename:OR("recentservers.xml","sftp-config.json")',
@'filename:customsettings.ini',
@'NEAR(OR("user","username","login"), OR("password","pass","passw","passwd","secret","key","credential"), n=4)',
@'OR(NEAR("schtasks", "p", n=10),NEAR("schtasks", "rp", n=10), NEAR("psexec*", "-p", n=10), "passw*", "net user ", "cmdkey ", NEAR("net use ", "/user:", n=10))',
@'OR(filename:credentials.xml,filename:jenkins.plugins.publish_over_ssh.BapSshPublisherPlugin.xml)',
@'filename:OR("SqlStudio.bin",".mysql_history",".psql_history",".pgpass",".dbeaver-data-sources.xml","credentials-config.json","dbvis.xml","robomongo.json")',
@'filename:OR("mobaxterm.ini","mobaxterm backup.zip","confCons.xml")',
@'filename:OR("id_rsa","id_dsa","id_ecdsa","id_ed25519")',
@'OR("database.yml",".secret_token.rb","knife.rb","carrerwave.rb","omiauth.rb")',
@'filename:or(MEMORY.DMP,hiberfil.sys,lsass.dmp,lsass.exe.dmp)',
@'OR(NEAR(OR("X-Amz-Credential", "aws_key", "awskey", "aws.key", "aws-key", "*aws*"), OR("AKIA*", "AGPA*", "AIPA*", "AROA*", "ANPA*", "ANVA*", "ASIA*"), n=10), "CF-Access-Client-Secret")',
@'filename:OR(".bash_history",".zsh_history",".sh_history","zhistory",".irb_history","ConsoleHost_History.txt")',
@'NEAR(BEGIN, OR(RSA, OPENSSH, DSA, EC, PGP), PRIVATE, KEY, n=1)',
@'filename:logins.json',
@'NEAR("data source", "password", n=30)',
@'NEAR("connectionstring*", "passw*", n=30)',
@'"DBI.connect"'];
CloudAppEvents
|where ActionType == "SearchQueryPerformed"
| extend SearchQueryStrings = tostring(RawEventData["SearchQueryText"])
| where SearchQueryStrings has_any (keywords)
| project Timestamp, Application, ActionType, SearchQueryStrings, AccountId, AccountType, AccountDisplayName, UserAgent, OSPlatform, IPAddress, IsAnonymousProxy, CountryCode, City, ISP, RawEventData
I improved this query by adding the following lines to the end of it to return counts. These are easier to sift through when threat hunting but can also be removed to view the individual queries performed by the user or identity.
//remove the below lines to see raw events
| summarize count() by bin(Timestamp,1h), AccountId, AccountDisplayName
//look for results where two or more search queries were performed
| where count_ > 1
| order by count_ desc
Changes to the tool and queries may require refactoring of the rule in the future. To attempt to combat this, I have manually broken out some of the search keywords in the query to provide additional detection coverage of the tool and to hopefully allow detection of similar tools or procedures used by threat actors to enumerate or discover sensitive data in SharePoint and OneDrive online.
This query has introduced a greater chance of false positives but is still something that can provide value when business and threat context are added to improve the keywords used for threat identification and filter out unnecessary environment specific noise.
//add sensitive keywords or search strings to fit your individual business needs.
let keywords = datatable (keyword:string)["password","passwords","filename:logins.json","NVRAM config last updated","simple-bind authenticated encrypt",
"pac key","snmp-server community","SqlStudio.bin",".mysql_history",".psql_history",".pgpass",".dbeaver-data-sources.xml","credentials-config.json",
"dbvis.xml","robomongo.json","recentservers.xml","sftp-config.json","filename:proftpdpasswd","filename:filezilla.xml","MEMORY.DMP","hiberfil.sys","lsass.dmp","lsass.exe.dmp", "connectionstring",".bash_history",".zsh_history",".sh_history","zhistory",".irb_history","ConsoleHost_History.txt","id_rsa","id_dsa","id_ecdsa","id_ed25519","database.yml",".secret_token.rb","knife.rb","carrerwave.rb","omiauth.rb",".git-credentials","filename:customsettings.ini",
"X-Amz-Credential","aws_key","awskey","aws.key","aws-key","*aws*",@'NEAR("getConnection*", "jdbc:", n=2)',"*validationkey* OR *decryptionkey*","validationkey","decryptionkey",
@'NEAR(OR("user","username","login"), OR("password","pass","passw","passwd","secret","key","credential"), n=4)',@'AND(NEAR("create", OR("user", "login"), n=1), OR("identified by", "with password"))',
@'OR(mysql_connect,mysql_pconnect,mysql_change_user,pg_connect,pg_pconnect)',
@'*validationkey* OR *decryptionkey*',
@'NEAR("getConnection*", "jdbc:", n=2)',
@'AND(NOT("*SENSITIVE*DATA*DELETED*"),OR(filename:Autounattend.xml,filename:unattend.xml))',
@'OR("mysql.connector.connect","psycopg2.connect")',
@'OR("-SecureString","-AsPlainText","Net.NetworkCredential")',
@'filename:OR("running-config.cfg","startup-config.cfg","running-config","startup-config")',
@'OR("NVRAM config last updated","simple-bind authenticated encrypt","pac key","snmp-server community")',
@'filename:OR(".git-credentials")',
@'OR(filename:proftpdpasswd,filename:filezilla.xml)',
@'filename:OR("recentservers.xml","sftp-config.json")',
@'filename:customsettings.ini',
@'NEAR(OR("user","username","login"), OR("password","pass","passw","passwd","secret","key","credential"), n=4)',
@'OR(NEAR("schtasks", "p", n=10),NEAR("schtasks", "rp", n=10), NEAR("psexec*", "-p", n=10), "passw*", "net user ", "cmdkey ", NEAR("net use ", "/user:", n=10))',
@'OR(filename:credentials.xml,filename:jenkins.plugins.publish_over_ssh.BapSshPublisherPlugin.xml)',
@'filename:OR("SqlStudio.bin",".mysql_history",".psql_history",".pgpass",".dbeaver-data-sources.xml","credentials-config.json","dbvis.xml","robomongo.json")',
@'filename:OR("mobaxterm.ini","mobaxterm backup.zip","confCons.xml")',
@'filename:OR("id_rsa","id_dsa","id_ecdsa","id_ed25519")',
@'OR("database.yml",".secret_token.rb","knife.rb","carrerwave.rb","omiauth.rb")',
@'filename:or(MEMORY.DMP,hiberfil.sys,lsass.dmp,lsass.exe.dmp)',
@'OR(NEAR(OR("X-Amz-Credential", "aws_key", "awskey", "aws.key", "aws-key", "*aws*"), OR("AKIA*", "AGPA*", "AIPA*", "AROA*", "ANPA*", "ANVA*", "ASIA*"), n=10), "CF-Access-Client-Secret")',
@'filename:OR(".bash_history",".zsh_history",".sh_history","zhistory",".irb_history","ConsoleHost_History.txt")',
@'password',
@'NEAR(BEGIN, OR(RSA, OPENSSH, DSA, EC, PGP), PRIVATE, KEY, n=1)',
@'filename:logins.json',
@'NEAR("data source", "password", n=30)',
@'NEAR("connectionstring*", "passw*", n=30)',
@'"DBI.connect"'];
let excludedKeywords = datatable(keyword:string)["reset","policy","change","hrms","existing","update","three words","changing","forgottten","lost","forgot"];
CloudAppEvents
|where ActionType == "SearchQueryPerformed" and UserId != "app@sharepoint"
| extend SearchQueryStrings = tostring(RawEventData["SearchQueryText"])
| where SearchQueryStrings has_any (keywords) and not (SearchQueryStrings has_any (excludedKeywords) and SearchQueryStrings has "password")
| project Timestamp, Application, ActionType, SearchQueryStrings, AccountId, AccountType, AccountDisplayName, UserAgent, OSPlatform, IPAddress, IsAnonymousProxy, CountryCode, City, ISP, RawEventData
//remove the below lines to see raw events
| summarize count() by bin(Timestamp,1h), AccountId, AccountDisplayName
//look for results where more than two search queries were performed
| where count_ > 1
| order by count_ desc
Key Takeaways
Purple team exercises are crucial for organisations to validate the effectiveness of their cyber defence strategies. As a cyber defence expert, I would like to provide the following key takeaways on the importance of using purple team exercises to test new and emerging TTPs (tactics, techniques, and procedures):
- Testing the latest TTPs: Attackers are always evolving their tactics to bypass security measures, and new TTPs emerge frequently. Purple team exercises provide an opportunity to consume the latest threat intelligence that is relevant to their organisation and to test related TTPs in a controlled environment to identify gaps in the defence posture and improve security controls.
- Identifying vulnerabilities: By simulating real-world attacks in a Purple Team exercise, organisations can identify vulnerabilities in their networks, systems, and applications that can be exploited by attackers. These vulnerabilities can then be remediated before an attacker exploits them in a real attack.
- Validating detection and response capabilities: Purple team exercises validate whether existing prevention, detection, and response capabilities are effective at defending against new TTPs. This provides an opportunity to identify and address any shortcomings in the organisation's defence strategy.
- Enhancing team collaboration: A purple team exercise involves collaboration between the red team (attackers) and blue team (defenders). This collaboration fosters a better understanding of each team's strengths and weaknesses, leading to better teamwork, communication, and a more effective defence strategy.
- Improving incident response: By conducting purple team exercises, organisations can improve their incident response capabilities, reducing the time it takes to detect and respond to an attack. This can minimise the impact of an attack and reduce the likelihood of a successful breach.
See Another of Our Latest Blogs
How Organisations Can Reduce the Heightened Risk to Western Critical National Infrastructure