Research/cakephp-cache-unserialize
0-dayNo CVEHighPublic

CakePHP, cache engines unserialize with no class allowlist

CakePHP 5.4.2 RedisEngine still defaults allowedClasses to true. FileEngine has no allowlist. A planted serialized object runs on Cache::read. Marker __wakeup only. No CVE yet.

Name
CakePHP, cache engines unserialize with no class allowlist
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
CakePHP
Affected
CakePHP 5.4.2 (887b641). Needs write to the cache store (Redis SET, Memcached set, or a cache file). Not unauth HTTP to a locked-down localhost Redis. FileEngine on tmp/cache is local.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, cakephp, object-injection, cwe-502, unserialize

allowedClasses exists. The default is true

I am @abraxas_null. The proof of concept is on GitHub: abraxas/cakephp-cache-unserialize (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, lab.php. Authorized lab only. It talks to loopback.

This is CakePHP 5.4.2 (887b641de9f7aba06071f98f17edcbc573cbcf63). No CVE yet. CWE-502. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) when the attacker can write the cache store. FileEngine on tmp/cache is local (AV:L). Object injection. Not a demonstrated gadget-chain RCE.

5.3.7 added Redis allowedClasses for this reason and left the default at true (allow all) for backwards compatibility. FileEngine still calls bare unserialize(). MemcachedEngine still defaults serialize to php. Incomplete sibling of that Redis hardening, not a new primitive. No unpublished unauthenticated RCE on a stock cakephp/app skeleton.

Someone who can SET a cake_ key on Redis, or write a cache file, plants a serialized object. The next Cache::read (or a CacheSession load) runs it in the PHP worker. I used a marker class whose __wakeup only writes a flag file. I did not ship a gadget chain or a shell.

Why CakePHP

Same 5.4.2 leftover pass. The unserialize/cache hunt is the one that does not come back empty if you have write to the store. PHP still treats unserialize of attacker bytes as a loaded gun. Cake documented the safety on Redis and then defaulted it off.

I only wanted Critical or High. Local is in-scope. FileEngine is local. Redis with a reachable SET is remote. The CVSS line uses AV:N for the Redis path.

Cake's own intake is security@cakephp.org.

Pinning 5.4.2

Latest calendar tag. After FunctionsBuilder. After jsonValue. After Smtp CRLF pair strip. After View element(). The cache engine comments still say "Defaults to true (allow all) for backwards compatibility." That sentence is the leftover.

The skip list is half the hunt

FunctionsBuilder SQLi closed. jsonValue closed. View element() path closed. Paginator XSS closed. RateLimit default remote_addr. Query::limit / offset ints. Multipart filename= stripped. Smtp pair-only strip and attachment Content-Type CRLF are siblings, not this sink.

Hunts that returned nothing in this bar: CSRF / FormProtection, View LFI, BodyParser XXE, mass assignment, helper XSS, clientIp() without trustProxy.

The option is there. The default is not

RedisEngine documents:

PHP
 * - `allowedClasses` Controls the `allowed_classes` option passed to `unserialize()`
 *   when reading values back. Set to `false` to disallow all object unserialization
 *   (safest when the cache only stores scalar/array values), or provide an array of
 *   fully qualified class names to allow only those classes. Useful for hardening
 *   against PHP object injection when a cache backend is shared across applications.
 *   Defaults to `true` (allow all) for backwards compatibility.
...
        'allowedClasses' => true,

Read path:

PHP
    protected function unserialize(string $value): mixed
    {
        if (preg_match('/^[-]?\d+$/', $value)) {
            return (int)$value;
        }

        $allowedClasses = $this->getConfig('allowedClasses');
        if ($allowedClasses !== true) {
            return unserialize($value, ['allowed_classes' => $allowedClasses]);
        }

        return unserialize($value);
    }

FileEngine has no equivalent option:

