Repository: jonasstrehle/supercookie Branch: main Commit: 0fad0559405e Files: 18 Total size: 133.7 KB Directory structure: gitextract_i4uyxewp/ ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md └── server/ ├── docker-compose.yml ├── main.js ├── main.ts ├── package.json ├── tsconfig.json └── www/ ├── 404.html ├── identity.html ├── index.html ├── launch.html ├── redirect.html ├── referrer-v2.html ├── referrer.html ├── tsconfig.json └── workwise.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: [jonasstrehle] patreon: unyt open_collective: # ko_fi: jonasstrehle tidelift: # community_bridge: # liberapay: # issuehunt: # otechie: # custom: ['https://www.buymeacoffee.com/jonasstrehle'] ================================================ FILE: .gitignore ================================================ .DS_Store /server/data.json ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Jonas Strehle Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

supercookie

Documentation

Website Status License

Fingerprint index N Redirects

**Supercookie** uses favicons to assign a unique identifier to website visitors.
Unlike traditional tracking methods, this ID can be stored almost persistently and cannot be easily cleared by the user. The tracking method works even in the browser's incognito mode and is not cleared by flushing the cache, closing the browser or restarting the operating system, using a VPN or installing AdBlockers. 🍿 [Live demo](https://supercookie.me). ## About ### 💭 Inspiration - Paper by Scientists at University of Illinois, Chicago: [par.nsf.gov](https://par.nsf.gov/servlets/purl/10268961) - Article by heise: [heise.de](https://heise.de/-5027814) ### 🌱 Purpose This repository is for **educational** and **demonstration purposes** only! The demo of "supercookie" as well as the publication of the source code of this repository is intended to draw attention to the problem of tracking possibilities using favicons. 📕 [Full documentation](https://supercookie.me/workwise) ## Installation ### 🔧 Docker **requirements**: [Docker daemon](https://docs.docker.com/get-docker/) 1. Clone repository ```bash git clone https://github.com/jonasstrehle/supercookie ``` 2. Update .env file in [supercookie/server/.env](https://github.com/jonasstrehle/supercookie/blob/main/server/.env) ```env HOST_MAIN=yourdomain.com #or localhost:10080 PORT_MAIN=10080 HOST_DEMO=demo.yourdomain.com #or localhost:10081 PORT_DEMO=10081 ``` 3. Run container ```bash cd supercookie/server docker-compose up ``` -> Webserver will be running at https://yourdomain.com ### 🤖 Local machine **requirements**: [Node.js](https://nodejs.org/) 1. Clone repository ```bash git clone https://github.com/jonasstrehle/supercookie ``` 2. Update .env file in [supercookie/server/.env](https://github.com/jonasstrehle/supercookie/blob/main/server/.env) ```env HOST_MAIN=localhost:10080 PORT_MAIN=10080 HOST_DEMO=localhost:10081 PORT_DEMO=10081 ``` 3. Run service ```bash cd supercookie/server node --experimental-json-modules main.js ``` -> Webserver will be running at http://localhost:10080 ## Workwise of [supercookie](https://supercookie.me/workwise) ### [📖 Background](https://supercookie.me/workwise#content-background) Modern browsers offer a wide range of features to improve and simplify the user experience. One of these features are the so-called favicons: A favicon is a small (usually 16×16 or 32×32 pixels) logo used by web browsers to brand a website in a recognizable way. Favicons are usually shown by most browsers in the address bar and next to the page's name in a list of bookmarks. To serve a favicon on their website, a developer has to include an attribute in the webpage’s header. If this tag does exist, the browser requests the icon from the predefined source and if the server response contains an valid icon file that can be properly rendered this icon is displayed by the browser. In any other case, a blank favicon is shown. ```html ``` The favicons must be made very easily accessible by the browser. Therefore, they are cached in a separate local database on the system, called the favicon cache (F-Cache). A F-Cache data entries includes the visited URL (subdomain, domain, route, URL paramter), the favicon ID and the time to live (TTL). While this provides web developers the ability to delineate parts of their website using a wide variety of icons for individual routes and subdomains, it also leads to a possible tracking scenario. When a user visits a website, the browser checks if a favicon is needed by looking up the source of the shortcut icon link reference of the requested webpage. The browser initialy checks the local F-cache for an entry containing the URL of the active website. If a favicon entry exists, the icon will be loaded from the cache and then displayed. However, if there is no entry, for example because no favicon has ever been loaded under this particular domain, or the data in the cache is out of date, the browser makes a GET request to the server to load the site's favicon. ### [💣 Threat Model](https://supercookie.me/workwise#content-threat-model) In the article a possible threat model is explained that allows to assign a unique identifier to each browser in order to draw conclusions about the user and to be able to identify this user even in case of applied anti-fingerprint measures, such as the use of a VPN, deletion of cookies, deletion of the browser cache or manipulation of the client header information. A web server can draw conclusions about whether a browser has already loaded a favicon or not: So when the browser requests a web page, if the favicon is not in the local F-cache, another request for the favicon is made. If the icon already exists in the F-Cache, no further request is sent. By combining the state of delivered and not delivered favicons for specific URL paths for a browser, a unique pattern (identification number) can be assigned to the client. When the website is reloaded, the web server can reconstruct the identification number with the network requests sent by the client for the missing favicons and thus identify the browser.

Supercookie Header

conventional cookies

supercookie

Identification accuracy - 100%
Incognito / Private mode detection
Persistent after flushed website cache and cookies
Identify multiple windows
Working with Anti-Tracking SW
### [🎯 Target](https://supercookie.me/workwise#content-target) It looks like all top browsers ( [Chrome](https://google.com/chrome/), [Firefox](https://www.mozilla.org/en-US/firefox/new/), [Safari](https://www.apple.com/safari/), [Edge](https://www.microsoft.com/edge/)) are vulnerable to this attack scenario.
Mobile browsers are also affected. #### Current versions

Browser

Windows

MacOS

Linux

iOS

Android

Info
Chrome (v 111.0) ? -
Safari (v 14.0) - - - -
Edge (v 87.0) -
Firefox (v 86.0) Fingerprint different in incognito mode
Brave (v 1.19.92) -
#### Previous versions

Browser

Windows

MacOS

Linux

iOS

Android

