Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS 17 Safari Anti-Fingerprinting #235

Open
JWally opened this issue Aug 7, 2023 · 6 comments
Open

iOS 17 Safari Anti-Fingerprinting #235

JWally opened this issue Aug 7, 2023 · 6 comments

Comments

@JWally
Copy link
Contributor

JWally commented Aug 7, 2023

I have iOS 17 (beta) installed on my phone, and noticed erratic fingerprints. Digging into the issue, it looks like Safari is somehow adding noise in the offlineAudioContextComputed so that the hash is variable with every run.

Rather than fiddling too much with that section, what are your thoughts on doing something like this?

Compute each section twice.
If there's a difference between them, something is intentionally adding noise.
Remove the noisy bits.
Retain a fairly stable fingerprint.

		let [
			workerScopeComputed,
			voicesComputed,
			offlineAudioContextComputed,
			canvasWebglComputed,
			canvas2dComputed,
			windowFeaturesComputed,
			htmlElementVersionComputed,
			cssComputed,
			cssMediaComputed,
			screenComputed,
			mathsComputed,
			consoleErrorsComputed,
			timezoneComputed,
			clientRectsComputed,
			fontsComputed,
			mediaComputed,
			svgComputed,
			resistanceComputed,
			intlComputed,
		] = await Promise.all([
			getBestWorkerScope(),
			getVoices(),
			getOfflineAudioContext(),
			getCanvasWebgl(),
			getCanvas2d(),
			getWindowFeatures(),
			getHTMLElementVersion(),
			getCSS(),
			getCSSMedia(),
			getScreen(),
			getMaths(),
			getConsoleErrors(),
			getTimezone(),
			getClientRects(),
			getFonts(),
			getMedia(),
			getSVG(),
			getResistance(),
			getIntl(),
		]).catch((error) => console.error(error.message))



		let [
			voicesComputed2,
			offlineAudioContextComputed2,
			canvasWebglComputed2,
			canvas2dComputed2,
			windowFeaturesComputed2,
			htmlElementVersionComputed2,
			cssComputed2,
			cssMediaComputed2,
			screenComputed2,
			mathsComputed2,
			consoleErrorsComputed2,
			timezoneComputed2,
			clientRectsComputed2,
			fontsComputed2,
			mediaComputed2,
			svgComputed2,
			resistanceComputed2,
			intlComputed2,
		] = await Promise.all([

			getVoices(),
			getOfflineAudioContext(),
			getCanvasWebgl(),
			getCanvas2d(),
			getWindowFeatures(),
			getHTMLElementVersion(),
			getCSS(),
			getCSSMedia(),
			getScreen(),
			getMaths(),
			getConsoleErrors(),
			getTimezone(),
			getClientRects(),
			getFonts(),
			getMedia(),
			getSVG(),
			getResistance(),
			getIntl(),
		]).catch((error) => console.error(error.message))

		voicesComputed = removeDifferences(voicesComputed, voicesComputed2);
		offlineAudioContextComputed = removeDifferences(offlineAudioContextComputed, offlineAudioContextComputed2);
		canvasWebglComputed = removeDifferences(canvasWebglComputed, canvasWebglComputed2);
		canvas2dComputed = removeDifferences(canvas2dComputed, canvas2dComputed2);
		windowFeaturesComputed = removeDifferences(windowFeaturesComputed, windowFeaturesComputed2);
		htmlElementVersionComputed = removeDifferences(htmlElementVersionComputed, htmlElementVersionComputed2);
		cssComputed = removeDifferences(cssComputed, cssComputed2);
		cssMediaComputed = removeDifferences(cssMediaComputed, cssMediaComputed2);
		screenComputed = removeDifferences(screenComputed, screenComputed2);
		mathsComputed = removeDifferences(mathsComputed, mathsComputed2);
		consoleErrorsComputed = removeDifferences(consoleErrorsComputed, consoleErrorsComputed2);
		timezoneComputed = removeDifferences(timezoneComputed, timezoneComputed2);
		clientRectsComputed = removeDifferences(clientRectsComputed, clientRectsComputed2);
		fontsComputed = removeDifferences(fontsComputed, fontsComputed2);
		mediaComputed = removeDifferences(mediaComputed, mediaComputed2);
		svgComputed = removeDifferences(svgComputed, svgComputed2);
		resistanceComputed = removeDifferences(resistanceComputed, resistanceComputed2);
		intlComputed = removeDifferences(intlComputed, intlComputed2);

		console.log("--------");
@abrahamjuliot
Copy link
Owner

Interesting, I'll take a look. I'm thinking we can plan to feature detect iOS 17 and remove audio from the fingerprints.

