Detection rules › Kusto
Azure VM Run Command operations executing a unique PowerShell script
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
| Tactic | Techniques |
|---|---|
| Execution | T1059.001 Command and Scripting Interpreter: PowerShell |
| Lateral Movement | T1570 Lateral Tool Transfer |
Event coverage
Rule body kusto
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 binding: totals
let totals = toscalar (RunCommandData
| summarize count());
Derived from RunCommandData.
Let binding: hashTotals
let hashTotals = RunCommandData
| summarize HashCount=count() by ScriptFingerprintHash;
Derived from RunCommandData.
The stages below define let RunCommandData (the rule's main pipeline source).
Stage 1: source
AzureActivity
Stage 2: where
| where OperationNameValue =~ "Microsoft.Compute/virtualMachines/runCommand/action"
Stage 3: where
| where Authorization has "virtualMachines"
Stage 4: summarize
| summarize StartTime=min(TimeGenerated), EndTime=max(TimeGenerated), max(CallerIpAddress), make_list(ActivityStatusValue) by CorrelationId, Authorization, Caller
Stage 5: where
| where list_ActivityStatusValue has_any ("Succeeded", "Success")
Stage 6: extend (5 consecutive steps)
| 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])
Stage 7: project
| project StartTime, EndTime, Subscription, VirtualMachineName, CorrelationId, Caller, CallerIpAddress=max_CallerIpAddress, Scope
Stage 8: join
| 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
Stage 9: where
| where PowershellFileCreatedTimestamp between (StartTime .. EndTime)
Stage 10: project
| project StartTime, EndTime, PowershellFileCreatedTimestamp, VirtualMachineName, Caller, CallerIpAddress, FileName, FileSize, InitiatingProcessId, InitiatingProcessAccountDomain, InitiatingProcessFolderPath, Scope
Stage 11: join
| join kind=inner(
DeviceEvents
| extend VirtualMachineName = tostring(split(DeviceName, ".")[0])
| where InitiatingProcessCommandLine has "-File"
| extend PowershellFileName = extract(@"\-File\s(script[0-9]{1,9}\.ps1)", 1, InitiatingProcessCommandLine)
| where isnotempty(PowershellFileName)
| extend PSCommand = tostring(parse_json(AdditionalFields).Command)
| where PSCommand != PowershellFileName
| 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
Stage 12: project
| project StartTime, EndTime, PowershellFileCreatedTimestamp, PowershellExecStartTime, PowershellExecEnd, PowershellFileName, PowershellScriptCommands=list_PSCommand, Caller, CallerIpAddress, InitiatingProcessCommandLine, PowershellFileSize=FileSize, VirtualMachineName, Scope
Stage 13: sort
| order by StartTime asc
Stage 14: extend
| extend TempFingerprintString = strcat(PowershellScriptCommands, PowershellFileSize)
Stage 15: extend
| extend ScriptFingerprintHash = hash_sha256(tostring(PowershellScriptCommands))
The stages below run on RunCommandData (the outer pipeline).
Stage 16: join
RunCommandData
| join kind=leftouter (
hashTotals
) on ScriptFingerprintHash
Stage 17: extend
| extend Prevalence = toreal(HashCount) / toreal(totals) * 100
References totals (defined above).
Stage 18: where
| where HashCount == 1
Stage 19: extend
| extend timestamp = StartTime
Stage 20: extend
| extend CallerName = tostring(split(Caller, "@")[0]), CallerUPNSuffix = tostring(split(Caller, "@")[1])
Stage 21: project
| project timestamp, StartTime, EndTime, PowershellFileName, VirtualMachineName, Caller, CallerName, CallerUPNSuffix, CallerIpAddress, PowershellScriptCommands, PowershellFileSize, ScriptFingerprintHash, Prevalence, Scope
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.
| Field | Kind | Values |
|---|---|---|
Authorization | match |
|
HashCount | eq |
|
InitiatingProcessCommandLine | match |
|
InitiatingProcessFileName | eq |
|
OperationNameValue | eq |
|
PSCommand | ne |
|
PowershellFileCreatedTimestamp | ge |
|
PowershellFileCreatedTimestamp | le |
|
PowershellFileName | is_not_null | |
list_ActivityStatusValue | match |
|
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.
| Field | Source |
|---|---|
Caller | project |
CallerIpAddress | project |
CallerName | project |
CallerUPNSuffix | project |
EndTime | project |
PowershellFileName | project |
PowershellFileSize | project |
PowershellScriptCommands | project |
Prevalence | project |
Scope | project |
ScriptFingerprintHash | project |
StartTime | project |
VirtualMachineName | project |
timestamp | project |