EXPLORE
← Back to Explore
elastichighTTP

Potential PowerShell Obfuscation via Concatenated Dynamic Command Invocation

Detects PowerShell scripts that builds commands from concatenated string literals inside dynamic invocation constructs like &() or .(). Attackers use concatenated dynamic invocation to obscure execution intent, bypass keyword-based detections, and evade AMSI.

MITRE ATT&CK

defense-evasionexecution

Detection Query

from logs-windows.powershell_operational* metadata _id, _version, _index
| where event.code == "4104" and powershell.file.script_block_text like "*+*"

// replace the patterns we are looking for with the 🔥 emoji to enable counting them
// The emoji is used because it's unlikely to appear in scripts and has a consistent character length of 1
| eval Esql.script_block_tmp = replace(
    powershell.file.script_block_text,
    """[.&]\(\s*(['"][A-Za-z0-9.-]+['"]\s*\+\s*)+['"][A-Za-z0-9.-]+['"]\s*\)""",
    "🔥"
)

// count how many patterns were detected by calculating the number of 🔥 characters inserted
| eval Esql.script_block_pattern_count = length(Esql.script_block_tmp) - length(replace(Esql.script_block_tmp, "🔥", ""))

// keep the fields relevant to the query, although this is not needed as the alert is populated using _id
| keep
    Esql.script_block_pattern_count,
    Esql.script_block_tmp,
    powershell.file.*,
    file.path,
    process.pid,
    powershell.sequence,
    powershell.total,
    _id,
    _version,
    _index,
    host.name,
    host.id,
    agent.id,
    user.id

// Filter for scripts that match the pattern at least once
| where Esql.script_block_pattern_count >= 1

Author

Elastic

Created

2025/04/15

Data Sources

PowerShell Logs

Tags

Domain: EndpointOS: WindowsUse Case: Threat DetectionTactic: Defense EvasionData Source: PowerShell LogsResources: Investigation Guide
Raw Content
[metadata]
creation_date = "2025/04/15"
integration = ["windows"]
maturity = "production"
updated_date = "2026/04/30"

