Potential PowerShell Obfuscation via Backtick-Escaped Variable Expansion

Detects PowerShell scripts that use backtick-escaped characters inside ${} variable expansion (multiple backticks between word characters) to reconstruct strings at runtime. Attackers use variable-expansion obfuscation to split keywords, hide commands, and evade static analysis and AMSI.

Elastic rule (View on GitHub)

  1[metadata]
  2creation_date = "2025/04/16"
  3integration = ["windows"]
  4maturity = "production"
  5updated_date = "2026/04/30"
  6
  7[rule]
  8author = ["Elastic"]
  9description = """
 10Detects PowerShell scripts that use backtick-escaped characters inside `${}` variable expansion (multiple backticks
 11between word characters) to reconstruct strings at runtime. Attackers use variable-expansion obfuscation to split
 12keywords, hide commands, and evade static analysis and AMSI.
 13"""
 14from = "now-9m"
 15language = "esql"
 16license = "Elastic License v2"
 17name = "Potential PowerShell Obfuscation via Backtick-Escaped Variable Expansion"
 18risk_score = 73
 19rule_id = "d43f2b43-02a1-4219-8ce9-10929a32a618"
 20severity = "high"
 21tags = [
 22    "Domain: Endpoint",
 23    "OS: Windows",
 24    "Use Case: Threat Detection",
 25    "Tactic: Defense Evasion",
 26    "Data Source: PowerShell Logs",
 27    "Resources: Investigation Guide",
 28]
 29timestamp_override = "event.ingested"
 30type = "esql"
 31
 32query = '''
 33from logs-windows.powershell_operational* metadata _id, _version, _index
 34| where event.code == "4104"
 35
 36// Filter out smaller scripts that are unlikely to implement obfuscation using the patterns we are looking for
 37| eval Esql.script_block_length = length(powershell.file.script_block_text)
 38| where Esql.script_block_length > 500
 39
 40// replace the patterns we are looking for with the 🔥 emoji to enable counting them
 41// The emoji is used because it's unlikely to appear in scripts and has a consistent character length of 1
 42| eval Esql.script_block_tmp = replace(powershell.file.script_block_text, """\$\{(\w++`){2,}\w++\}""", "🔥")
 43
 44// count how many patterns were detected by calculating the number of 🔥 characters inserted
 45| eval Esql.script_block_pattern_count = length(Esql.script_block_tmp) - length(replace(Esql.script_block_tmp, "🔥", ""))
 46
 47// keep the fields relevant to the query, although this is not needed as the alert is populated using _id
 48| keep
 49    Esql.script_block_pattern_count,
 50    Esql.script_block_length,
 51    Esql.script_block_tmp,
 52    powershell.file.*,
 53    file.path,
 54    file.name,
 55    process.pid,
 56    powershell.sequence,
 57    powershell.total,
 58    _id,
 59    _version,
 60    _index,
 61    host.name,
 62    host.id,
 63    agent.id,
 64    user.id
 65
 66// Filter for scripts that match the pattern at least once
 67| where Esql.script_block_pattern_count >= 1
 68'''
 69
 70note = """## Triage and analysis
 71
 72### Investigating Potential PowerShell Obfuscation via Backtick-Escaped Variable Expansion
 73
 74#### Possible investigation steps
 75
 76- Did you reconstruct the complete alerting script block and confirm the escaped-variable pattern?
 77  - Focus: alert-local `Esql.script_block_pattern_count`, `Esql.script_block_tmp`, `Esql.script_block_length`, and reconstructed `powershell.file.script_block_text`. $investigate_2
 78  - Hint: query PowerShell Operational events from logs-windows.powershell_operational*, then reconstruct with `powershell.file.script_block_id` + `powershell.sequence` + `powershell.total` on the same `host.id`; confirm fragment count before judging intent.
 79  - Implication: escalate when the complete block repeats ${} backtick expansion across command, variable, or string-building logic; lower suspicion when one isolated escaped token sits inside readable build or template code. Missing fragments keep the alert unresolved, not benign.
 80
 81- What execution-critical text appears when the backticks inside ${} are removed?
 82  - Focus: reconstructed `powershell.file.script_block_text`, alert-local `Esql.script_block_tmp`, and the command, variable, or string tokens exposed by removing the backticks.
 83  - Implication: escalate when escaped expansions hide cmdlets, invocation operators, download strings, encoded payloads, AMSI or logging bypass names, or variables that feed execution; lower suspicion when they only protect literal template placeholders and no decoded token changes execution.
 84
 85- Does the script origin and user-host context fit one bounded generation workflow?
 86  - Focus: `file.path`, `file.name`, `user.id`, `host.name`, and `host.id`.
 87  - Implication: escalate when `file.path` is absent for a long obfuscated block, the path is user-writable, temporary, or delivery-oriented, or the account-host pair does not fit script generation or deployment; lower suspicion only when origin, account, host, and decoded content match one recognized build, packaging, updater, or test workflow.
 88
 89- If process telemetry is available, how was the PowerShell instance launched?
 90  - Focus: alert-preserved `process.pid`, plus recovered `process.executable`, `process.command_line`, `process.parent.executable`, and `process.parent.command_line`.
 91  - Hint: recover the matching process via `host.id + process.pid`; around alert `@timestamp`, prefer the closest start event for that `host.id` if PID reuse creates multiple matches. $investigate_3
 92  - Implication: escalate when the chain starts from a browser, document process, archive, remote tooling, scheduled task, non-PowerShell host process using System.Management.Automation, or encoded/in-memory command path that does not fit the script purpose; lower suspicion when executable, parent, and command line match the same recognized build, packaging, updater, or test workflow. Missing endpoint process telemetry keeps lineage unresolved, not benign.
 93
 94- Does the decoded content request a second execution stage?
 95  - Focus: decoded execution, download, credential, policy, persistence, or payload-staging commands in `powershell.file.script_block_text`.
 96  - Hint: after process recovery via `host.id + process.pid`, review child events where `process.parent.entity_id` equals the recovered PowerShell `process.entity_id`; use `host.id` plus `process.parent.pid` as a weaker tight-window fallback. $investigate_4
 97  - Implication: escalate when hidden tokens feed execution operators, downloaded content, child processes, credential access, policy tampering, persistence, or payload staging; lower suspicion when decoded actions stay inside the same recognized generation or update task and no second execution path appears. Missing endpoint process telemetry leaves child-process correlation unresolved, not benign.
 98
 99- If local evidence remains suspicious or unresolved, does this escaped-variable pattern appear elsewhere?
100  - Focus: related alerts for `user.id` and `host.id` in the last 48 hours, decoded token fragments from `powershell.file.script_block_text`, and the same `file.path` when present.
101  - Hint: start with related alerts for the same `user.id`; if sparse, pivot to the same `host.id`. $investigate_0 $investigate_1
102  - Implication: broaden scope when the same escaped-variable technique or decoded execution strings appear on unrelated hosts, users, or source paths; keep local when recurrence stays inside the same confirmed workflow and local evidence is otherwise clean.
103
104- Escalate on strong unauthorized execution evidence from decoded tokens, fileless or unusual origin, launch chain, second-stage behavior, or repeated scope; close only when telemetry and any needed owner or change confirmation explain every suspicious token as one recognized build, packaging, updater, or test workflow; preserve and escalate when fragments, endpoint process telemetry, or workflow proof are missing for a suspicious script.
105
106### False positive analysis
107
108- Code generation, packaging, build, updater, bootstrap, or test harness workflows can emit backtick-escaped ${} sequences while producing PowerShell text. Confirm only when reconstructed `powershell.file.script_block_text` is limited to template, packaging, installer, updater, or test logic; `file.path` or its absence fits that source; any recovered launch chain supports the same tool; and `user.id` plus `host.id` match the same operating scope. If change records are unavailable, require the same file origin, decoded token family, and user-host scope across prior alerts from this rule.
109- Treat one benign-looking token as insufficient for closure. Do not close when decoded content contains execution, download, defense-evasion, credential, or persistence logic that the named workflow does not require.
110- Before creating an exception, anchor it to stable indexed fields such as `user.id`, `host.id`, and `file.path` or `file.name`, plus the stable decoded token family and recovered parent context when available. Do not use `Esql.script_block_pattern_count` or `Esql.script_block_tmp` in exceptions because they are alert-local summaries, not exception-safe fields.
111
112### Response and remediation
113
114- If confirmed benign, document the evidence that explained the alert first: reconstructed script intent, file origin or fileless source, `user.id`, `host.id`, and the recovered launch context when available. Then reverse any temporary containment and create a narrow exception only after the same workflow pattern is stable across prior alerts.
115- If suspicious but unconfirmed, preserve the alert, reconstructed `powershell.file.script_block_text`, `powershell.file.script_block_id`, ordered 4104 fragments, file origin, host-user scope, recovered launch context when available, and decoded indicators before containment. Apply reversible containment such as heightened monitoring or host isolation only if the host role can tolerate it, then escalate before deleting artifacts or resetting credentials.
116- If confirmed malicious, preserve the same script, source-event, process, and decoded-indicator evidence before destructive action. Isolate the host when the evidence shows unauthorized execution and host criticality allows it, record the recovered process identifier before termination, block confirmed malicious decoded indicators, and remove only the scripts, payloads, startup items, policy changes, or persistence artifacts identified during the investigation. Reset credentials only when the investigation shows account misuse beyond local script execution.
117- Post-incident hardening: retain PowerShell script-block logging, keep endpoint process telemetry sufficient for `host.id + process.pid` recovery, restrict recurring script generation to recognized signed tooling and service scopes, and document the confirmed benign workflow or malicious decoded token family for future triage.
118"""
119
120setup = """## Setup
121
122PowerShell Script Block Logging must be enabled to generate the events used by this rule (e.g., 4104).
123Setup instructions: https://ela.st/powershell-logging-setup
124"""
125
126[rule.investigation_fields]
127field_names = [
128    "@timestamp",
129    "user.id",
130    "powershell.file.script_block_text",
131    "powershell.file.script_block_id",
132    "powershell.sequence",
133    "powershell.total",
134    "file.path",
135    "file.name",
136    "host.name",
137    "host.id",
138    "process.pid",
139    "Esql.script_block_pattern_count",
140    "Esql.script_block_length"
141]
142
143[transform]
144
145[[transform.investigate]]
146label = "Alerts associated with the user"
147description = ""
148providers = [
149  [
150    { excluded = false, field = "event.kind", queryType = "phrase", value = "signal", valueType = "string" },
151    { excluded = false, field = "user.id", queryType = "phrase", value = "{{user.id}}", valueType = "string" }
152  ]
153]
154relativeFrom = "now-48h/h"
155relativeTo = "now"
156
157[[transform.investigate]]
158label = "Alerts associated with the host"
159description = ""
160providers = [
161  [
162    { excluded = false, field = "event.kind", queryType = "phrase", value = "signal", valueType = "string" },
163    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" }
164  ]
165]
166relativeFrom = "now-48h/h"
167relativeTo = "now"
168
169[[transform.investigate]]
170label = "Script block fragments for the same script"
171description = ""
172providers = [
173  [
174    { excluded = false, field = "powershell.file.script_block_id", queryType = "phrase", value = "{{powershell.file.script_block_id}}", valueType = "string" },
175    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" }
176  ]
177]
178relativeFrom = "now-1h"
179relativeTo = "now"
180
181[[transform.investigate]]
182label = "Process events for the PowerShell instance"
183description = ""
184providers = [
185  [
186    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" },
187    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
188    { excluded = false, field = "event.category", queryType = "phrase", value = "process", valueType = "string" }
189  ]
190]
191relativeFrom = "now-1h"
192relativeTo = "now"
193
194[[transform.investigate]]
195label = "Child process activity from the PowerShell instance"
196description = ""
197providers = [
198  [
199    { excluded = false, field = "process.parent.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" },
200    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
201    { excluded = false, field = "event.category", queryType = "phrase", value = "process", valueType = "string" },
202    { excluded = false, field = "event.type", queryType = "phrase", value = "start", valueType = "string" }
203  ]
204]
205relativeFrom = "now-1h"
206relativeTo = "now"
207
208[[rule.threat]]
209framework = "MITRE ATT&CK"
210
211[[rule.threat.technique]]
212id = "T1027"
213name = "Obfuscated Files or Information"
214reference = "https://attack.mitre.org/techniques/T1027/"
215
216[[rule.threat.technique.subtechnique]]
217id = "T1027.010"
218name = "Command Obfuscation"
219reference = "https://attack.mitre.org/techniques/T1027/010/"
220
221[[rule.threat.technique]]
222id = "T1140"
223name = "Deobfuscate/Decode Files or Information"
224reference = "https://attack.mitre.org/techniques/T1140/"
225
226[rule.threat.tactic]
227id = "TA0005"
228name = "Defense Evasion"
229reference = "https://attack.mitre.org/tactics/TA0005/"
230
231[[rule.threat]]
232framework = "MITRE ATT&CK"
233
234[[rule.threat.technique]]
235id = "T1059"
236name = "Command and Scripting Interpreter"
237reference = "https://attack.mitre.org/techniques/T1059/"
238
239[[rule.threat.technique.subtechnique]]
240id = "T1059.001"
241name = "PowerShell"
242reference = "https://attack.mitre.org/techniques/T1059/001/"
243
244[rule.threat.tactic]
245id = "TA0002"
246name = "Execution"
247reference = "https://attack.mitre.org/tactics/TA0002/"

