
Detailed analysis and proof-of-concept for CVE-2023-41564, a stored XSS vulnerability in Cockpit CMS <=2.6.3, with code-level root cause and mitigation guidance.
CVE-2023-415641 is a stored XSS vulnerability in the CMS Cockpit (<= v2.6.3).
When a .shtml HTML file is uploaded during file upload, JavaScript is executed the moment the uploaded file is opened.
1: https://nvd.nist.gov/vuln/detail/CVE-2023-41564
First, we will explain the attack method based on the PoC2 published by the person believed to be the vulnerability reporter.
First, log in to the site with an account that has file upload permissions.
Then upload an shtml file like the following:
Copy the URL of the uploaded file (Asset).
When you access the copied link, the script described in the above HTML is executed.
2: https://github.com/LongHair00/Mitre_opensource_report/blob/main/CockpitCMS-StoredXSS.md
From here, I will explain the results of my further investigation.
The Assets upload process starts with a POST request to the /assets/upload path.
The upload request processing is defined in line 513 of modules/Assets/bootstrap.php.
The code is shown below:
// assets api
$this->module('assets')->extend([
// omitted
'upload' => function(string|array $param = 'files', array $meta = [], bool $isUpload = true) {
$files = [];
if (is_string($param) && isset($this->app->request->files[$param])) {
$files = $this->app->request->files[$param];
} elseif (is_array($param) && isset($param['name'], $param['error'], $param['tmp_name'])) {
$files = $param;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$uploaded = [];
$failed = [];
$_files = [];
$assets = [];
$allowed = $this->app->retrieve('assets/allowed_uploads', '*');
$allowed = $allowed == '*' ? true : str_replace([' ', ','], ['', '|'], preg_quote(is_array($allowed) ? implode(',', $allowed) : $allowed));
$max_size = $this->app->retrieve('assets/max_upload_size', 0);
$forbiddenExtension = ['bat', 'exe', 'sh', 'php', 'phar', 'phtml', 'phps', 'htm', 'html', 'xhtml', 'htaccess'];
$forbiddenMime = [
'application/x-httpd-php', 'application/x-php', 'text/x-php',
'text/html', 'application/xhtml+xml'
];
if (isset($files['name']) && is_array($files['name'])) {
$cnt = count($files['name']);
for ($i = 0; $i < $cnt; $i++) {
$_file = $this->app->path('#tmp:').'/'.$files['name'][$i];
$_mime = $finfo->file($files['tmp_name'][$i]);
$_isAllowed = $allowed === true ? true : preg_match("/\.({$allowed})$/i", $_file);
$_sizeAllowed = $max_size ? filesize($files['tmp_name'][$i]) < $max_size : true;
$extension = strtolower(pathinfo(parse_url($_file, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!$extension) {
$_isAllowed = false;
}
// prevent uploading php / html files
if ($_isAllowed && (
in_array($extension, $forbiddenExtension) ||
in_array(strtolower($_mime), $forbiddenMime)
)) {
$_isAllowed = false;
}
if (!$files['error'][$i] && $_isAllowed && $_sizeAllowed && ($isUpload ? move_uploaded_file($files['tmp_name'][$i], $_file) : rename($files['tmp_name'][$i], $_file))) {
$_files[] = $_file;
$uploaded[] = $files['name'][$i];
if (\preg_match('/\.(svg|xml)$/i', $_file)) {
file_put_contents($_file, \SVGSanitizer::clean(\file_get_contents($_file)));
}
} else {
$failed[] = $files['name'][$i];
}
}
}
if (count($_files)) {
$assets = $this->add($_files, $meta, isset($meta['_id']));
foreach ($_files as $file) {
unlink($file);
}
}
return ['uploaded' => $uploaded, 'failed' => $failed, 'assets' => $assets];
},
// omitted
]);
The upload function is stored in a member variable of the App class that holds functions of the Assets module, called registry['modules']['assets'].
Therefore, when calling the upload function, it is called as $this->module('assets')->upload('file', $file);.
The upload function registered here is called by the Assets class4.
public function upload() {
this->helper('session')->close();
this->hasValidCsrfToken(true);
if (!this->isAllowed('assets/upload')) {
return $this->stop(['error' => 'Upload not allowed'], 401);
}
$meta = ['folder' => $this->param('folder', '')];
return $this->module('assets')->upload('files', $meta);
}
As you can see, the upload function registered in registry['modules']['assets'] is being called.
Now let's look at the upload function described in bootstrap.php.
Here, forbidden extensions are defined in the $forbiddenExtension variable.
However, the shtml extension is missing.
Therefore, this is a flaw, making it possible to upload shtml files.
3: https://github.com/Cockpit-HQ/Cockpit/blob/2.6.3/modules/Assets/bootstrap.php#L51
4: https://github.com/Cockpit-HQ/Cockpit/blob/2.6.3/modules/Assets/Controller/Assets.php#L140
Since the cause is that shtml is not forbidden, it can be mitigated by adding shtml to forbiddenExtension.
$forbiddenExtension = ['bat', 'exe', 'sh', 'php', 'phar', 'phtml', 'phps', 'htm', 'html', 'xhtml', 'htaccess', 'shtml'];