[rule]
author = ["Elastic"]
description = """
Detects PowerShell scripts that builds commands from concatenated string literals inside dynamic invocation constructs
like &() or .(). Attackers use concatenated dynamic invocation to obscure execution intent, bypass keyword-based
detections, and evade AMSI.
"""
from = "now-9m"
language = "esql"
license = "Elastic License v2"
name = "Potential PowerShell Obfuscation via Concatenated Dynamic Command Invocation"
risk_score = 73
rule_id = "083383af-b9a4-42b7-a463-29c40efe7797"
severity = "high"
tags = [
    "Domain: Endpoint",
    "OS: Windows",
    "Use Case: Threat Detection",
    "Tactic: Defense Evasion",
    "Data Source: PowerShell Logs",
    "Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "esql"

query = '''
from logs-windows.powershell_operational* metadata _id, _version, _index
| where event.code == "4104" and powershell.file.script_block_text like "*+*"

// replace the patterns we are looking for with the 🔥 emoji to enable counting them
// The emoji is used because it's unlikely to appear in scripts and has a consistent character length of 1
| eval Esql.script_block_tmp = replace(
    powershell.file.script_block_text,
    """[.&]\(\s*(['"][A-Za-z0-9.-]+['"]\s*\+\s*)+['"][A-Za-z0-9.-]+['"]\s*\)""",
    "🔥"
)

// count how many patterns were detected by calculating the number of 🔥 characters inserted
| eval Esql.script_block_pattern_count = length(Esql.script_block_tmp) - length(replace(Esql.script_block_tmp, "🔥", ""))

// keep the fields relevant to the query, although this is not needed as the alert is populated using _id
| keep
    Esql.script_block_pattern_count,
    Esql.script_block_tmp,
    powershell.file.*,
    file.path,
    process.pid,
    powershell.sequence,
    powershell.total,
    _id,
    _version,
    _index,
    host.name,
    host.id,
    agent.id,
    user.id

// Filter for scripts that match the pattern at least once
| where Esql.script_block_pattern_count >= 1
'''

note = """## Triage and analysis

### Investigating Potential PowerShell Obfuscation via Concatenated Dynamic Command Invocation

#### Possible investigation steps

- What did the alert preserve about the concatenated dynamic invocation?
  - Focus: `Esql.script_block_pattern_count`, `powershell.file.script_block_text`, and `powershell.file.script_block_id`.
  - Hint: use full-alert `Esql.script_block_tmp` only when you need the match-local slice.
  - Implication: escalate faster when multiple call-operator or dot-sourced matches sit near download, reflection, credential, persistence, or execution logic; lower suspicion only when one short match resolves to a transparent helper and source recovery supports the same recognized workflow.
- Can you reconstruct the full source 4104 script block before interpreting context?
  - Why: PowerShell can split large script blocks, and this ES|QL alert keeps summary fields that do not replace source-event recovery.
  - Focus: query PowerShell Operational source events with `host.id`, `powershell.file.script_block_id`, `powershell.sequence`, and `powershell.total`; order fragments and record source `process.pid` when recovered. $investigate_2
  - Implication: incomplete fragments are unresolved, not benign; escalation is stronger when reconstruction exposes hidden stages, fileless delivery, or missing execution context.
- What command or script does the concatenation resolve to, and does the operator expand impact?
  - Focus: reconstructed `powershell.file.script_block_text`, surrounding variable assignments, and call-operator versus dot-sourcing use.
  - Implication: escalate when the resolved token hides invocation or LOLBin logic that the surrounding code then executes; lower suspicion when reconstruction leaves one readable helper inside a recognized module or compatibility wrapper.
- Does the source event show a file-backed or fileless origin that fits this user and host?
  - Focus: recovered `file.path`, `user.id`, source-event `user.name`, source-event `user.domain`, and `host.id`.
  - Implication: escalate when the script is fileless or sourced from temp, downloads, profiles, shares, or another user-writable path under an unexpected identity; lower suspicion when the file path and user-host pairing match the same recognized admin module or compatibility workflow.
  - Hint: absent `file.path` after source recovery means interactive, pasted, or memory-only activity; require stronger corroboration before closure.
- Can you recover the PowerShell process launch chain?
  - Focus: source `process.pid` plus same-host process-start telemetry for recovered `process.entity_id`, `process.command_line`, and `process.parent.executable`.
  - Hint: if endpoint process telemetry is unavailable, keep later pivots bounded to `host.id` plus `user.id` or `user.name` in the alert window rather than assuming process scope. $investigate_3
  - Implication: escalate when PowerShell is launched by Office, a browser, an archive extractor, a LOLBin, an unexpected service, or a remote session; lower suspicion when the launch chain matches the same recognized management tool or scheduled task already supported by source evidence.
- Does the reconstructed script show layered obfuscation or payload-delivery logic beyond concatenation?
  - Focus: `powershell.file.script_block_entropy_bits`, `powershell.file.script_block_surprisal_stdev`, `powershell.file.script_block_length`, and reconstructed `powershell.file.script_block_text`; compare `powershell.file.script_block_length` against `Esql.script_block_pattern_count` to detect dead-code inflation around few match sites.
  - Implication: escalate when concatenation sits beside encoding, reflection, decoder routines, download strings, hidden payload material, dead-code padding, or Get-Command wildcard resolution.
- Did the recovered process or host-window activity retrieve, stage, or execute follow-on content?
  - Focus: child starts from recovered `process.entity_id`, same-PID 4104 blocks, and file, DNS, or connection side effects: `file.path`, `dns.question.name`, and `destination.ip`.
  - Hint: missing file, DNS, or network telemetry is unresolved, not benign; if `process.entity_id` was not recovered, scope only by `host.id` plus `user.id` or `user.name` in the alert window. $investigate_4 $investigate_5 $investigate_6
  - Implication: escalate when the same process chain spawns shells, writes scripts or binaries, or reaches rare external destinations.
- If local findings remain suspicious or unresolved, does related alert history change scope?
  - Focus: related alerts for the same `user.id` in the last 48 hours, prioritizing repeated obfuscated PowerShell, the same resolved token, or script path. $investigate_0
  - Hint: if the user view is sparse or shared, pivot to the same `host.id` in the last 48 hours. $investigate_1
  - Implication: broaden response when repeated obfuscation, AMSI tampering, encoded commands, download, credential-access, or persistence alerts cluster on the same user or host; keep scope local when the alert is isolated and local evidence resolves to one recognized workflow.

- Escalate on intentionally hidden PowerShell execution across match details, reconstruction, origin, launch chain, layered obfuscation, or follow-on activity; close only when recovered script, resolved token, origin, user-host context, launch chain, and side-effect telemetry align with one recognized workflow; preserve artifacts and escalate when reconstruction, process recovery, or file/network visibility stays incomplete.

### False positive analysis

- Internal compatibility wrappers, module loaders, code-protected vendor scripts, or administrative scripts may concatenate or dot-source helper names. Confirm recovered `powershell.file.script_block_text`, resolved token, `file.path` or stable helper path, recovered parent executable, dot-sourced location, and `user.id` plus `host.id` all align with one recognized workflow, with child process, file, DNS, and network effects contained to it. If external records are unavailable, require the same `file.path` or helper path, resolved token, parent executable, and user-host pairing to recur across prior alerts from this rule.
- Before creating an exception, anchor it on stable `file.path`, resolved token, recovered parent executable, and relevant `host.id` or `user.id` scope. Avoid exceptions on `Esql.script_block_pattern_count`, `Esql.script_block_tmp`, `user.name`, or `powershell.file.script_block_text` alone.

### Response and remediation

- If confirmed benign, reverse any temporary containment and document the evidence that proved one recognized workflow: resolved token, recovered `file.path`, launch chain, and `user.id` plus `host.id` scope. Create an exception only after the same pattern recurs consistently.
- If suspicious but unconfirmed, preserve the alert, reconstructed script fragments, recovered process identifiers, launch chain, staged file paths, DNS names, destination IPs, and case timeline before containment or cleanup.
- Apply reversible containment first: heightened monitoring, temporary outbound restrictions, or PowerShell restrictions on the affected `host.id`. Escalate to host isolation only when launch-chain or follow-on evidence indicates likely payload execution, lateral movement, or active command-and-control.
- If confirmed malicious, isolate the endpoint or contain the account based on the identity, launch-chain, file, and network evidence. Before suspending or terminating PowerShell, record the recovered process entity ID, command line, parent chain, resolved token, reconstructed script fragments, and staged file or network indicators.
- Review related hosts and users for the same resolved token, stable file path, parent executable, and destination indicators before removing artifacts so scoping completes before evidence is destroyed.
- Remove only the unauthorized scripts, dropped payloads, and persistence artifacts identified during the investigation, then remediate the delivery path or administrative-control gap that allowed obfuscated PowerShell execution.
- Post-incident hardening: retain Script Block Logging and endpoint process/file/network telemetry, restrict PowerShell where it is not required, and document the resolved token, script path, launch chain, and side-effect pattern that distinguished benign workflow from abuse."""

setup = """## Setup

PowerShell Script Block Logging must be enabled to generate the events used by this rule (e.g., 4104).
Setup instructions: https://ela.st/powershell-logging-setup
"""

[rule.investigation_fields]
field_names = [
    "@timestamp",
    "host.name",
    "host.id",
    "user.id",
    "file.path",
    "process.pid",
    "powershell.file.script_block_text",
    "powershell.file.script_block_id",
    "powershell.sequence",
    "powershell.total",
    "powershell.file.script_block_entropy_bits",
    "powershell.file.script_block_surprisal_stdev",
    "powershell.file.script_block_unique_symbols",
    "powershell.file.script_block_length",
    "Esql.script_block_pattern_count"
]

[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"

[[transform.investigate]]
label = "Script block fragments for the same script"
description = ""
providers = [
  [
    { excluded = false, field = "powershell.file.script_block_id", queryType = "phrase", value = "{{powershell.file.script_block_id}}", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" }
  ]
]
relativeFrom = "now-1h"
relativeTo = "now"

[[transform.investigate]]
label = "Process events for the PowerShell instance"
description = ""
providers = [
  [
    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
    { excluded = false, field = "event.category", queryType = "phrase", value = "process", valueType = "string" }
  ]
]
relativeFrom = "now-1h"
relativeTo = "now"

[[transform.investigate]]
label = "Child process activity from the PowerShell instance"
description = ""
providers = [
  [
    { excluded = false, field = "process.parent.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
    { excluded = false, field = "event.category", queryType = "phrase", value = "process", valueType = "string" },
    { excluded = false, field = "event.type", queryType = "phrase", value = "start", valueType = "string" }
  ]
]
relativeFrom = "now-1h"
relativeTo = "now"

[[transform.investigate]]
label = "File, network, and DNS events for the PowerShell PID"
description = ""
providers = [
  [
    { excluded = false, field = "event.category", queryType = "phrase", value = "file", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
  ],
  [
    { excluded = false, field = "event.category", queryType = "phrase", value = "network", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
  ],
  [
    { excluded = false, field = "event.category", queryType = "phrase", value = "dns", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
  ]
]
relativeFrom = "now-1h"
relativeTo = "now"

[[transform.investigate]]
label = "Script block events for the PowerShell PID"
description = ""
providers = [
  [
    { excluded = false, field = "event.code", queryType = "phrase", value = "4104", valueType = "string" },
    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
  ]
]
relativeFrom = "now-1h"
relativeTo = "now"

[[rule.threat]]
framework = "MITRE ATT&CK"

[[rule.threat.technique]]
id = "T1027"
name = "Obfuscated Files or Information"
reference = "https://attack.mitre.org/techniques/T1027/"

[[rule.threat.technique.subtechnique]]
id = "T1027.010"
name = "Command Obfuscation"
reference = "https://attack.mitre.org/techniques/T1027/010/"

[[rule.threat.technique]]
id = "T1140"
name = "Deobfuscate/Decode Files or Information"
reference = "https://attack.mitre.org/techniques/T1140/"

[rule.threat.tactic]
id = "TA0005"
name = "Defense Evasion"
reference = "https://attack.mitre.org/tactics/TA0005/"

[[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/"