Triage and analysis

Investigating Potential PowerShell Obfuscation via Backtick-Escaped Variable Expansion

Possible investigation steps

  • Did you reconstruct the complete alerting script block and confirm the escaped-variable pattern?

    • Focus: alert-local Esql.script_block_pattern_count, Esql.script_block_tmp, Esql.script_block_length, and reconstructed powershell.file.script_block_text. $investigate_2
    • Hint: query PowerShell Operational events from logs-windows.powershell_operational*, then reconstruct with powershell.file.script_block_id + powershell.sequence + powershell.total on the same host.id; confirm fragment count before judging intent.
    • Implication: escalate when the complete block repeats ${} backtick expansion across command, variable, or string-building logic; lower suspicion when one isolated escaped token sits inside readable build or template code. Missing fragments keep the alert unresolved, not benign.
  • What execution-critical text appears when the backticks inside ${} are removed?

    • Focus: reconstructed powershell.file.script_block_text, alert-local Esql.script_block_tmp, and the command, variable, or string tokens exposed by removing the backticks.
    • Implication: escalate when escaped expansions hide cmdlets, invocation operators, download strings, encoded payloads, AMSI or logging bypass names, or variables that feed execution; lower suspicion when they only protect literal template placeholders and no decoded token changes execution.
  • Does the script origin and user-host context fit one bounded generation workflow?

    • Focus: file.path, file.name, user.id, host.name, and host.id.
    • Implication: escalate when file.path is absent for a long obfuscated block, the path is user-writable, temporary, or delivery-oriented, or the account-host pair does not fit script generation or deployment; lower suspicion only when origin, account, host, and decoded content match one recognized build, packaging, updater, or test workflow.
  • If process telemetry is available, how was the PowerShell instance launched?

    • Focus: alert-preserved process.pid, plus recovered process.executable, process.command_line, process.parent.executable, and process.parent.command_line.
    • Hint: recover the matching process via host.id + process.pid; around alert @timestamp, prefer the closest start event for that host.id if PID reuse creates multiple matches. $investigate_3
    • Implication: escalate when the chain starts from a browser, document process, archive, remote tooling, scheduled task, non-PowerShell host process using System.Management.Automation, or encoded/in-memory command path that does not fit the script purpose; lower suspicion when executable, parent, and command line match the same recognized build, packaging, updater, or test workflow. Missing endpoint process telemetry keeps lineage unresolved, not benign.
  • Does the decoded content request a second execution stage?

    • Focus: decoded execution, download, credential, policy, persistence, or payload-staging commands in powershell.file.script_block_text.
    • Hint: after process recovery via host.id + process.pid, review child events where process.parent.entity_id equals the recovered PowerShell process.entity_id; use host.id plus process.parent.pid as a weaker tight-window fallback. $investigate_4
    • Implication: escalate when hidden tokens feed execution operators, downloaded content, child processes, credential access, policy tampering, persistence, or payload staging; lower suspicion when decoded actions stay inside the same recognized generation or update task and no second execution path appears. Missing endpoint process telemetry leaves child-process correlation unresolved, not benign.
  • If local evidence remains suspicious or unresolved, does this escaped-variable pattern appear elsewhere?

    • Focus: related alerts for user.id and host.id in the last 48 hours, decoded token fragments from powershell.file.script_block_text, and the same file.path when present.
    • Hint: start with related alerts for the same user.id; if sparse, pivot to the same host.id. $investigate_0 $investigate_1
    • Implication: broaden scope when the same escaped-variable technique or decoded execution strings appear on unrelated hosts, users, or source paths; keep local when recurrence stays inside the same confirmed workflow and local evidence is otherwise clean.
  • Escalate on strong unauthorized execution evidence from decoded tokens, fileless or unusual origin, launch chain, second-stage behavior, or repeated scope; close only when telemetry and any needed owner or change confirmation explain every suspicious token as one recognized build, packaging, updater, or test workflow; preserve and escalate when fragments, endpoint process telemetry, or workflow proof are missing for a suspicious script.