I like the idea of checking read back functions, but it can make things too slow. This would be a good idea to showcase on a test page.

@JWally
Copy link
Contributor Author

JWally commented Aug 8, 2023

For what its worth, it looks like the service-worker functions are the time-vampires in your script.

Running everything in parallel is pretty zippy, fwiw:

image

Also, here's my diffing function - suboptimal I'm sure, but it seems to work with the snippet above:

function deepEqual(obj1, obj2) {
  // Handle primitive types
  if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
    return obj1 === obj2;
  }

  const keys1 = Object.keys(obj1);
  const keys2 = Object.keys(obj2);

  if (keys1.length !== keys2.length) return false;

  for (const key of keys1) {
    if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false;
  }

  return true;
}

function removeDifferences(obj1, obj2) {
  // If obj1 and obj2 are primitive types and equal, return obj1
  if (typeof obj1 !== 'object' || obj1 === null) {
    return deepEqual(obj1, obj2) ? obj1 : null;
  }

  // If obj1 is an array, handle it as an array
  if (Array.isArray(obj1)) {
    const result = [];
    for (const key of Object.keys(obj1)) {
      if (obj2.hasOwnProperty(key) && deepEqual(obj1[key], obj2[key])) {
        result[key] = removeDifferences(obj1[key], obj2[key]);
      }
    }
    return result;
  }

  // Default object handling
  const result = {};
  for (const key of Object.keys(obj1)) {
    if (obj2.hasOwnProperty(key) && deepEqual(obj1[key], obj2[key])) {
      result[key] = removeDifferences(obj1[key], obj2[key]);
    }
  }
  return result;
}

  export {removeDifferences}
  

@abrahamjuliot
Copy link
Owner

The technique is useful. I'm thinking we could try something like this on a test page to show when there's a random per-execution leak.

Since the worker does not block the main thread, it is not an issue. But response time is awful. I need to optimize or compress how much data is sent via messages. We could also phase out workers. It's non-essential.

WebGL, canvas, DOM element can add up and block other operations, so running twice is probably too much. At some point, I want to move away from detecting lies on the client and instead let as much randomness as possible and just use simple server-side anomaly detection. We can move the lie detection stuff to test pages and the main script should run a lot faster.

@abrahamjuliot
Copy link
Owner

It looks like the audio on iOS 17 is a bug? There are a handful of recent issues reported.
https://bugs.webkit.org/buglist.cgi?quicksearch=audio

@JWally
Copy link
Contributor Author

JWally commented Aug 18, 2023

WebGL, canvas, DOM element can add up and block other operations, so running twice is probably too much.
For collecting an extremely effective fingerprint, I think its fair to charge an extra 200ms - but I guess it depends on the application.

If browser juking becomes a problem in the future and it's worth the effort, maybe A/B test running 1x vs 2x?

At some point, I want to move away from detecting lies on the client and instead let as much randomness as possible and just use simple server-side anomaly detection. We can move the lie detection stuff to test pages and the main script should run a lot faster.

A lot of your stuff is over my head, so I apologize if you cover this elsewhere - how would this work? Profile a bunch of browsers and get a range for a bunch of tests - and anyone who identifies as ${x} that falls out of those norms has their credibility decremented?

It looks like the audio on iOS 17 is a bug?
Maybe? The safari on iOS 17 gives an option like "blah blah might break certain sites. Do you want to turn it off?". Turning it off seems to make the issue stop. Cloudflare hates it. Couple it with VPN and you're not getting into a site without multiplying toasters or some other dumb test.

I'm fine if you want to close this btw. Maybe worth coming back to to when iOS takes 17 out of beta?

@abrahamjuliot
Copy link
Owner

abrahamjuliot commented Aug 18, 2023

maybe A/B test running 1x vs 2x

It can be useful for classifying the behavior, but it's simple to get around via session-based fingerprints. The main issue with putting all traffic through lie detection on the client is it bloats the code, maps all the steps needed to reverse it, and uses an unnecessary degree of computation for normal traffic.

There is a place for it, but it's much quicker to generate a lite fingerprint, check for anomalies on the server, and conditionally respond with more fingerprinting scripts, perhaps dozens.

how would this work?

Every site is different, so ideally the site determines what is and what is not normal and how to react to it. Traffic on site A can be 100% legit there, but not entirely if it shows up on site B. How to handle that is also a site decision. Credibility is one way, but it could be something more welcoming, like taking additional site-based steps to learn more about the traffic.

I'll leave this open for a bit and may skip audio for iOS 17 if the issue persists. There's also an issue with lack of OffscreenCanvas that has been unresolved for some time now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants