Detection rules › Kusto
Anomalous login followed by Teams action
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). Please note, if the initial logic of prevalence to find suspicious logon activity is noisy then consider adding filtering based on Location. 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.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- Office 365 Teams member removed (YARA-L)
Rule body
id: 2b701288-b428-4fb8-805e-e4372c574786
name: 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).
Please note, if the initial logic of prevalence to find suspicious logon activity is noisy then consider adding filtering based on Location.
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
requiredDataConnectors:
- connectorId: Office365
dataTypes:
- OfficeActivity
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: AzureActiveDirectory
dataTypes:
- AADNonInteractiveUserSignInLogs
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
- InitialAccess
- Persistence
relevantTechniques:
- T1199
- T1136
- T1078
- T1098
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")
| where not (Operation in~ ("MemberAdded", "MemberRemoved") and CommunicationType in~ ("GroupChat", "OneonOne")) // These events have been noisy and are related to initiaing chat conversation and not admin operations.
| 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])
entityMappings:
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: UserPrincipalName
- identifier: Name
columnName: AccountName
- identifier: UPNSuffix
columnName: AccountUPNSuffix
- entityType: IP
fieldMappings:
- identifier: Address
columnName: SuspiciousIP
version: 1.1.2
kind: Scheduled
metadata:
source:
kind: Community
author:
name: Microsoft Security Research
support:
tier: Community
categories:
domains: [ "Security - Others" ]
Stages and Predicates
Parameters
let minimumCountries = 2;
let deltaThreshold = 95;
let countryPrevalenceThreshold = 10;
let projectedEndTime = 60m;
let queryfrequency = 1d;
let queryperiod = 14d;
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
Let binding: aadFunc
let aadFunc = (tableName: string) {
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);
Let binding: countryPrevalence
let countryPrevalence = signinData
| summarize CountCountrySignin = count() by Country
| extend TotalSignin = toscalar(signinData | summarize count())
| extend CountryPrevalence = toreal(CountCountrySignin) / toreal(TotalSignin) * 100;
Let binding: userIpSignin
let userIpSignin = signinData
| summarize CountIPSignin = count(), Country = any(Country), ListSigninTimeGenerated = make_list(TimeGenerated) by IPAddress, UserPrincipalName;
Let binding: userIpDelta
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;
Stage 1: union
union of 2 branches
Stage 2: source
OfficeActivity
Stage 3: where
where TimeGenerated > ago(86400s)
Stage 4: where
where Operation in~ ("AppInstalled", "BotAddedToTeam", "MemberAdded", "MemberRemoved", "MemberRoleChanged", "TeamsAdminAction")
Stage 5: where
where not ((CommunicationType in~ ("GroupChat", "OneonOne") and Operation in~ ("MemberAdded", "MemberRemoved")))
Stage 6: project
project Operation, OperationTimeGenerated, UserId
Stage 7: join
join kind=inner (userIpDelta) on UserId, UserPrincipalName
Stage 8: mv-expand
mv-expand SigninTimeGenerated
Stage 9: extend
extend SigninTimeGenerated
Stage 10: where OperationTimeGenerated - SigninTimeGenerated >= 0s AND OperationTimeGenerated - SigninTimeGenerated <= 1h
where OperationTimeGenerated - SigninTimeGenerated >= 0s AND OperationTimeGenerated - SigninTimeGenerated <= 1h
Stage 11: source
OfficeActivity
Stage 12: where
where TimeGenerated > ago(86400s)
Stage 13: where
where Operation in~ ("AppInstalled", "BotAddedToTeam", "MemberAdded", "MemberRemoved", "MemberRoleChanged", "TeamsAdminAction")
Stage 14: where
where not ((CommunicationType in~ ("GroupChat", "OneonOne") and Operation in~ ("MemberAdded", "MemberRemoved")))
Stage 15: project
project Operation, OperationTimeGenerated, UserId
Stage 16: join
join kind=inner (userIpDelta) on UserId, UserPrincipalName
Stage 17: mv-expand
mv-expand SigninTimeGenerated
Stage 18: extend
extend SigninTimeGenerated
Stage 19: where OperationTimeGenerated - SigninTimeGenerated >= 0s AND OperationTimeGenerated - SigninTimeGenerated <= 1h
where OperationTimeGenerated - SigninTimeGenerated >= 0s AND OperationTimeGenerated - SigninTimeGenerated <= 1h
Stage 20: summarize
summarize by UserPrincipalName, SuspiciousIP, OperationTimeGenerated
Stage 21: summarize
summarize ActivitySummary by UserPrincipalName, SuspiciousIP, SuspiciousSigninCountry, SuspiciousCountryPrevalence
Stage 22: extend
extend AccountName, AccountUPNSuffix
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CommunicationType | in | GroupChat, OneonOne | excludes:CommunicationType field:"CommunicationType" value:"GroupChat" field:"CommunicationType" value:"OneonOne" |
Operation | in | MemberAdded, MemberRemoved | excludes:Operation field:"Operation" value:"MemberAdded" field:"Operation" value:"MemberRemoved" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CountryPrevalence | lt |
| field:"CountryPrevalence" kind:lt value:"10" |
DistinctCountries | ge |
| field:"DistinctCountries" kind:ge value:"2" |
Operation | in |
| field:"Operation" kind:in |
UserIPDelta | ge |
| field:"UserIPDelta" kind:ge value:"95" |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
ActivitySummary | summarize |
SuspiciousCountryPrevalence | summarize |
SuspiciousIP | summarize |
SuspiciousSigninCountry | summarize |
UserPrincipalName | summarize |
AccountName | extend |
AccountUPNSuffix | extend |