Author: Dronex
This article is part 3 of the Fuzzing Farm series, which consists of 4 chapters. You can check the previous post at "Fuzzing Farm #2: Evaluating the Performance of Fuzzer."
The Fuzzing Farm team concentrates on exploiting OSS products, including 1-day and 0-day analysis. The remaining two parts of the Fuzzing Farm series will cover our activities related to 1-day/0-day exploit development.
Proof-of-Concepts (PoCs) are a powerful tool for attackers. By reading and running a PoC, attackers can easily investigate the exploitability of a bug. PoCs are also useful for achieving more powerful exploits, such as Remote Code Execution (RCE) and Local Privilege Escalation (LPE).
However, bug reports may not always include PoCs. Additionally, most bug reports regarding a product's security are kept private. For example, CVE-2021-30633 is a CVE assigned to a bug in Google Chrome on April 13, 2021. The Fuzzing Farm team investigated this bug in November 2021, but as of July 2023, the bug report is still private.
If there is no access to a proof of concept (PoC), attackers must create their own. One of the responsibilities of the Fuzzing Farm team is to explore the exploitability of security bugs that are not publicly disclosed.
This blog post explains the process of finding a patch corresponding to a specific CVE, analyzing it, and writing a PoC. We'll use CVE-2021-30551 as an example, which is a Use-after-Free vulnerability in Google Chrome as sufficient time passed since its bug fix.
According to Google's official announcement regarding this CVE, the bug is described as follows:
High CVE-2021-30633: Use after free in Indexed DB API. Reported by Anonymous on 2021-09-08
Reportedly, the vulnerability had already been fixed before Chromium 93.0.4577.82, but the bug report is still private.
Before analyzing the patch, let's get an overview of the bug. What is "Indexed DB" in the announcement?
IndexedDB is a data schema used to store data on the client-side (browser) and is supported on modern browsers, not just Chrome. This feature allows us to create, edit, and delete data on IndexedDB through the IndexedDB API. It is similar to the Web Storage API, but unlike Web Storage, IndexedDB can store not only string values but also structured data.
IndexedDB has the following characteristics:
Commit
or destroyed/rolled back by Abort
.Since the Renderer process of the browser does not have the privilege to access local storage, every capital operation in IndexedDB is handled in the Browser process.
To locate the vulnerable code corresponding to CVE-2021-30633, we searched the Chromium code base and studied some commits related to IndexedDB prior to Chromium 93.0.4577.82. We found the following 2 commits as a result:
M93: [IndexedDB] Add browser-side checks for committing transactions.
M93: [IndexedDB] Don't ReportBadMessage for Commit calls.
The first commit is a patch related to transactions in IndexedDB. The following code shows a portion of this patch:
@@ -87,6 +87,13 @@
return;
}
+ if (!transaction->IsAcceptingRequests()) {
+ mojo::ReportBadMessage(
+ "RenameObjectStore was called after committing or aborting the "
+ "transaction");
+ return;
+ }
+
transaction->ScheduleTask(
blink::mojom::IDBTaskType::Preemptive,
BindWeakOperation(&IndexedDBDatabase::RenameObjectStoreOperation,
This commit adds several branches to abort some operations after Commit
. The second commit is a patch that ensures the integrity of the first patch and is not related to the bug itself.
@@ -295,8 +295,8 @@
return;
if (!transaction_->IsAcceptingRequests()) {
- mojo::ReportBadMessage(
- "Commit was called after committing or aborting the transaction");
+ // This really shouldn't be happening, but seems to be happening anyway. So
+ // rather than killing the renderer, simply ignore the request.
return;
}
Figure 1 illustrates the flow of a typical transaction in IndexedDB.
Figure 1. The flow of a typical transaction in IndexedDB |
If we try to send a request from JavaScript API after Commit, an exception is thrown.
If you try to request an operation after Commit from the JavaScript API, an exception will occur. However, if you make a request directly from Mojo 1, rather than from the JavaScript API, it should not be accepted, but you can send requests to the browser process as many times as you want.
Here, let's take a look again at the code added by the patch in question.
if (!transaction->IsAcceptingRequests()) {
mojo::ReportBadMessage(
"RenameObjectStore was called after committing or aborting the "
"transaction");
return;
}
Based on the method name, we can infer that this code aborts a transaction if the transaction no longer accepts any requests. Therefore, we can assume that the vulnerability occurs when an operation is requested after the transaction has been committed, which is when the transaction stops accepting requests.
Database operations are not executed immediately upon request arrival; instead, they are first pushed to the task queue. The task queue is also used to wait for database requests until a Commit is requested.
Figure 2 illustrates the flow of the Commit request. During the interval of each step, the Renderer process can send other requests.
is_commit_pending_
flag of the transaction is set.CommitPhaseOne
first, while others skip it and execute CommitPhaseTwo
.CommitPhaseTwo
is executed. The transaction's state is set to dead, and it is no longer processed.Figure 2. Operations after a Commit request arrives |
Any request made after step 1 will be discarded if we apply the patch. The crash we discovered (explained later on) occurs between step 2 and 3, leading us to conclude that this is the bug corresponding to CVE-2021-30633.
We will investigate the cause of the Use-after-Free vulnerability that is believed to correspond to CVE-2021-30633.
IndexedDBBackingStore::Transaction::CommitPhaseOne
is called when a Commit request arrives, and it handles the first phase of the Commit process. If external_object_change_map_
is not empty, this method writes data. external_object_change_map_
is a variable that holds external objects modified by the transaction. External objects are used to store file handles when an external file is used. This occurs when the File System Access API is called or when the data is too large to use a Blob.
The writing process is implemented in IndexedDBBackingStore::Transaction::WriteNewBlobs
with the following code (in content/browser/indexed_db/indexed_db_backing_store.cc
).
for (auto& iter : external_object_change_map_) {
for (auto& entry : iter.second->mutable_external_objects()) {
switch (entry.object_type()) {
case IndexedDBExternalObject::ObjectType::kFile:
case IndexedDBExternalObject::ObjectType::kBlob:
/* ... snipped ... */
case IndexedDBExternalObject::ObjectType::kFileSystemAccessHandle: {
if (!entry.file_system_access_token().empty())
continue;
// TODO(dmurph): Refactor IndexedDBExternalObject to not use a
// SharedRemote, so this code can just move the remote, instead of
// cloning.
mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken>
token_clone;
entry.file_system_access_token_remote()->Clone(
token_clone.InitWithNewPipeAndPassReceiver());
backing_store_->file_system_access_context_->SerializeHandle(
std::move(token_clone),
base::BindOnce(
[](base::WeakPtr<Transaction> transaction,
IndexedDBExternalObject* object,
base::OnceCallback<void(
storage::mojom::WriteBlobToFileResult)> callback,
const std::vector<uint8_t>& serialized_token) {
// |object| is owned by |transaction|, so make sure
// |transaction| is still valid before doing anything else.
if (!transaction)
return;
if (serialized_token.empty()) {
std::move(callback).Run(
storage::mojom::WriteBlobToFileResult::kError);
return;
}
object->set_file_system_access_token(serialized_token);
std::move(callback).Run(
storage::mojom::WriteBlobToFileResult::kSuccess);
},
weak_ptr_factory_.GetWeakPtr(), &entry,
write_result_callback));
break;
}
}
}
}
Let's focus on the case IndexedDBExternalObject::ObjectType::kFileSystemAccessHandle
in the switch statement. This block is executed when a value with a file handle is requested with Put.
Here, a callback function is given as an argument to the backing_store_->file_system_access_context_->SerializeHandle
call, and the raw pointer &entry
is bound as an argument to the callback function.
entry
is an element of the value returned by mutable_external_object()
, which is an element of external_object_change_map_
. The summary of the variables is as follows:
external_object_change_map_
IndexedDBExternalObjectChangeRecord
class.entry
IndexedDBExternalObject
.mutable_external_objects()
:
extern_objects_
.extern_objects_
is std::vector<IndexedDBExternalObject>
. If the memory region referenced by external_objects_
is modified after the callback is set, the pointer &enter
becomes invalidated. This can lead to Use-after-Free issues in the following code. (Note that the variable object
refers to the pointer entry
.)
object->set_file_system_access_token(serialized_token);
If this line of code is executed for an invalid entry
, the program will try to write to invalid memory, potentially causing a Use-after-Free vulnerability.
We need to free the memory pointed by external_objects_
in order to cause Use-after-Free. Let’s find a code that frees the address.
IndexedDBBackingStore::Transaction::PutExternalObjects
method is called if an IndexedDBValue
is requested with Put with non-empty external_objects_
.
To cause the Use-after-Free, we need to free the memory pointed to by external_objects_
. Let’s find a code to accomplish this.
The IndexedDBBackingStore::Transaction::PutExternalObjects
method is called when an IndexedDBValue
is passed with a non-empty external_objects_
parameter in a Put request.
const auto& it = external_object_change_map_.find(object_store_data_key);
IndexedDBExternalObjectChangeRecord* record = nullptr;
if (it == external_object_change_map_.end()) {
std::unique_ptr<IndexedDBExternalObjectChangeRecord> new_record =
std::make_unique<IndexedDBExternalObjectChangeRecord>(
object_store_data_key);
record = new_record.get();
external_object_change_map_[object_store_data_key] = std::move(new_record);
} else {
record = it->second.get(); // [1]
}
record->SetExternalObjects(external_objects); // [2]
If the key of the Put request already exists in the database, it will go through the else clause [1], retrieve the existing record record
, and then reach [2]. The SetExternalObjects
method simply replaces the contents of external_objects_
, that is, it replaces the existing data for the key with the new data.
void IndexedDBExternalObjectChangeRecord ::SetExternalObjects(
std::vector<IndexedDBExternalObject>* external_objects) {
external_objects_.clear();
if (external_objects)
external_objects_.swap(*external_objects);
}
The clear
method call invokes the destructor for each element of IndexedDBExternalObject
. Additionally, the subsequent swap
method swaps the pointers of member variable external_objects_
and argument external_objects
. As a result, the old pointer is released when external_objects
is destroyed. If the callback in question is called at this timing, it can lead to Use-after-Free.
To reproduce the crash, we need to make it possible to use Mojo from JavaScript to directly request operations from the Renderer to the Browser. Mojo is available from JavaScript when a feature called MojoJS is enabled, but this feature is disabled by default. Attackers usually enable this feature by exploiting vulnerabilities in the Renderer process. However, we can enable it by passing --enable=blink-features=MojoJS,MojoJSTest
as command line arguments to Chrome, since this is an experiment for writing a PoC.
We can cause the Use-after-Free vulnerability in the following steps:
external_objects
set after WriteNewBlobs
is called and before the callback is invoked.Figure 3. The flow of race condition |
We repeat these steps until the race condition triggers a Use-after-Free vulnerability.
To easily confirm the vulnerability, we can execute the PoC on Chromium compiled with AddressSanitizer. The resulting crash is shown in Figure 4.
Figure 4. Use-after-Free triggering a crash |
Based on this crash message, we can confirm that there is a Use-after-Free occurring in the code we have investigated. The PoC code is available in the gist link below.
https://github.com/RICSecLab/exploit-poc-public/tree/main/CVE-2021-30633
This article explains the process from when an attacker investigates a CVE to writing a PoC. Once the PoC is written, the next step is to examine its exploitability. In this case, the vulnerability is useful for sandbox escape in Chrome when combined with exploiting the Renderer process and enabling Mojo in advance.
Writing a PoC based on limited vulnerability information can be helpful in determining the exploitability and how we can achieve code execution.
In the next article, we will cover the 0-day which the Fuzzing Farm team discovered and exploited.
1: A low-layer API used for communication between Renderer and Browser processes.