Detecting incognito mode in Chrome 76 with a timing attack

tl;dr: FileSystem API writes are measurably faster and less noisy in incognito mode, allowing websites to detect incognito visitors by benchmarking their write speed. Results

This post has a Russian translation kindly provided by Babur Muradov, who runs pngset.

Background

Chrome 76 makes the FileSystem API available in incognito mode, preventing websites from detecting incognito users based on the presence of the API.

In incognito mode, Chrome stores data written to the API in memory instead of persisting it to disk like in normal mode. When we choose to use memory, we make some tradeoffs: RAM is temporary storage, making it an attractive medium for incognito. But side effects include smaller space and higher speed than disk.

Recently, security researcher Vikas Mishra discovered that we can infer incognito state based on the amount of space which the API makes available.

In this blog post, I present a proof-of-concept of a technique which websites could use to detect incognito users by measuring the speed of writes to the API.

Method

The setup is relatively simple: benchmark the filesystem by repeatedly writing large strings to it and measuring how long that takes. Because memory is faster than disk, we should be able to tell by the speed whether the site visitor is in incognito.

Code is in the appendix. View the full source on GitHub or run it yourself if you’d like to replicate my results.

Results

Over 100 iterations of the benchmark each (which takes a few minutes), we can see that writes to the writes to disk are massively spikier and take up to 3-4x longer than writes to memory.

Line chart showing normal vs. incognito write timings

The histogram of timings tells a similar story. Incognito write speeds tightly cluster to the left, while writes in normal mode vary wildly. By calculating basic stats like the average and standard deviation, it should be possible to identify with reasonable certainty whether a visitor is in incognito. From my measurements, the average benchmarked time in incognito is about 792 ms, compared to 2281 ms in normal mode – 2.8x longer. And the standard deviation is 67 ms in incognito, compared to 1183 ms in normal mode – 17.7x more spread out.

Histogram of normal and incognito write timing distributions

Full data available here.

Limitations

This timing attack depends on taking many measurements to get accurate stats on a fast operation like copying a few kilobytes of data, meaning it takes on the order of minutes or tens of seconds to get sufficient data – far slower than existing techniques, which all work almost instantly.

In addition, the effectiveness of the attack varies across hardware configurations. Computers and smartphones all have different CPU, memory, and disk speeds, all of which affect timings. Background processes running on the device can also introduce noise – copying or downloading files, playing a video in another tab, or launching apps, will all skew results one way or another.

The final limitation is that the attack doesn’t really detect incognito mode – it detects the backing storage of the FileSystem API, which turns out to be a decent proxy for detecting incognito mode. It may produce false-positives for situations in which disk is memory, like live USBs or Chrome profiles stored on a tmpfs. One could argue that such configurations are attempts to circumvent tracking, making them incognito-equivalent.

The bottom line is that this technique is slower and less reliable, but harder to patch than existing methods because it attacks the underlying technical decision to store data in memory instead of on disk.

Mitigations

The only way to prevent this attack is for both incognito mode and normal mode to use the same storage medium, so that the API runs at the same speed regardless.

Chrome developers saw this coming: in a design document from March 2018, they identified the risk of attacks on timing and quota, and outlined an alternative implementation which would have prevented both my attack and Mishra’s:

We could alternatively only keep the metadata in memory, and encrypt the files on disk. This would address the risk of sites using timing to differentiate between in-memory and disk backed storage, as well as eliminate the difference in available quota and filesystem types (temporary vs. persistent).

However, such a solution comes with its own tradeoffs. While it’s resistant to our attacks, it leaves behind metadata: even if the data itself cannot be decrypted, its mere existence provides evidence of incognito usage, and leaks when the user last used incognito mode and the approximate size of the data they wrote to disk.

If we consider incognito mode’s threat model, its primary purpose is to provide privacy from other users of the same device, not privacy from the websites you visit. The tradeoff isn’t worth it, and is in fact a weaker solution for the problem which incognito mode aims to solve.

Incognito mode New Tab page

Appendix

My PoC code is just a few loops writing randomly generated strings to the FileSystem API. Full source code here. PRs, issues, and tips welcome if you have experience in writing timing attacks or benchmarking disk/memory.

const largeStrings = [
  // These strings are 5000 characters long. I generated them by running
  // base64 /dev/urandom -w 0 | head -c 5000
  'odE141SCRsNhfNBb95VhqRubp+fXTF1Dricc0G9wWrQcXRvu3uhGRh4t2TiUZF1BdSKLOrnG...',
  'pdfhLvvnkBGjbuR1/0WcCcM2li/cYOQ/wZGPAofjBXxo6PvhoEAWYtEMtTlbcLm+dPxwQFm8...',
  'Xfo5aKCHnIQc9zMtUWmGYiwzBJuDQLEVyg0t9ID2ZsCVMnVD7h8juo9Bmd+e2VdmofvGkFoa...',
  'jsYalJDnye4x5Vvl9w+F7aRrVx+WcJT5E7rzB9UNxb7iyY+mFAvsllN95ZDom50+GhhBuT+l...',
  'QcaZ/f91np7UkMvy4jrJks5Iogpgik0JZA0kCeXEPc2vdFYHKKIVT+nKmrva0qUee14LXh9Y...'
]
const SIZE = 6*1024*1024 // 6 MB
// Completely arbitrary numbers. Probably make them as high as you can tolerate:
const NUM_BENCHMARK_ITERATIONS = 200
const NUM_MEASUREMENTS = 100

const writeToFile = (fs, data) => {
  return new Promise((resolve) => {
    fs.root.getFile('data', { create: true }, (fileEntry) => {
      fileEntry.createWriter((fileWriter) => {
        fileWriter.onwriteend = resolve

        var blob = new Blob([data], { type: 'text/plain' });
        fileWriter.write(blob);
      })
    })
  })
}

const runBenchmark = async (fs) => {
  const time = new Date()
  for (let i = 0; i < NUM_BENCHMARK_ITERATIONS; i++) {
    for (let j = 0; j < largeStrings.length; j++) {
      await writeToFile(fs, largeStrings[j])
    }
  }
  return new Date() - time
}

const onInitFs = async (fs) => {
  const timings = []
  for (let i = 0; i < NUM_MEASUREMENTS; i++) {
    timings.push(await runBenchmark(fs))
  }

  console.log(timings)
}

window.webkitRequestFileSystem(window.TEMPORARY, SIZE, onInitFs)