Runbook : reproduction pas à pas (chaîne Salat, du script batch à la configuration C2)
Toutes les commandes se lancent depuis salatstealer-killchain/. Les scripts Python utilisent python3 -I (ou ~/.venvs/ctf-reverse/bin/python -I quand capstone est nécessaire). Rien n'est exécuté : lecture d'octets, désassemblage, calculs hors ligne.
Choix de la cible#
Le tag SalatStealer de MalwareBazaar reçoit des centaines d'échantillons par jour. Les rapports publics décrivent déjà les capacités du voleur, l'angle retenu est donc la chaîne de livraison et la protection de la configuration. Le paramètre limit=1000 de l'API get_taginfo renvoie une erreur 502, limit=500 fonctionne :
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $(cat ../.mb_api_key)" \
-d "query=get_taginfo&tag=SalatStealer&limit=500" -o osint/tag_SalatStealer.json
Acquisition et vérification#
tools/mb_pull.py télécharge par SHA-256 (archive infected), vérifie l'empreinte et renomme en <sha256>.bin :
python3 -I tools/mb_pull.py binaries/tag <sha256> [<sha256> ...]
python3 -I tools/mb_pull.py binaries/salat_upx -f osint/salat_upx_hashes.txt # 204 builds UPX
Les 204 hashs UPX sont ceux de l'étiquette UPX déposés par un même rapporteur (osint/salat_upx_hashes.txt).
Triage statique#
python3 -I tools/triage.py binaries/tag binaries/extracted
Pour chaque fichier : type (PE, UPX, Go, PyInstaller, Rust, .NET), version de Go, chemin de module, nombre de dépendances et drapeaux -X des ldflags. Constats : deux scripts batch, un chargeur Go aux ldflags parlants (main.pass, main.url en base64), des chargeurs Go amd64 sans ldflags, un binaire Go sans symboles, et six conteneurs (.NET, PyInstaller, Rust UPX, NSIS) laissés de côté.
Décodage du script batch#
Le script reconstruit un PE par lignes echo <base64>>> "%b64%". Pour obtenir le PE sans l'exécuter :
grep '^echo [A-Za-z0-9+/=]*>> "%b64%"$' binaries/tag/e855*.bin | sed 's/^echo //; s/>> "%b64%"$//' | tr -d '\n' | base64 -d > binaries/tag/fcaf1f46...bin
# comparaison de deux scripts : 16 octets différents, tous dans le commentaire final ":: <16 hex>"
cmp -l binaries/tag/e855*.bin binaries/tag/792b*.bin | wc -l
Les deux scripts décodés donnent le même PE (fcaf1f46...), absent de MalwareBazaar.
Noms de fonctions dans un binaire Go sans symboles#
Les options -s -w retirent la table de symboles ELF/PE mais pas .gopclntab. tools/go_pclntab_funcs.py lit cette table (Go 1.20 et suivants) et affiche adresse, taille et nom de chaque fonction :
python3 -I tools/go_pclntab_funcs.py binaries/tag/fcaf1f46...bin > osint/fcaf1f46_funcs.txt
grep -E ' (main|5q7z)[./]' osint/fcaf1f46_funcs.txt
Résultat sur le chargeur : main.main appelle uac.RunDefaultUAC, stealth.RunStealth, puis déchiffre un blob CBOR, décompresse en zstd, écrit un exécutable updater_<aléatoire>.exe, le lance, attend, puis télécharge l'URL décodée.
Désassemblage annoté#
tools/go_dis.py désassemble les fonctions dont le nom correspond à une expression régulière et annote chaque appel direct et chaque chaîne Go chargée (motif lea adresse puis mov longueur) :
~/.venvs/ctf-reverse/bin/python -I tools/go_dis.py binaries/tag/fcaf1f46...bin 'main\.main' --calls
Extraction de la charge des chargeurs#
Les chargeurs 5q7z/rogw (386) et stub (amd64) embarquent un blob CBOR {c: [morceaux], n: [nonces de 12 octets]}, chaque morceau scellé en ChaCha20-Poly1305, le clair concaténé étant un flux zstd. La clé de 32 octets est une chaîne hexadécimale du binaire (dans les ldflags pour 5q7z/rogw). tools/extract_loader.py ne présume ni de l'adresse du blob ni de celle de la clé : il localise la map CBOR par ses deux premières clés, essaie chaque chaîne de 64 caractères hexadécimaux et retient celle dont le tag Poly1305 du premier morceau valide :
python3 -I tools/extract_loader.py binaries/extracted binaries/tag/fcaf1f46*.bin
Validation : six chargeurs, six charges (0a9d56f0..., 569d2c40..., 5fcbdcf3..., 2ab8b874..., 06aa319f..., 6321c132...). Le dépaquetage UPX donne e575276b..., cdb7de4b..., 498dc093....
Reconstitution du déchiffrement de la configuration de Salat#
Lecture de main.main, puis main.dec (plusieurs modes selon un argument : AES-GCM, XOR cumulatif, XOR par clé, combinaisons des deux, et un mode qui construit l'entier RSA), main.md5str, main.GetHWID, main.gb, main.getEp :
~/.venvs/ctf-reverse/bin/python -I tools/go_dis.py binaries/salat_unpacked/<sha>.upx-dec.bin 'main\.(dec|md5str|gb|getEp|GetHWID)'
Points de lecture : md5str renvoie les 16 octets bruts (pas l'hexadécimal), donc la clé est de l'AES-128. GetHWID pose sur la pile 49 octets (un octet puis douze mots de 32 bits), auxquels s'ajoute un suffixe hexadécimal de 10 caractères lu dans .rdata : 54 octets, soit 12 de nonce, 26 de clair et 16 de tag GCM. Pour chaque tableau de 129 mots, le premier mot XOR 0xa5a5a5a5 donne la longueur utile. Le cheminement complet est dans l'en-tête de tools/extract_salat_config.py.
Piège rencontré : une version retranscrite à la main de la clé XOR (une lettre cyrillique en minuscule au lieu de la majuscule) fait échouer les tags GCM. La résolution octet par octet (chaque position doit produire des caractères hexadécimaux) l'a révélé. L'extracteur final utilise les octets lus dans le binaire.
Extraction sur les 204 builds#
tools/batch_salat.sh binaries/salat_upx binaries/salat_unpacked osint/salat_configs.jsonl
python3 -I tools/extract_salat_config.py binaries/salat_unpacked/<sha>.upx-dec.bin --json
Les motifs de l'extracteur : tableaux repérés par la séquence lea esi, [adresse] ; mov ecx, 0x81 ; rep movsd, module RSA essayé sur chaque candidat jusqu'à ce que les autres tableaux se déchiffrent, identifiant de build lu dans main.main. Résultat : 204 configurations, un module RSA, un jeu de dix entrées, 13 valeurs de bld.
Décodage des 75 scripts batch#
python3 -I tools/mb_pull.py binaries/bat -f osint/salat_bat_hashes.txt
python3 -I tools/decode_bat.py binaries/bat --csv osint/salat_bat_decoded.csv --out binaries/bat_pe
decode_bat.py concatène les lignes echo <base64>>> "%b64%", décode, hache le PE, et calcule un « squelette » (le script sans son commentaire final). Résultat : un seul PE (fcaf1f46..., 9 851 392 octets), un seul squelette, 75 commentaires distincts.
Paquet d'origine du script batch#
ThreatFox rattache l'URL GitHub de la seconde charge à un échantillon MalwareBazaar de type ZIP :
curl -s -X POST https://threatfox-api.abuse.ch/api/v1/ -H "Auth-Key: $(cat ../.mb_api_key)" -H "Content-Type: application/json" \
-d '{"query":"search_ioc","search_term":"<url de la seconde charge>","exact_match":true}' # champ "reference"
python3 -I tools/mb_pull.py binaries/tag 5c5548382cec74538a7b81454bfe0868c17d60dc0a74944e92e04b04a12eed0c
7z x -o binaries/extracted/zip_5c554838 binaries/tag/5c554838....bin
( cd binaries/extracted/zip_5c554838 && sha256sum -c SHA256SUMS.txt | grep -v ': OK' )
python3 -I tools/decode_bat.py binaries/extracted/zip_5c554838/zapret-discord.bat
Le contrôle des sommes montre service.bat listé mais absent, la ligne de SHA256SUMS.txt lui-même non conforme, et zapret-discord.bat hors liste ; ces deux écarts existent en amont. La comparaison fichier par fichier avec la release légitime donne : 42 fichiers identiques, service.bat retiré, zapret-discord.bat et .salt ajoutés.
curl -s https://api.github.com/repos/Flowseal/zapret-discord-youtube/releases -o osint/raw/upstream/releases.json # release dont la date correspond à celle des fichiers de l'archive (1.9.9d)
curl -sL -o osint/raw/upstream/x.zip <browser_download_url>; 7z x -oosint/raw/upstream/x osint/raw/upstream/x.zip
# puis comparaison des SHA-256 fichier par fichier (os.walk + hashlib)
Le hash de bin/winws.exe est aussi comparé à celui du leurre des binders .NET.
Binders .NET#
ilspycmd -p reconstruit le projet et son .resx (les ressources en base64). tools/xbinder_extract.py lit chaque ressource (4 octets de taille décompressée puis GZip) :
ilspycmd -p -o /tmp/ilspy_<sha> <binder>.exe
python3 -I tools/xbinder_extract.py /tmp/ilspy_<sha>/<espace>.resx binaries/extracted/xbinder_<sha>
La liste List du Program.cs donne, par fichier : nom, lancement, caché, destination (%Temp%) et exclusion Defender (commande PowerShell Add-MpPreference -ExclusionPath). Les deux binders déposent le même Hackmgr.exe (Salat UPX) avec un leurre différent. L'extracteur de configuration est appliqué au fichier dépacké.
RAT Python empaqueté avec PyInstaller#
python3 -I tools/pyi_extract.py binaries/tag/3745e309....bin binaries/extracted/pyi_soundcloud
strings -n 5 binaries/extracted/pyi_soundcloud/sc # entrée 'sc' = script principal (code marshalé, Python 3.14)
L'adresse du C2 et son port sont lus dans les constantes du code marshalé (entier i suivi de quatre octets) : 77.239.96.159, port 4444. Les commandes (STREAM_START, WEBCAM_START, AUDIO_START, GET_PROCS, KILL_PROC:, UPLOAD_FILE::, OPEN_URL::, CMD:) sont des chaînes littérales.
Build Rust#
Les appels directs call rel32 vers une fonction qu'une analyse automatique ne référence pas se retrouvent par balayage des octets de .text ; les débuts de fonction se lisent dans la table .pdata (RUNTIME_FUNCTION). Les chaînes base64 de la structure de configuration se décodent avec base64 -d. tools/decompile_at.py accepte une adresse située dans une fonction.
upx -d dépack le fichier ; les chemins /.cargo/registry/src/... donnent la liste des crates (tungstenite, h3, quinn, rustls, aes-gcm, chacha20poly1305, rusqlite, nokhwa, cpal) et src/*.rs la liste des modules. User-Agent: salat/2.0, tonapi.io et orbs.network relient ce build au repli TON de la version Go.
~/.venvs/ctf-reverse/bin/python -I tools/decompile_refs_range.py <rust.exe> sortie.txt "src/config.rs:0:13" "tonapi.io:30:120"
Exécutables sans symboles ni build info#
Le chargeur b00784d9... (Go amd64 « garblé ») n'a pas de ldflags ni de noms de paquets lisibles, mais l'URL de la seconde charge est en clair dans .rdata : tools/extract_loader.py la relève et la règle Salat_Loader_GitHub_Release_URL la couvre.
Conteneur à pied de page chiffré (TgWsProxy)#
~/.venvs/ctf-reverse/bin/python -I tools/decompile_importers.py <stub>.exe sortie.txt SetFilePointerEx ReadFile CreateFileW GetModuleFileNameW
~/.venvs/ctf-reverse/bin/python -I tools/decompile_at.py <stub>.exe sortie.txt <adresse> [<adresse> ...]
Le stub lit les 16 derniers octets de son propre fichier (offset de l'en-tête, graine, contrôle XOR avec 0x3f8a7d2b), déchiffre l'en-tête par une clé de 32 bits, puis chaque fichier annexé.
Recherche de canaux et de contacts embarqués#
python3 -I tools/scan_channels.py binaries/salat_unpacked binaries/extracted binaries/tag binaries/bat_pe | tee osint/channels_scan.txt
Liens t.me, jetons de bots, webhooks Discord, .onion, courriels, comptes GitHub et noms de domaine. Les jetons sont masqués à l'affichage. Résultat sur 446 fichiers distincts : rien d'exploitable (le bruit vient des bibliothèques Go, Python et OpenSSL). Une recherche de chaînes citées par des rapports publics (nyash, Lightshot, Procmon, RuntimeBroker, alive.php, sniff_domain_list, transferCookie) donne zéro correspondance ; webrat apparaît seulement dans le marqueur de fabrication décrit plus bas, et Glagol est le nom d'un alphabet dans la table Unicode de Go (faux positif).
Marqueur de fabrication et comptes GitHub#
python3 -I tools/extract_pack_marker.py binaries/salat_upx binaries/tag binaries/extracted --csv osint/pack_markers.csv
python3 -I - <<'PY' # comptes distincts, hors libellé flowseal
import csv; print("\n".join(sorted({r["depot"].split("/")[0] for r in csv.DictReader(open("osint/pack_markers.csv")) if r["etiquette"]=="git-root"})))
PY
python3 -I tools/github_accounts_meta.py osint/github_accounts_from_builds.txt osint/raw/github --repos
python3 -I tools/build_github_table.py osint/pack_markers.csv osint/raw/github osint/github_accounts.csv
Le marqueur est dans la zone de remplissage du packer UPX et subsiste après dépaquetage. github_accounts_meta.py appelle uniquement api.github.com/users/<compte> et /repos en anonyme (60 requêtes par heure : l'outil attend la remise à zéro). Aucun fichier de dépôt ni release n'est téléchargé.
Liste des comptes et canaux#
python3 -I tools/build_accounts_channels.py comptes_et_canaux.md
Rassemble les identifiants lus dans les fichiers déjà collectés (GitHub, portefeuilles, domaines, IP, noms cités par des rapports publics) avec leur source et un niveau de confiance. Aucun accès réseau, aucun contact.
Recoupement avec les rapports publics#
python3 -I tools/osint_pivots.py osint/raw/pivots <domaines et adresses> # OTX, urlscan, DNS inverse, Internet Archive
curl -s https://otx.alienvault.com/api/v1/pulses/<id> # contenu d'une impulsion citée
Les rapports lus (CYFIRMA 2025, Solar 4RAYS 2025 et 2026) donnent l'attribution, les tarifs, les domaines de panneau et les portefeuilles. Pour convertir une adresse TON « conviviale » en adresse brute, décoder le base64 URL puis lire l'octet de chaîne et les 32 octets d'adresse, avec la fonction suivante.
import base64
def raw(f):
b = base64.urlsafe_b64decode(f + "=" * (-len(f) % 4))
return "%d:%s" % (int.from_bytes(b[1:2], "big", signed=True), b[2:34].hex())
Suivi des portefeuilles de paiement#
python3 -I tools/ton_wallet_flows.py 0:<adresse> osint/raw/ton/<prefixe>
Appliqué au portefeuille racine, au portefeuille de juin et au portefeuille « conduit ». Les destinataires des sorties du conduit sont étiquetés par GET /v2/accounts/<adresse> (champ name). Une bande de prix est définie par le contenu des entrées (9 à 13 TON ou 14 à 17,5 USD₮) et ne sert qu'à compter des payeurs distincts.
Recherches abuse.ch par indicateur#
curl -s -X POST https://urlhaus-api.abuse.ch/v1/url/ -H "Auth-Key: $(cat ../.mb_api_key)" -d "url=<url>"
curl -s -X POST https://threatfox-api.abuse.ch/api/v1/ -H "Auth-Key: $(cat ../.mb_api_key)" -H "Content-Type: application/json" \
-d '{"query":"search_ioc","search_term":"wruser.org","exact_match":false}'
Le port alternatif wruser.org:992 est saisi dans ThreatFox depuis le 2026-10-06. Le deuxième compte GitHub (EmpressRecognize22) n'y figure pas.
OSINT passif sur les indicateurs#
python3 -I tools/osint_passive.py osint/raw/passive salator.es wruser.org websalat.top sa1atik.cn salat.cn wrat.in policlinika.top \
196.196.208.17 196.196.208.18 195.208.21.65 46.173.27.15 168.222.202.171 217.26.28.234 91.142.75.172 62.109.0.189
python3 -I tools/summarize_passive.py osint/raw/passive osint/infrastructure.csv
Services tiers uniquement : crt.sh, rdap.org, dns.google, ThreatFox, URLhaus, RIPEstat. Le dossier osint/raw/ n'est pas publié (réponses RDAP brutes).
Historique des enregistrements TON#
for d in webratpanel.ton remoteadministratortool.ton webratxaye1.ton shyetootval.ton; do
curl -s "https://tonapi.io/v2/dns/$d" -o osint/ton/info_$d.json # adresse de l'élément NFT et propriétaire
# puis /v2/blockchain/accounts/<élément>/transactions?limit=100 -> osint/ton/tx_$d.json
done
python3 -I tools/ton_dns_history.py osint/ton --csv osint/ton_dns_history.csv
Chaque message change_dns_record portant la clé SHA-256 de wallet donne la valeur écrite. L'adresse est décodée en ASCII après suppression des zéros de remplissage.
Flux publics du portefeuille racine#
python3 -I tools/ton_wallet_flows.py 0:c441f18d6f9ffe8ba3e4f329af8501fb311fab5f0efb7e96d925f791a89e0451 osint/raw/ton/c441f18d6f
Agrégats par mois uniquement dans la sortie ; les adresses de contreparties restent dans osint/raw/ et ne sont pas publiées. Les micro-virements (moins de 0,01) sont écartés.
Règles YARA#
yara -w -r salatstealer.yar binaries/ # couverture sur le corpus
yara -w -r salatstealer.yar /usr/bin # contrôle de faux positifs
Ce qui reste ouvert#
- Contenu des
service.exedes deux comptes GitHub supprimés (ThreatFox classe l'URL du premier compte sous SalatStealer, sans hash de fichier). - Origine de l'adresse de serveur du Salat réécrit en Rust (chaîne remontée jusqu'au fil d'exécution
0x1400e734fqui reçoit la configuration ; aucun champ d'adresse dans la structure, les champs vides sont des options d'installation ; deux autres builds Rust ont le même schéma) : la structure de configuration (fonction0x1400ea1cd) n'en contient pas, et les appels de la résolution TON (0x1400dc26a) n'ont pas été rattachés à une adresse de portefeuille. - Portefeuille de vente actif depuis juillet 2026 (60 payeurs récents examinés avec
tools/ton_next_payments.py, aucun destinataire commun ; le portefeuille de juin se vide dans un portefeuille de conservation inactif depuis le 2026-07-22) ; identité des payeurs. - Aucun canal Telegram, forum ou panneau interrogé (choix, hors périmètre passif).
- Contenu des 158 dépôts thématiques (comparaison avec la release légitime) : seule l'archive référencée par ThreatFox a été comparée.
Les releases des 191 dépôts se lisent avec python3 -I tools/github_releases_meta.py osint/raw/github_releases osint/github_releases.csv (métadonnées seulement, 60 appels par heure en anonyme, environ trois heures pour les 191 dépôts).
La page de la note CERT-UA (cert.gov.ua/article/6287707) se construit en JavaScript : google-chrome-stable --headless=new --disable-gpu --no-sandbox --virtual-time-budget=15000 --dump-dom <url> en sort le texte complet, qu'on réduit ensuite en retirant les balises.
Archives de release des dépôts jetables#
Après accord explicite de téléchargement, les 155 archives .zip ont été récupérées et analysées par python3 -I tools/analyze_release_zips.py osint/github_releases.csv binaries/release_payloads osint/release_zip_analysis.csv (téléchargement par curl -L de https://github.com/<compte>/<dépôt>/releases/download/<tag>/<fichier>, SHA-256 comparé au champ digest, lecture en mémoire, suppression de l'archive, conservation de la charge seule, aucune exécution). La charge se lit à la fin de bin/stun.bin : les 8 derniers octets donnent un décalage, à ce décalage se trouvent GBZAP1, la taille sur 8 octets puis la charge. Chaque charge est dépackée avec upx -d puis passée à tools/extract_salat_config.py --json ; les noms connus de MalwareBazaar se lisent avec get_info sur le SHA-256. Les captures d'écran (screens/) viennent de google-chrome-stable --headless=new --screenshot=... --window-size=1400,<hauteur> --virtual-time-budget=20000 <url> sur des pages publiques.
Les échantillons récents d'une étiquette générique se testent en masse : get_siginfo sur RustyStealer, filtre sur la date et la taille, tools/mb_pull.py, puis recherche des chaînes salat/2.0, src/hwid.rs et tonapi.io (après upx -d si besoin).