Cross-examination: unveiling JavaScript injection fingerprint masking attempts


Anyone looking at solutions to mask their fingerprint online has likely come across ones employing JavaScript injections: it's a relatively easy and cheap method to use. However, does that mean it is also less secure? According to our research, the answer is simply yes. Join us as we explain its weaknesses and show our detection method – including a piece of code link for you to have a go yourself.

What is masking through JavaScript injection?

There are three main methods of approaching browser fingerprint masking. These are:

  • JavaScript injection

  • Native approach

  • Hybrid of the two above

Info: What do we mean by fingerprinting?

Systems that detect if we are who we say we are collect and build information about us into a distinct fingerprint, a value that defines us as uniquely as possible. Detection systems can ask questions using some browser functionalities, known as APIs. For instance, they can ask the browser you're using, your operating system, which languages it supports, and then combine this to build your fingerprint. If there are inconsistencies or missing information, it's a red flag.

As a brief overview, the native approach uses new browser executables based on vanilla (not already customized) browsers like Chromium. The method replaces certain values of your fingerprint with different ones for masking purposes. As the name implies, it is more genuine and difficult to detect, but requires more time and resources to be sustainable.

As for JavaScript (JS) injection, it is about inserting code into a page to override values of certain attributes. Let's explain it with an analogy. Our research team likes to compare JS injection to a lawyer in a police interrogation. Picture Saul, the son-of-a-gun lawyer in TV's Better Call Saul; he is inserted between the accused and the police, answering the police's questions in the suspect's name – with the aim of hiding their identity. Instead of reflecting who the suspect is, answers reflect someone else they are mimicking. To come full circle, he has overridden certain attributes.

In the same way, JS injection can mask a browser's attributes to make it appear as if it is different to what it actually is.

It is perhaps the most commonly used, as it is cheap and easy, but, as we will show, the trade-off is that it is easily detectable.

By what ways is JS injection deployed?

Fingerprint-masking solutions prefer one of several ways for JS injection. Some adopt it through browser automation frameworks like Selenium and Puppeteer. Others still use browser extensions, perhaps the most convenient way to do so. Our research also shows it would be possible to use mitmproxy, an open-source HTTPS proxy; however, we are not aware of solutions doing this in the wild.

JS Injection attempt via browser automation framework, PuppeteerThe easiest and most feasible method for a solution to perform JS injection, then, is through browser extensions.

How does JS injection through browser extensions work?

As we explained in much greater depth in our previous article, extensions are a vital way to extend the functionality of our browsers. Extensions are made up of a number of elements, as you can see in the diagram below. This includes:

  • manifest.json: the file that describes our extension

  • content script files

  • core made up of background scripts


Testing JS injection in the wild

Introducing our extension

Now for the fun part! Let's introduce our very own browser extension, which we will use as our test guinea pig. Meet Better JS Injection Call - Saul - an artistic solution for fingerprint masking.

