window: Fingerprinting Browser Extensions through Page-Visible Execution Traces and InteractionsAccording to the Google Chrome source code and the Mozilla documentation, a browser extension can inject JavaScript into the Web page visited by the user as early as the first script to be executed by the browser, when injected at document_start.
However, we observe that the injection and execution of JavaScript code injected by browser extensions with respect to the execution of the Web page JavaScript actually depend on:
First, we observe distinct behavior between JavaScript injection from the content script. That is, when an extension injects a piece of code into the page from their content script, the execution order depends on whether it was injected inline, using the innerText property, or through the src property of the script element.
var t = document.createElement('script');
t.innerText = "console.log('Script Injected Successfully!');";
(document.head || document.documentElement).appendChild(t);
src property executes only after some Web page JavaScript has executed, thus, not being able to obtain the clean references to built-in APIs.var s = document.createElement('script');
s.src = chrome.runtime.getURL('inject.js');
(document.head || document.documentElement).appendChild(s);
Importantly, inline code injection is only possible with extensions adhering to the MV2 standards and while this is forbidden in the MV3 standards.
Secondly, if an extension injects JavaScript from their background/service worker (using the scripting/tabs API), the injected script behaves identical to the script injected by the content script through the src property of the script element, as shown above.
// Manifest V2
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
chrome.tabs.executeScript(tabId=tabId, {
allFrames: true,
code: "console.log('Script Injected Successfully!');",
runAt: "document_start"
, });
});
// Manifest V3:
let x = function() { console.log('Script Injected Successfully!');}
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
chrome.scripting.executeScript({
target: { tabId: tabId },
function: x,
world: 'MAIN',
});
});
We demonstrate this with a minimal set of extensions (with MV2 & MV3 standards) with script injection capabilities, as shown above. To verify above claims, please do the following:
MV2-test-extension extension in your browser available in this folder.Array.prototype.forEach in our test page for example purposes.MV3-test-extension.The MV3 extension standards allow developers to specify the namespace in which the content scripts should execute. This is possible by supplying the world attribute with either MAIN or ISOLATED as their value for individual content scripts. The corresponding script executes in the same namespace when supplied with MAIN, otherwise it executes in an ISOLATED namespace.
"content_scripts": [
{
"matches": [
"<all_urls>",
],
"js": [
"contentScript.js"
],
"run_at": "document_start",
"all_frames": true,
"match_about_blank": true,
"world": "MAIN" // or "ISOLATED"
}
]
An extension with a content script marked to be injected into the MAIN world, executes before any page JavaScript, similar to the inline scripts in the MV2 standards above. Thus, it is possible for extension developers to inject at least one content script in the MAIN world to freeze the native definition of global JavaScript APIs, before any page JavaScript executes. This way, the attacker is no more able to hook into the JavaScript APIs.
Object.freeze(Array.prototype);
Object.freeze(String.prototype);
...
Note: The world attribute is optional in the manifest and the default value is ISOLATED.