Detection rules › Kusto

Tailscale: User role elevated to admin or owner

Status
available
Severity
high
Time window
2d
Group by
UserId
Source
github.com/Azure/Azure-Sentinel

Identifies when a user's tailnet role changes from a lower-privilege role to admin, network-admin, or owner between consecutive snapshots. Privilege escalation is a high-value attacker objective and warrants prompt review.

MITRE ATT&CK coverage

Rule body

id: d3c4e5f6-3456-7890-12ab-cdef12345003
name: "Tailscale: User role elevated to admin or owner"
description: |
  Identifies when a user's tailnet role changes from a lower-privilege role to admin, network-admin, or owner between consecutive snapshots. Privilege escalation is a high-value attacker objective and warrants prompt review.
severity: High
status: Available
requiredDataConnectors:
  - connectorId: TailscaleCCF
    dataTypes:
      - Tailscale_Users_CL
queryFrequency: 1h
queryPeriod: 2d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - PrivilegeEscalation
  - Persistence
relevantTechniques:
  - T1078
  - T1098
query: |
  let elevated = dynamic(["admin", "network-admin", "owner"]);
  let recent =
      Tailscale_Users_CL
      | where TimeGenerated > ago(1h)
      | summarize arg_max(TimeGenerated, *) by UserId
      | where Role in (elevated)
      | project UserId, LoginName, RoleNow = Role;
  let prior =
      Tailscale_Users_CL
      | where TimeGenerated between (ago(2d) .. ago(1h))
      | summarize arg_max(TimeGenerated, *) by UserId
      | project UserId, RolePrior = Role;
  recent
  | join kind=inner prior on UserId
  | where RoleNow != RolePrior
  | where RolePrior !in (elevated)
  | project TimeGenerated = now(), UserId, LoginName, RolePrior, RoleNow
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: LoginName
incidentConfiguration:
  createIncident: true
  groupingConfiguration:
    enabled: true
    reopenClosedIncident: false
    lookbackDuration: P1D
    matchingMethod: AllEntities
kind: Scheduled
version: 1.0.0

Stages and Predicates

Parameters

let elevated = dynamic(["admin", "network-admin", "owner"]);

let recent is inlined into the numbered stages below.

Let binding: prior used in Stage 6

let prior = Tailscale_Users_CL
    | where TimeGenerated between (ago(2d) .. ago(1h))
    | summarize arg_max(TimeGenerated, *) by UserId
    | project UserId, RolePrior = Role;

Stages 1 to 5 define let recent (the rule's main pipeline source); stages 6 to 9 run on it.

Stage 1: source

Tailscale_Users_CL

Stage 2: where

| where TimeGenerated > ago(1h)

Stage 3: summarize

| summarize arg_max(TimeGenerated, *) by UserId

Stage 4: where

| where Role in (elevated)

Stage 5: project

| project UserId, LoginName, RoleNow = Role

Stage 6: join

recent
| join kind=inner prior on UserId

Stage 7: where

| where RoleNow != RolePrior

Stage 8: where

| where RolePrior !in (elevated)

Stage 9: project

| project TimeGenerated = now(), UserId, LoginName, RolePrior, RoleNow

Exclusions

The rule actively suppresses these predicates.

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
Rolein
  • admin
  • network-admin
  • owner
field:"Role" kind:in
RoleNowcross_field_compare
  • RolePrior transforms: op:ne
field:"RoleNow" kind:cross_field_compare value:"RolePrior"

Output fields

These fields are emitted when the rule matches.

FieldSource
LoginNameproject
RoleNowproject
RolePriorproject
TimeGeneratedproject
UserIdproject