For the code-minded, first up we have the heart of our extension, the manifest.json file. It defines all the abilities of our extension.

  "manifest_version": 2,
  "name": "Better JS Injection Call - Saul - A artistic solution for fingerprint masking",
  "version": "1.0.0",
  "content_scripts": [
      "matches": [""],
      "js": ["bettercall.js"],
      "run_at": "document_end",
      "all_frames": true,
      "match_about_blank": true

 Then we have our extension's content script, bettercall.js. It is the only thing we will need to inject our JavaScript code to mask the browser's fingerprint. In the content_script block of our manifest file above (fifth line onwards), you see some instructions that look like gibberish. Essentially, they mean that our content script will be added to all web pages and frames (in other words, some HTML elements that load other webpages or HTML pages inside the webpage we're visiting).

Now to the business logic of our extension. I present you our bettercall.js:

const maskLanguage = (language) => {
  Object.defineProperty(navigator, 'language', {
    get: () => language,

const doMask = (method, ...args) => {  
  const stringifiedMethod = method instanceof Function
    ? method.toString()
    : `() => { ${method} }`;
  const stringifiedArgs = JSON.stringify(args);

  const scriptContent = `

  const scriptElement = document.createElement('script');
  scriptElement.textContent = scriptContent;
  const scriptElement2 = document.createElement('script');
  scriptElement2.textContent = "document.body.innerHTML += 'Injected';";

doMask(maskLanguage, 'eo-Multilogin');

What this means is our extension overrides the navigator.language attribute, changing it from the language we have set to eo-Multilogin. EO is the language symbol of Esperanto, a constructed language designed to make global communication easy for everyone.

This business logic code also adds an "Injected" string to the body of all contexts which are injected, which we will use a flag to help us understand the comprehensiveness of our extension.

Putting it into practice

Let's enable our extension from the chrome://extension menu and see what's happened to our navigator.language.

Our expectation, if injection works, is that our extension will inject itself into all pages that our browser loads, including frames. If it doesn't, then a clever detection system would understand that some properties have been changed.

Let's continue our police interrogation example and picture the frames that the main page we visit loads to interrogation rooms in a police station. Ideally, we would expect our lawyer to be with us throughout the whole investigation, just as we expect full injection...

<iframe width="100" height="55" id="normalFrame" src="http://www.iframetest.test/iframe.html"></iframe><br>
<iframe width="100" height="55" id="aboutBlankFrame" src="about:blank"></iframe><br>
<iframe width="100" height="55" id="dataFrame" src="data:text/html,<html><body></body></html>"></iframe><br>
<iframe width="100" height="55" id="jsFrame" src='javascript:const a=0;'></iframe><br>
<iframe width="100" height="55" id="sandboxFrame" sandbox src="http://www.iframetest.test/iframe.html"></iframe><br>
<iframe width="100" height="55" id="srcdocFrame" srcdoc="<html><head></head><body></body></html>"></iframe><br>

Below we see the results.

As we can see from our handy "Injected" flag, most show injection. However, the frames that use data:// and javascript:// URLs do not. Unfortunately, our hypothetical Saul is a very busy lawyer, and is not with us on all occasions. So, in our interrogation rooms data:// and javascript://, our browser will be answering some questions about itself without its lawyer covering. Let's see how that goes...

Cross-examination with one click

We're all familiar (at least from television) with the police's technique of cross-examination: asking the same questions to the same people or elements in a case again and again and in a different order. Many under suspicion cannot bear it and eventually give themselves away.

It is the same with our poor browser that has found itself without its lawyer. The JavaScript code below begins its cross examination, asking the same questions to the webpage and sub-page that our page loads by using an iframe element.

Here is a page that contains a javascript:// iframe:

<iframe id="jsFrame" src="javascript:console.log('hello');"/></iframe/> 

We can prove that the interrogated one lies after cross-examination. Our content script, despite being designed to inject JavaScript for fingerprint masking, could not be injected itself inside the data:// and javascript:// frames. So the result should have been equal, but is not.

 Look at the bottom left and you will see the second one displays not eo-Multilogin, or Esperanto as we would expect, but en-US. Bullseye!So, after comparing the answers given by the top window's navigator.language and the javascript:// frames, the difference shows that there is an anomaly, and it deserves to be reported as a JavaScript injection attempt. And we come full circle to the beginning, when we highlighted that inconsistencies or anomalies in your fingerprint are an instant red flag.

Over to you: your own experiments

What else might cause the above?

Would you like to try it yourself?

Here is our brand-new code, Cross-Examination, to bust all those trying to mask browser fingerprints through JS injection.

Try it for yourself: click through for the HTML PoC file

Our code collects some pieces of information like the User-Agent string, canvas and audio fingerprint, WebRTC public IP, screen resolution, timezone from both top window and iframe, and generates a hash value from the collected data.

This hashed value is used as a visitor ID for both top window and javascript:// iframe. Our code then compares the visitor IDs obtained from botht he top window and iframe. In the case of different IDs, it reports an anomaly.

N.B. We use javascript:// iframe and not data:// iframe because our experiments show some browser automation frameworks are successful injecting into data:// iframes, but not with javascript:// iframes.

JavaScript injection in popular extensions

In addition to the commercial solutions that adopt JS injection for fingerprint masking, Google Chrome web store has hundreds of extensions that promise to hide/spoof some browser attributes like canvas hash, User-Agent string, etc., through JS injection.

We have analyzed some of the most downloaded solutions and have found that they are detectable by the method we have suggested. Some extensions even adopt worse and more awkward approaches like not injecting JavaScript code inside even a normal frame!

We analyzed them by looking at the content_script section in their manifest.json file, and have reached the following conclusions:

It's an important lesson to be aware of, when you consider that User-Agent Switcher for Chrome (djflhoibgkdhkhhcedjiklpkjnoahfmg), offered by Google, has two million downloads and yet, despite claiming to change the User-Agent string, does not override navigator.userAgent value. The extension only intercepts the HTTP request and modifies the User-Agent HTTP header.

Conclusion: JavaScript injection is a significant risk

There is only one conclusion we can draw from our experiments: using JavaScript injection to mask browser fingerprints is a significant risk for any business that depends on these solutions to be able to manage multiple accounts or profiles.

Applying it is easy and cheap, so it is a widespread solution but, as our experiments have shown, it simply is not reliable and is easy to detect. Even a hybrid solution does not decrease this risk: while one part of the solution may be better protected, the part that uses masking through JS injection is just as easily detectable as in non-hybrid solutions.

This is why Multilogin uses its own native solutions: Mimic browser, based on Chromium; and StealthFox, based on Mozilla Firefox's codebase. Both two solutions are free from these risks due to avoiding using JavaScript injection – and that is the most foolproof way to get around these dangers. Why take the risk when you don't need to?