
PoC pour CVE-2026-54350 — Budibase injection d'opérateur NoSQL non authentifié (CVSS 10.0). Lire/écrire en masse toute collection de documents via une requête PUBLIQUE.
Injection d'opérateur JSON/NoSQL non authentifiée dans Budibase. Tout visiteur anonyme d'une application Budibase publiée qui expose une requête PUBLIQUE soutenue par une source de données documentaire (MongoDB, CouchDB, Elasticsearch, DynamoDB-PartiQL, ou REST avec un corps JSON) peut lire chaque document de la collection sous-jacente et, si une requête d'écriture publique existe, modifier chaque document — avec une seule requête HTTP non authentifiée.
| CVE | CVE-2026-54350 |
| Advisory | GHSA-8qv3-p479-cj62 |
| CVSS 3.1 | 10.0 — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N |
| CWE | CWE-943 (Mauvaise neutralisation des éléments spéciaux dans une requête de données), CWE-89 |
| Auth | Aucune (non authentifiée) |
| Affected | Budibase <= 3.39.0 (périmètre de l'avis du fournisseur) |
| Fixed | 3.39.12 selon NVD ; les guillemets injectés sont déjà neutralisés à partir de 3.39.1, et le correctif explicite (processJsonStringSync) arrive dans 3.39.9 |
| Verdict | CONFIRMÉ — reproduit de bout en bout sur 3.39.0 |
Lors de l'exécution d'une requête, Budibase enrichit les paramètres utilisateur dans le corps JSON brut de la requête avec Handlebars, puis analyse (JSON.parse) le résultat :
enrichContext() packages/server/src/sdk/workspace/queries/queries.ts
enrichedQuery.json = processStringSync('{"name":"{{ name }}"}', { name: <user> }, { noEscaping: true })
enrichedQuery.json = JSON.parse(enrichedQuery.json)
Sur les builds affectés, le paramètre est interpolé sans échappement JSON, donc une valeur contenant un " ferme la chaîne prévue et injecte des clés sœurs dans l'objet analysé. Le seul filtre d'entrée, validateQueryInputs() (api/controllers/query/index.ts), rejette uniquement les marqueurs Handlebars ({{, }}) — il ne touche pas à ", \, } ou $.
Pour un find MongoDB, l'objet analysé est passé directement à collection.find() (integrations/mongodb.ts). Une clé name en double dont la valeur est un objet opérateur gagne lors de la fusion JSON.parse, transformant une égalité de chaîne en {$exists: true} et renvoyant toute la collection. La même primitive contre une requête updateMany élargit le filtre à chaque document.
Le contrôle d'accès est contourné car authorized() (middleware/authorized.ts:141) fait un court-circuit avec return next() lorsque le rôle de ressource de la requête est PUBLIC, sautant à la fois l'authentification de session et le CSRF. L'en-tête x-budibase-app-id nécessaire pour atteindre la requête est public — il fait partie de chaque URL d'application publiée.
Voir ANALYSIS.md pour la description complète au niveau du code et l'investigation des limites de version.
Corps du modèle de requête (tel que configuré dans le builder) :
{"name":"{{ name }}"}
Valeur injectée pour le paramètre name :
zzz","name":{"$exists":true},"$comment":"cve-2026-54350
Après interpolation et JSON.parse (fusion de clés en double, la dernière gagne) :
{ "name": { "$exists": true }, "$comment": "cve-2026-54350" }
$comment est un méta-opérateur MongoDB inerte qui absorbe le "} final du modèle afin que le corps entier reste un JSON valide.
exploit.py — bibliothèque standard Python 3 uniquement, aucune dépendance.
# Dump the whole collection through a PUBLIC read (find) query
python3 exploit.py --url http://target --app-id app_<published> \
--query-id query_<...> --mode read
# Modify every document through a PUBLIC updateMany query
python3 exploit.py --url http://target --app-id app_<published> \
--query-id query_<...> --mode write
--app-id et --query-id sont des valeurs publiques observées dans le trafic API de l'application publiée elle-même. --field remplace la clé JSON à laquelle le paramètre est lié (par défaut --param).
Nécessite Docker. Ce laboratoire utilise --network host (aucun réseau pont supposé) et mongo:4.4 (l'hôte n'a pas d'AVX ; MongoDB 5.0+ en nécessite).
bash lab/setup.sh
# prints PROD_APP_ID / READ_QUERY / UPDATE_QUERY, then:
python3 exploit.py --url http://127.0.0.1 --app-id <PROD_APP_ID> \
--query-id <READ_QUERY> --mode read
lab/provision.py utilise uniquement les API légitimes du constructeur — il n'affaiblit aucun paramètre par défaut. Marquer le rôle d'accès d'une requête comme PUBLIC est une fonctionnalité de premier ordre du constructeur (les applications publiées exposent les données exactement via ce mécanisme). Exécution complète capturée dans EVIDENCE.txt.
Le NVD liste la plage affectée comme < 3.39.12. Empiriquement, le vecteur de guillemets nus n'est exploitable que sur <= 3.39.0, ce qui est exactement la plage indiquée dans l'avis GHSA du fournisseur. À partir de 3.39.1, la couche de templating échappe déjà les guillemets injectés (vérifié : la même requête contre 3.39.8 est neutralisée), et 3.39.9 introduit le correctif explicite processJsonStringSync. Cette preuve de concept cible donc 3.39.0, la dernière version dans la plage affectée du fournisseur.
>= 3.39.12, ou au minimum >= 3.39.9 où processJsonStringSync échappe les paramètres interpolés au format JSON).PUBLIC ; restreignez-les aux rôles authentifiés lorsque possible.Recherchez les requêtes POST /api/v2/queries/<id>, provenant de sessions non authentifiées, dont les valeurs parameters contiennent un " suivi d'un opérateur MongoDB préfixé par $ ($exists, $ne, $gt, $where, $regex, ...) ou une clé JSON en double. Sur MongoDB, activez le profilage et alertez lorsqu'un paramètre de requête normalement une chaîne arrive sous forme d'objet.
Recherche et preuve de concept par Caio Fabrício (@BiiTts).