CVE-2026-26980 — Ghost CMS Content API unauthenticated blind SQLi lab
A Docker-based exploitation lab for CVE-2026-26980, an unauthenticated blind SQL injection in Ghost CMS's Content API (versions before 6.19.1). The bug lives in slug-filter-order.js, where slug values from the array-notation filter syntax (filter=slug:['value']) are interpolated unsanitized into an ORDER BY CASE statement, letting NQL's single-quote-wrapped literals smuggle raw SQL past input validation; since MySQL treats || as OR and empty strings as falsy, attackers get a clean boolean oracle to extract data (e.g. admin email/password hash) from any Ghost site using only the publicly embedded Content API key. The repo includes exploit.py for full setup/verify/extract, a docker-compose lab spinning up both vulnerable (6.18.0) and patched (6.19.1) Ghost instances, and also mentions a second patched bug (jsonpath/static-eval prototype pollution in the {{#get}} Handlebars helper leading to RCE via themes).
What it is
CVE-2026-26980, an unauthenticated blind SQL injection in Ghost CMS's Content API, fixed in 6.19.1. The repo is a Docker lab that stands up a vulnerable 6.18.0 instance and a patched 6.19.1 instance side by side, plus exploit.py to verify and extract data. The summary has the mechanism; what follows is the concrete signal, the extraction constraint, the fix shape, and the commands to run it.
How it works
The oracle is an error-based boolean, not a timing one. The injected CASE returns either a valid integer or EXP(710), which overflows in MySQL and throws — so a TRUE condition yields HTTP 200 and a FALSE yields 500. The tool's phase-1 output confirms this: CASE WHEN 1=1: 200 (TRUE) / CASE WHEN 1=0: 500 (FALSE).
One extraction constraint the summary omits: slugFilterOrder splits on commas, so payloads use SUBSTR((...) FROM pos FOR len) rather than comma syntax to slice out characters during binary search, e.g.:
slug:['||CASE WHEN ORD(SUBSTR((SELECT email FROM users LIMIT 1) FROM 1 FOR 1)) > 64 THEN 0 ELSE EXP(710) END||',news]The fix replaces interpolation with parameterised bindings — WHEN \${table}\.\slug\ = ? THEN ? with bindings.push(slug.trim(), index) — threaded through orderByRaw in crud.js, and bumps @tryghost/bookshelf-plugins to 0.6.29 for binding support.
What to do
Patch to Ghost 6.19.1 or later. To reproduce the lab:
git clone <this-repo> && cd ghost-cve-2026-26980
pip install -r requirements.txt
docker compose up -d
# wait ~45s for Ghost to initialise
python3 exploit.py --url http://localhost:2368Confirm the patch blocks it against the fixed instance:
docker compose --profile fixed up -d
python3 exploit.py --url http://localhost:2369 --validate-fixexploit.py flags worth knowing: --extract-password (admin bcrypt hash), --extract-api-key (admin API secret), --content-key KEY to skip setup against an external target, -v for per-query oracle results. validate.sh wraps the full end-to-end run; tear down with docker compose --profile fixed down -v.
Caveats
The lab confines itself to CVE-2026-26980; the second bug patched in 6.19.1 (jsonpath/static-eval prototype-pollution RCE via the {{#get}} Handlebars helper) is described but not exploited here, since it requires a malicious theme and so is less useful to an external attacker than the unauthenticated SQLi.
Organisations named here
- CWE-89
CVEs named here
- CVE-2026-26980
Technologies named here
- Docker / Docker Compose
- Ghost CMS
- exploit.py
- @tryghost/bookshelf-plugins
- validate.sh
- requests
- MySQL
- Python
- Docker
Techniques named here
- NQL filter quote-passthrough bypass
- Boolean-based blind SQLi oracle via || and EXP
- Comma-avoidance via SUBSTR
- Binary-search blind data extraction
- Prototype traversal to Function via jsonpath/static-eval
Related in AppSec
Flowise MCP Security Bypass Enables RCE
Inside an Oracle Database SQL Injection Attack | Huntress
OASIS
