· par · 9 min de lecture

PowerShell -EncodedCommand : onze façons d’écrire le même paramètre


%COMSPEC% /b /c start /b /min powershell -nop -w hidden -encodedcommand …

C’est la ligne que lance le module psexec_psh de Cobalt Strike, telle que The DFIR Report l’a relevée dans une intrusion BlackSuit publiée en août 2024. Le même rapport décrit aussi une commande PowerShell encodée utilisée pour activer RDP sur un serveur de fichiers. Rien d’exotique : c’est le PowerShell encodé le plus banal qui soit.

Face à ça, la première règle qui vient à l’esprit ressemble à CommandLine contains "-encodedcommand". Elle attrape la ligne ci-dessus. Elle rate presque tout le reste.

Encoder, ce n’est pas chiffrer

-EncodedCommand prend un script encodé en Base64, et pas n’importe lequel : la documentation de pwsh précise que la chaîne doit représenter du texte en UTF-16LE. Microsoft présente le paramètre comme une solution aux problèmes de guillemets imbriqués, et c’est vrai. Un administrateur qui pousse un script via un outil de déploiement a de bonnes raisons de l’utiliser.

Pour un attaquant, l’intérêt est ailleurs. La commande n’est pas lisible d’un coup d’œil dans un SIEM, les mots-clés comme DownloadString ou IEX disparaissent de la ligne de commande, et une recherche texte naïve passe à côté. Mais il n’y a aucun secret : n’importe qui décode ça en une ligne. L’encodage ne protège pas contre un SOC. Il protège contre un analyste pressé et contre une règle trop littérale.

Le lab : onze variantes, onze exécutions

La documentation liste trois formes du paramètre : -EncodedCommand, -e et -ec. J’ai voulu savoir ce que PowerShell accepte vraiment, alors j’ai installé pwsh 7.6.7 (le build Linux arm64 officiel) et passé la même charge avec onze écritures différentes du paramètre. La charge est inoffensive : Write-Output "purple test", encodée en UTF-16LE.

Sortie d’un terminal : onze lancements de pwsh avec -EncodedCommand, -enc, -en, -e, -ec, -encod, /enc, /e et trois tirets Unicode (U+2013, U+2014, U+2015). Chaque ligne affiche « purple test », donc toutes les variantes ont exécuté la charge.
Les onze variantes exécutent la charge sur pwsh 7.6.7. Sortie réelle, capture : PurpleLog.

Toutes passent. Les abréviations (-en, -enc, -encod), la barre oblique à la place du tiret, et même trois tirets Unicode : le demi-cadratin –, le cadratin — et la barre horizontale ―. Ce dernier point est celui qui fait mal, parce qu’un copier-coller depuis un document Word ou un mail produit exactement ces caractères, et qu’une règle écrite avec un tiret ASCII ne les verra jamais.

Une limite honnête : j’ai testé pwsh 7 sur Linux, pas Windows PowerShell 5.1 sur un vrai Windows. La documentation de powershell.exe confirme que la barre oblique fonctionne depuis cmd.exe, mais je n’ai pas vérifié les tirets Unicode sur 5.1. Si vous avez une VM Windows sous la main, le test prend deux minutes. Je partirais du principe que ça marche aussi, plutôt que l’inverse.

Ce que les logs voient vraiment

Avant d’écrire la moindre règle, il faut savoir où regarder. La même exécution laisse des traces très différentes selon la source.

Schéma : une exécution « powershell.exe -nop -w hidden -enc » et ce qu’en voient quatre sources. Security 4688 sans ligne de commande : seulement le nom du processus, rien d’exploitable. Security 4688 avec l’option GPO : la ligne de commande avec la charge encodée. Sysmon EID 1 : ligne de commande, OriginalFileName, ParentImage et hashes. PowerShell 4104 : le script lisible, Write-Output “purple test”.
La même exécution vue par quatre sources de logs. Schéma : PurpleLog.

L’événement 4688 du journal Security ne contient la ligne de commande que si deux réglages sont actifs : la stratégie d’audit « Audit Process Creation », et le paramètre « Include command line in process creation events », désactivé par défaut. Sans le second, vous savez que powershell.exe a démarré, point.

Sysmon (événement 1, ProcessCreate) donne la ligne de commande complète, le nom de fichier d’origine inscrit dans les métadonnées du binaire (OriginalFileName), le processus parent et les hashes. C’est la source que je préfère pour ce cas, à égalité avec l’EDR.

