Anomalous login followed by Teams action
Id | 2b701288-b428-4fb8-805e-e4372c574786 |
Rulename | Anomalous login followed by Teams action |
Description | Detects anomalous IP address usage by user accounts and then checks to see if a suspicious Teams action is performed. Query calculates IP usage Delta for each user account and selects accounts where a delta >= 90% is observed between the most and least used IP. To further reduce results the query performs a prevalence check on the lowest used IP’s country, only keeping IP’s where the country is unusual for the tenant (dynamic ranges) Finally the user accounts activity within Teams logs is checked for suspicious commands (modifying user privileges or admin actions) during the period the suspicious IP was active. |
Severity | Medium |
Tactics | InitialAccess Persistence |
Techniques | T1199 T1136 T1078 T1098 |
Required data connectors | AzureActiveDirectory Office365 |
Kind | Scheduled |
Query frequency | 1d |
Query period | 14d |
Trigger threshold | 0 |
Trigger operator | gt |
Source Uri | https://github.com/Azure/Azure-Sentinel/blob/master/Detections/MultipleDataSources/AnomalousIPUsageFollowedByTeamsAction.yaml |
Version | 1.1.1 |
Arm template | 2b701288-b428-4fb8-805e-e4372c574786.json |
//The bigger the window the better the data sample size, as we use IP prevalence, more sample data is better.
//The minimum number of countries that the account has been accessed from [default: 2]
let minimumCountries = 2;
//The delta (%) between the largest in-use IP and the smallest [default: 95]
let deltaThreshold = 95;
//The maximum (%) threshold that the country appears in login data [default: 10]
let countryPrevalenceThreshold = 10;
//The time to project forward after the last login activity [default: 60min]
let projectedEndTime = 60m;
let queryfrequency = 1d;
let queryperiod = 14d;
let aadFunc = (tableName: string) {
// Get successful signins to Teams
let signinData =
table(tableName)
| where TimeGenerated > ago(queryperiod)
| where AppDisplayName has "Teams" and ConditionalAccessStatus =~ "success"
| extend Country = tostring(todynamic(LocationDetails)['countryOrRegion'])
| where isnotempty(Country) and isnotempty(IPAddress);
// Calculate prevalence of countries
let countryPrevalence =
signinData
| summarize CountCountrySignin = count() by Country
| extend TotalSignin = toscalar(signinData | summarize count())
| extend CountryPrevalence = toreal(CountCountrySignin) / toreal(TotalSignin) * 100;
// Count signins by user and IP address
let userIpSignin =
signinData
| summarize CountIPSignin = count(), Country = any(Country), ListSigninTimeGenerated = make_list(TimeGenerated) by IPAddress, UserPrincipalName;
// Calculate delta between the IP addresses with the most and minimum activity by user
let userIpDelta =
userIpSignin
| summarize MaxIPSignin = max(CountIPSignin), MinIPSignin = min(CountIPSignin), DistinctCountries = dcount(Country), make_set(Country) by UserPrincipalName
| extend UserIPDelta = toreal(MaxIPSignin - MinIPSignin) / toreal(MaxIPSignin) * 100;
// Collect Team operations the user account has performed within a time range of the suspicious signins
OfficeActivity
| where TimeGenerated > ago(queryfrequency)
| where Operation in~ ("TeamsAdminAction", "MemberAdded", "MemberRemoved", "MemberRoleChanged", "AppInstalled", "BotAddedToTeam")
| project OperationTimeGenerated = TimeGenerated, UserId = tolower(UserId), Operation
| join kind = inner(
userIpDelta
// Check users with activity from distinct countries
| where DistinctCountries >= minimumCountries
// Check users with high IP delta
| where UserIPDelta >= deltaThreshold
// Add information about signins and countries
| join kind = leftouter userIpSignin on UserPrincipalName
| join kind = leftouter countryPrevalence on Country
// Check activity that comes from nonprevalent countries
| where CountryPrevalence < countryPrevalenceThreshold
| project
UserPrincipalName,
SuspiciousIP = IPAddress,
UserIPDelta,
SuspiciousSigninCountry = Country,
SuspiciousCountryPrevalence = CountryPrevalence,
EventTimes = ListSigninTimeGenerated
) on $left.UserId == $right.UserPrincipalName
// Check the signins occured 60 min before the Teams operations
| mv-expand SigninTimeGenerated = EventTimes
| extend SigninTimeGenerated = todatetime(SigninTimeGenerated)
| where OperationTimeGenerated between (SigninTimeGenerated .. (SigninTimeGenerated + projectedEndTime))
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
union isfuzzy=true aadSignin, aadNonInt
| summarize arg_max(SigninTimeGenerated, *) by UserPrincipalName, SuspiciousIP, OperationTimeGenerated
| summarize
ActivitySummary = make_bag(pack(tostring(SigninTimeGenerated), pack("Operation", tostring(Operation), "OperationTime", OperationTimeGenerated)))
by UserPrincipalName, SuspiciousIP, SuspiciousSigninCountry, SuspiciousCountryPrevalence
| extend AccountName = tostring(split(UserPrincipalName, "@")[0]), AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1])
kind: Scheduled
relevantTechniques:
- T1199
- T1136
- T1078
- T1098
metadata:
support:
tier: Community
author:
name: Microsoft Security Research
categories:
domains:
- Security - Others
source:
kind: Community
description: |
'Detects anomalous IP address usage by user accounts and then checks to see if a suspicious Teams action is performed.
Query calculates IP usage Delta for each user account and selects accounts where a delta >= 90% is observed between the most and least used IP.
To further reduce results the query performs a prevalence check on the lowest used IP's country, only keeping IP's where the country is unusual for the tenant (dynamic ranges)
Finally the user accounts activity within Teams logs is checked for suspicious commands (modifying user privileges or admin actions) during the period the suspicious IP was active.'
queryPeriod: 14d
queryFrequency: 1d
tactics:
- InitialAccess
- Persistence
name: Anomalous login followed by Teams action
requiredDataConnectors:
- connectorId: Office365
dataTypes:
- OfficeActivity
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: AzureActiveDirectory
dataTypes:
- AADNonInteractiveUserSignInLogs
entityMappings:
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: UserPrincipalName
- identifier: Name
columnName: AccountName
- identifier: UPNSuffix
columnName: AccountUPNSuffix
- entityType: IP
fieldMappings:
- identifier: Address
columnName: SuspiciousIP
triggerThreshold: 0
version: 1.1.1
id: 2b701288-b428-4fb8-805e-e4372c574786
query: |
//The bigger the window the better the data sample size, as we use IP prevalence, more sample data is better.
//The minimum number of countries that the account has been accessed from [default: 2]
let minimumCountries = 2;
//The delta (%) between the largest in-use IP and the smallest [default: 95]
let deltaThreshold = 95;
//The maximum (%) threshold that the country appears in login data [default: 10]
let countryPrevalenceThreshold = 10;
//The time to project forward after the last login activity [default: 60min]
let projectedEndTime = 60m;
let queryfrequency = 1d;
let queryperiod = 14d;
let aadFunc = (tableName: string) {
// Get successful signins to Teams
let signinData =
table(tableName)
| where TimeGenerated > ago(queryperiod)
| where AppDisplayName has "Teams" and ConditionalAccessStatus =~ "success"
| extend Country = tostring(todynamic(LocationDetails)['countryOrRegion'])
| where isnotempty(Country) and isnotempty(IPAddress);
// Calculate prevalence of countries
let countryPrevalence =
signinData
| summarize CountCountrySignin = count() by Country
| extend TotalSignin = toscalar(signinData | summarize count())
| extend CountryPrevalence = toreal(CountCountrySignin) / toreal(TotalSignin) * 100;
// Count signins by user and IP address
let userIpSignin =
signinData
| summarize CountIPSignin = count(), Country = any(Country), ListSigninTimeGenerated = make_list(TimeGenerated) by IPAddress, UserPrincipalName;
// Calculate delta between the IP addresses with the most and minimum activity by user
let userIpDelta =
userIpSignin
| summarize MaxIPSignin = max(CountIPSignin), MinIPSignin = min(CountIPSignin), DistinctCountries = dcount(Country), make_set(Country) by UserPrincipalName
| extend UserIPDelta = toreal(MaxIPSignin - MinIPSignin) / toreal(MaxIPSignin) * 100;
// Collect Team operations the user account has performed within a time range of the suspicious signins
OfficeActivity
| where TimeGenerated > ago(queryfrequency)
| where Operation in~ ("TeamsAdminAction", "MemberAdded", "MemberRemoved", "MemberRoleChanged", "AppInstalled", "BotAddedToTeam")
| project OperationTimeGenerated = TimeGenerated, UserId = tolower(UserId), Operation
| join kind = inner(
userIpDelta
// Check users with activity from distinct countries
| where DistinctCountries >= minimumCountries
// Check users with high IP delta
| where UserIPDelta >= deltaThreshold
// Add information about signins and countries
| join kind = leftouter userIpSignin on UserPrincipalName
| join kind = leftouter countryPrevalence on Country
// Check activity that comes from nonprevalent countries
| where CountryPrevalence < countryPrevalenceThreshold
| project
UserPrincipalName,
SuspiciousIP = IPAddress,
UserIPDelta,
SuspiciousSigninCountry = Country,
SuspiciousCountryPrevalence = CountryPrevalence,
EventTimes = ListSigninTimeGenerated
) on $left.UserId == $right.UserPrincipalName
// Check the signins occured 60 min before the Teams operations
| mv-expand SigninTimeGenerated = EventTimes
| extend SigninTimeGenerated = todatetime(SigninTimeGenerated)
| where OperationTimeGenerated between (SigninTimeGenerated .. (SigninTimeGenerated + projectedEndTime))
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
union isfuzzy=true aadSignin, aadNonInt
| summarize arg_max(SigninTimeGenerated, *) by UserPrincipalName, SuspiciousIP, OperationTimeGenerated
| summarize
ActivitySummary = make_bag(pack(tostring(SigninTimeGenerated), pack("Operation", tostring(Operation), "OperationTime", OperationTimeGenerated)))
by UserPrincipalName, SuspiciousIP, SuspiciousSigninCountry, SuspiciousCountryPrevalence
| extend AccountName = tostring(split(UserPrincipalName, "@")[0]), AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1])
triggerOperator: gt
OriginalUri: https://github.com/Azure/Azure-Sentinel/blob/master/Detections/MultipleDataSources/AnomalousIPUsageFollowedByTeamsAction.yaml
severity: Medium
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"workspace": {
"type": "String"
}
},
"resources": [
{
"apiVersion": "2024-01-01-preview",
"id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/2b701288-b428-4fb8-805e-e4372c574786')]",
"kind": "Scheduled",
"name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/2b701288-b428-4fb8-805e-e4372c574786')]",
"properties": {
"alertRuleTemplateName": "2b701288-b428-4fb8-805e-e4372c574786",
"customDetails": null,
"description": "'Detects anomalous IP address usage by user accounts and then checks to see if a suspicious Teams action is performed.\nQuery calculates IP usage Delta for each user account and selects accounts where a delta >= 90% is observed between the most and least used IP.\nTo further reduce results the query performs a prevalence check on the lowest used IP's country, only keeping IP's where the country is unusual for the tenant (dynamic ranges)\nFinally the user accounts activity within Teams logs is checked for suspicious commands (modifying user privileges or admin actions) during the period the suspicious IP was active.'\n",
"displayName": "Anomalous login followed by Teams action",
"enabled": true,
"entityMappings": [
{
"entityType": "Account",
"fieldMappings": [
{
"columnName": "UserPrincipalName",
"identifier": "FullName"
},
{
"columnName": "AccountName",
"identifier": "Name"
},
{
"columnName": "AccountUPNSuffix",
"identifier": "UPNSuffix"
}
]
},
{
"entityType": "IP",
"fieldMappings": [
{
"columnName": "SuspiciousIP",
"identifier": "Address"
}
]
}
],
"OriginalUri": "https://github.com/Azure/Azure-Sentinel/blob/master/Detections/MultipleDataSources/AnomalousIPUsageFollowedByTeamsAction.yaml",
"query": "//The bigger the window the better the data sample size, as we use IP prevalence, more sample data is better.\n//The minimum number of countries that the account has been accessed from [default: 2]\nlet minimumCountries = 2;\n//The delta (%) between the largest in-use IP and the smallest [default: 95]\nlet deltaThreshold = 95;\n//The maximum (%) threshold that the country appears in login data [default: 10]\nlet countryPrevalenceThreshold = 10;\n//The time to project forward after the last login activity [default: 60min]\nlet projectedEndTime = 60m;\nlet queryfrequency = 1d;\nlet queryperiod = 14d;\nlet aadFunc = (tableName: string) {\n // Get successful signins to Teams\n let signinData =\n table(tableName)\n | where TimeGenerated > ago(queryperiod)\n | where AppDisplayName has \"Teams\" and ConditionalAccessStatus =~ \"success\"\n | extend Country = tostring(todynamic(LocationDetails)['countryOrRegion'])\n | where isnotempty(Country) and isnotempty(IPAddress);\n // Calculate prevalence of countries\n let countryPrevalence =\n signinData\n | summarize CountCountrySignin = count() by Country\n | extend TotalSignin = toscalar(signinData | summarize count())\n | extend CountryPrevalence = toreal(CountCountrySignin) / toreal(TotalSignin) * 100;\n // Count signins by user and IP address\n let userIpSignin =\n signinData\n | summarize CountIPSignin = count(), Country = any(Country), ListSigninTimeGenerated = make_list(TimeGenerated) by IPAddress, UserPrincipalName;\n // Calculate delta between the IP addresses with the most and minimum activity by user\n let userIpDelta =\n userIpSignin\n | summarize MaxIPSignin = max(CountIPSignin), MinIPSignin = min(CountIPSignin), DistinctCountries = dcount(Country), make_set(Country) by UserPrincipalName\n | extend UserIPDelta = toreal(MaxIPSignin - MinIPSignin) / toreal(MaxIPSignin) * 100;\n // Collect Team operations the user account has performed within a time range of the suspicious signins\n OfficeActivity\n | where TimeGenerated > ago(queryfrequency)\n | where Operation in~ (\"TeamsAdminAction\", \"MemberAdded\", \"MemberRemoved\", \"MemberRoleChanged\", \"AppInstalled\", \"BotAddedToTeam\")\n | project OperationTimeGenerated = TimeGenerated, UserId = tolower(UserId), Operation\n | join kind = inner(\n userIpDelta\n // Check users with activity from distinct countries\n | where DistinctCountries >= minimumCountries\n // Check users with high IP delta\n | where UserIPDelta >= deltaThreshold\n // Add information about signins and countries\n | join kind = leftouter userIpSignin on UserPrincipalName\n | join kind = leftouter countryPrevalence on Country\n // Check activity that comes from nonprevalent countries\n | where CountryPrevalence < countryPrevalenceThreshold\n | project\n UserPrincipalName,\n SuspiciousIP = IPAddress,\n UserIPDelta,\n SuspiciousSigninCountry = Country,\n SuspiciousCountryPrevalence = CountryPrevalence,\n EventTimes = ListSigninTimeGenerated\n ) on $left.UserId == $right.UserPrincipalName\n // Check the signins occured 60 min before the Teams operations\n | mv-expand SigninTimeGenerated = EventTimes\n | extend SigninTimeGenerated = todatetime(SigninTimeGenerated)\n | where OperationTimeGenerated between (SigninTimeGenerated .. (SigninTimeGenerated + projectedEndTime))\n};\nlet aadSignin = aadFunc(\"SigninLogs\");\nlet aadNonInt = aadFunc(\"AADNonInteractiveUserSignInLogs\");\nunion isfuzzy=true aadSignin, aadNonInt\n| summarize arg_max(SigninTimeGenerated, *) by UserPrincipalName, SuspiciousIP, OperationTimeGenerated\n| summarize\n ActivitySummary = make_bag(pack(tostring(SigninTimeGenerated), pack(\"Operation\", tostring(Operation), \"OperationTime\", OperationTimeGenerated)))\n by UserPrincipalName, SuspiciousIP, SuspiciousSigninCountry, SuspiciousCountryPrevalence\n| extend AccountName = tostring(split(UserPrincipalName, \"@\")[0]), AccountUPNSuffix = tostring(split(UserPrincipalName, \"@\")[1])\n",
"queryFrequency": "P1D",
"queryPeriod": "P14D",
"severity": "Medium",
"subTechniques": [],
"suppressionDuration": "PT1H",
"suppressionEnabled": false,
"tactics": [
"InitialAccess",
"Persistence"
],
"techniques": [
"T1078",
"T1098",
"T1136",
"T1199"
],
"templateVersion": "1.1.1",
"triggerOperator": "GreaterThan",
"triggerThreshold": 0
},
"type": "Microsoft.OperationalInsights/workspaces/providers/alertRules"
}
]
}