Skip to content

z00mtrack: User Tracking via the Browser Zoom Levels

Ahmed Elsobky edited this page Nov 24, 2019 · 1 revision

Background:

User-tracking techniques have been widely in use for a very long time. In the very beginning, it was as easy as collecting your public IP address or storing a unique identifier in a browser cookie. But with the rapid growth of the web, these methods were no longer enough. IP addresses became very dynamic, and browser cookies could easily be wiped out clean (even with powerful approaches like evercookie).

So it was about time for passive user-tracking techniques such as browser fingerprinting to evolve—bits of data ranging from your installed plugins, fonts, user-agent headers, screen resolution, time zone, and what not could be used to construct a unique, stateless user-tracking identifier for each website visitor. Things kept evolving until we reached cross-browser, and even cross-device, tracking techniques with varying accuracy and reliability degrees.

What is new?

While working on an unrelated project, I noticed that most web browsers preserve zoom levels on a site-specific basis and keep them as content settings with virtually no expiration date. These zoom levels are also honored in private browsing or what's known as incognito mode (i.e., if you set the zoom level on a website to 110% and then open it later in an incognito window, the zoom level will still be at 110%). Now you wonder if those zoom level settings can be abused for user tracking?

Simply put, in stateless tracking techniques, every single bit of information that we collect adds more uniqueness to the calculated identifier. The right question here to ask is, how can we detect the current zoom levels for a given host?

There's no standard browser API that would tell us directly what the current zoom level for a website is (is there?). But luckily, some property values such as window.devicePixelRatio can indirectly reflect the current zoom level.

The value of the window.devicePixelRatio property is simply the ratio between the screen resolution in physical pixels to the screen resolution in CSS pixels for the current display device. On most display devices, this property returns 1 at a one hundred percent zoom level. But on retina displays on the other hand, the value is 2 by default.

You might expect the ratio to be consistent across browsers, but on my testing machine at a 110% zoom level, the ratio was different between Chrome and Firefox—1.100000023841858 and 1.0909090909090908 respectively. After some Googling, I found a set of sample window.devicePixelRatio values collected in the following GitHub gist: https://gist.github.com/cheeaun/8838438

So not only this value can be unique for each display device, but also for each browser engine. Yet on some web browsers such as Apple's Safari, the ratio is not affected by the current zoom level whatsoever. So as a workaround, and for additional entropy, we take the ratio between screen.height and window.innerHeight into account as well.

It's also worth mentioning that the default zoom levels available in both Chrome and Firefox are as follows:

Browser Zoom Levels
Firefox 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%
Chrome 25%, 33%, 50%, 67%, 75%, 80%, 90%, 100%, 110%, 125%, 150%, 175%, 200%, 250%, 300%, 400%, 500%

PoC||GTFO:

You can find a live demo demonstrating this approach at:

https://z00mtrack.herokuapp.com

The source code for this demo is written in JavaScript and is divided into two separate Node.js apps:
z00mtrack
const express = require('express');
const app = express();
const storage = require('node-persist');
const PORT = process.env.PORT || 9333;

app.all('/', (req, res) => res.send(`
    You have to zoom in or zoom out and then <s>refresh the page.</s>
    <sup>it should refresh itself!</sup>
    <script>
        window.onload = () => {
            let getSignature = () => {
                let orientation = screen.orientation ? screen.orientation.angle :
                    window.orientation;
                let screenHeight = Math.abs(orientation) !== 90 ? screen.height : screen.width;
                return window.devicePixelRatio + screenHeight / window.innerHeight;
            };
            let signature = getSignature();
            switch (location.hash.slice(1)) {
                case '':
                    location.replace('https://z00mdetect.herokuapp.com');
                    break;
                case signature.toString():
                    break;
                default:
                    !location.pathname.startsWith('/signature/') &&
                        location.replace('/signature/' + signature);
            }
            window.onresize = () => {
                if (getSignature() !== signature)
                    location.replace('');
            };
            history.pushState(null, null, '/');
        };
    </script>
`));

app.all('/signature/:signature([\\w.]+)', (req , res) => {
    let id, signatureId;
    let signature = req.params.signature;

    storage.initSync();
    id = storage.getItemSync('id') || 1;
    id += 1;
    storage.setItemSync('id', id);
    signatureId = storage.getItemSync(signature);

    if (!signatureId) {
        storage.setItemSync(signature, id);
        res.send(`
            You don't appear to have visited this site before!<br>
            id = ${id}<br>
            signature = ${signature}
        `);
    } else {
        res.send(`
            Looks like you have visited this site before!<br>
            Your id was #${signatureId}.<br>
            P.S. Your signature will get automatically erased from our servers after 
            30 minutes, because we <s>take your privacy very seriously</s> are running 
            on a free Heroku dyno!
        `);
    }
});

app.listen(PORT);
z00mdetect
const express = require('express');
const app = express();
const PORT = process.env.PORT || 9333;

app.all('/', (req, res) => res.send(`
    Calculating your default signature....
    <script>
        window.onload = () => {
            let orientation = screen.orientation ? screen.orientation.angle :
                window.orientation;
            let screenHeight = Math.abs(orientation) !== 90 ? screen.height : screen.width;
            let signature = window.devicePixelRatio + screenHeight / window.innerHeight;
            location.href = '//z00mtrack.herokuapp.com/#' + signature;
        };
    </script>
`));

app.listen(PORT);

As you can see, the demo calculates a unique signature using very simple arithmetic operations on window.devicePixelRatio, screen.width, screen.height, and window.innerHeight. And by utilizing two separate hostnames, we can simply rule out the default value for window.devicePixelRatio so that we avoid false positives....

Notes:

  • The full source code for this demo is publicly available at z00mtrack and z00mdetect.

  • It might be possible to implement this technique without relying on JavaScript by using CSS3 media queries.

  • Changing the screen resolution or the browser window dimensions might change your signature, but that's not quite a volatile setting. How many times did you change your screen resolution last year? Probably never?!

  • Adding a third host under our control to the mix, a user who zooms in or out will end up with an even more unique signature (think of "triangulation").

  • Two visitors could probably end up with the same signature, but they would need to have the same zoom level, screen resolution, display device model, browser engine, window dimensions, and also visit within the same time frame. Note, however, that this approach is not intended to be used on its own but in conjunction with other methods.

Countermeasures:

Alternating between different user profiles might mitigate this, but you cannot as easily change your behavior. If you find the text to be too small on a frequently visited website, you'll have to zoom back again to the same zoom level that makes your eyes comfortable (and you end up with the same signature again).

A simple mitigation for this technique is to change the default configurations of your web browser so that the zoom levels are not preserved on a site-specific basis but on a tab-by-tab basis instead. This can be done on Firefox by following these steps:

  1. Head to "about:config".
  2. Look for the following setting "browser.zoom.siteSpecific".
  3. Toggle it to "false".

There's no equivalent setting for Chrome that I'm aware of, but you can easily wipe out preserved zoom levels by heading to "chrome://settings/content/zoomLevels".

Conclusion:

It's become quite clear that evading user tracking completely is almost impossible to achieve these days. User tracking techniques will continue to evolve even further, and the tools the users have to combat against that are rather limited—awareness being the most effective tool we have.

Now I could probably do some math and an empirical study then turn this into a research paper, but I'd rather leave that as an exercise for the academics (too busy with another research project at the moment).

If you have any suggestions that could improve this technique, feel free to file an issue or send a pull request. Thanks for reading, and until next time!