This post provides detailed analysis and an exploit achieving remote code execution for the recently fixed Chrome vulnerability that was observed by Google to be exploited in the wild.
The release notes from Google are short on information as usual:
[$N/A][936448] High CVE-2019-5786: Use-after-free in FileReader. Reported by Clement Lecigne of Google’s Threat Analysis Group on 2019-02-27
As described on MDN, the “FileReader object lets web applications asynchronously read the contents of files (or raw data buffers) stored on the user’s computer, using File or Blob objects to specify the file or data to read”. It can be used to read the contents of files selected in a file open dialog by the user or Blobs created by script code. An example usage is shown in below.
1 | let reader = new FileReader();
reader.onloadend = function(evt) {
reader.onprogress = function(evt) {
let contents = "filecontents"; |
It is important to note that the File or Blob contents are read asynchronously and the user JS code is notified of the progress via callbacks. The onprogress event may be fired multiple times while the reading is in progress, giving access to the contents read so far. The onloadend event is triggered once the operation is completed, either in success or failure.
Searching for the issue number in the Chromium git logs quickly reveals the patch for the vulnerability, which alters a single function. The original, vulnerable version is shown below.
1 | DOMArrayBuffer* FileReaderLoader::ArrayBufferResult() {
// If the loading is not started or an error occurs, return an empty result.
DOMArrayBuffer* result = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer()); |
This function gets called each time the result property is accessed in a callback after a FileReader.readAsArrayBuffer call in JavaScript.
While the object hierarchy around the C++ implementation of ArrayBuffers is relatively complicated, the important pieces are described below. Note that the C++ namespaces of the different classes are included so that distinguishing between objects implemented in Chromium (the WTF and blink namespaces) and v8 (everything under the v8 namespace) is easier.
Comparing the code to the fixed version shown below, the most important difference is that if loading is not finished, the patched version creates new ArrayBuffer objects using the ArrayBuffer::Create function while the vulnerable version simply passes on a reference to the existing ArrayBuffer to the DOMArrayBuffer::Create function. ToArrayBuffer always returns the actual state of the ArrayBuffer being built but since the reading is a asynchronous, it may return the same one under some circumstances.
1 | DOMArrayBuffer* FileReaderLoader::ArrayBufferResult() {
// If the loading is not started or an error occurs, return an empty result.
if (!finished_loading_) {
array_buffer_result_ = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer()); |
What are those circumstances? The raw_data_ variable in the code is of the type ArrayBufferBuilder, which is used to construct the result ArrayBuffer from the incrementally read data by dynamically allocating larger and larger underlying ArrayBuffers as needed. The ToArrayBuffer method returns a smart pointer to this underlying ArrayBuffer if the contents read so far fully occupy the currently allocated buffer and creates a new one via slicing if the buffer is not fully used yet.
1 | scoped_refptr ArrayBufferBuilder::ToArrayBuffer() {
return buffer_->Slice(0, bytes_used_); |
One way to abuse the multiple references to the same ArrayBuffer is by detaching the ArrayBuffer through one and using the other, now dangling, reference. The javascript postMessage() method can be used to send messages to a JS Worker. It also has an additional parameter, transfer, which is an array of Transferable objects, the ownership of which are transfered to the Worker.
The transfer is done by the blink::SerializedScriptValue::TransferArrayBufferContents function, which iterates over the DOMArrayBuffers provided in the transfer parameter to postMessage and invokes the Transfer method of each, as shown below. blink::-DOMArrayBuffer::Transfer calls into WTF::ArrayBuffer::Transfer, which transfers the ownership of the underlying data buffer.
The vulnerability can be triggered by passing multiple blink::DOMArrayBuffers that reference the same underlying ArrayBuffer to postMessage. Transferring the first will take ownership of its buffer, then the transfer of the second will fail because its underlying ArrayBuffer has already been neutered. This causes blink::SerializedScriptValue::TransferArrayBufferContents to enter an error path, freeing the already transferred ArrayBuffer but leaving a dangling reference to it in the second blink::DOMArrayBuffer, which can then be used to access the freed memory through JavaScript.
1 | SerializedScriptValue::TransferArrayBufferContents(
wtf_size_t index = static_cast(std::distance(array_buffers.begin(), it));
if (!array_buffer->Transfer(isolate, contents.at(index))) { |
The vulnerability can be turned into an arbitrary read/write primitive by reclaiming the memory region pointed to by the dangling pointer with JavaScript TypedArrays and corrupting their length and backing store pointers. This can then be further utilized to achieve arbitrary code execution in the renderer process.
There are several aspects of memory management in Chrome that affect the reliability of the vulnerability. Chrome uses PartitionAlloc to allocate the backing store of ArrayBuffers. This effectively separates ArrayBuffer backing stores from other kinds of allocations, making the vulnerability unexploitable if the region that is freed is below 2MiB in size because PartitionAlloc will never reuse those allocations for other kinds of data. If the backing store size is above 2MiB, it is placed in a directly mapped region. Once freed, other kinds of allocations can reuse such a region. However, successfully reclaiming the freed region is only possible on 32-bit platforms, as PartitionAlloc adds additional randomness to its allocations via VirtualAlloc and mmap address hinting on 64-bit platforms beside their ASLR slides.
On a 32-bit Windows 7 install, the address space of a fresh Chrome process is similar to the one shown below. Note that these addresses are not static and will differ by the ASLR slide of Windows. Bottom-up allocations start from the lower end of the address space, the last one is the reserved region starting at 36681000. Windows heaps, PartitionAlloc regions, garbage collected heaps of v8 and Chrome, thread stacks are all placed among these regions in a bottom-up fashion. The backing store of the vulnerable ArrayBuffer will also reside here. An important thing to note is that Chrome makes a 512MiB reserved allocation (from 4600000 on the listing below) early on. This is done because the address space on x86 Windows systems is tight and gets fragmented quickly, therefore Chrome makes an early reservation to be able to hand it out for large contiguous allocations, like ArrayBuffers, if needed. Once an ArrayBuffer allocation fails, Chrome frees this reserved region and tries again. The logic that handles this could complicate exploitation, so the exploit starts out by attempting a large (1GiB) ArrayBuffer allocation. This will cause Chrome to free the reserved region, then fail to allocate again, since the address space cannot have a gap of the requested size. While most OOM conditions kill the renderer process, ArrayBuffer allocation failures are recoverable from JavaScript via exception handling.
1 | ... |
Another important factor is the non-deterministic nature of the multiple garbage collectors that are involved in the managed heaps of Chrome. This introduces noise in the address space that is hard to control from JavaScript. Since the onprogress events used to trigger the vulnerability are also fired a non-deterministic number of times, and each event causes an allocation, the final location of the vulnerable ArrayBuffer is uncontrollable without the ability to trigger garbage collections on demand from JavaScript. The exploit uses the code shown below to invoke garbage collection. This makes it possible to free the results of onprogress events continuously, which helps in avoiding out-of-memory kills of the renderer process and also forces the dangling pointer created upon triggering the vulnerability to point to the lower end of the address space, somewhere into the beginning of the original 512MiB reserved region.
1 | function force_gc() { |
The exploit achieves code execution by the following steps:
A single run of the exploit (which uses the steps detailed above) yields a success rate of about 25%, but using a trick you can turn that into effectively 100% reliability. Abusing the site isolation feature of Chrome enables brute-forcing, as described in another post on this blog by Ki Chan Ahn (look for the section titled “Making a Stealth Exploit by abusing Chrome’s Site Isolation”). A site corresponds to a (scheme:host) tuple, therefore hosting the brute forcing wrapper script on one site which loads the exploit repeatedly in an iframe from another host will cause new processes to be created for each exploit attempt. These iframes can be hidden from the user, resulting in a silent compromise. Using multiple sites to host the exploit code, the process can be parallelized (subject to memory and site-isolation process limits). The exploit developed uses a conservative timeout of 10 seconds for one iteration without parallelization and achieves code execution on average under half a minute.
The entire exploit code can be found on our github and it can be seen in action below.
The exploit doesn’t rely on any uncommon features or cause unusual behavior in the renderer process, which makes distinguishing between malicious and benign code difficult without false positive results.
Disabling JavaScript execution via the Settings / Advanced settings / Privacy and security / Content settings menu provides effective mitigation against the vulnerability.
It’s interesting to see exploits in the wild still targeting older platforms like Windows 7 x86. The 32-bit address space is so crowded that additional randomization is disabled in PartitionAlloc and win32k lockdown is only available starting Windows 8. Therefore, the lack of mitigations on Windows 7 that are present in later versions of Windows make it a relatively soft target for exploitation.
Subscribers of our N-Day feed can leverage our in-depth analysis of critical vulnerabilities to defend themselves better, or use the provided exploits during internal penetration tests.