False positive analysis

  • Code generation, packaging, build, updater, bootstrap, or test harness workflows can emit backtick-escaped ${} sequences while producing PowerShell text. Confirm only when reconstructed powershell.file.script_block_text is limited to template, packaging, installer, updater, or test logic; file.path or its absence fits that source; any recovered launch chain supports the same tool; and user.id plus host.id match the same operating scope. If change records are unavailable, require the same file origin, decoded token family, and user-host scope across prior alerts from this rule.
  • Treat one benign-looking token as insufficient for closure. Do not close when decoded content contains execution, download, defense-evasion, credential, or persistence logic that the named workflow does not require.
  • Before creating an exception, anchor it to stable indexed fields such as user.id, host.id, and file.path or file.name, plus the stable decoded token family and recovered parent context when available. Do not use Esql.script_block_pattern_count or Esql.script_block_tmp in exceptions because they are alert-local summaries, not exception-safe fields.

Response and remediation

  • If confirmed benign, document the evidence that explained the alert first: reconstructed script intent, file origin or fileless source, user.id, host.id, and the recovered launch context when available. Then reverse any temporary containment and create a narrow exception only after the same workflow pattern is stable across prior alerts.
  • If suspicious but unconfirmed, preserve the alert, reconstructed powershell.file.script_block_text, powershell.file.script_block_id, ordered 4104 fragments, file origin, host-user scope, recovered launch context when available, and decoded indicators before containment. Apply reversible containment such as heightened monitoring or host isolation only if the host role can tolerate it, then escalate before deleting artifacts or resetting credentials.
  • If confirmed malicious, preserve the same script, source-event, process, and decoded-indicator evidence before destructive action. Isolate the host when the evidence shows unauthorized execution and host criticality allows it, record the recovered process identifier before termination, block confirmed malicious decoded indicators, and remove only the scripts, payloads, startup items, policy changes, or persistence artifacts identified during the investigation. Reset credentials only when the investigation shows account misuse beyond local script execution.
  • Post-incident hardening: retain PowerShell script-block logging, keep endpoint process telemetry sufficient for host.id + process.pid recovery, restrict recurring script generation to recognized signed tooling and service scopes, and document the confirmed benign workflow or malicious decoded token family for future triage.

Related rules

to-top