Detection rules › Kusto

Detect suspicious foci token logins

Author
Robbe Van den Daele
Source
github.com/HybridBrothers/Hunting-Queries-Detection-Rules

FOCI tokens (Family of Client IDs tokens) are special refresh tokens that allow multiple applications within the same "family" to share authentication tokens. This means that once a user authenticates with one application, they can access other applications in the same family without needing to re-authenticate. For adversaries, these are very interesting tokens to abuse since they can access a normal application (Microsoft Teams for example), and reuse that refresh token to access another application (like Azure CLI). To detect a suspicious foci token combination, we look for all the logins using foci tokens and group them by Session ID (since these belong to the same session). Then we take the first login where no refresh token was provided, and look at the logins that used refresh tokens as incomming token types within that same session. If the second login application is one that is typically abused by adversaries and the application for the first login is a 'normal' application, we flag the event. We added a second version for this query in this repo, to also flag when an adversary is using the same application to get new access tokens but with another scope. The v2 version focusses more on RoadTool detection tho, while this detection is more broad. Some organizations have a high BP hit count on Microsoft Azure CLI. To limit those hits, you have three finetune options to enable in the query: - Only alert when first and second login has X time between each other (default 90 minutes if enabled) - Only alert on Microsoft Azure CLI when Global Administrator scope is used in token - Only alert on Microsoft Azure CLI when Global Administrator scope is used in token and request came from a non-compliant device

MITRE ATT&CK coverage

TacticTechniques
ExecutionT1651 Cloud Administration Command
Credential AccessT1606 Forge Web Credentials

References

Rule body yaml

// TimeDiff threshold in minutes. Needed for some environments with a lot of BP hits on long time frames. Used in scenario where you expect adversary to quickly request new tokens after first token request.
let maxTimeDiff = 90;
// External lookup to get list of FOCI applications
let FociClientApplications = toscalar(externaldata(client_id: string)
    [@"https://raw.githubusercontent.com/secureworks/family-of-client-ids-research/refs/heads/main/known-foci-clients.csv"] with (format="csv", ignoreFirstRecord=true)
    //| project-rename FociClientId = client_id
    | summarize FociClientId = make_list(client_id)
    );
// Get all token requests for Foci clients
let FociTokenRequest = materialize (
    AADNonInteractiveUserSignInLogs
    | where TimeGenerated > ago(6h)
    // Filter for sign-ins to home tenant only
    | where HomeTenantId == ResourceTenantId
    // Lookup for FOCI client
    | where AppId in (FociClientApplications)
    );
FociTokenRequest
// First get all initial logins without refresh tokens as incomming token type
| where IncomingTokenType == "none"
// Then get logins with refresh tokens for same session
| join kind=inner (
    FociTokenRequest
    | where IncomingTokenType != "none"
    | project-rename
        SecondAppDisplayName = AppDisplayName,
        SecondRequestTimeGenerated = TimeGenerated,
        SecondAppId = AppId
    )
    on SessionId, UserPrincipalName
// Exclude when First App ID and Second are the same
| where AppDisplayName != SecondAppDisplayName
// Only get requests where refresh token was used after first sign-in
| extend TimeDiff = datetime_diff('minute', SecondRequestTimeGenerated, TimeGenerated)
| where TimeDiff >= 0 //and TimeDiff <= maxTimeDiff // Remove from comment you want to apply time difference restriction
// Only project needed columns
| project
    FirstRequestTimeGenerated = TimeGenerated,
    FirstResult = ResultType,
    FirstResultDescription = ResultDescription,
    Identity,
    Location,
    FirstAppDisplayName = AppDisplayName,
    FirstAppId = AppId,
    ClientAppUsed,
    DeviceDetail,
    SecondDeviceDetail = DeviceDetail1,
    IPAddress,
    LocationDetails,
    UserAgent,
    SecondRequestTimeGenerated,
    SecondResult = ResultType,
    SecondResultDescription = ResultDescription1,
    SecondAppDisplayName,
    SecondAppId,
    SeconIncomingTokenType = IncomingTokenType1,
    SessionId,
    TimeDiff,
    AuthenticationProcessingDetails,
    SecondAuthenticationProcessingDetails = AuthenticationProcessingDetails1
