Detection rules › Kusto

Azure VM Run Command operations executing a unique PowerShell script

Severity
medium
Time window
1d
Group by
Authorization, Caller, CorrelationId, FileName, PowershellFileName, ScriptFingerprintHash, VirtualMachineName
Author
Microsoft Security Research
Source
github.com/Azure/Azure-Sentinel

Identifies when Azure Run command is used to execute a PowerShell script on a VM that is unique. The uniqueness of the PowerShell script is determined by taking a combined hash of the cmdLets it imports and the file size of the PowerShell script. Alerts from this detection indicate a unique PowerShell was executed in your environment.

MITRE ATT&CK coverage

Telemetry coverage

Rule body

id: 5239248b-abfb-4c6a-8177-b104ade5db56
name: Azure VM Run Command operations executing a unique PowerShell script
description: |
  'Identifies when Azure Run command is used to execute a PowerShell script on a VM that is unique.
  The uniqueness of the PowerShell script is determined by taking a combined hash of the cmdLets it imports and the file size of the PowerShell script. Alerts from this detection indicate a unique PowerShell was executed in your environment.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActivity
    dataTypes:
      - AzureActivity
  - connectorId: MicrosoftThreatProtection
    dataTypes:
      - DeviceFileEvents
      - DeviceEvents
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - LateralMovement
  - Execution
relevantTechniques:
  - T1570
  - T1059.001
query: |
  let RunCommandData = materialize ( AzureActivity
  // Isolate run command actions
  | where OperationNameValue =~ "Microsoft.Compute/virtualMachines/runCommand/action"
  // Confirm that the operation impacted a virtual machine
  | where Authorization has "virtualMachines"
  // Each runcommand operation consists of three events when successful, StartTimeed, Accepted (or Rejected), Successful (or Failed).
  | summarize StartTime=min(TimeGenerated), EndTime=max(TimeGenerated), max(CallerIpAddress), make_list(ActivityStatusValue) by CorrelationId, Authorization, Caller
  // Limit to Run Command executions that Succeeded
  | where list_ActivityStatusValue has_any ("Succeeded", "Success")
  // Extract data from the Authorization field, allowing us to later extract the Caller (UPN) and CallerIpAddress
  | extend Authorization_d = parse_json(Authorization)
  | extend Scope = Authorization_d.scope
  | extend Scope_s = split(Scope, "/")
  | extend Subscription = tostring(Scope_s[2])
  | extend VirtualMachineName = tostring(Scope_s[-1])
  | project StartTime, EndTime, Subscription, VirtualMachineName, CorrelationId, Caller, CallerIpAddress=max_CallerIpAddress, Scope
  | join kind=leftouter (
      DeviceFileEvents
      | where InitiatingProcessFileName == "RunCommandExtension.exe"
      | extend VirtualMachineName = tostring(split(DeviceName, ".")[0])
      | project VirtualMachineName, PowershellFileCreatedTimestamp=TimeGenerated, FileName, FileSize, InitiatingProcessAccountName, InitiatingProcessAccountDomain, InitiatingProcessFolderPath, InitiatingProcessId
  ) on VirtualMachineName
  // We need to filter by time sadly, this is the only way to link events
  | where PowershellFileCreatedTimestamp between (StartTime .. EndTime)
  | project StartTime, EndTime, PowershellFileCreatedTimestamp, VirtualMachineName, Caller, CallerIpAddress, FileName, FileSize, InitiatingProcessId, InitiatingProcessAccountDomain, InitiatingProcessFolderPath, Scope
  | join kind=inner(
      DeviceEvents
      | extend VirtualMachineName = tostring(split(DeviceName, ".")[0])
      | where InitiatingProcessCommandLine has "-File"
      // Extract the script name based on the structure used by the RunCommand extension
      | extend PowershellFileName = extract(@"\-File\s(script[0-9]{1,9}\.ps1)", 1, InitiatingProcessCommandLine)
      // Discard results that didn't successfully extract, these are not run command related
      | where isnotempty(PowershellFileName)
      | extend PSCommand = tostring(parse_json(AdditionalFields).Command)
      // The first execution of PowerShell will be the RunCommand script itself, we can discard this as it will break our hash later
      | where PSCommand != PowershellFileName 
      // Now we normalise the cmdlets, we're aiming to hash them to find scripts using rare combinations
      | extend PSCommand = toupper(PSCommand)
      | order by PSCommand asc
      | summarize PowershellExecStartTime=min(TimeGenerated), PowershellExecEnd=max(TimeGenerated), make_list(PSCommand) by PowershellFileName, InitiatingProcessCommandLine
  ) on $left.FileName == $right.PowershellFileName
  | project StartTime, EndTime, PowershellFileCreatedTimestamp, PowershellExecStartTime, PowershellExecEnd, PowershellFileName, PowershellScriptCommands=list_PSCommand, Caller, CallerIpAddress, InitiatingProcessCommandLine, PowershellFileSize=FileSize, VirtualMachineName, Scope
  | order by StartTime asc 
  // We generate the hash based on the cmdlets called and the size of the powershell script
  | extend TempFingerprintString = strcat(PowershellScriptCommands, PowershellFileSize)
  | extend ScriptFingerprintHash = hash_sha256(tostring(PowershellScriptCommands)));
  let totals = toscalar (RunCommandData
  | summarize count());
  let hashTotals = RunCommandData
  | summarize HashCount=count() by ScriptFingerprintHash;
  RunCommandData
  | join kind=leftouter (
  hashTotals
  ) on ScriptFingerprintHash
  // Calculate prevalence, while we don't need this, it may be useful for responders to know how rare this script is in relation to normal activity
  | extend Prevalence = toreal(HashCount) / toreal(totals) * 100
  // Where the hash was only ever seen once.
  | where HashCount == 1
  | extend timestamp = StartTime
  | extend CallerName = tostring(split(Caller, "@")[0]), CallerUPNSuffix = tostring(split(Caller, "@")[1])
  | project timestamp, StartTime, EndTime, PowershellFileName, VirtualMachineName, Caller, CallerName, CallerUPNSuffix, CallerIpAddress, PowershellScriptCommands, PowershellFileSize, ScriptFingerprintHash, Prevalence, Scope
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: Caller
      - identifier: Name
        columnName: CallerName
      - identifier: UPNSuffix
        columnName: CallerUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: CallerIpAddress
  - entityType: Host
    fieldMappings:
      - identifier: HostName
        columnName: VirtualMachineName
      - identifier: AzureID
        columnName: Scope