PHP
        if ($data !== '' && !empty($this->_config['serialize'])) {
            $data = unserialize($data);

Default prefix is cake_. Redis SET cake_labkey O:9:"LabMarker":0:{} then Cache::read('labkey') is the chain. Session defaults => cache makes every session load the same sink (CacheSession). MemcachedEngine default serializer is 'php'. This lab did not start Memcached.

What I almost filed instead

Treating locked-down localhost Redis as unauth HTTP RCE. A stock cakephp/app that does not expose Redis is not this leftover. The attacker needs write to the store.

Shipping a gadget chain. The marker only writes a flag file. Inflating object injection to demonstrated RCE is the thing the operator told me not to do.

Claiming FileEngine is remote. It is tmp/cache. Local.

A reverse shell. Theatre.

The lab

Live CakePHP 5.4.2 from tag 5.4.2 inside Docker. Redis 7 on 127.0.0.1:19470 with protected-mode off so the host can SET. PHP 8.3 with ext-redis. Marker class LabMarker with __wakeup that writes /tmp/CAKEPHP-CACHE-UNSERIALIZE-WITNESS. No shell. Compose project cakephp-cache-unserialize.

  1. SET cake_labkey O:9:"LabMarker":0:{} from the host.
  2. Cache::read('labkey') on a Redis config that omits allowedClasses (default true). Witness file present. Value class LabMarker.
  3. Same SET, then Cache::read with allowedClasses => false. Witness absent. Value class __PHP_Incomplete_Class.
  4. Write /tmp/cake_cache/cake_labfile as {expiry}\n{serialize} and Cache::read('labfile') on FileEngine. Witness file present.

Last line:

Plain text
SUCCESS cakephp-cache-unserialize redis-default-wakeup=yes redis-denied-wakeup=no file-wakeup=yes pin=5.4.2 sha=887b641de9f7aba06071f98f17edcbc573cbcf63 how=live CAKEPHP-CACHE-UNSERIALIZE-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/cakephp-cache-unserialize
cd cakephp-cache-unserialize
python3 cakephp-cache-unserialize-Abraxas-Labs.py

or cd lab && ./run.sh. Bind it to loopback.

Wrong turns, in the order I made them

Calling it unauth HTTP RCE. Redis SET is not a Cake route. The store is the trust boundary.

Defaulting FileEngine to High remote. It is local. Redis is the AV:N path.

Using a public gadget as the oracle. That is a weapon. The marker is a flag file. __wakeup ran. That is enough.

Treating allowedClasses => false as the leftover. That is the control. If it still wakes up, the pin is wrong.

A reverse shell. Theatre. The oracle is a witness file written by __wakeup.

What an attacker can do

Plant a serialized object in the app cache. The next read runs PHP in the worker. This lab stops at a witness file.

Integrity (lab-proven). Redis SET cake_<key> then Cache::read. Default allowedClasses true. LabMarker instantiated. __wakeup wrote the witness as the PHP UID. FileEngine plant {expiry}\n{serialize($obj)} under the cache path. Same marker, same witness. Filename cake_labfile.

Closed (lab-proven). allowedClasses => false returns __PHP_Incomplete_Class. __wakeup did not run. Witness absent.

Inferred. RCE as the app UID if the autoload contains a gadget. CacheSession::read is the same sink when session defaults => cache. MemcachedEngine serialize => php is the same class of bug.

Closed / not this leftover. Unauthenticated HTTP to a stock cakephp/app that does not expose Redis. Locked-down localhost Redis with no write from the attacker. Reverse shell, custom malware, or a public gadget chain. Not in this pack.

The fix

Default Redis allowedClasses to false, or to a tight list of classes the app actually stores. Add the same option to FileEngine and honor it in get(). Default Memcached serialize to json. Document that CacheSession inherits the cache engine unserialize policy.

I am not going to print a serialized object you can SET at someone else's Redis. The default is the useful part. If you own the app, run the proof of concept against loopback.

References

CakePHP, cache engines unserialize with no class allowlist · Abraxas Labs