Potential PowerShell Obfuscation via Character Array Reconstruction

edit
IMPORTANT: This documentation is no longer updated. Refer to Elastic's version policy and the latest documentation.

Potential PowerShell Obfuscation via Character Array Reconstruction

edit

Detects PowerShell scripts that reconstructs strings from char[] arrays, index lookups, or repeated ([char]NN)+ concatenation/join logic. Attackers use character-array reconstruction to hide commands, URLs, or payloads and evade static analysis and AMSI.

Rule type: esql

Rule indices: None

Severity: high

Risk score: 73

Runs every: 5m

Searches indices from: now-9m (Date Math format, see also Additional look-back time)

Maximum alerts per execution: 100

References: None

Tags:

  • Domain: Endpoint
  • OS: Windows
  • Use Case: Threat Detection
  • Tactic: Defense Evasion
  • Data Source: PowerShell Logs
  • Resources: Investigation Guide

Version: 11

Rule authors:

  • Elastic

Rule license: Elastic License v2

Investigation guide

edit

Triage and analysis

Investigating Potential PowerShell Obfuscation via Character Array Reconstruction

Possible investigation steps

  • What hidden text is reconstructed at the alert-marked sites?
  • Focus: powershell.file.script_block_text, powershell.file.script_block_length, and alert-local Esql.script_block_pattern_count and Esql.script_block_tmp reconstruction markers.
  • Implication: escalate when reconstructed strings reveal execution, download, staging, persistence, credential, or policy-control intent; lower concern only when decoded text is limited to static formatting, localization, or configuration constants in an otherwise readable generated script.
  • Is the full source script block available for pivots?
  • Why: ES|QL preserves alert evidence, but split 4104 events and omitted source fields can hide the decoded action or later pivot keys.
  • Focus: same host.id and powershell.file.script_block_id, ordered by powershell.sequence and checked against powershell.total, with each fragment’s powershell.file.script_block_text. !{investigate{"description":"","label":"Script block fragments for the same script","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"}}
  • Hint: if fragments do not match powershell.total, query logs-windows.powershell_operational* before closing. Record source process.pid and file.path.
  • Implication: escalate when complete reconstruction exposes hidden stages, payload material, or omitted execution context; keep unresolved when missing fragments or source fields could contain the decoded action or pivot key.
  • Which source event and launch context explain this PowerShell?
  • Why: endpoint launch context must be recovered before parentage or process-scoped pivots are trusted.
  • Focus: if endpoint process telemetry is available, use source process.pid plus host.id around @timestamp to recover process.entity_id, process.command_line, and process.parent.executable. !{investigate{"description":"","label":"Process events for the PowerShell PID","providers":[[{"excluded":false,"field":"event.category","queryType":"phrase","value":"process","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"}}
  • Hint: do not require powershell.exe; hosted PowerShell can still produce 4104. If endpoint process telemetry is missing, bound later pivots to host.id, user.id, and a tight alert window.
  • Implication: escalate when the script is fileless, sourced from user-writable or delivery paths, launched by a browser/document/remoting/scheduled-task parent, or run with a command line that does not fit the actor; lower concern only when recovered source and launch evidence identify one generator or updater workflow and decoded intent stays non-executing.
  • Does decoded content add obfuscation, execution, staging, or persistence?
  • Focus: powershell.file.script_block_text, decoded strings, source file.path, and, with endpoint file or registry telemetry, registry.path. Use same-PID artifact events around @timestamp to validate writes or registry changes. !{investigate{"description":"","label":"File and registry events for the PowerShell PID","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":"registry","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"}}
  • Hint: scope file or registry review with recovered process.entity_id or fallback host.id + user.id + tight alert window. Use registry.data.strings only after registry.path points to persistence or policy state.
  • Implication: escalate when decoded content feeds Invoke-Expression, reflection, Base64, decompression, dynamic member access, payload writes, or persistence/policy registry changes; lower concern only when decoded values are static data and available endpoint telemetry does not contradict that. Missing endpoint file or registry telemetry leaves follow-on activity unresolved.
  • Do decoded or recovered process destinations fit the decoded intent?
  • Focus: if endpoint network telemetry is available, DNS lookup_result and connection fields: dns.question.name, dns.resolved_ip, destination.ip, and destination.port.
  • Hint: scope network review with same-PID events around @timestamp, or with recovered process.entity_id where available. Correlate DNS dns.resolved_ip to connection destination.ip. Missing network telemetry is unresolved, not benign. !{investigate{"description":"","label":"Network and DNS events for the PowerShell PID","providers":[[{"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"}}
  • Implication: escalate when decoded URLs, domains, or IPs lead to rare public infrastructure, direct IP access, nonstandard ports, or destinations unrelated to the recovered workflow; lower concern when destinations are internal, proxy, or vendor services aligned with the same generated-script or updater workflow.
  • If local evidence is suspicious or unresolved, does the pattern recur beyond this host or user?
  • Focus: related alerts for the same user.id in the last 48 hours, comparing decoded strings, reconstruction pattern, and file.path. !{investigate{"description":"","label":"Alerts associated with the user","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"}}
  • Hint: if the user view is sparse, pivot to same-host.id alerts and compare decoded indicators or source path. !{investigate{"description":"","label":"Alerts associated with the host","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"}}
  • Implication: broaden scope when the same decoded indicator or reconstruction pattern appears on unrelated hosts or accounts; keep scope local when confined to one recovered workflow. Do not close solely because no prior alerts exist.
  • Escalate for hidden execution, download, staging, persistence, credential, policy-control, or defense-evasion behavior; close only when alert-local and recovered evidence bind one benign generated-script or updater workflow with no contradictions; preserve and escalate when decoding, fragments, or conditional endpoint telemetry remain incomplete.

False positive analysis

  • Generated build, packaging, localization, templating, vendor bootstrap, or updater scripts can legitimately rebuild strings from character codes. Confirm only when decoded content resolves to static data, expected generator output, installation, or update logic; file.path, user.id, and host.id fit that workflow; recovered process.command_line or process.parent.executable supports it; and any available destination evidence reaches vendor, proxy, or internal update services rather than staging infrastructure. Without change or ownership records, use telemetry-only confirmation: the same source path, actor/host scope, and decoded-string purpose recur across prior alerts from this rule. Recurrence can support a future exception, but should not be the primary reason to close the first alert.
  • Before creating an exception, anchor it to indexed alert fields such as user.id, host.id, file-backed file.path, and a tightly bounded powershell.file.script_block_text pattern that represents the confirmed generator or updater. Do not use Esql.script_block_pattern_count or Esql.script_block_tmp in exceptions because they are alert-local summaries rather than stable exception anchors.

Response and remediation

  • If confirmed benign, record the evidence that proved the workflow first: decoded script purpose, validated file.path or repeated fileless pattern, user.id, host.id, and any recovered endpoint process or destination evidence. Then reverse temporary containment and create a narrow exception only after the same workflow recurs consistently.
  • If suspicious but unconfirmed, preserve evidence first: export the alert, source 4104 event, ordered script fragments, decoded strings, source process.pid, and any recovered process.entity_id, command line, parent, file, registry, DNS, or destination artifacts. Apply reversible containment tied to the findings, such as heightened monitoring, temporary destination blocking, or host isolation when the host role allows it, then escalate before deleting artifacts or resetting accounts.
  • If confirmed malicious, preserve the same script, source-event, process, artifact, and destination evidence before destructive actions. Isolate the host when business impact allows, record the malicious PowerShell process.entity_id before termination when it was recovered, block confirmed malicious domains, URLs, IPs, and hashes, and remove only files, registry changes, scheduled tasks, or other persistence tied to the decoded script. Reset credentials only when the investigation shows account misuse beyond local execution.
  • Post-incident hardening: retain PowerShell script-block logging and endpoint telemetry needed for process, file, registry, DNS, and network recovery; constrain PowerShell automation to signed or centrally managed workflows where feasible; record the benign workflow or malicious artifact set so repeat alerts can be handled with the same evidence standard.

Setup

edit

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 query

edit
from logs-windows.powershell_operational* metadata _id, _version, _index
| where event.code == "4104"

// Filter for scripts that contain the "char" keyword using MATCH, boosts the query performance
| where powershell.file.script_block_text : "char"

// 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,
    """(char\[\]\]\(\d+,\d+[^)]+|(\s?\(\[char\]\d+\s?\)\+){2,})""",
    "🔥"
)

// 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,
    powershell.sequence,
    powershell.total,
    _id,
    _version,
    _index,
    host.name,
    host.id,
    agent.id,
    user.id,
    process.pid

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

Framework: MITRE ATT&CKTM