Detection rules › Elastic

Potential Malicious PowerShell Based on Alert Correlation

Status
production
Severity
high
Time window
9m
Group by
Esql.script_block_id
Author
Elastic
Source
github.com/elastic/detection-rules

Identifies PowerShell script blocks linked to multiple distinct PowerShell detections via the same ScriptBlock ID, indicating compound suspicious behavior. Attackers often chain obfuscation, decoding, and execution within a single script block.

MITRE ATT&CK coverage

Rule body elastic

[metadata]
creation_date = "2025/04/16"
maturity = "production"
updated_date = "2026/05/01"

[rule]
author = ["Elastic"]
description = """
Identifies PowerShell script blocks linked to multiple distinct PowerShell detections via the same ScriptBlock ID,
indicating compound suspicious behavior. Attackers often chain obfuscation, decoding, and execution within a single
script block.
"""
from = "now-9m"
language = "esql"
license = "Elastic License v2"
name = "Potential Malicious PowerShell Based on Alert Correlation"
risk_score = 73
rule_id = "f770ce79-05fd-4d74-9866-1c5d66c9b34b"
severity = "high"
tags = [
    "Domain: Endpoint",
    "OS: Windows",
    "Use Case: Threat Detection",
    "Tactic: Execution",
    "Rule Type: Higher-Order Rule",
    "Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "esql"

query = '''
from .alerts-security.* metadata _id

// Filter for PowerShell related alerts
| where kibana.alert.rule.name like "*PowerShell*"

// as alerts don't have non-ECS fields, parse the script block ID using grok
| grok message "ScriptBlock ID: (?<Esql.script_block_id>.+)"
| where Esql.script_block_id is not null

// keep relevant fields for further processing
| keep kibana.alert.rule.name, Esql.script_block_id, _id, user.id, process.pid, host.id

// count distinct alerts and filter for matches above the threshold
| stats
    Esql.kibana_alert_rule_name_count_distinct = count_distinct(kibana.alert.rule.name),
    Esql.kibana_alert_rule_name_values = values(kibana.alert.rule.name),
    Esql._id_values = values(_id),
    Esql.user_id_values = values(user.id),
    Esql.process_pid_values = values(process.pid),
    Esql.host_id_values = values(host.id)
  by Esql.script_block_id

// Apply detection threshold
| where Esql.kibana_alert_rule_name_count_distinct >= 5
| eval user.id = MV_MIN(Esql.user_id_values),
       process.pid = MV_MIN(Esql.process_pid_values),
       host.id = MV_MIN(Esql.host_id_values)
| keep host.id, user.id, process.pid, Esql.*
'''

note = """## Triage and analysis

### Investigating Potential Malicious PowerShell Based on Alert Correlation

#### Possible investigation steps

- What does the ES|QL grouped alert preserve about the suspicious PowerShell mix?
  - Focus: treat `Esql.script_block_id`, `Esql.kibana_alert_rule_name_values`, `Esql._id_values`, preserved `host.id` / `user.id`, and `Esql.kibana_alert_rule_name_count_distinct` as search clues, not evidence.
  - Implication: escalate if rule names span obfuscation, download, execution, persistence, credential access, or defense evasion for one host/user; lower suspicion only when recovered source alerts/events show one recognized detection-validation script or controlled encoded-content automation pattern.

- Do the contributing alerts bind the summary to one script execution?
  - Focus: recover contributing alerts around grouped-alert time using preserved host/user, `Esql.kibana_alert_rule_name_values`, and script-block ID; use `Esql._id_values` only when alert search supports those IDs.
  - Hint: recover contributing alerts before interpreting grouped behavior; ES|QL grouped alerts lack member-event Timeline pivots and reliable source-event time, PID, or entity anchors.
  - Implication: treat as one execution chain only when source alerts and events align to one host, one user, one source-event window, and one script-block ID; keep unresolved if timestamps, script evidence, PID reuse risk, or entity scope conflict.

- Can you reconstruct and interpret the source PowerShell script block?
  - Focus: using recovered source-event host, process, and time, query PowerShell 4104 or source events; match `powershell.file.script_block_id`, order `powershell.sequence` / `powershell.total`, and read script-block text.
  - Implication: escalate when reconstructed text shows encoded/decoded stages, download cradles, reflection, hosted System.Management.Automation execution, credential access, persistence, or defense evasion; missing fragments or source PowerShell telemetry are unresolved, not benign.

- Which process and launch chain executed the script block?
  - Focus: use recovered time, `host.id`, and process identifiers to find the process start; collect `process.entity_id`, `process.command_line`, `process.parent.command_line`, and `process.Ext.authentication_id`.
  - Hint: if no process start appears, expand time first; if still missing, scope later file, registry, and network review to recovered source-event host/user/process/time.
  - Implication: escalate when the launcher is a document, browser, remote-management tool, scheduled task, unexpected script or .NET host, or command line with encoded, hidden, bypass, download, or dynamic evaluation; lower suspicion only when the same parent, command, user, and host bind to the exact recovered benign workflow.

- Did the process stage payloads, persistence, or security-impacting changes?
  - Focus: scope file and registry events to recovered `process.entity_id` or fallback source-event host/PID/time context; review `file.path`, `file.origin_url`, `registry.path`, `registry.value`, and `registry.data.strings`.
  - Implication: escalate when the script writes executable or scriptable content to user-writable or startup paths, leaves internet provenance, modifies persistence or security keys, or later executes staged content; lower suspicion only when artifacts stay inside the exact recovered benign workflow. Missing file or registry telemetry does not clear the alert.

- Did network or session evidence fit offensive PowerShell use?
  - Focus: scope DNS/connections to recovered `process.entity_id` or source-event host/PID/time context; read `dns.question.name`, `destination.ip`, and `destination.port`; when origin matters, bridge `process.Ext.authentication_id` to `winlog.event_data.TargetLogonId`.
  - Implication: escalate when the script reaches rare or public destinations, pulls content, contacts infrastructure unrelated to the recovered workflow, or runs from an unexpected remote session; missing network or Windows Security telemetry is unresolved, not benign.

- If local evidence is suspicious or unresolved, does the same pattern recur elsewhere enough to change scope?
  - Focus: related alerts for preserved `user.id` over 48 hours, looking for the same `Esql.kibana_alert_rule_name_values`, reconstructed script fragment, launch context, or extracted indicators. $investigate_0
  - Hint: if user-scoped alerts are quiet or ambiguous, compare related alerts for preserved `host.id` over 48 hours. $investigate_1
  - Implication: broaden response when the same script body, rule-name mix, or indicators appear on unrelated hosts or users; keep scope local when recurrence stays on the host under review, but do not close from recurrence alone.

- Weigh contributing-alert alignment, reconstructed script, launch chain, artifacts, destinations, and host/user scope; escalate for offensive tooling, unauthorized execution, staged payloads, persistence, or suspicious destinations; close only when source evidence binds to one exact benign workflow, using outside confirmation only for facts telemetry cannot prove; preserve artifacts and escalate on conflicts or missing telemetry.

### False positive analysis

- Authorized red-team, lab, or detection-validation can trigger several PowerShell rules on one script. Confirm alert mix, reconstructed script, launch chain, `user.id`, and `host.id` match exact test scope; if records are unavailable, recurrence can support scoping but cannot close the alert.
- Endpoint management or deployment automation can trigger multiple detections through encoded content, dynamic script generation, or controlled downloads. Confirm `powershell.file.script_block_text`, parent/child command lines, artifacts, and destinations align with one managed workflow. If script body, destinations, or follow-on artifacts diverge, do not close as benign.
- Before an exception, validate stable benign anchors: `user.id`, `host.id`, parent/child command lines, script fragment, and destination or artifact pattern. Avoid exceptions on ES|QL summary fields or `Esql.script_block_id`; they are alert-local or execution-specific.

### Response and remediation

- If confirmed benign, document the alert mix, reconstructed script, launch chain, and recovered host/user context before reversing containment. Build exceptions from stable recovered workflow anchors, not ES|QL summary fields alone.
- If suspicious but unconfirmed, preserve contributing alerts, `Esql._id_values`, `Esql.script_block_id`, rule-name values, reconstructed script text, recovered parent/child command lines, file/registry paths, and DNS/destination indicators before cleanup. Apply reversible containment first, such as temporary destination restrictions or heightened monitoring on recovered `host.id` and `user.id`; isolate or strengthen account action only if the script is still executing, staging payloads, or reaching suspicious destinations and host role allows.
- If confirmed malicious, record entity IDs, command lines, and indicators, then isolate the host when lateral-movement or active-payload risk is present. Stop the PowerShell process or descendants only after preserving evidence; if direct response is unavailable, escalate with the evidence set. Block confirmed malicious destinations, URLs, hashes, and script paths; eradicate only tied files, registry changes, tasks, services, and payloads; remediate the launch path; and review related hosts/users before destructive cleanup.
- Post-incident hardening: preserve or expand PowerShell 4104 logging, source-alert retention, endpoint process telemetry, and Windows Security logs when gaps limited recovery; where operations allow, restrict high-risk PowerShell through signed scripts, Constrained Language Mode, JEA, or WinRM controls. Document the rule-name mix, observables, and adjacent variants such as indirect System.Management.Automation execution.
"""

[rule.investigation_fields]
field_names = [
    "host.id",
    "user.id",
    "process.pid",
    "Esql.script_block_id",
    "Esql._id_values",
    "Esql.kibana_alert_rule_name_values",
    "Esql.kibana_alert_rule_name_count_distinct",
]

[transform]

[[transform.investigate]]
label = "Alerts associated with the user"
description = ""
providers = [
  [
    { excluded = false, field = "event.kind", queryType = "phrase", value = "signal", valueType = "string" },
    { excluded = false, field = "user.id", queryType = "phrase", value = "{{user.id}}", valueType = "string" }
  ]
]
relativeFrom = "now-48h/h"
relativeTo = "now"

[[transform.investigate]]
label = "Alerts associated with the host"
description = ""
providers = [
  [
    { excluded = false, field = "event.kind", queryType = "phrase", value = "signal", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" }
  ]
]
relativeFrom = "now-48h/h"
relativeTo = "now"

[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1059"
name = "Command and Scripting Interpreter"
reference = "https://attack.mitre.org/techniques/T1059/"
[[rule.threat.technique.subtechnique]]
id = "T1059.001"
name = "PowerShell"
reference = "https://attack.mitre.org/techniques/T1059/001/"

[rule.threat.tactic]
id = "TA0002"
name = "Execution"
reference = "https://attack.mitre.org/tactics/TA0002/"

Stages and Predicates

Stage 1: from

from .alerts-security.* metadata _id

Stage 2: where

| where kibana.alert.rule.name like "*PowerShell*"

Stage 3: grok

| grok message "ScriptBlock ID: (?<Esql.script_block_id>.+)"

Stage 4: where

| where Esql.script_block_id is not null

Stage 5: keep

| keep kibana.alert.rule.name, Esql.script_block_id, _id, user.id, process.pid, host.id

Stage 6: stats

| stats
    Esql.kibana_alert_rule_name_count_distinct = count_distinct(kibana.alert.rule.name),
    Esql.kibana_alert_rule_name_values = values(kibana.alert.rule.name),
    Esql._id_values = values(_id),
    Esql.user_id_values = values(user.id),
    Esql.process_pid_values = values(process.pid),
    Esql.host_id_values = values(host.id)
  by Esql.script_block_id

Stage 7: where

| where Esql.kibana_alert_rule_name_count_distinct >= 5

Stage 8: eval

| eval user.id = MV_MIN(Esql.user_id_values),
       process.pid = MV_MIN(Esql.process_pid_values),
       host.id = MV_MIN(Esql.host_id_values)

Stage 9: keep

| keep host.id, user.id, process.pid, Esql.*

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
Esql.kibana_alert_rule_name_count_distinctge
  • 5
Esql.script_block_idis_not_null
  • (no value, null check)
kibana.alert.rule.namewildcard
  • *PowerShell*

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
host.idKEEP host.id
user.idKEEP user.id
process.pidKEEP process.pid
Esql.*KEEP Esql.*