Detection rules › Kusto
Tailscale: User role elevated to admin or owner
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
| Tactic | Techniques |
|---|---|
| Persistence | |
| Privilege Escalation |
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
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.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
RolePrior | in | admin, network-admin, owner | excludes:RolePrior field:"RolePrior" value:"admin" field:"RolePrior" value:"network-admin" field:"RolePrior" value:"owner" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Role | in |
| field:"Role" kind:in |
RoleNow | cross_field_compare |
| field:"RoleNow" kind:cross_field_compare value:"RolePrior" |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
LoginName | project |
RoleNow | project |
RolePrior | project |
TimeGenerated | project |
UserId | project |