Et puis il y a l’événement 4104, le Script Block Logging. Une fois activé (GPO « Turn on PowerShell Script Block Logging »), PowerShell enregistre le contenu de tous les blocs de script qu’il traite, donc le script tel qu’il s’exécute, déjà décodé. Pour le tri, c’est de loin le plus utile. Microsoft recommande d’ailleurs d’y associer le Protected Event Logging, parce que des identifiants qui traînent dans un script finissent aussi dans le journal.

La règle Sigma

Voici la version que je garde. Elle a passé sigma check sans erreur ni avertissement, avec les données ATT&CK v20 embarquées par pySigma.

title: PowerShell lancé avec une commande encodée (préfixes de -EncodedCommand)
id: c90a658a-4717-40f5-afb7-074a7ce0ba97
status: experimental
description: |
  Détecte powershell.exe ou pwsh.exe lancé avec -EncodedCommand ou l'une de ses
  abréviations (-e, -ec, -en, -enc…), y compris avec / ou un tiret Unicode.
references:
  - https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_pwsh
  - https://attack.mitre.org/techniques/T1059/001/
author: PurpleLog
date: 2026-10-17
tags:
  - attack.execution
  - attack.t1059.001
  - attack.stealth
  - attack.t1027.010
logsource:
  category: process_creation
  product: windows
detection:
  selection_img:
    - Image|endswith:
        - '\powershell.exe'
        - '\pwsh.exe'
    # Un powershell.exe renommé garde son nom d'origine dans les métadonnées PE
    - OriginalFileName:
        - 'PowerShell.EXE'
        - 'pwsh.dll'
  selection_cli:
    # windash étend « - » à /, –, — et ―
    CommandLine|windash|contains:
      - ' -e '
      - ' -ec '
      - ' -en'
  condition: all of selection_*
falsepositives:
  - Agents de déploiement et de supervision qui passent leurs scripts encodés (SCCM, Intune, certains RMM)
level: medium

Deux choix méritent une explication. Le modificateur windash génère tout seul les variantes avec / et les trois tirets Unicode : pas besoin de les écrire à la main, et pas de risque d’en oublier un. Et la vérification sur OriginalFileName couvre le cas d’un binaire copié sous un autre nom.

Un détail que j’ai découvert en vérifiant les tags : dans ATT&CK v20, la tactique TA0005 ne s’appelle plus « Defense Evasion » mais « Stealth ». Une règle taguée attack.defense-evasion remonte désormais un avertissement dans sigma check. La version précédente de cet article avait exactement ce défaut.

Converti avec le pipeline microsoft_xdr de pySigma, ça donne cette requête Defender XDR (sortie brute, sans retouche) :

DeviceProcessEvents
| where ((FolderPath endswith "\\powershell.exe" or FolderPath endswith "\\pwsh.exe") or (ProcessVersionInfoOriginalFileName in~ ("PowerShell.EXE", "pwsh.dll"))) and (ProcessCommandLine contains " -e " or ProcessCommandLine contains " /e " or ProcessCommandLine contains " –e " or ProcessCommandLine contains " —e " or ProcessCommandLine contains " ―e " or ProcessCommandLine contains " -ec " or ProcessCommandLine contains " /ec " or ProcessCommandLine contains " –ec " or ProcessCommandLine contains " —ec " or ProcessCommandLine contains " ―ec " or ProcessCommandLine contains " -en" or ProcessCommandLine contains " /en" or ProcessCommandLine contains " –en" or ProcessCommandLine contains " —en" or ProcessCommandLine contains " ―en")

Le piège du « -en »

J’aurais pu m’arrêter là. Puis j’ai écrit un petit script pour rejouer la logique de la règle sur des lignes de commande légitimes, et le motif ' -en' m’a sauté au visage.

Sortie d’un script de test sur douze lignes de commande. Les sept lignes malveillantes sont détectées par la règle Sigma et par la regex. Parmi les cinq lignes légitimes, la règle Sigma déclenche à tort sur « -File deploy.ps1 -Environment Prod » et « -File backup.ps1 -Engine fast », la regex ne déclenche sur aucune.
Même jeu de test pour la règle Sigma et pour la regex : deux faux positifs pour Sigma, aucun pour la regex. Sortie réelle, capture : PurpleLog.

powershell.exe -File deploy.ps1 -Environment Prod déclenche. -Engine fast aussi. Tout paramètre de script qui commence par « en » fait sonner la règle, et dans un parc avec des scripts d’administration, il y en a forcément.