// Flag logins to the following applications as second login (since they are very popular for attackers and we rather not see logins to these via foci tokens)
| where SecondAppDisplayName in ("Microsoft Azure CLI", "Microsoft Azure PowerShell", "Office 365 Management")
// ENVIRONMENT SPECIFIC FINETUNING - BEGIN
// Most BP triggers are mainly on Microsoft Azure CLI, so we provide two ways of handling these BP detections (strongly depends on environment)
// OPTION 1 - Flag login to Azure CLI using 'Global Administrator' ID in token scope
//| where (SecondAppDisplayName in ("Microsoft Azure PowerShell", "Office 365 Management") or (SecondAppDisplayName == "Microsoft Azure CLI" and SecondAuthenticationProcessingDetails contains "62e90394-69f5-4237-9190-012177145e10"))
// OPTION 2 - Flag login to Azure CLI using 'Global Administrator' ID in token scope from non compliant device
//| where (SecondAppDisplayName in ("Microsoft Azure PowerShell", "Office 365 Management") or (SecondAppDisplayName == "Microsoft Azure CLI" and SecondAuthenticationProcessingDetails contains "62e90394-69f5-4237-9190-012177145e10" and todynamic(SecondDeviceDetail).isCompliant != "true"))
// ENVIRONMENT SPECIFIC FINETUNING - END

Stages and Predicates

Parameters

let maxTimeDiff = 90;

Let binding: FociClientApplications

let FociClientApplications = toscalar(externaldata(client_id: string)
    [@"https://raw.githubusercontent.com/secureworks/family-of-client-ids-research/refs/heads/main/known-foci-clients.csv"] with (format="csv", ignoreFirstRecord=true)
    | summarize FociClientId = make_list(client_id)
    );

The stages below define let FociTokenRequest (the rule's main pipeline source).

Stage 1: source

AADNonInteractiveUserSignInLogs

Stage 2: where

| where TimeGenerated > ago(6h)

Stage 3: where

| where HomeTenantId == ResourceTenantId

Stage 4: where

| where AppId in (FociClientApplications)

References FociClientApplications (defined above).

The stages below run on FociTokenRequest (the outer pipeline).

Stage 5: where

FociTokenRequest
| where IncomingTokenType == "none"

Stage 6: join

| join kind=inner (
    FociTokenRequest
    | where IncomingTokenType != "none"
    | project-rename
        SecondAppDisplayName = AppDisplayName,
        SecondRequestTimeGenerated = TimeGenerated,
        SecondAppId = AppId
    )
    on SessionId, UserPrincipalName

Stage 7: where

| where AppDisplayName != SecondAppDisplayName

Stage 8: extend

| extend TimeDiff = datetime_diff('minute', SecondRequestTimeGenerated, TimeGenerated)

Stage 9: where

| where TimeDiff >= 0

Stage 10: project

| project
    FirstRequestTimeGenerated = TimeGenerated,
    FirstResult = ResultType,
    FirstResultDescription = ResultDescription,
    Identity,
    Location,
    FirstAppDisplayName = AppDisplayName,
    FirstAppId = AppId,
    ClientAppUsed,
    DeviceDetail,
    SecondDeviceDetail = DeviceDetail1,
    IPAddress,
    LocationDetails,
    UserAgent,
    SecondRequestTimeGenerated,
    SecondResult = ResultType,
    SecondResultDescription = ResultDescription1,
    SecondAppDisplayName,
    SecondAppId,
    SeconIncomingTokenType = IncomingTokenType1,
    SessionId,
    TimeDiff,
    AuthenticationProcessingDetails,
    SecondAuthenticationProcessingDetails = AuthenticationProcessingDetails1

Stage 11: where

| where SecondAppDisplayName in ("Microsoft Azure CLI", "Microsoft Azure PowerShell", "Office 365 Management")

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
AppDisplayNamene
  • SecondAppDisplayName transforms: cased
AppIdin
  • FociClientApplications transforms: cased
HomeTenantIdeq
  • ResourceTenantId transforms: cased
IncomingTokenTypeeq
  • none transforms: cased
IncomingTokenTypene
  • none transforms: cased
SecondAppDisplayNamein
  • Microsoft Azure CLI transforms: cased
  • Microsoft Azure PowerShell transforms: cased
  • Office 365 Management transforms: cased
TimeDiffge
  • 0 transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AuthenticationProcessingDetailsproject
ClientAppUsedproject
DeviceDetailproject
FirstAppDisplayNameproject
FirstAppIdproject
FirstRequestTimeGeneratedproject
FirstResultproject
FirstResultDescriptionproject
IPAddressproject
Identityproject
Locationproject
LocationDetailsproject
SeconIncomingTokenTypeproject
SecondAppDisplayNameproject
SecondAppIdproject
SecondAuthenticationProcessingDetailsproject
SecondDeviceDetailproject
SecondRequestTimeGeneratedproject
SecondResultproject
SecondResultDescriptionproject
SessionIdproject
TimeDiffproject
UserAgentproject