
CVE-2023-415641는 Cockpit이라는 CMS(<= v2.6.3)에 존재하는 저장형 XSS 취약점입니다.
파일 업로드 시 .shtml 확장자를 가진 HTML 파일을 업로드하면, 그 업로드된 파일을 여는 순간 JavaScript가 실행되어 버리는 것입니다.
1: https://nvd.nist.gov/vuln/detail/CVE-2023-41564
먼저 취약점 보고자로 보이는 사람이 공개한 PoC2를 기반으로 공격 방법을 설명하겠습니다.
먼저 파일 업로드 권한이 있는 계정으로 사이트에 로그인합니다.
그런 다음 아래와 같은 shtml 파일을 업로드합니다.
업로드된 파일(Asset)의 URL을 복사합니다.
복사한 링크에 접속하면 위 HTML에 기술된 스크립트가 작동합니다.
2: https://github.com/LongHair00/Mitre_opensource_report/blob/main/CockpitCMS-StoredXSS.md
여기서부터는 제가 더 깊이 파고들어 조사한 결과를 설명하겠습니다.
Assets 업로드 처리는 /assets/upload 경로에 대한 POST 요청이 시작점입니다.
업로드 요청 처리는 modules/Assets/bootstrap.php의 51번째 줄3에 정의되어 있습니다.
아래에 코드를 보여드립니다.
// assets api
$this->module('assets')->extend([
// 省略
'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];
},
// 省略
]);
upload 함수는 App 클래스의 registry['modules']['assets']라는 Assets 모듈의 함수를 저장하는 멤버 변수에 저장됩니다.
따라서 upload 함수를 호출할 때는 $this->module('assets')->upload('file', $file);처럼 호출합니다.
여기에 등록된 upload 함수는 Assets 클래스4가 호출합니다.
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);
}
registry['modules']['assets']에 등록된 upload 함수가 호출되고 있음을 알 수 있습니다.
여기서 bootstrap.php에 기술된 upload 함수를 살펴보겠습니다.
여기서는 $forbiddenExtension 변수에 금지할 확장자를 정의하고 있습니다.
하지만 shtml 확장자가 빠져 있습니다.
그래서 이 부분이 결함이 되어 shtml 파일 업로드가 가능한 것입니다.
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
shtml이 허용되지 않은 것이 원인이므로 forbiddenExtension에 shtml을 추가하면 대응할 수 있습니다.
$forbiddenExtension = ['bat', 'exe', 'sh', 'php', 'phar', 'phtml', 'phps', 'htm', 'html', 'xhtml', 'htaccess', 'shtml'];