1. Introduction
On 30th of July 2019, Chromium 76.0.3809.87 was released, fixing multiple vulnerabilities. In addition to the listed vulnerabilities, another vulnerability silently suffered a sudden death due to the refactoring of the browser-side part of the IndexedDB implementation of Chrome. The referenced commit changed the way open databases were tracked in memory. Instead of using raw pointers, the commit changed these pointers to be smart pointers, which eventually fixed the original vulnerability.
In this article we will take a deep dive into the vulnerability and its root cause and show how it can be turned into a reliable exploit, escaping the Chrome sandbox. We use the vulnerability as a case example to demonstrate how a typical vulnerability in one of the IPC interfaces exposed to the sandboxed renderer process can be turned into an inter-process info leak and finally used to execute arbitrary code in the browser process outside the sandbox. Complete proof-of-concept exploit code which spawns a reverse shell can be found at the end of this article.
Although the details might differ for other bugs, concepts are quite similar and we hope to provide some useful insights on how typical bugs in exposed IPC interfaces can successfully be exploited to escape the sandbox of recent versions of Google Chrome.
The following analysis was performed on the stable Chromium release for Android version 75.0.3770.89.
2. IndexedDB Internals
The IndexedDB API implements a persistent storage for structed client-side data. It can be used by a web application to store large amounts of data inside the browser. Data is stored as key/value pairs while values can be complex structured JavaScript objects. IndexedDB is built upon a transactional database model and every operation on the database happens in the context of a transaction.
The IndexedDB implementation in Chrome is quite complex and exposes various IPC interfaces to the sandboxed renderer processes which makes it an attractive target for finding bugs to escape the Chrome sandbox.
2.1. Mojo Interfaces
The major part of the IndexedDB implementation in Chrome is implemented in the browser process. Several different mojo IPC interfaces exist both in the browser as well as in the renderer to allow the communication between both processes and enable the sandboxed renderer to perform IndexedDB operations.
The IDBFactory mojo interface provides the main entry point for the renderer. Among a few utility methods it provides the Open method which corresponds to the open() method of the IDBFactory JavaScript interface and can be used to request opening a connection to a database.
Two of the utility methods provided by the IDBFactory interface relevant for our discussion below are AbortTransactionsAndCompactDatabase and AbortTransactionsForDatabase. Calling any of these methods will immediately finish all ongoing transactions. Interestingly the renderer never made use of these functions in the version we analyzed.
Two of the arguments passed to the Open method are interface pointers pointing to the IDBCallbacks and IDBDatabaseCallbacks mojo interfaces implemented in the renderer process. While the former is used by the browser process to return results back to the renderer for individual requests, the latter is used to notify the renderer about out of band events related to these requests.
Once a call to the Open method succeeded, the browser sends an interface pointer to the IDBDatabase interface back to the renderer. The IDBDatabase interface provides all the methods to perform operations on the opened database. When the renderer is done working with the database, it calls the Close method on the IDBDatabase interface which will close the connection to the database on the browser-side.
There are many more mojo interfaces defined for IndexedDB, but the ones described above are enough for our discussion below. The complete list of mojo interfaces for IndexedDB can be found in the corresponding mojom file at third_party/blink/public/mojom/indexeddb/indexeddb.mojom.
2.2. Databases, Connections and Requests
IndexedDB has the concept of databases and connections. In the Chrome implementation they are represented by the IndexedDBDatabase and IndexedDBConnection classes respectively. Multiple connections to the same database can exist at a given time but there will only be a single IndexedDBDatabase object per database.
A renderer using the IDBDatabase mojo interface to talk to a database will always go through the current connection to perform methods on the corresponding database object.
Another important concept to understand are requests. Opening or deleting a database does not happen synchronously but will schedule a request to perform the corresponding action. The IndexedDBDatabase::OpenRequest and IndexedDBDatabase::DeleteRequest classes implement this functionality.
As stated above, IndexedDB is based on a transactional database model. The code implements a single transaction as a IndexedDBTransaction object. Most operations are performed in the context of a transaction which can be rolled back in case of failure.
2.3. The Database Map
In order to keep track of all open databases, a database map indexed by origin and database name stores raw pointers to the corresponding IndexedDBDatabase objects. The database map is stored in the IndexedDBFactoryImpl class as database_map_.
When the renderer requests to open a connection to a database by calling the Open method of the IDBFactory interface, it will consult the database map [1] in order to figure out if the requested database is already open.
void IndexedDBFactoryImpl::Open( const base::string16& name, std::unique_ptr<IndexedDBPendingConnection> connection, const Origin& origin, const base::FilePath& data_directory) { IDB_TRACE("IndexedDBFactoryImpl::Open"); IndexedDBDatabase::Identifier unique_identifier(origin, name); auto it = database_map_.find(unique_identifier); [1] if (it != database_map_.end()) { it->second->OpenConnection(std::move(connection)); [2] return; } [...] scoped_refptr<IndexedDBDatabase> database; [3] std::tie(database, s) = IndexedDBDatabase::Create( name, backing_store.get(), this, std::make_unique<IndexedDBMetadataCoding>(), unique_identifier, backing_store->lock_manager()); [...] database->OpenConnection(std::move(connection)); if (database->ConnectionCount() > 0) { database_map_[unique_identifier] = database.get(); [4] origin_dbs_.insert(std::make_pair(origin, database.get())); } }
In case the database is not yet open, a new IndexedDBDatabase object is created [3] and a raw pointer to the object is stored in the database map [4].
If the requested database is already open, the raw pointer to the IndexedDBDatabase object is taken from the map [1] and used to create a new connection to the database using the IndexedDBDatabase::OpenConnection method [2].
In either case, a IDBDatabase mojo interface pointer corresponding to the database object is returned to the renderer in the end, allowing the renderer to talk to the corresponding database.
2.4. IndexedDBDatabase Object Lifetime
The IndexedDBDatabase object is a reference counted object. Counted references to it are kept in the IndexedDBConnection object, the IndexedDBTransaction object as well as in ongoing or pending request objects. Once the reference count drops to zero the object is freed.
In case the database object is freed, it's important that the corresponding raw pointer to the IndexedDBDatabase is removed from the database map as well. This happens inside the IndexedDBDatabase::Close method when a connection to the database is closed.
void IndexedDBDatabase::Close(IndexedDBConnection* connection, bool forced) { DCHECK(connections_.count(connection)); DCHECK(connection->IsConnected()); DCHECK(connection->database() == this); IDB_TRACE("IndexedDBDatabase::Close"); // Abort outstanding transactions from the closing connection. This can not // happen if the close is requested by the connection itself as the // front-end defers the close until all transactions are complete, but can // occur on process termination or forced close. connection->FinishAllTransactions(IndexedDBDatabaseError( [5] blink::kWebIDBDatabaseExceptionUnknownError, "Connection is closing.")); // Abort transactions before removing the connection; aborting may complete // an upgrade, and thus allow the next open/delete requests to proceed. The // new active_request_ should see the old connection count until explicitly // notified below. connections_.erase(connection); [6] // Notify the active request, which may need to do cleanup or proceed with // the operation. This may trigger other work, such as more connections or // deletions, so |active_request_| itself may change. if (active_request_) [7] active_request_->OnConnectionClosed(connection); // If there are no more connections (current, active, or pending), tell the // factory to clean us up. if (connections_.empty() && !active_request_ && pending_requests_.empty()) { [8] backing_store_ = nullptr; factory_->ReleaseDatabase(identifier_, forced); [9] } }
The Close method will first abort any outstanding transactions on the current connection [5]. Additionally it will notify the currently executing transaction and let it know that the current database is about to be closed [7].
Finally the code checks if the currently closed connection was the last connection to the database and that there are no more ongoing or pending requests [8]. Only in that case the code removes the raw IndexedDBDatabase pointer from the database map by calling IndexedDBFactoryImpl::ReleaseDatabase at [9].
If that condition is not true, the raw pointer to the database object will be kept in the database map. The intend of the code is to remove the raw database pointer from the database map once the last connection to the database and all its corresponding requests were closed.
The assumption is that in case the condition evaluates to false, there's either a connection or a request still holding a reference to the IndexedDBDatabase object to keep it alive. However, as it turns out, this assumption is flawed.
3. IndexedDB Race Condition
The code is vulnerable to a race condition which can lead to a dangling raw pointer to a freed IndexedDBDatabase object in the database map.
In order to create this scenario, we start by opening a database, specifying a version of 0. This will create a new IndexedDBDatabase object and immediately open a new connection to it.
Afterwards we request to open the same database again, but this time specifying a higher version of 2. This requires a database upgrade operation. However since we still have the connection with version 0 open to the database, the upgrade is not started immediately when the OpenRequest is performed, but is delayed until OpenRequest::OnConnectionClosed is called and all connections to the database are closed.
void OnConnectionClosed(IndexedDBConnection* connection) override { // This connection closed prematurely; signal an error and complete. if (connection && connection->callbacks() == pending_->database_callbacks) { pending_->callbacks->OnError( IndexedDBDatabaseError(blink::kWebIDBDatabaseExceptionAbortError, "The connection was closed.")); db_->RequestComplete(this); return; } if (!db_->connections_.empty()) [10] return; std::vector<ScopesLockManager::ScopeLockRequest> lock_requests = { {kDatabaseRangeLockLevel, GetDatabaseLockRange(db_->metadata_.id), ScopesLockManager::LockType::kExclusive}}; db_->lock_manager_->AcquireLocks( std::move(lock_requests), base::BindOnce(&IndexedDBDatabase::OpenRequest::StartUpgrade, weak_factory_.GetWeakPtr())); }
After we opened the second database connection, IndexedDBDatabase::active_request_ is pointing to the OpenRequest object with version 2 which delayed the database upgrade operation.
If we now close the first database connection for version 0, IndexedDBDatabase::Close will first delete the last connection to the database [6] and then call OpenRequest::OnConnectionClosed on the current OpenRequest pointed to by IndexedDBDatabase::active_request_.
Since the last connection to the database was removed, OpenRequest::OnConnectionClosed will then start the delayed upgrade operation by calling IndexedDBDatabase::OpenRequest::StartUpgrade. StartUpgrade will create a new connection and schedule a new VersionChangeOperation task in the current transaction:
// Initiate the upgrade. The bulk of the work actually happens in // IndexedDBDatabase::VersionChangeOperation in order to kick the // transaction into the correct state. void StartUpgrade(std::vector locks) { connection_ = db_->CreateConnection(pending_->database_callbacks, pending_->child_process_id); DCHECK_EQ(db_->connections_.count(connection_.get()), 1UL); std::vector<int64_t> object_store_ids; IndexedDBTransaction* transaction = connection_->CreateTransaction( pending_->transaction_id, std::set<int64_t>(object_store_ids.begin(), object_store_ids.end()), blink::mojom::IDBTransactionMode::VersionChange, new IndexedDBBackingStore::Transaction(db_->backing_store())); transaction->ScheduleTask( base::BindOnce(&IndexedDBDatabase::VersionChangeOperation, db_, pending_->version, pending_->callbacks)); transaction->Start(std::move(locks)); }
If we now reach the check [8] in the last lines of IndexedDBDatabase::Close, the condition will evaluate to false and the raw pointer to the current IndexedDBDatabase object will not be removed since there's still a connection to the database.
Immediately after calling the mojo Close method to close the connection to database version 0, we now quickly call the AbortTransactionsForDatabase method on the IDBFactory mojo interface from the renderer so that it executes before the posted IndexedDBDatabase::VersionChangeOperation task got a chance to run.
Calling the AbortTransactionsForDatabase mojo method ends up calling IndexedDBConnection::FinishAllTransactions on every connection to the database:
void IndexedDBConnection::FinishAllTransactions( const IndexedDBDatabaseError& error) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); std::unordered_map<int64_t, std::unique_ptr<IndexedDBTransaction>> temp_map; std::swap(temp_map, transactions_); for (const auto& pair : temp_map) { auto& transaction = pair.second; if (transaction->is_commit_pending()) { IDB_TRACE1("IndexedDBDatabase::Commit", "transaction.id", transaction->id()); transaction->ForcePendingCommit(); } else { IDB_TRACE1("IndexedDBDatabase::Abort(error)", "transaction.id", transaction->id()); transaction->Abort(error); } } }
Since there's no pending commit, the code calls the IndexedDBTransaction::Abort method on every transaction which then ends up calling IndexedDBDatabase::TransactionFinished [11] to signal that the transaction finished.
void IndexedDBTransaction::Abort(const IndexedDBDatabaseError& error) { [...] database_->TransactionFinished(mode_, false); [11] // RemoveTransaction will delete |this|. // Note: During force-close situations, the connection can be destroyed during // the |IndexedDBDatabase::TransactionFinished| call if (connection_) connection_->RemoveTransaction(id_); }
The IndexedDBDatabase::TransactionFinished now calls OpenRequest::UpgradeTransactionFinished which ends up calling IndexedDBDatabase::RequestComplete, finishing the active request and clearing the IndexedDBDatabase::active_request_ pointer at [12]:
void IndexedDBDatabase::RequestComplete(ConnectionRequest* request) { DCHECK_EQ(request, active_request_.get()); scoped_refptr<IndexedDBDatabase> protect(this); active_request_.reset(); [12] // Exit early if |active_request_| held the last reference to |this|. if (protect->HasOneRef()) return; if (!pending_requests_.empty()) ProcessRequestQueue(); }
The OpenRequest object owns the corresponding IndexedDBConnection object. When the active OpenRequest is destroyed by clearing the IndexedDBDatabase::active_request_ pointer, the IndexedDBConnection object is freed as well, including all its transactions.
At this point all the references to the IndexedDBDatabase object are gone and it will be freed. We also closed all the connections to the database but tricked the code into not removing the raw IndexedDBDatabase pointer from the database map. So we successfully created a scenario where the database map has a dangling raw pointer to a freed IndexedDBDatabase object!
If we now try to open the same database again from the renderer we'll end up operating on the freed IndexedDBDatabase object.
4. Exploitation
Once we successfully triggered the use-after-free scenario via the race condition there are no more time constraints and we can nicely control when the freed object is used again.
The target of the exploit described in the following sections is the 64-bit version of Chrome running on Android. But with slight modifications the vulnerability can also be exploited on Linux or Windows.
4.1. Building the Info Leak
The first thing we need to do is to turn the bug into an info leak to leak the Chrome base address into the renderer. There are several ways this can be done, but we decided to use the IndexedDB mojo interfaces and their callbacks for this purpose.
We didn't find a way to leak the Chrome base address directly, so we need to trigger the bug two times.
4.1.1. Leaking a Heap Pointer
We trigger the bug a first time by doing the two Open calls with version 0 and version 2 respectively, followed by the call to Close and AbortTransactionsForDatabase to trigger the race condition and the eventual freeing of the IndexedDBDatabase object.
We then misuse the CreateObjectStore method to reallocate the freed IndexedDBDatabase object with the IDB keypath string of the metadata which is created for the corresponding object store.
We fully control the content of the keypath string and we use it to craft a fake IndexedDBDatabase object, setting the fields pending_requests_.buffer and pending_requests_.capacity to 0.
If we now call the Open method to get a mojo interface pointer to the freed IndexedDBDatabase object we are operating on the fake object we crafted. Calling Open will just add a new OpenRequest to the pending_requests_ queue. Since we set the buffer and capacity of it to 0, a new buffer will be allocated, and the pending_requests_.buffer field of the fake object will be set to a new heap pointer which is effectively stored inside the keypath string.
The metadata of an object store can be leaked back to the renderer, by calling the Commit method, so we can easily leak this heap pointer into the renderer.
Before leaking the pointer, we keep calling the Open method on the freed IndexedDBDatabase, which will add more and more OpenRequest pointers to the pending_requests_ queue and thus grow the underlying backing buffer. By controlling the number of calls to Open we thus control the size of the allocated backing buffer.
We then leak the pointer to this backing buffer, by calling the Commit method and extracting the heap pointer from the returned metadata.
4.1.2. Replacing Memory of Heap Pointer with Object
Once we leaked the heap pointer to the pending_requests_ backing buffer, we keep calling the Open method a few more times on the freed IndexedDBDatabase object, which after a few calls will reallocate the backing buffer again to grow it. This has the effect that the heap pointer we leaked is now freed.
In order to prevent unrelated code from using the memory pointed to by the leaked pointer we use the CreateObjectStore method again, to reallocate the freed backing buffer with the keypath of a new object store. This gives us control over when we want to free the memory again later.
At this point we have leaked a heap pointer into the renderer which now points to an allocated keypath string inside the metadata of an object store.
4.1.3. Leaking the Vtable Pointer
In order to now leak a vtable pointer, we trigger the vulnerability one more time. First we need to reallocate the previously freed IndexedDBDatabase object with a valid IndexedDBDatabase object again, to not cause crashes when triggering the bug a second time. Because calling the AbortTransactionsForDatabase method will iterate over the database_map_ and touch every referenced object.
After triggering the bug a second time, we use the CreateObjectStore method again to reallocate the freed IndexedDBDatabase object with a new crafted fake object.
In the crafted fake object, we set the pending_requests_.buffer field to the previously leaked heap pointer (which points to a keypath string in the metadata of a previously created object store) and we set the pending_requests_.capacity field to 1.
We now call Open on the freed IndexedDBDatabase a single time, which will try to append a new OpenRequest to the pending_requests_ queue of the fake object. Since we set the capacity to 1, the code will try to reallocate the backing buffer, effectively freeing the memory pointed to by pending_requests_.buffer and replacing it with a larger buffer.
This frees the memory pointed to by the leaked heap pointer which we then reallocate with a valid IndexedDBDatabase object, by repeatedly calling Open with new database names again.
One trick we use here is to set the names of the created databases to a very large string of 0x4000 bytes. In a later stage we will leak the content of one of the created IndexedDBDatabase objects, which will not only leak the vtable pointer, but also the pointer to the name string of the database, which will then provide us with a heap pointer pointing to a large slack space we can use to store the ROP chain and shellcode later.
Now we just read back the metadata of the previously created object store, which will receive the content of one of the IndexedDBDatabase objects.
We use the vtable pointer from the leaked IndexedDBDatabase object and the pointer to the name string of the object to leak a pointer to enough memory to store the ROP chain and shellcode.
4.2. Getting Code Execution
Once we leaked the pointer to the slack memory and the Chrome base address, we use the memory of the slack memory to place a fake OpenRequest object where we use its virtual Perform method to get code execution and start the ROP chain.
We then replace the memory of the freed IndexedDBDatabase with a crafted fake object setting processing_pending_requests_ to 0 and the pending_requests_.buffer to the memory where we placed the fake OpenRequest pointer. Since processing_pending_requests_ is 0, a call to the Open method on the freed IndexedDBDatabase will then start calling the Perform method on requests stored in pending_requests_ which will then use our fake object and gives us code execution.
4.2.1. ROP Chain
We are misusing the IndexedDBDatabase::ProcessRequestQueue method to get control over the program counter:
void IndexedDBDatabase::ProcessRequestQueue() { // Don't run re-entrantly to avoid exploding call stacks for requests that // complete synchronously. The loop below will process requests until one is // blocked. if (processing_pending_requests_) return; DCHECK(!active_request_); DCHECK(!pending_requests_.empty()); base::AutoReset<bool> processing(&processing_pending_requests_, true); do { active_request_ = std::move(pending_requests_.front()); pending_requests_.pop(); active_request_->Perform(); [13] // If the active request completed synchronously, keep going. } while (!active_request_ && !pending_requests_.empty()); }
We get control when the ConnectionRequest::Perform method is executed at [13]. At the time of the call, register x0 points to the slack space we previously allocated and we fully control. The corresponding assembly code is shown below:
<content::IndexedDBDatabase::ProcessRequestQueue()+72>: ldr x0, [x21] <content::IndexedDBDatabase::ProcessRequestQueue()+76>: ldr x8, [x0] <content::IndexedDBDatabase::ProcessRequestQueue()+80>: ldr x8, [x8,#16] <content::IndexedDBDatabase::ProcessRequestQueue()+84>: blr x8 [13] <content::IndexedDBDatabase::ProcessRequestQueue()+88>: ldr x8, [x21]
4.2.1.1. Strategy
Since we leaked the address of our allocated slack space (x0) and the chrome base address before, we can build a simple ROP chain which just changes page permissions of the slack space to be read/write/executable and finally jump into the shellcode placed after the ROP chain in the slack space memory.
4.2.1.2. Gadgets
We are using the following six ROP gadgets (offsets specific to our test build):
* G1: 0x4959c14 : ldr x8, [x0, #0x48]! ; ldr x1, [x8, #0x100] ; br x1 * G2: 0x1df7f8c : ldr x9, [x8, #0x190] ; ldr x6, [x8, #0x80] ; blr x9 * G3: 0x3e7a4b0 : ldr x20, [x0, #0x68] ; ldr x9, [x8] ; mov x0, x8 ; ldr x9, [x9, #0xf8] ; blr x9 * G4: 0x3f9152c : ldr x2, [x0, #0x18] ; ldr x0, [x0, #0x38] ; br x2 * G5: 0x2fbf400 : ldr x8, [x8, #0x10] ; blr x8 ; ldr x8, [x20, #0x3b8] ; cbz x8, #0x2fbf424 ; blr x8 * G6: 0x3f0fd88 : ldr x5, [x6, #0x28] ; ldr x4, [x6, #0x20] ; ldr x3, [x6, #0x18] ; ldr x2, [x6, #0x10] ; ldr x1, [x6, #8] ; mov x8, x0 ; ldr x0, [x6] ; svc #0 ; ret
The corresponding offsets should be looked up (e.g. using ROPgadget) on the target binary. The first gadget G1 is the one we use to start the ROP chain.
4.2.1.3. Memory Layout Preparation
The ROP chain as well as the shellcode will be placed into the slack memory space. Specifically it will be setup in the following way:
| Offset | Value | Used By | Comment | | ------------------ |:-------------------:|:-------:| ------------------------------------------:| | 0x50 (0x48+8) | slackbase+0x100 | G1 | x8 = slackbase+0x100 | | 0xb8 (0x48+0x68+8) | slackbase | G3 | x20 = slackbase | | 0x100 | slackbase+0x260 | G3 | x9 = slackbase+0x260, x0 = slackbase+0x100 | | 0x110 (0x100+0x10) | gadget addr G6 | G5 | x30 = addr of ldr x8 | | 0x118 (0x100+0x18) | gadget addr G5 | G4 | x2 = addr of G5 | | 0x138 (0x100+0x38) | 226 | G4 | x0 = 226 | | 0x180 (0x100+0x80) | slackbase+0x300 | G2 | x6 = slackbase+0x300 | | 0x200 (0x100+0x100) | gadget addr G2 | G1 | x1 = addr of G2 | | 0x290 (0x100+0x190) | gadget addr G3 | G2 | x9 = addr of G3 | | 0x300 | slackbase (aligned) | G6 | x0 = aligned slackbase | | 0x308 (0x300+0x8) | 0x4000 | G6 | x1 = 0x4000 | | 0x310 (0x300+0x10) | 7 | G6 | x2 = 7 | | 0x318 (0x300+0x18) | 0 | G6 | x3 = 0 | | 0x320 (0x300+0x20) | 0 | G6 | x4 = 0 | | 0x328 (0x300+0x28) | 0 | G6 | x5 = 0 | | 0x358 (0x260+0xf8) | gadget addr G4 | G3 | x9 = addr of G4 | | 0x3b8 | 0x1000 | G5 | x8 = slackbase+0x1000 | | 0x1000 | shellcode | | |
This ends up changing the permissions of the slack memory to be read/write and executable and jump into the shellcode placed in that memory.
For process continuation, we just return directly after the virtual function call where we got control over the program flow to safely let the program continue to run.
4.3. Android Exploit
The final exploit targeting the 64-bit version of Chrome for Android can be found here. It's provided as a set of renderer source code patches for Chromium with some simple JavaScript bindings for testing. Offsets and gadgets need to be adjusted. Successful exploitation executes a reverse shell payload inside the privileged browser process.