{"id":3312,"date":"2025-03-13T14:53:38","date_gmt":"2025-03-13T12:53:38","guid":{"rendered":"https:\/\/certitude.consulting\/blog\/?p=3312"},"modified":"2025-03-13T14:53:39","modified_gmt":"2025-03-13T12:53:39","slug":"bolt-cms","status":"publish","type":"post","link":"https:\/\/certitude.consulting\/blog\/en\/bolt-cms\/","title":{"rendered":"CVE-2025-25599: A Cautionary Tale of Insecure Temporary Files"},"content":{"rendered":"\n<p>During a security assessment of <a href=\"https:\/\/boltcms.io\/\" data-type=\"link\" data-id=\"https:\/\/boltcms.io\/\">Bolt<\/a>, an open-source content management system, it was discovered that temporary files are used <em>insecurely<\/em> when uploading an avatar from a URL, leading to arbitrary file disclosure (<a href=\"https:\/\/cve.mitre.org\/cgi-bin\/cvename.cgi?name=CVE-2025-25599\" data-type=\"link\" data-id=\"https:\/\/cve.mitre.org\/cgi-bin\/cvename.cgi?name=CVE-2025-25599\">CVE-2025-25599<\/a>):<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"605\" height=\"170\" src=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-1.png\" alt=\"\" class=\"wp-image-3401\" srcset=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-1.png 605w, https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-1-300x84.png 300w\" sizes=\"auto, (max-width: 605px) 100vw, 605px\" \/><\/figure>\n\n\n\n<p>The <a href=\"https:\/\/github.com\/bolt\/core\/blob\/6b8b1ee340e2b5966b3deef410f3f25e52417482\/src\/Controller\/Backend\/Async\/UploadController.php#L81\" data-type=\"link\" data-id=\"https:\/\/github.com\/bolt\/core\/blob\/6b8b1ee340e2b5966b3deef410f3f25e52417482\/src\/Controller\/Backend\/Async\/UploadController.php#L81\">source code<\/a> for the backend function which handles such requests is listed below. Note the four lines <strong>highlighted<\/strong>; we can see that the backend performs these steps in the following order:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>A temporary folder is created<\/li>\n\n\n\n<li>The file pointed to by the URL is downloaded into the temporary folder<\/li>\n\n\n\n<li>The function <code>handleUpload<\/code> is executed<\/li>\n\n\n\n<li>The temporary file is deleted<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>\/**\n * @Route(\"\/upload-url\", name=\"bolt_async_upload_url\", methods={\"POST\"})\n *\/\npublic function handleURLUpload(Request $request): Response\n{\n    try {\n        $this-&gt;validateCsrf('upload');\n    } catch (InvalidCsrfTokenException $e) {\n        return new JsonResponse(&#91;\n            'error' =&gt; &#91;\n                'message' =&gt; 'Invalid CSRF token',\n            ],\n        ], Response::HTTP_FORBIDDEN);\n    }\n\n    $url = $request-&gt;get('url', '');\n    $filename = basename($url);\n\n    $locationName = $request-&gt;get('location', '');\n    $path = $request-&gt;get('path') . $filename;\n    $folderpath = $this-&gt;config-&gt;getPath($locationName, true, 'tmp\/');\n    $target = $this-&gt;config-&gt;getPath($locationName, true, 'tmp\/' . $path);\n\n    try {\n        \/\/ Make sure temporary folder exists\n        <strong><mark class=\"has-inline-color has-vivid-red-color\">$this-&gt;filesystem-&gt;mkdir($folderpath);<\/mark><\/strong>\n        \/\/ Create temporary file\n        <strong><mark class=\"has-inline-color has-vivid-red-color\">$this-&gt;filesystem-&gt;copy($url, $target);<\/mark><\/strong>\n    } catch (Throwable $e) {\n        return new JsonResponse(&#91;\n            'error' =&gt; &#91;\n                'message' =&gt; $e-&gt;getMessage(),\n            ],\n        ], Response::HTTP_BAD_REQUEST);\n    }\n\n    $file = new UploadedFile($target, $filename);\n    $bag = new FileBag();\n    $bag-&gt;add(&#91;$file]);\n    $request-&gt;files = $bag;\n\n    <strong><mark class=\"has-inline-color has-vivid-red-color\">$response = $this-&gt;handleUpload($request);<\/mark><\/strong>\n\n    \/\/ The file is automatically deleted. It may be that we don't need this.\n    <strong><mark class=\"has-inline-color has-vivid-red-color\">$this-&gt;filesystem-&gt;remove($target);<\/mark><\/strong>\n\n    return $response;\n}<\/code><\/pre>\n\n\n\n<p>Looking at the <a href=\"https:\/\/github.com\/bolt\/core\/blob\/6b8b1ee340e2b5966b3deef410f3f25e52417482\/src\/Controller\/Backend\/Async\/UploadController.php#L130\" data-type=\"link\" data-id=\"https:\/\/github.com\/bolt\/core\/blob\/6b8b1ee340e2b5966b3deef410f3f25e52417482\/src\/Controller\/Backend\/Async\/UploadController.php#L130\">source code<\/a> for <code>handleUpload<\/code>, we see that before setting a new avatar, various validation checks are carried out against the file size, extension, and contents. If all checks pass, only then is the user\u2019s avatar updated.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>...SNIP...<\/em>\n$acceptedFileTypes = array_merge($this-&gt;config-&gt;getMediaTypes()-&gt;toArray(), $this-&gt;config-&gt;getFileTypes()-&gt;toArray());\n$maxSize = $this-&gt;config-&gt;getMaxUpload();\n\n$uploadHandler-&gt;addRule(\n    'extension',\n    &#91;'allowed' =&gt; $acceptedFileTypes],\n    'The file for field \\'{label}\\' was &lt;u&gt;not&lt;\/u&gt; uploaded. It should be a valid file type. Allowed are &lt;code&gt;' . implode('&lt;\/code&gt;, &lt;code&gt;', $acceptedFileTypes) . '.',\n    'Upload file'\n);\n\n$uploadHandler-&gt;addRule(\n    'size',\n    &#91;'size' =&gt; $maxSize],\n    'The file for field \\'{label}\\' was &lt;u&gt;not&lt;\/u&gt; uploaded. The upload can have a maximum filesize of &lt;b&gt;' . $this-&gt;textExtension-&gt;formatBytes($maxSize) . '&lt;\/b&gt;.',\n    'Upload file'\n);\n\n$uploadHandler-&gt;addRule(\n    'callback',\n    &#91;'callback' =&gt; &#91;$this, 'checkJavascriptInSVG']],\n    'It is not allowed to upload SVG\\'s with embedded Javascript.',\n    'Upload file'\n);\n\n$uploadHandler-&gt;setSanitizerCallback(function ($name) {\n    return $this-&gt;sanitiseFilename($name);\n});\n<em>...SNIP...<\/em><\/code><\/pre>\n\n\n\n<p>So, what is the issue? It turns out that the temporary folder is <code>[BOLT_PATH]\/public\/files\/tmp<\/code>, which, as the name suggests, is <em>publicly accessible<\/em> (e.g. <a href=\"http:\/\/localhost:8001\/files\/tmp\/\">http:\/\/example.com\/files\/tmp\/<\/a>). This means that for the few milliseconds they exist, <em>temporary files may be downloaded<\/em>. Furthermore, avatar URLs are not properly validated, and it is possible to \u201cupload\u201d local files using the <code>file:\/\/<\/code> protocol, e.g. <a href=\"\/\/\/etc\/passwd\">file:\/\/\/etc\/passwd<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"535\" height=\"223\" src=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-7.png\" alt=\"\" class=\"wp-image-3411\" srcset=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-7.png 535w, https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-7-300x125.png 300w\" sizes=\"auto, (max-width: 535px) 100vw, 535px\" \/><\/figure>\n\n\n\n<p>Either by reviewing the source code, or by monitoring the temporary folder, we can determine the naming convention for temporary files. In the case of <code>\/etc\/passwd<\/code>, a temporary file called <code>avatarspasswd<\/code> is created.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>root@host:\/opt\/bolt\/public\/files\/tmp# while &#91; 1==1 ]; do ls; done \navatarspasswd \navatarspasswd \navatarspasswd \navatarspasswd \navatarspasswd \n^C<\/code><\/pre>\n\n\n\n<p>This means that if an attacker were able to time the request correctly, they could download the file from <a href=\"http:\/\/localhost:8001\/files\/tmp\/avatarspasswd\">http:\/\/example.com\/files\/tmp\/avatarspasswd<\/a>. To demonstrate this exploit manually, we can first set up the following BurpSuite Intruder attack to spam download attempts:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"605\" height=\"225\" src=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-2.png\" alt=\"\" class=\"wp-image-3402\" style=\"width:840px;height:auto\" srcset=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-2.png 605w, https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-2-300x112.png 300w\" sizes=\"auto, (max-width: 605px) 100vw, 605px\" \/><\/figure>\n\n\n\n<p>Next, intercept a request to upload an avatar from <code>file:\/\/\/etc\/passwd<\/code> and send it to Repeater:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"605\" height=\"385\" src=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-3.png\" alt=\"\" class=\"wp-image-3403\" style=\"width:840px;height:auto\" srcset=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-3.png 605w, https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-3-300x191.png 300w\" sizes=\"auto, (max-width: 605px) 100vw, 605px\" \/><\/figure>\n\n\n\n<p>With both ready, we can go ahead and start the Intruder attack, then spam the upload request. The goal is to <em>send a request to download the temporary file before it gets deleted<\/em>. If the attack was successfull, there will be a few responses with a <code>200 OK<\/code> status code, containing the contents of <code>\/etc\/passwd<\/code>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"658\" src=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-4.png\" alt=\"\" class=\"wp-image-3405\" srcset=\"https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-4.png 1024w, https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-4-300x193.png 300w, https:\/\/certitude.consulting\/blog\/wp-content\/uploads\/2025\/03\/image-4-768x494.png 768w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Conclusion<\/strong><\/h2>\n\n\n\n<p>Patches addressing this issue on the <a href=\"https:\/\/github.com\/bolt\/core\/releases\/tag\/5.1.25\" data-type=\"link\" data-id=\"https:\/\/github.com\/bolt\/core\/releases\/tag\/5.1.25\">5.1.x<\/a> and <a href=\"https:\/\/github.com\/bolt\/core\/releases\/tag\/5.2.2\" data-type=\"link\" data-id=\"https:\/\/github.com\/bolt\/core\/releases\/tag\/5.2.2\">5.2.x<\/a> branches were released on <strong>2025-03-11<\/strong>. Our full advisory is available <a href=\"https:\/\/certitude.consulting\/advisories\/CSA_2025_0001_Bolt_Bolt_Arbitrary_File_Read.md.txt\" data-type=\"link\" data-id=\"https:\/\/certitude.consulting\">here<\/a>.<\/p>\n\n\n\n<p>A Docker container for testing, as well as a proof of concept script for reproducing this exploit is available on our <a href=\"https:\/\/github.com\/Certitude-Consulting\/CVE-2025-25599\" data-type=\"link\" data-id=\"https:\/\/github.com\/Certitude-Consulting\/CVE-2025-25599\">GitHub<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>During a security assessment of Bolt, an open-source content management system, it was discovered that temporary files are used insecurely when uploading an avatar from a URL, leading to arbitrary file disclosure (CVE-2025-25599): The source code for the backend function which handles such requests is listed below. Note the four lines highlighted; we can see [&hellip;]<\/p>\n","protected":false},"author":18,"featured_media":3414,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[60,103],"tags":[645,647,649,651],"class_list":["post-3312","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-technical-analysis","category-vulnerability-research-en","tag-bolt","tag-cms","tag-file-upload","tag-local-file-disclosure"],"_links":{"self":[{"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/posts\/3312","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/users\/18"}],"replies":[{"embeddable":true,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/comments?post=3312"}],"version-history":[{"count":7,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/posts\/3312\/revisions"}],"predecessor-version":[{"id":3415,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/posts\/3312\/revisions\/3415"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/media\/3414"}],"wp:attachment":[{"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/media?parent=3312"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/categories?post=3312"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/certitude.consulting\/blog\/wp-json\/wp\/v2\/tags?post=3312"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}