Info
Brave (v 1.14.0) -
Firefox (< v 84.0) -
### [⚙ Scalability & Performance](https://supercookie.me/workwise#content-scalability-performance) By varying the number of bits that corresponds to the number of redirects to subpaths, this attack can be scaled almost arbitrarily. It can distinguish 2^N unique users, where N is the number of redirects on the client side. The time taken for the read and write operation increases as the number of distinguishable clients does.
In order to keep the number of redirects as minimal as possible, N can have a dynamic length. More about this [here](https://supercookie.me/workwise#content-scalability-performance). ### [📌How to defend against?](https://supercookie.me/workwise) The most straightforward solution is to disable the favicon cache completely. As long as the browser vendors do not provide a feature against this vulnerability it's probably the best way to clear the F-cache. * [Chrome](https://www.google.com/chrome/) • **MacOS**
- Delete `~/Library/Application Support/Google/Chrome/Default/Favicons` - Delete `~/Library/Application Support/Google/Chrome/Default/Favicons-journal` * [Chrome](https://www.google.com/chrome/) • **Windows**
- Delete `C:\Users\username\AppData\Local\Google\Chrome\User Data\Default` * [Safari](https://www.apple.com/safari/) • **MacOS**
- Delete content of `~/Library/Safari/Favicon Cache` * [Edge](https://www.microsoft.com/edge) • **MacOS**
- Delete `~/Library/Application Support/Microsoft Edge/Default/Favicon` - Delete `~/Library/Application Support/Microsoft Edge/Default/Favicons-journal` ## Other ### 🙎‍♂️ About me I am a twenty five year old student from 🇩🇪 Germany. I like to work in software design and development and have an interest in the IT security domain. This repository, including the setup of a demonstration portal, was created within two days as part of a private research project on the topic of "Tracking on the Web". ### [💖 Support the project](https://ko-fi.com/jonasstrehle) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/jonasstrehle) ## Spread the world! Liked the project? Just give it a star ⭐ and spread the world! * [Bruce Schneier on schneier.com](https://www.schneier.com/crypto-gram/archives/2021/0315.html#cg5) * [Matthew Gault on vice.com](https://www.vice.com/amp/en/article/n7v5y7/browser-favicons-can-be-used-as-undeletable-supercookies-to-track-you-online?__twitter_impression=true) * [Rhett Jones on gizmodo.com](https://gizmodo.com/favicons-could-be-the-supercookie-that-tracks-you-every-1846229089/) * [Dev Kundaliyaon on computing.co.uk](https://www.computing.co.uk/news/4027035/tiny-favicons-utilised-track-users-movements-online) * [Barclay Ballard on techradar.com](https://www.techradar.com/news/these-tiny-icons-could-be-tracking-you-across-the-internet) * [Discussion on ycombinator.com](https://news.ycombinator.com/item?id=26051370) * 🇩🇪 [Andreas Proschofsky on derstandard.de](https://www.derstandard.de/story/2000124123751/supercookies-datensammler-finden-immer-neue-wege-die-nutzer-auszuspionieren) * 🇩🇪 [Dieter Petereit on t3n.de](https://t3n.de/news/tracking-id-favicons-supercookie-1355514/) * 🇪🇸 [ALVY on microsiervos.com](https://www.microsiervos.com/archivo/seguridad/supercookie-me-identificador-personal-imborrable-icono-favicon.html) * 🇧🇷 [Felipe Demartini on canaltech.com.br](https://canaltech.com.br/seguranca/favicons-podem-ser-usados-para-rastrear-usuarios-online-permanentemente-178834/) * 🇧🇬 [Daniel Despodov on kaldata.com](https://www.kaldata.com/it-%D0%BD%D0%BE%D0%B2%D0%B8%D0%BD%D0%B8/%D0%BD%D0%BE%D0%B2-%D0%BC%D0%B5%D1%82%D0%BE%D0%B4-%D0%B7%D0%B0-%D0%B8%D0%B4%D0%B5%D0%BD%D1%82%D0%B8%D1%84%D0%B8%D0%BA%D0%B0%D1%86%D0%B8%D1%8F-%D0%BD%D0%B0-%D0%BA%D0%BE%D0%BD%D0%BA%D1%80%D0%B5%D1%82-355279.html) * 🇫🇷 [Guillaume Belfiore on clubic.com](https://www.clubic.com/navigateur-internet/actualite-353236-publicite-les-favicons-des-sites-web-pourraient-se-montrer-un-peu-trop-curieux.html) * 🇨🇳 [study875 on cnbeta.com (Via archive.org)](https://web.archive.org/web/20220701105509/http://cnbeta.com/articles/tech/1089095.htm) * 🇷🇺 [ITSumma on habr.com](https://habr.com/ru/company/itsumma/blog/542734/) * 🇷🇺 [securitylab.ru](https://www.securitylab.ru/news/516436.php) * [Seytonic on YouTube](https://youtu.be/X7OW5hTt5hY) ================================================ FILE: server/docker-compose.yml ================================================ version: "3" services: proxy: image: "traefik:v2.0" container_name: supercookie-proxy hostname: supercookie-proxy restart: always command: - --api=true - --api.insecure=true - --ping - --providers.docker=true - --providers.docker.network=main - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.web-secure.address=:443 - --certificatesresolvers.myhttpchallenge.acme.httpchallenge=true - --certificatesresolvers.myhttpchallenge.acme.httpchallenge.entrypoint=web - --certificatesresolvers.myhttpchallenge.acme.caserver=https://acme-v02.api.letsencrypt.org/directory - --certificatesresolvers.myhttpchallenge.acme.email=postmaster@unyt.cc - --certificatesresolvers.myhttpchallenge.acme.storage=/letsencrypt/acme.json ports: - "80:80" - "443:443" expose: - 80 networks: - "main" - "internal" volumes: - ./letsencrypt:/letsencrypt - /var/run/docker.sock:/var/run/docker.sock:ro supercookie: container_name: supercookie-web hostname: supercookie-web restart: always image: "node" build: . working_dir: /home/node/app volumes: - ./:/home/node/app - ./node_modules:/home/node/app/node_modules - ./tsconfig.json:/home/node/app/tsconfig.json - ./.env:/home/node/app/.env expose: - ${PORT_MAIN} - ${PORT_DEMO} labels: - "traefik.enable=true" - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" # web main - "traefik.http.services.service-supercookie-web-1.loadbalancer.server.port=${PORT_MAIN}" - "traefik.http.routers.supercookie-web-1.rule=Host(`${HOST_MAIN}`)" - "traefik.http.routers.supercookie-web-1.entrypoints=web" - "traefik.http.routers.supercookie-web-1.middlewares=redirect-to-https@docker" - "traefik.http.routers.supercookie-web-1.service=service-supercookie-web-1" - "traefik.http.routers.supercookie-web-1-secured.rule=Host(`${HOST_MAIN}`)" - "traefik.http.routers.supercookie-web-1-secured.tls=true" - "traefik.http.routers.supercookie-web-1-secured.tls.certresolver=myhttpchallenge" - "traefik.http.routers.supercookie-web-1-secured.service=service-supercookie-web-1" # web demo - "traefik.http.services.service-supercookie-web-2.loadbalancer.server.port=${PORT_DEMO}" - "traefik.http.routers.supercookie-web-2.rule=Host(`${HOST_DEMO}`)" - "traefik.http.routers.supercookie-web-2.entrypoints=web" - "traefik.http.routers.supercookie-web-2.middlewares=redirect-to-https@docker" - "traefik.http.routers.supercookie-web-2.service=service-supercookie-web-2" - "traefik.http.routers.supercookie-web-2-secured.rule=Host(`${HOST_DEMO}`)" - "traefik.http.routers.supercookie-web-2-secured.tls=true" - "traefik.http.routers.supercookie-web-2-secured.tls.certresolver=myhttpchallenge" - "traefik.http.routers.supercookie-web-2-secured.service=service-supercookie-web-2" command: bash -c "node --experimental-json-modules ./main.js" networks: - main - internal networks: main: external: true internal: ================================================ FILE: server/main.js ================================================ import express from "express"; import path from "path"; import fs from "fs"; import cookieParser from "cookie-parser"; import crypto from "crypto"; import cors from "cors"; import dotenv from "dotenv"; const generateUUID = (pattern = "xxxx-xxxx-xxxx-xxxx-xxxx", charset = "abcdefghijklmnopqrstuvwxyz0123456789") => pattern.replace(/[x]/g, () => charset[Math.floor(Math.random() * charset.length)]); const hashNumber = (value) => crypto.createHash("MD5") .update(value.toString()) .digest("hex").slice(-12).split(/(?=(?:..)*$)/) .join(' ').toUpperCase(); const createRoutes = (base, count) => { const array = []; for (let i = 0; i < count; i++) array.push(crypto.createHash("MD5") .update(`${base}${i.toString()}`).digest("base64") .replace(/(\=|\+|\/)/g, '0').substring(0, 22)); return array; }; class Storage { constructor() { this._path = path.join(path.resolve(), "data.json"); this._content = {}; if (!this.existsPersistent()) this.createPersistent(); this.read(); } get content() { return this._contentProxy; } set content(data) { this._content = data; const _this = this; const proxy = { get(target, key) { if (typeof target[key] === 'object' && target[key] !== null) return new Proxy(target[key], proxy); else return target[key]; }, set(target, key, value) { target[key] = value; _this.write(_this.content); return true; } }; this._contentProxy = new Proxy(this._content, proxy); _this.write(_this.content); } read() { return this.content = JSON.parse(fs.readFileSync(this._path).toString() || "{}"), this; } write(content) { fs.writeFileSync(this._path, JSON.stringify(content, null, '\t')); return this; } createPersistent() { this.write({}); } existsPersistent() { return fs.existsSync(this._path); } } const STORAGE = new Storage().content; dotenv.config(); const WEBSERVER_DOMAIN_1 = process.env["HOST_MAIN"] ?? "localhost:10080"; const WEBSERVER_DOMAIN_2 = process.env["HOST_DEMO"] ?? "localhost:10081"; const WEBSERVER_PORT_1 = +process.env["PORT_MAIN"] ?? 10080; const WEBSERVER_PORT_2 = +process.env["PORT_DEMO"] ?? 10081; const CACHE_IDENTIFIER = STORAGE.cacheID ?? generateUUID("xxxxxxxx", "0123456789abcdef"); const N = 32; const FILE = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="; const webserver_1 = express(); const webserver_2 = express(); const maxN = 2 ** N - 1; webserver_1.options('*', cors()); webserver_2.options('*', cors()); console.info(`supercookie | Starting up using N=${N}, C-ID='${CACHE_IDENTIFIER}' ...`); console.info(`supercookie | There are ${Math.max(maxN - 1 - (STORAGE.index ?? 1), 0)}/${maxN - 1} unique identifiers left.`); let Webserver = (() => { class Webserver { static getVector(identifier) { const booleanVector = (identifier >>> 0).toString(2) .padStart(this.routes.length, '0').split('') .map((element) => element === '1') .reverse(); const vector = new Array(); booleanVector.forEach((value, index) => value ? vector.push(this.getRouteByIndex(index)) : void 0); return vector; } static getIdentifier(vector, size = vector.size) { return parseInt(this.routes.map((route) => vector.has(route) ? 0 : 1) .join('').slice(0, size).split('').reverse().join(''), 2); } static hasRoute(route) { return this.routes.includes(route); } static getRouteByIndex(index) { return this.routes[index] ?? null; } static getIndexByRoute(route) { return this.routes.indexOf(route) ?? null; } static getNextRoute(route) { const index = this.routes.indexOf(route); if (index === -1) throw "Route is not valid."; return this.getRouteByIndex(index + 1); } static setCookie(res, name, value, options = { httpOnly: false, expires: new Date(Date.now() + 60 * 1000) }) { return res.cookie(name, value, options), res; } static sendFile(res, route, options = {}, type = "html") { let content = fs.readFileSync(route).toString(); Object.keys(options).sort((a, b) => b.length - a.length).forEach((key) => { content = content.replace(new RegExp(`\{\{${key}\}\}`, 'g'), (options[key]?.toString() || '') .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'")); }); res.header({ "Cache-Control": "private, no-cache, no-store, must-revalidate", "Expires": -1, "Pragma": "no-cache" }); res.type(type); return res.send(content), res; } } Webserver.routes = createRoutes(CACHE_IDENTIFIER, N).map((value) => `${CACHE_IDENTIFIER}:${value}`); return Webserver; })(); let Profile = (() => { class Profile { constructor(uid, identifier = null) { this._identifier = null; this._visitedRoutes = new Set(); this._storageSize = -1; this._uid = uid; if (identifier !== null) this._identifier = identifier, this._vector = Webserver.getVector(identifier); Profile.list.add(this); } static get(uid) { return this.has(uid) ? Array.from(this.list).filter((profile) => profile.uid === uid)?.pop() : null; } static has(uid) { return Array.from(this.list).some((profile) => profile.uid === uid); } static from(uid, identifier) { return !this.has(uid) ? new Profile(uid, identifier) : null; } destructor() { Profile.list.delete(this); } get uid() { return this._uid; } get vector() { return this._vector; } get visited() { return this._visitedRoutes; } get identifier() { return this._identifier; } getRouteByIndex(index) { return this.vector[index] ?? null; } _isReading() { return this._identifier === null; } _visitRoute(route) { this._visitedRoutes.add(route); } _calcIdentifier() { return this._identifier = Webserver.getIdentifier(this._visitedRoutes, this._storageSize), this.identifier; } _setStorageSize(size) { this._storageSize = size; } get storageSize() { return this._storageSize; } } Profile.list = new Set(); return Profile; })(); ; webserver_2.set("trust proxy", 1); webserver_2.use(cookieParser()); webserver_2.use((req, res, next) => { if (new RegExp(`https?:\/\/${WEBSERVER_DOMAIN_2}`).test(req.headers.origin)) res.setHeader("Access-Control-Allow-Origin", req.headers.origin); res.header("Access-Control-Allow-Methods", "GET, OPTIONS"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); return next(); }); const midSet = new Set(); const generateWriteToken = () => { const uuid = generateUUID(); setTimeout(() => midSet.delete(uuid), 1000 * 60); return midSet.add(uuid), uuid; }; const deleteWriteToken = (token) => midSet.delete(token); const hasWriteToken = (token) => midSet.has(token); webserver_2.get("/read", (_req, res) => { const uid = generateUUID(); console.info(`supercookie | Visitor uid='${uid}' is known • Read`); const profile = Profile.from(uid); profile._setStorageSize(Math.floor(Math.log2(STORAGE.index ?? 1)) + 1); if (profile === null) return res.redirect("/read"); Webserver.setCookie(res, "uid", uid); res.redirect(`/t/${Webserver.getRouteByIndex(0)}?f=${generateUUID()}`); }); webserver_2.get("/write/:mid", (req, res) => { const mid = req.params.mid; if (!hasWriteToken(mid)) return res.redirect('/'); res.clearCookie("mid"); deleteWriteToken(mid); const uid = generateUUID(); console.info(`supercookie | Visitor uid='${uid}' is unknown • Write`, STORAGE.index); const profile = Profile.from(uid, STORAGE.index); if (profile === null) return res.redirect('/'); STORAGE.index++; Webserver.setCookie(res, "uid", uid); res.redirect(`/t/${Webserver.getRouteByIndex(0)}`); }); webserver_2.get("/t/:ref", (req, res) => { const referrer = req.params.ref; const uid = req.cookies.uid; const profile = Profile.get(uid); if (!Webserver.hasRoute(referrer) || profile === null) return res.redirect('/'); const route = Webserver.getNextRoute(referrer); if (profile._isReading() && profile.visited.has(referrer)) return res.redirect('/'); let nextReferrer = null; const redirectCount = profile._isReading() ? profile.storageSize : Math.floor(Math.log2(profile.identifier)) + 1; if (route) nextReferrer = `t/${route}?f=${generateUUID()}`; if (!profile._isReading()) { if (Webserver.getIndexByRoute(referrer) >= redirectCount - 1) nextReferrer = "read"; } else if (Webserver.getIndexByRoute(referrer) >= redirectCount - 1 || nextReferrer === null) nextReferrer = "identity"; const bit = !profile._isReading() ? profile.vector.includes(referrer) : "{}"; Webserver.sendFile(res, path.join(path.resolve(), "www/referrer.html"), { delay: profile._isReading() ? 500 : 800, referrer: nextReferrer, favicon: referrer, bit: bit, index: `${Webserver.getIndexByRoute(referrer) + 1} / ${redirectCount}` }); }); webserver_2.get("/identity", (req, res) => { const uid = req.cookies.uid; const profile = Profile.get(uid); if (profile === null) return res.redirect('/'); res.clearCookie("uid"); res.clearCookie("vid"); const identifier = profile._calcIdentifier(); if (identifier === maxN || profile.visited.size === 0 || identifier === 0) return res.redirect(`/write/${generateWriteToken()}`); if (identifier !== 0) { const identifierHash = hashNumber(identifier); console.info(`supercookie | Visitor successfully identified as '${identifierHash}' • (#${identifier}).`); Webserver.sendFile(res, path.join(path.resolve(), "www/identity.html"), { hash: identifierHash, identifier: `#${identifier}`, url_workwise: `${WEBSERVER_DOMAIN_1}/workwise`, url_main: WEBSERVER_DOMAIN_1 }); } else Webserver.sendFile(res, path.join(path.resolve(), "www/identity.html"), { hash: "AN ON YM US", identifier: "browser not vulnerable", url_workwise: `${WEBSERVER_DOMAIN_1}/workwise`, url_main: WEBSERVER_DOMAIN_1 }); }); webserver_2.get(`/${CACHE_IDENTIFIER}`, (req, res) => { const rid = !!req.cookies.rid; res.clearCookie("rid"); if (!rid) Webserver.sendFile(res, path.join(path.resolve(), "www/redirect.html"), { url_demo: WEBSERVER_DOMAIN_2 }); else Webserver.sendFile(res, path.join(path.resolve(), "www/launch.html"), { favicon: CACHE_IDENTIFIER }); }); webserver_2.get('/', (_req, res) => { Webserver.setCookie(res, "rid", true); res.clearCookie("mid"); res.redirect(`/${CACHE_IDENTIFIER}`); }); webserver_2.get("/l/:ref", (_req, res) => { console.info(`supercookie | Unknown visitor detected.`); Webserver.setCookie(res, "mid", generateWriteToken()); const data = Buffer.from(FILE, "base64"); res.writeHead(200, { "Cache-Control": "public, max-age=31536000", "Expires": new Date(Date.now() + 31536000000).toUTCString(), "Content-Type": "image/png", "Content-Length": data.length }); res.end(data); }); webserver_2.get("/i/:ref", (req, res) => { const data = Buffer.from(FILE, "base64"); res.writeHead(200, { "Cache-Control": "public, max-age=31536000", "Expires": new Date(Date.now() + 31536000000).toUTCString(), "Content-Type": "image/png", "Content-Length": data.length }); res.end(data); }); webserver_2.get("/f/:ref", (req, res) => { const referrer = req.params.ref; const uid = req.cookies.uid; if (!Profile.has(uid) || !Webserver.hasRoute(referrer)) return res.status(404), res.end(); const profile = Profile.get(uid); if (profile._isReading()) { profile._visitRoute(referrer); console.info(`supercookie | Favicon requested by uid='${uid}' • Read `, Webserver.getIndexByRoute(referrer), "•", Array.from(profile.visited).map(route => Webserver.getIndexByRoute(route))); return; } if (!profile.vector.includes(referrer)) { console.info(`supercookie | Favicon requested by uid='${uid}' • Write`, Webserver.getIndexByRoute(referrer), "•", Array.from(profile.vector).map(route => Webserver.getIndexByRoute(route))); return; } const data = Buffer.from(FILE, "base64"); res.writeHead(200, { "Cache-Control": "public, max-age=31536000", "Expires": new Date(Date.now() + 31536000000).toUTCString(), "Content-Type": "image/png", "Content-Length": data.length }); res.end(data); }); webserver_1.use("/assets", express.static(path.join(path.resolve(), "www/assets"), { index: false })); webserver_2.use("/assets", express.static(path.join(path.resolve(), "www/assets"), { index: false })); webserver_1.get('/', (_req, res) => { Webserver.sendFile(res, path.join(path.resolve(), "www/index.html"), { url_demo: WEBSERVER_DOMAIN_2 }); }); webserver_1.get("/favicon.ico", (_req, res) => { res.sendFile(path.join(path.resolve(), "www/favicon.ico")); }); webserver_2.get("/favicon.ico", (_req, res) => { res.sendFile(path.join(path.resolve(), "www/favicon.ico")); }); webserver_1.get("/workwise", (_req, res) => { Webserver.sendFile(res, path.join(path.resolve(), "www/workwise.html"), { url_main: WEBSERVER_DOMAIN_1 }); }); webserver_1.get("/api", (_req, res) => { res.type("json"); res.status(200); res.send({ index: STORAGE.index, cache: STORAGE.cacheID, bits: Math.floor(Math.log2(STORAGE.index ?? 1)) + 1, N: N, maxN: maxN }); }); webserver_1.get('*', (_req, res) => { res.redirect('/'); }); webserver_2.get('*', (req, res) => { Webserver.sendFile(res, path.join(path.resolve(), "www/404.html"), { path: decodeURIComponent(req.path), url_main: WEBSERVER_DOMAIN_1 }); }); webserver_1.listen(WEBSERVER_PORT_1, () => console.info(`express-web | Webserver-1 for '${WEBSERVER_DOMAIN_1}' running on port:`, WEBSERVER_PORT_1)); webserver_2.listen(WEBSERVER_PORT_2, () => console.info(`express-web | Webserver-2 for '${WEBSERVER_DOMAIN_2}' running on port:`, WEBSERVER_PORT_2)); STORAGE.index = STORAGE.index ?? 1; STORAGE.cacheID = CACHE_IDENTIFIER; ================================================ FILE: server/main.ts ================================================ import express from "express"; import path from "path"; import fs from "fs"; import cookieParser from "cookie-parser"; import crypto from "crypto"; import cors from "cors"; import dotenv from "dotenv"; /** * Creates UUID in the specified pattern's * form using charset * @param pattern * @param charset */ const generateUUID = ( pattern: string = "xxxx-xxxx-xxxx-xxxx-xxxx", charset: string = "abcdefghijklmnopqrstuvwxyz0123456789"): string => pattern.replace(/[x]/g, () => charset[Math.floor(Math.random() * charset.length)]); /** * Creates HEX-hash from number * @param value */ const hashNumber = (value: number): string => crypto.createHash("MD5") .update(value.toString()) .digest("hex").slice(-12).split(/(?=(?:..)*$)/) .join(' ').toUpperCase(); /** * Creates string-array with length "count" * from value "base" * @param base * @param count */ const createRoutes = (base: string, count: number): Array => { const array = []; for (let i=0; i = createRoutes(CACHE_IDENTIFIER, N).map((value: string) => `${CACHE_IDENTIFIER}:${value}`); public static getVector(identifier: number): Array { const booleanVector: Array = (identifier >>> 0).toString(2) .padStart(this.routes.length, '0').split('') .map((element: '0' | '1') => element === '1') .reverse(); const vector = new Array(); booleanVector.forEach((value: boolean, index: number) => value ? vector.push(this.getRouteByIndex(index)) : void 0); return vector; } public static getIdentifier(vector: Set, size: number = vector.size): number { return parseInt(this.routes.map((route: string) => vector.has(route) ? 0 : 1) .join('').slice(0, size).split('').reverse().join(''), 2); } public static hasRoute(route: string): boolean { return this.routes.includes(route); } public static getRouteByIndex(index: number): string { return this.routes[index] ?? null; } public static getIndexByRoute(route: string): number { return this.routes.indexOf(route) ?? null; } public static getNextRoute(route: string): string | null { const index = this.routes.indexOf(route); if (index === -1) throw "Route is not valid."; return this.getRouteByIndex(index+1); } public static setCookie(res: express.Response, name: string, value: any, options: express.CookieOptions = { httpOnly: false, expires: new Date(Date.now() + 60 * 1000) }): express.Response { return res.cookie(name, value, options), res; } public static sendFile( res: express.Response, route: string, options: any = {}, type: string = "html"): express.Response { let content = fs.readFileSync(route).toString(); Object.keys(options).sort((a: string, b: string) => b.length - a.length).forEach((key: string) => { content = content.replace( new RegExp(`\{\{${key}\}\}`, 'g'), (options[key]?.toString() || '') .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") ); }); res.header({ "Cache-Control": "private, no-cache, no-store, must-revalidate", "Expires": -1, "Pragma": "no-cache" }); res.type(type); return res.send(content), res; } } /** * @class Profile * Read / Write class */ class Profile { public static list: Set = new Set(); public static get(uid: string): Profile { return this.has(uid) ? Array.from(this.list).filter((profile: Profile) => profile.uid === uid)?.pop(): null; } public static has(uid: string): boolean { return Array.from(this.list).some((profile: Profile) => profile.uid === uid); } public static from(uid: string, identifier?: number): Profile { return !this.has(uid) ? new Profile(uid, identifier): null; } private _uid: string; private _vector: Array; private _identifier: number = null; private _visitedRoutes: Set = new Set(); private _storageSize: number = -1; constructor(uid: string, identifier: number = null) { this._uid = uid; if (identifier !== null) this._identifier = identifier, this._vector = Webserver.getVector(identifier); Profile.list.add(this); } public destructor() { Profile.list.delete(this); } public get uid(): string { return this._uid; } public get vector(): Array { return this._vector; } public get visited(): Set { return this._visitedRoutes; } public get identifier(): number { return this._identifier; } public getRouteByIndex(index: number): string { return this.vector[index] ?? null; } public _isReading(): boolean { return this._identifier === null; } public _visitRoute(route: string) { this._visitedRoutes.add(route); } public _calcIdentifier(): number { return this._identifier = Webserver.getIdentifier(this._visitedRoutes, this._storageSize), this.identifier; } public _setStorageSize(size: number) { this._storageSize = size; } public get storageSize(): number { return this._storageSize; } }; webserver_2.set("trust proxy", 1); webserver_2.use(cookieParser()); webserver_2.use((req: express.Request, res: express.Response, next: Function) => { if (new RegExp(`https?:\/\/${WEBSERVER_DOMAIN_2}`).test(req.headers.origin)) res.setHeader("Access-Control-Allow-Origin", req.headers.origin); res.header("Access-Control-Allow-Methods", "GET, OPTIONS"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); return next(); }); /** * @description * Using token based "write authentification" to avoid spam to /write path */ const midSet: Set = new Set(); const generateWriteToken = (): string => { const uuid = generateUUID(); setTimeout(() => midSet.delete(uuid), 1_000 * 60); return midSet.add(uuid), uuid; } const deleteWriteToken = (token: string) => midSet.delete(token); const hasWriteToken = (token: string): boolean => midSet.has(token); /** * @description * When navigating to path /read the mode of an (known) visitor is set to "write". * Assuming that the data has already been written to the browser, the webserver * is redirecting the user to the first route. */ webserver_2.get("/read", (_req: express.Request, res: express.Response) => { const uid = generateUUID(); console.info(`supercookie | Visitor uid='${uid}' is known • Read`); const profile: Profile = Profile.from(uid); profile._setStorageSize(Math.floor(Math.log2(STORAGE.index ?? 1)) + 1); if (profile === null) return res.redirect("/read"); Webserver.setCookie(res, "uid", uid); res.redirect(`/t/${Webserver.getRouteByIndex(0)}?f=${generateUUID()}`) }); /** * @description * If a user navigates to path /write a new (unknown) visitor entry is created. * Assuming that the data has not been written to the browser, the webserver * is redirecting the user to the first route. */ webserver_2.get("/write/:mid", (req: express.Request, res: express.Response) => { const mid = req.params.mid; if (!hasWriteToken(mid)) return res.redirect('/'); res.clearCookie("mid"); deleteWriteToken(mid); const uid = generateUUID(); console.info(`supercookie | Visitor uid='${uid}' is unknown • Write`, STORAGE.index); const profile: Profile = Profile.from(uid, STORAGE.index); if (profile === null) return res.redirect('/'); STORAGE.index++; Webserver.setCookie(res, "uid", uid); res.redirect(`/t/${Webserver.getRouteByIndex(0)}`); }); /** * @description * Under the /t path, the user is redirected to the next possible route. */ webserver_2.get("/t/:ref", (req: express.Request, res: express.Response) => { const referrer: string = req.params.ref; const uid: string = req.cookies.uid; const profile: Profile = Profile.get(uid); if (!Webserver.hasRoute(referrer) || profile === null) return res.redirect('/'); const route: string = Webserver.getNextRoute(referrer); /** reload issue */ if (profile._isReading() && profile.visited.has(referrer)) return res.redirect('/'); let nextReferrer: string = null; const redirectCount: number = profile._isReading() ? profile.storageSize: Math.floor(Math.log2(profile.identifier)) + 1; if (route) nextReferrer = `t/${route}?f=${generateUUID()}`; if (!profile._isReading()) { if (Webserver.getIndexByRoute(referrer) >= redirectCount - 1) nextReferrer = "read"; } else if (Webserver.getIndexByRoute(referrer) >= redirectCount - 1 || nextReferrer === null) nextReferrer = "identity"; const bit = !profile._isReading() ? profile.vector.includes(referrer) : "{}"; Webserver.sendFile(res, path.join(path.resolve(), "www/referrer.html"), { delay: profile._isReading() ? 500 : 800, referrer: nextReferrer, favicon: referrer, bit: bit, index: `${Webserver.getIndexByRoute(referrer)+1} / ${redirectCount}` }); }); /** * @description * After finishing the reading process, the browser is redirected to the /identity route. * Here, the browser is assigned the calculated identifier and displayed to the user. */ webserver_2.get("/identity", (req: express.Request, res: express.Response) => { const uid: string = req.cookies.uid; const profile: Profile = Profile.get(uid); if (profile === null) return res.redirect('/'); res.clearCookie("uid"); res.clearCookie("vid"); const identifier = profile._calcIdentifier(); if (identifier === maxN || profile.visited.size === 0 || identifier === 0) return res.redirect(`/write/${generateWriteToken()}`); if (identifier !== 0) { const identifierHash: string = hashNumber(identifier); console.info(`supercookie | Visitor successfully identified as '${identifierHash}' • (#${identifier}).`); Webserver.sendFile(res, path.join(path.resolve(), "www/identity.html"), { hash: identifierHash, identifier: `#${identifier}`, url_workwise: `${WEBSERVER_DOMAIN_1}/workwise`, url_main: WEBSERVER_DOMAIN_1 }); } else Webserver.sendFile(res, path.join(path.resolve(), "www/identity.html"), { hash: "AN ON YM US", identifier: "browser not vulnerable", url_workwise: `${WEBSERVER_DOMAIN_1}/workwise`, url_main: WEBSERVER_DOMAIN_1 }); }); /** * @description * Fixing a chrome (v 87.0) problem using javascript redirect instead of * express redirect (in redirect.html) */ webserver_2.get(`/${CACHE_IDENTIFIER}`, (req: express.Request, res: express.Response) => { const rid: boolean = !!req.cookies.rid; res.clearCookie("rid"); if (!rid) Webserver.sendFile(res, path.join(path.resolve(), "www/redirect.html"), { url_demo: WEBSERVER_DOMAIN_2 }); else Webserver.sendFile(res, path.join(path.resolve(), "www/launch.html"), { favicon: CACHE_IDENTIFIER }); }); /** * @description * Main route / is redirecting to /CACHE_IDENTIFIER */ webserver_2.get('/', (_req: express.Request, res: express.Response) => { Webserver.setCookie(res, "rid", true); res.clearCookie("mid"); res.redirect(`/${CACHE_IDENTIFIER}`); }); /** * @description * When requesting the favicon under /l, it is excluded that a user already has valid data in the cache. */ webserver_2.get("/l/:ref", (_req: express.Request, res: express.Response) => { console.info(`supercookie | Unknown visitor detected.`); Webserver.setCookie(res, "mid", generateWriteToken()); const data = Buffer.from(FILE, "base64"); res.writeHead(200, { "Cache-Control": "public, max-age=31536000", "Expires": new Date(Date.now() + 31536000000).toUTCString(), "Content-Type": "image/png", "Content-Length": data.length }); res.end(data); }); webserver_2.get("/i/:ref", (req: express.Request, res: express.Response) => { const data = Buffer.from(FILE, "base64"); res.writeHead(200, { "Cache-Control": "public, max-age=31536000", "Expires": new Date(Date.now() + 31536000000).toUTCString(), "Content-Type": "image/png", "Content-Length": data.length }); res.end(data); }); /** * @description * /f route handles requests for favicons by the browser. * In write mode, some icons are delivered and other requests are aborted. * In read mode every request fails to not corrupt the cache. */ webserver_2.get("/f/:ref", (req: express.Request, res: express.Response) => { const referrer: string = req.params.ref; const uid: string = req.cookies.uid; if (!Profile.has(uid) || !Webserver.hasRoute(referrer)) return res.status(404), res.end(); const profile: Profile = Profile.get(uid); if (profile._isReading()) { profile._visitRoute(referrer); console.info(`supercookie | Favicon requested by uid='${uid}' • Read `, Webserver.getIndexByRoute(referrer), "•", Array.from(profile.visited).map(route => Webserver.getIndexByRoute(route))); return; // res.type("gif"), res.status(404), res.end(); } if (!profile.vector.includes(referrer)) { console.info(`supercookie | Favicon requested by uid='${uid}' • Write`, Webserver.getIndexByRoute(referrer), "•", Array.from(profile.vector).map(route => Webserver.getIndexByRoute(route))); return; // res.type("gif"), res.status(404), res.end(); } const data = Buffer.from(FILE, "base64"); res.writeHead(200, { "Cache-Control": "public, max-age=31536000", "Expires": new Date(Date.now() + 31536000000).toUTCString(), "Content-Type": "image/png", "Content-Length": data.length }); res.end(data); }); webserver_1.use("/assets", express.static(path.join(path.resolve(), "www/assets"), { index: false })); webserver_2.use("/assets", express.static(path.join(path.resolve(), "www/assets"), { index: false })); webserver_1.get('/', (_req: express.Request, res: express.Response) => { Webserver.sendFile(res, path.join(path.resolve(), "www/index.html"), { url_demo: WEBSERVER_DOMAIN_2 }); }); webserver_1.get("/favicon.ico", (_req: express.Request, res: express.Response) => { res.sendFile(path.join(path.resolve(), "www/favicon.ico")); }); webserver_2.get("/favicon.ico", (_req: express.Request, res: express.Response) => { res.sendFile(path.join(path.resolve(), "www/favicon.ico")); }); webserver_1.get("/workwise", (_req: express.Request, res: express.Response) => { Webserver.sendFile(res, path.join(path.resolve(), "www/workwise.html"), { url_main: WEBSERVER_DOMAIN_1 }); }); webserver_1.get("/api", (_req: express.Request, res: express.Response) => { res.type("json"); res.status(200); res.send({ index: STORAGE.index, cache: STORAGE.cacheID, bits: Math.floor(Math.log2(STORAGE.index ?? 1)) + 1, N: N, maxN: maxN }); }); webserver_1.get('*', (_req: express.Request, res: express.Response) => { res.redirect('/'); }); webserver_2.get('*', (req: express.Request, res: express.Response) => { Webserver.sendFile(res, path.join(path.resolve(), "www/404.html"), { path: decodeURIComponent(req.path), url_main: WEBSERVER_DOMAIN_1 }); }); webserver_1.listen(WEBSERVER_PORT_1, () => console.info(`express-web | Webserver-1 for '${WEBSERVER_DOMAIN_1}' running on port:`, WEBSERVER_PORT_1)); webserver_2.listen(WEBSERVER_PORT_2, () => console.info(`express-web | Webserver-2 for '${WEBSERVER_DOMAIN_2}' running on port:`, WEBSERVER_PORT_2)); STORAGE.index = STORAGE.index ?? 1; STORAGE.cacheID = CACHE_IDENTIFIER; ================================================ FILE: server/package.json ================================================ { "type": "module", "dependencies": { "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.9", "@types/express": "^4.17.11", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "dotenv": "^8.2.0", "express": "^4.17.1" } } ================================================ FILE: server/tsconfig.json ================================================ { "compilerOptions": { "allowSyntheticDefaultImports": true, "sourceMap": false, "removeComments": true, "target": "ESNext", "module": "ESNext", "moduleResolution": "Node", "allowJs": false, "experimentalDecorators": true, "lib": [ "esnext.array", "esnext", "dom" ] } } ================================================ FILE: server/www/404.html ================================================ supercookie • progress

{{path}} not found!

Back ================================================ FILE: server/www/identity.html ================================================ id • {{hash}}
================================================ FILE: server/www/index.html ================================================ supercookie • welcome

Browser-Fingerprinting
via Favicon

visibilityTo the Demo!
================================================ FILE: server/www/launch.html ================================================ supercookie

...

================================================ FILE: server/www/redirect.html ================================================ supercookie • progress

...

================================================ FILE: server/www/referrer-v2.html ================================================ supercookie • {{index}}

{{index}}

================================================ FILE: server/www/referrer.html ================================================ supercookie • {{index}}

{{index}}

================================================ FILE: server/www/tsconfig.json ================================================ { "compilerOptions": { "sourceMap": false, "removeComments": true, "target": "ESNext", "module": "ESNext", "allowJs": false, "experimentalDecorators": true, "lib": [ "esnext.array", "esnext", "dom" ] } } ================================================ FILE: server/www/workwise.html ================================================ supercookie • workwise

Workwise • supercookie

📚 Please have a look at this elaboration from University of Illinois: www.cs.uic.edu

Introduction

Data is the new gold!

Browsers are the most widespread access medium that makes it incredibly easy for us humans to connect to the Word Wide Web.
Due to the constant development of the Internet, such as the continuous elaboration of new standards and features, the introduction of powerful APIs and further interfaces on the browser side, the possibilities for collecting and analyzing data have also significantly expanded over the last few decades!

First and foremost, there is nothing wrong with collecting data at all. All of us collect data, whether unconsciously in private everyday life or completely consciously in school or at work - collecting data, interpreting it and drawing conclusions is actually incredibly important!

With the launch of the WWW for the public and the development of the first online services, data collection also started to become interesting for the various website providers, according to the motto if I own a website, I also want to know who is surfing it.
However, in most cases we as consumers only want to disclose as little as possible and only the data necessary for the intended service - in fact, my private data is no one else's business.

The above-mentioned further development of the WWW's capabilities has allowed data to be assigned to individual profiles, enabling the recognition of unique users and the ability to trace their browsing activities even across different pages - the so called device fingerprinting.
Some known methods for assigning a unique fingerprint to browsers are hardware benchmarking, fingerprinting via Canvas and WebGL or analysis of active browser extensions.
This article is about a less known way to achieve something similar!

Background

Modern browsers offer a wide range of features to improve and simplify the user experience.
One of these features are the so-called favicons: A favicon is a small (usually 16×16 or 32×32 pixels) logo used by web browsers to brand a website in a recognizable way. Favicons are usually shown by most browsers in the address bar and next to the page's name in a list of bookmarks.

To serve a favicon on their website, a developer has to include an <link rel> attribute in the webpage’s header. If this tag does exist, the browser requests the icon from the predefined source and if the server response contains an valid icon file that can be properly rendered this icon is displayed by the browser. In any other case, a blank favicon is shown.

<link rel="icon" href="/favicon.ico" type="image/x-icon">
The favicons must be made very easily accessible by the browser. Therefore, they are cached in a separate local database on the system, called the favicon cache (F-Cache). A F-Cache data entries includes the visited URL (subdomain, domain, route, URL paramter), the favicon ID and the time to live (TTL).
While this provides web developers the ability to delineate parts of their website using a wide variety of icons for individual routes and subdomains, it also leads to a possible tracking scenario.

When a user visits a website, the browser checks if a favicon is needed by looking up the source of the shortcut icon link reference of the requested webpage.
The browser initialy checks the local F-cache for an entry containing the URL of the active website. If a favicon entry exists, the icon will be loaded from the cache and then displayed. However, if there is no entry, for example because no favicon has ever been loaded under this particular domain, or the data in the cache is out of date, the browser makes a GET request to the server to load the site's favicon.

Threat Model

In the article a possible threat model is explained that allows to assign a unique identifier to each browser in order to draw conclusions about the user and to be able to identify this user even in case of applied anti-fingerprint measures, such as the use of a VPN, deletion of cookies, deletion of the browser cache or manipulation of the client header information.

A web server can draw conclusions about whether a browser has already loaded a favicon or not:
So when the browser requests a web page, if the favicon is not in the local F-cache, another request for the favicon is made. If the icon already exists in the F-Cache, no further request is sent.
By combining the state of delivered and not delivered favicons for specific URL paths for a browser, a unique pattern (identification number) can be assigned to the client.
When the website is reloaded, the web server can reconstruct the identification number with the network requests sent by the client for the missing favicons and thus identify the browser.


  1. Write identification

    The goal of the write operation is to generate a unique identifier and store it on the client side.
    First step is to create a new N-bit ID on the server and translate it to a path vector as shown below.

    Example:

    const N = 4;
    const ROUTES = ["/a", "/b", "/c", "/d"];
    const ID = generateNewID(); // -> 1010 • (select unassigned decimal number, here ten: 10 -> 1010b in binary)
    const vector = generateVectorFromID(ID); // -> ["/a", "/c"] • (because [a, b, c, d] where [1, 0, 1, 0] is 1 -> a, c)

    Second step is to store the actual data inside the browser:
    The user will be redirected along all of the website paths, starting at /a, navigating to /b, to /c and finally to /d.
    • /a
    • /b
    • /c
    • /d

    While the user is redirected on every load the browser requests a favicon for the respective route, going the same way like/a/favicon.ico, to /b/favicon.ico, to /c/favicon.ico and finally to /d/favicon.ico.
    • /a/favicon.ico
    • /b/favicon.ico
    • /c/favicon.ico
    • /d/favicon.ico

    The webserver will now only process those favicon requests whose path is present in the previously created path vector. If the route is present the webserver answers with the favicon file and Status 200 OK.
    If the requested route is not in the path vector, the webserver aborts the request with an Error 404 Not Found, or sends no response.
    Since the browser - as described earlier - only stores the delivered favicons in the F-Cache, we have now stored our unique identification number and the writing process is complete.

    In the above example, the webserver only responds to requests for the favicons under paths /a/favicon.ico and /c/favicon.ico. The F-Cache only has favicons-entries for these two paths.



  2. Read identification

    Here the goal is to re-identify a returning user based on his existing F-Cache entries.

    In read mode the server always responds to favicon requests with an Error 404 Not Found status, but responds normally to all other requests. This preserves the integrity of the cached favicons during the read operation, since no new F-cache entry is created by the browser.
    To reconstruct a visitor's identifier, the browser must be routed through all available routes. The server records which favions are requested by the browser (those that are not present in the browsers F-cache) and which are not.

    Example:

    const visitedRoutes = [];
    Webserver.onvisit = (route) => visitedRoutes.push(route); // -> ["/b", "/d"]
    Webserver.ondone = () => { const ID = getIDFromVector(visitedRoutes) }; // -> 10 • (because "/a" and "/b" are missing -> 1010b)
    The server can thus reconstruct the identification from the missing favicon requests and the reading process is complete.


Target

It looks like all top browsers are vulnerable to this attack scenario.
Mobile browsers are also affected.

Browser

Windows

MacOS

Linux

iOS

Android

Info
Chrome (v 87.0) -
Safari (v 14.0) - - - -
Edge (v 87.0) -
Firefox (v 85.0) Fingerprint different in incognito mode
Brave (v 1.19.92) -

Browser

Windows

MacOS

Linux

iOS

Android

Info
Brave (v 1.14.0) -
Firefox (< v 84.0) -


The demonstration also impressively shows that applying anti-tracking software, adblockers, VPN or surfing in incognito mode does not offer any significant improvement and the browser remains vulnerable to the tracking even with these measures:

Browser Incognito / Private mode Clear Website Data VPN Adblock / Anti-Tracking
Chrome
Safari
Edge
Firefox

Scalability & Performance

By varying the number of bits that corresponds to the number of redirects to subpaths, this attack can be scaled almost arbitrarily.
It can distinguish 2^N unique users, where N is the number of redirects on the client side.

Since each subpath redirection increases the duration of the identification, the performance of the attack the webserver can dynamically increase the number of redirects. This is done trivially by appending a new subpath in the sequence of subpaths.
The calculation of the number of redirects (N) is done by the operation: "floor(log2(id))+1", where id corresponds to the decimal identification number.
For example, if the server changes from 3-bit identifiers to 4-bit identifiers, the subpath vector will change from ["/a", "/b", "/c"] to ["/a", "/b", "/c", "/d"] and the identifier of a client (here dec. 6) changes from "011" to "0110" without changing the actual value of already written F-Cache identifiers.

This leads to the fact that only the minimum number of redirections is necessary for the attack.

The time taken for the read and write operation increases as the number of distinguishable clients and redirects does.

The following measured times prove to be the minimum time required for this attack to work. The actual time required in practice depends on many more factors, such as Internet speed, location, hardware setup and browser type.

Redirects
(N bit)
distinguishable clients write time read time scale information
2 4 < 300ms < 300ms One user with four browsers
3 8 < 300ms ~ 300ms About the amount of Kardashians
4 16 < 1s ~ 1s Bunch of your neighbors
8 256 < 1s ~ 1s All your facebook-friends
10 1024 < 1.2s ~ 1s Really small village
20 1,048,576 < 1.8s < 1.5s Small city (San Jose, California)
24 16,777,216 < 2.4s < 2s Whole Netherlands
32 4,294,967,296 ~ 3s < 3s All people with internet access
34 17,179,869,184 ~ 4s ~ 4s All people with internet access each using 4 different browsers

Related work

  • cs.uic.edu: Study by Scientists at the University of Illinois, Chicago
  • heise.de: Browser-Fingerprinting: Favicons als "Super-Cookies"