La correction tient en une idée : un vrai -EncodedCommand est suivi d’un long blob Base64. On exige donc les deux. C’est faisable en KQL et en SPL avec une regex, et la regex passe le même jeu de test sans aucun faux positif :

DeviceProcessEvents
| where Timestamp > ago(7d)
| where FileName in~ ("powershell.exe", "pwsh.exe")
    or ProcessVersionInfoOriginalFileName in~ ("PowerShell.EXE", "pwsh.dll")
// préfixe de -EncodedCommand (avec -, /, –, — ou ―) suivi d'au moins 20 caractères Base64
| where ProcessCommandLine matches regex @"(?i)\s[-/–—―]e(c|n[a-z]*)?\s+[A-Za-z0-9+/=]{20,}"
| extend Payload = extract(@"(?i)\s[-/–—―]e(?:c|n[a-z]*)?\s+([A-Za-z0-9+/=]{20,})", 1, ProcessCommandLine)
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName,
    InitiatingProcessParentFileName, ProcessCommandLine, Payload
index=windows sourcetype=XmlWinEventLog source="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1
    (Image="*\\powershell.exe" OR Image="*\\pwsh.exe" OR OriginalFileName="PowerShell.EXE" OR OriginalFileName="pwsh.dll")
| regex CommandLine="(?i)\s[-/–—―]e(c|n[a-z]*)?\s+[A-Za-z0-9+/=]{20,}"
| table _time host User ParentImage CommandLine

Soyons clairs sur ce qui a été testé. La regex, oui : en Python, sur les douze lignes de la capture. Les noms de tables et de champs ont été vérifiés dans la référence DeviceProcessEvents et dans la documentation de l’add-on Sysmon de Splunk. Mais je n’ai pas lancé ces deux requêtes sur un vrai tenant ni sur un vrai Splunk pour cet article. Faites-le sur sept jours de données avant de les passer en règle.

Ma préférence, si votre SIEM le permet : garder la règle Sigma pour la portabilité et la couverture, et utiliser la version regex comme règle d’alerte. Le volume n’est pas le même.

Trier l’alerte

Quand elle sonne, voici l’ordre dans lequel je regarde, et pourquoi.

Le parent d’abord. Un PowerShell encodé lancé par un agent de déploiement connu, sur un serveur d’administration, à l’heure habituelle : c’est probablement du bruit à documenter et à exclure proprement. Le même lancé par winword.exe, wmiprvse.exe ou un service fraîchement créé (l’événement 7045 du journal System, celui que The DFIR Report a utilisé pour BlackSuit), c’est une autre histoire.

Ensuite, la charge. Et là, une erreur classique : décoder la chaîne comme si c’était de l’UTF-8. Voici ce que ça donne avec la charge de test :

>>> raw = base64.b64decode(blob)
>>> raw[:24]
b'W\x00r\x00i\x00t\x00e\x00-\x00O\x00u\x00t\x00p\x00u\x00t\x00'
>>> raw.decode("utf-16-le")
'Write-Output "purple test"'

Un octet nul toutes les deux lettres : c’est la signature de l’UTF-16LE. En PowerShell, la bonne méthode est [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($b64)). Dans CyberChef, enchaîner From Base64 puis Decode text en UTF-16LE.

Puis le 4104, s’il existe. La charge décodée n’est souvent qu’un premier étage : un IEX sur un DownloadString, ou un autre blob Base64 décompressé en mémoire. Le Script Block Logging montre chaque étage au fur et à mesure qu’il s’exécute. C’est beaucoup plus fiable que de dérouler la chaîne à la main.

Enfin, le reste de la machine. Connexions réseau du processus, fichiers créés, tâches planifiées. Une commande encodée isolée qui écrit « hello » dans un fichier temporaire ne mérite pas une escalade. Une commande encodée suivie d’une connexion sortante vers une IP jamais vue, si.

Ce qui reste ouvert

Cette règle détecte un paramètre, pas une intention. Un attaquant qui passe son script par -Command avec un [Convert]::FromBase64String à l’intérieur, ou qui lit le script depuis l’entrée standard, ne la déclenchera pas. C’est pour ça que le 4104 compte plus que n’importe quelle règle sur la ligne de commande.

Et j’aimerais vraiment savoir ce que donnent les tirets Unicode sur Windows PowerShell 5.1. Si quelqu’un fait le test avant moi, je suis preneur du résultat.

Références