Potential PowerShell Obfuscation via String Concatenation

Detects PowerShell scripts that repeatedly concatenate multiple quoted string literals with + to assemble commands or tokens at runtime. Attackers use string concatenation to fragment keywords or URLs and evade static analysis and AMSI.

Elastic rule (View on GitHub)

  1[metadata]
  2creation_date = "2025/04/14"
  3integration = ["windows"]
  4maturity = "production"
  5updated_date = "2026/04/30"
  6
  7[rule]
  8author = ["Elastic"]
  9description = """
 10Detects PowerShell scripts that repeatedly concatenate multiple quoted string literals with + to assemble commands
 11or tokens at runtime. Attackers use string concatenation to fragment keywords or URLs and evade static analysis
 12and AMSI.
 13"""
 14from = "now-9m"
 15language = "esql"
 16license = "Elastic License v2"
 17name = "Potential PowerShell Obfuscation via String Concatenation"
 18risk_score = 73
 19rule_id = "f6d8c743-0916-4483-8333-3c6f107e0caa"
 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(
 43    powershell.file.script_block_text,
 44    """['"][A-Za-z0-9.]+['"](\s?\+\s?['"][A-Za-z0-9.,\-\s]+['"]){2,}""",
 45    "🔥"
 46)
 47
 48// count how many patterns were detected by calculating the number of 🔥 characters inserted
 49| eval Esql.script_block_pattern_count = length(Esql.script_block_tmp) - length(replace(Esql.script_block_tmp, "🔥", ""))
 50
 51// keep the fields relevant to the query, although this is not needed as the alert is populated using _id
 52| keep
 53    Esql.script_block_pattern_count,
 54    Esql.script_block_length,
 55    Esql.script_block_tmp,
 56    powershell.file.*,
 57    file.path,
 58    powershell.sequence,
 59    powershell.total,
 60    _id,
 61    _version,
 62    _index,
 63    host.name,
 64    host.id,
 65    agent.id,
 66    user.id,
 67    process.pid
 68
 69// Filter for scripts that match the pattern at least twice
 70| where Esql.script_block_pattern_count >= 2
 71'''
 72
 73note = """## Triage and analysis
 74
 75### Investigating Potential PowerShell Obfuscation via String Concatenation
 76
 77#### Possible investigation steps
 78
 79- What does the alert-local summary show about the concatenation pattern and where it appears in the preserved script block?
 80  - Focus: alert-local `Esql.script_block_pattern_count`, `Esql.script_block_length`, `Esql.script_block_tmp`, and `powershell.file.script_block_text`.
 81  - Implication: escalate sooner when repeated matches sit near execution, download, decode, or persistence logic; lower suspicion when they resolve to inert configuration or output text, but do not close until the full script block and origin are checked.
 82- Is the full script block reconstructed before interpretation?
 83  - Focus: source 4104 events in logs-windows.powershell_operational* for `host.id` and `powershell.file.script_block_id`, ordered by `powershell.sequence` against `powershell.total`. $investigate_2
 84  - Implication: escalate when fragments add hidden stages, payload material, or omitted execution context; missing sequence fragments are unresolved because omitted text may contain the decisive string use.
 85- What do the concatenated strings reconstruct to, and do they feed execution?
 86  - Focus: reconstructed `powershell.file.script_block_text`, quoted fragments around each match, and statistical cues from `powershell.file.script_block_entropy_bits` and `powershell.file.script_block_surprisal_stdev`.
 87  - Implication: escalate when strings reveal fragmented keywords, URLs or domains, paths, registry keys, encoded blobs, .NET reflection names, or decode inputs feeding invocation, download, file write, or persistence; lower suspicion when they remain inert data construction inside one stable script pattern.
 88- Does the source event and origin context explain who ran the script and from where?
 89  - Focus: recovered `file.path`, `user.id`, and `host.id`, plus whether `file.path` is absent.
 90  - Hint: absent `file.path` after source-event recovery reduces origin provenance because the script may be interactive, pasted, or memory-only; require stronger corroboration before benign closure.
 91  - Implication: escalate when execution is fileless or from temp, downloads, profiles, mounted shares, or other user-writable locations under an unexpected identity; lower suspicion only when origin, user, host, and string use match one recognized automation or build workflow.
 92- If endpoint process telemetry is available, does launch context support benign automation or abuse?
 93  - Focus: recover `process.pid` from the 4104 event, then match `host.id` and the alert window in endpoint process-start events for `process.entity_id`, `process.command_line`, and `process.parent.executable`. $investigate_3
 94  - Hint: anchor process starts to `@timestamp`; if PID reuse creates multiple or distant matches, keep launch context unresolved. Without endpoint process telemetry, bound downstream checks to `host.id`, `user.id` or `user.name`, and the alert window; missing launch telemetry is unresolved, not benign.
 95  - Implication: escalate when PowerShell starts from Office, a browser, an archive extractor, a LOLBin, a remote context, or a service context with encoded or fileless delivery; lower suspicion when the launch chain and command line match the same recognized automation workflow.
 96- Did the recovered process or host-window activity retrieve, stage, or execute follow-on content?
 97  - Focus: child process starts from the PowerShell PID and file, network, or DNS events for the same PID. $investigate_4 $investigate_5
 98  - Hint: if PID recovery failed in the prior step, scope follow-on review manually with `host.id`, `user.id`, and a tight alert window; that fallback is broader, so prioritize timestamps and script-linked paths or destinations. Missing file or network telemetry is unresolved, not benign.
 99  - Implication: escalate when scoped activity spawns shells, writes scripts or binaries, reaches rare destinations, or stages persistence; lower suspicion when telemetry shows no effects outside the same recognized script workflow.
100- If local findings remain suspicious or unresolved, is this part of broader obfuscated PowerShell activity?
101  - Focus: related alerts for `user.id` in the last 48 hours to test whether this obfuscation follows the actor. $investigate_0
102  - Hint: if the user view is sparse or shared, pivot to `host.id` related alerts in the last 48 hours to test whether obfuscated PowerShell, encoded command, download, or persistence alerts stay localized to the asset. $investigate_1
103  - Implication: escalate scope when the same user or host shows repeated obfuscated PowerShell or adjacent suspicious behavior; keep scope local when the alert is isolated and local evidence resolves to one recognized workflow.
104- Based on script reconstruction, string use, origin, launch context, effects, and broader scope, what disposition is supported?
105  - Escalate on string use supporting hidden execution, download, payload handling, persistence, or unresolved high-risk fragments. Close only when reconstruction, origin, launch/effects if available, and related-alert scope bind the alert to one exact recognized workflow; with mixed or incomplete evidence, preserve artifacts and escalate.
106
107### False positive analysis
108
109- Internal templating, packaging, or configuration-generation scripts may concatenate many string literals to build arguments, paths, or output text. Confirm recovered `powershell.file.script_block_text`, reconstructed string family, `file.path`, `user.id` plus `host.id` scope, and any launch context align with one recognized repository, build, deployment, or administrative workflow. Without repository or change context, do not rely on recurrence alone; close only when local telemetry proves the exact benign workflow.
110- Compatibility wrappers or vendor-protected administrative scripts may fragment helper names, module paths, or destination strings at runtime. Confirm reconstructed strings map to recognized internal domains, script paths, module functions, or vendor helpers and side effects stay inside that workflow. Without vendor notes or admin records, do not rely on recurrence alone; close only when local telemetry proves the exact helper workflow.
111- Before creating an exception, anchor it on stable `file.path`, reconstructed string family, `host.id` or `user.id`, and recovered launch context when available. Avoid exceptions on alert-local `Esql.script_block_pattern_count`, `Esql.script_block_length`, `Esql.script_block_tmp`, `user.name`, or `powershell.file.script_block_text` alone.
112
113### Response and remediation
114
115- If confirmed benign, record the evidence that proved the workflow: reconstructed strings, recovered `file.path`, user-host scope, launch context when available, and lack of contradictory side effects. Then reverse temporary containment. Create an exception only when that evidence pattern is narrow enough to avoid suppressing lookalike obfuscation; recurrence strengthens the case but is not required when local proof is complete.
116- If suspicious but unconfirmed, preserve the alert record, source 4104 events, full `powershell.file.script_block_text`, `powershell.file.script_block_id`, `powershell.sequence`, `powershell.total`, reconstructed strings, `file.path`, `host.id`, `user.id`, and any recovered process, file, DNS, or destination artifacts before containment or cleanup.
117- If suspicious but unconfirmed, apply reversible containment tied to the findings, such as heightened monitoring, outbound restrictions, or temporary PowerShell controls on the affected host or account. Escalate to host isolation only when launch context or follow-on activity indicates likely payload execution or spread.
118- If confirmed malicious, record recovered process identifiers, command lines, parent context, script fragments, reconstructed strings, staged files, and destination indicators before isolation, process termination, or suspension. Then isolate the endpoint when host role allows and restrict the affected account if identity misuse is evident.
119- Review related hosts and users for the same reconstructed string family, `file.path`, origin pattern, and destination indicators before removing artifacts so scoping completes before evidence is destroyed.
120- 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 the obfuscated PowerShell execution.
121- Post-incident hardening: retain Script Block Logging and endpoint telemetry that enabled reconstruction, restrict PowerShell where it is not required, and record any telemetry gaps that limited reconstruction or containment."""
122
123setup = """## Setup
124
125PowerShell Script Block Logging must be enabled to generate the events used by this rule (e.g., 4104).
126Setup instructions: https://ela.st/powershell-logging-setup
127"""
128
129[rule.investigation_fields]
130field_names = [
131    "@timestamp",
132    "host.name",
133    "host.id",
134    "user.id",
135    "file.path",
136    "process.pid",
137    "powershell.file.script_block_text",
138    "powershell.file.script_block_id",
139    "powershell.sequence",
140    "powershell.total",
141    "powershell.file.script_block_entropy_bits",
142    "powershell.file.script_block_surprisal_stdev",
143    "Esql.script_block_length",
144    "Esql.script_block_pattern_count"
145]
146
147[transform]
148
149[[transform.investigate]]
150label = "Alerts associated with the user"
151description = ""
152providers = [
153  [
154    { excluded = false, field = "event.kind", queryType = "phrase", value = "signal", valueType = "string" },
155    { excluded = false, field = "user.id", queryType = "phrase", value = "{{user.id}}", valueType = "string" }
156  ]
157]
158relativeFrom = "now-48h/h"
159relativeTo = "now"
160
161[[transform.investigate]]
162label = "Alerts associated with the host"
163description = ""
164providers = [
165  [
166    { excluded = false, field = "event.kind", queryType = "phrase", value = "signal", valueType = "string" },
167    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" }
168  ]
169]
170relativeFrom = "now-48h/h"
171relativeTo = "now"
172
173[[transform.investigate]]
174label = "Script block fragments for the same script"
175description = ""
176providers = [
177  [
178    { excluded = false, field = "powershell.file.script_block_id", queryType = "phrase", value = "{{powershell.file.script_block_id}}", valueType = "string" },
179    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" }
180  ]
181]
182relativeFrom = "now-1h"
183relativeTo = "now"
184
185[[transform.investigate]]
186label = "Process start events for the PowerShell PID"
187description = ""
188providers = [
189  [
190    { excluded = false, field = "event.category", queryType = "phrase", value = "process", valueType = "string" },
191    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
192    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
193  ]
194]
195relativeFrom = "now-1h"
196relativeTo = "now"
197
198[[transform.investigate]]
199label = "Child process activity from the PowerShell instance"
200description = ""
201providers = [
202  [
203    { excluded = false, field = "process.parent.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" },
204    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
205    { excluded = false, field = "event.category", queryType = "phrase", value = "process", valueType = "string" },
206    { excluded = false, field = "event.type", queryType = "phrase", value = "start", valueType = "string" }
207  ]
208]
209relativeFrom = "now-1h"
210relativeTo = "now"
211
212[[transform.investigate]]
213label = "File, network, and DNS events for the PowerShell PID"
214description = ""
215providers = [
216  [
217    { excluded = false, field = "event.category", queryType = "phrase", value = "file", valueType = "string" },
218    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
219    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
220  ],
221  [
222    { excluded = false, field = "event.category", queryType = "phrase", value = "network", valueType = "string" },
223    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
224    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
225  ],
226  [
227    { excluded = false, field = "event.category", queryType = "phrase", value = "dns", valueType = "string" },
228    { excluded = false, field = "host.id", queryType = "phrase", value = "{{host.id}}", valueType = "string" },
229    { excluded = false, field = "process.pid", queryType = "phrase", value = "{{process.pid}}", valueType = "string" }
230  ]
231]
232relativeFrom = "now-1h"
233relativeTo = "now"
234
235[[rule.threat]]
236framework = "MITRE ATT&CK"
237
238[[rule.threat.technique]]
239id = "T1027"
240name = "Obfuscated Files or Information"
241reference = "https://attack.mitre.org/techniques/T1027/"
242
243[[rule.threat.technique.subtechnique]]
244id = "T1027.010"
245name = "Command Obfuscation"
246reference = "https://attack.mitre.org/techniques/T1027/010/"
247
248[[rule.threat.technique]]
249id = "T1140"
250name = "Deobfuscate/Decode Files or Information"
251reference = "https://attack.mitre.org/techniques/T1140/"
252
253[rule.threat.tactic]
254id = "TA0005"
255name = "Defense Evasion"
256reference = "https://attack.mitre.org/tactics/TA0005/"
257
258[[rule.threat]]
259framework = "MITRE ATT&CK"
260
261[[rule.threat.technique]]
262id = "T1059"
263name = "Command and Scripting Interpreter"
264reference = "https://attack.mitre.org/techniques/T1059/"
265
266[[rule.threat.technique.subtechnique]]
267id = "T1059.001"
268name = "PowerShell"
269reference = "https://attack.mitre.org/techniques/T1059/001/"
270
271[rule.threat.tactic]
272id = "TA0002"
273name = "Execution"
274reference = "https://attack.mitre.org/tactics/TA0002/"

Triage and analysis

Investigating Potential PowerShell Obfuscation via String Concatenation

Possible investigation steps

  • What does the alert-local summary show about the concatenation pattern and where it appears in the preserved script block?
    • Focus: alert-local Esql.script_block_pattern_count, Esql.script_block_length, Esql.script_block_tmp, and powershell.file.script_block_text.
    • Implication: escalate sooner when repeated matches sit near execution, download, decode, or persistence logic; lower suspicion when they resolve to inert configuration or output text, but do not close until the full script block and origin are checked.
  • Is the full script block reconstructed before interpretation?
    • Focus: source 4104 events in logs-windows.powershell_operational* for host.id and powershell.file.script_block_id, ordered by powershell.sequence against powershell.total. $investigate_2
    • Implication: escalate when fragments add hidden stages, payload material, or omitted execution context; missing sequence fragments are unresolved because omitted text may contain the decisive string use.
  • What do the concatenated strings reconstruct to, and do they feed execution?
    • Focus: reconstructed powershell.file.script_block_text, quoted fragments around each match, and statistical cues from powershell.file.script_block_entropy_bits and powershell.file.script_block_surprisal_stdev.
    • Implication: escalate when strings reveal fragmented keywords, URLs or domains, paths, registry keys, encoded blobs, .NET reflection names, or decode inputs feeding invocation, download, file write, or persistence; lower suspicion when they remain inert data construction inside one stable script pattern.
  • Does the source event and origin context explain who ran the script and from where?
    • Focus: recovered file.path, user.id, and host.id, plus whether file.path is absent.
    • Hint: absent file.path after source-event recovery reduces origin provenance because the script may be interactive, pasted, or memory-only; require stronger corroboration before benign closure.
    • Implication: escalate when execution is fileless or from temp, downloads, profiles, mounted shares, or other user-writable locations under an unexpected identity; lower suspicion only when origin, user, host, and string use match one recognized automation or build workflow.
  • If endpoint process telemetry is available, does launch context support benign automation or abuse?
    • Focus: recover process.pid from the 4104 event, then match host.id and the alert window in endpoint process-start events for process.entity_id, process.command_line, and process.parent.executable. $investigate_3
    • Hint: anchor process starts to @timestamp; if PID reuse creates multiple or distant matches, keep launch context unresolved. Without endpoint process telemetry, bound downstream checks to host.id, user.id or user.name, and the alert window; missing launch telemetry is unresolved, not benign.
    • Implication: escalate when PowerShell starts from Office, a browser, an archive extractor, a LOLBin, a remote context, or a service context with encoded or fileless delivery; lower suspicion when the launch chain and command line match the same recognized automation workflow.
  • Did the recovered process or host-window activity retrieve, stage, or execute follow-on content?
    • Focus: child process starts from the PowerShell PID and file, network, or DNS events for the same PID. $investigate_4 $investigate_5
    • Hint: if PID recovery failed in the prior step, scope follow-on review manually with host.id, user.id, and a tight alert window; that fallback is broader, so prioritize timestamps and script-linked paths or destinations. Missing file or network telemetry is unresolved, not benign.
    • Implication: escalate when scoped activity spawns shells, writes scripts or binaries, reaches rare destinations, or stages persistence; lower suspicion when telemetry shows no effects outside the same recognized script workflow.
  • If local findings remain suspicious or unresolved, is this part of broader obfuscated PowerShell activity?
    • Focus: related alerts for user.id in the last 48 hours to test whether this obfuscation follows the actor. $investigate_0
    • Hint: if the user view is sparse or shared, pivot to host.id related alerts in the last 48 hours to test whether obfuscated PowerShell, encoded command, download, or persistence alerts stay localized to the asset. $investigate_1
    • Implication: escalate scope when the same user or host shows repeated obfuscated PowerShell or adjacent suspicious behavior; keep scope local when the alert is isolated and local evidence resolves to one recognized workflow.
  • Based on script reconstruction, string use, origin, launch context, effects, and broader scope, what disposition is supported?
    • Escalate on string use supporting hidden execution, download, payload handling, persistence, or unresolved high-risk fragments. Close only when reconstruction, origin, launch/effects if available, and related-alert scope bind the alert to one exact recognized workflow; with mixed or incomplete evidence, preserve artifacts and escalate.

False positive analysis

  • Internal templating, packaging, or configuration-generation scripts may concatenate many string literals to build arguments, paths, or output text. Confirm recovered powershell.file.script_block_text, reconstructed string family, file.path, user.id plus host.id scope, and any launch context align with one recognized repository, build, deployment, or administrative workflow. Without repository or change context, do not rely on recurrence alone; close only when local telemetry proves the exact benign workflow.
  • Compatibility wrappers or vendor-protected administrative scripts may fragment helper names, module paths, or destination strings at runtime. Confirm reconstructed strings map to recognized internal domains, script paths, module functions, or vendor helpers and side effects stay inside that workflow. Without vendor notes or admin records, do not rely on recurrence alone; close only when local telemetry proves the exact helper workflow.
  • Before creating an exception, anchor it on stable file.path, reconstructed string family, host.id or user.id, and recovered launch context when available. Avoid exceptions on alert-local Esql.script_block_pattern_count, Esql.script_block_length, Esql.script_block_tmp, user.name, or powershell.file.script_block_text alone.

Response and remediation

  • If confirmed benign, record the evidence that proved the workflow: reconstructed strings, recovered file.path, user-host scope, launch context when available, and lack of contradictory side effects. Then reverse temporary containment. Create an exception only when that evidence pattern is narrow enough to avoid suppressing lookalike obfuscation; recurrence strengthens the case but is not required when local proof is complete.
  • If suspicious but unconfirmed, preserve the alert record, source 4104 events, full powershell.file.script_block_text, powershell.file.script_block_id, powershell.sequence, powershell.total, reconstructed strings, file.path, host.id, user.id, and any recovered process, file, DNS, or destination artifacts before containment or cleanup.
  • If suspicious but unconfirmed, apply reversible containment tied to the findings, such as heightened monitoring, outbound restrictions, or temporary PowerShell controls on the affected host or account. Escalate to host isolation only when launch context or follow-on activity indicates likely payload execution or spread.
  • If confirmed malicious, record recovered process identifiers, command lines, parent context, script fragments, reconstructed strings, staged files, and destination indicators before isolation, process termination, or suspension. Then isolate the endpoint when host role allows and restrict the affected account if identity misuse is evident.
  • Review related hosts and users for the same reconstructed string family, file.path, origin pattern, 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 the obfuscated PowerShell execution.
  • Post-incident hardening: retain Script Block Logging and endpoint telemetry that enabled reconstruction, restrict PowerShell where it is not required, and record any telemetry gaps that limited reconstruction or containment.

Related rules

to-top