version: 1.0.8
kind: Scheduled
metadata:
    source:
        kind: Community
    author:
        name: Microsoft Security Research
    support:
        tier: Community
    categories:
        domains: [ "Security - Others", "Identity" ]

Stages and Predicates

let RunCommandData is inlined into the numbered stages below.

Let binding: totals

let totals = toscalar (RunCommandData
| summarize count());

Let binding: hashTotals used in Stages 2, 18

let hashTotals = RunCommandData
| summarize HashCount=count() by ScriptFingerprintHash;

Stage 1: source

let RunCommandData

Stage 2: source

let hashTotals

Stage 3: source

AzureActivity

Stage 4: where

where OperationNameValue =~ "Microsoft.Compute/virtualMachines/runCommand/action"

Stage 5: where

where Authorization contains "virtualMachines"

Stage 6: summarize

summarize EndTime, StartTime by CorrelationId, Authorization, Caller

Stage 7: where

where (list_ActivityStatusValue contains "Succeeded" or list_ActivityStatusValue contains "Success")

Stage 8: extend (5 consecutive steps)

extend Authorization_d, Scope, Scope_s, Subscription, VirtualMachineName

Stage 9: project

project Caller, CallerIpAddress, CorrelationId, EndTime, Scope, StartTime, Subscription, VirtualMachineName

Stage 10: join

join kind=leftouter (DeviceFileEvents) on VirtualMachineName

Stage 11: where

where PowershellFileCreatedTimestamp >= "StartTime" and PowershellFileCreatedTimestamp <= "EndTime"

Stage 12: project

project Caller, CallerIpAddress, EndTime, FileName, FileSize, InitiatingProcessAccountDomain, InitiatingProcessFolderPath, InitiatingProcessId, PowershellFileCreatedTimestamp, Scope, StartTime, VirtualMachineName

Stage 13: join

join kind=inner (DeviceEvents) on FileName, PowershellFileName

Stage 14: project

project Caller, CallerIpAddress, EndTime, InitiatingProcessCommandLine, PowershellExecEnd, PowershellExecStartTime, PowershellFileCreatedTimestamp, PowershellFileName, PowershellFileSize, PowershellScriptCommands, Scope, StartTime, VirtualMachineName

Stage 15: sort

sort by StartTime

Stage 16: extend

extend TempFingerprintString

Stage 17: extend

extend ScriptFingerprintHash

Stage 18: join

join kind=leftouter (hashTotals) on ScriptFingerprintHash

Stage 19: extend

extend Prevalence

Stage 20: where

where HashCount == 1

Stage 21: extend

extend timestamp

Stage 22: extend

extend CallerName, CallerUPNSuffix

Stage 23: project

project Caller, CallerIpAddress, CallerName, CallerUPNSuffix, EndTime, PowershellFileName, PowershellFileSize, PowershellScriptCommands, Prevalence, Scope, ScriptFingerprintHash, StartTime, VirtualMachineName, timestamp

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
Callerproject
CallerIpAddressproject
CallerNameproject
CallerUPNSuffixproject
EndTimeproject
PowershellFileNameproject
PowershellFileSizeproject
PowershellScriptCommandsproject
Prevalenceproject
Scopeproject
ScriptFingerprintHashproject
StartTimeproject
VirtualMachineNameproject
timestampproject