Tuesday, July 18, 2023

Fuzzing Farm #3: Patch Analysis and PoC Development

Author: Dronex

Introduction

 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.

CVE-2021-30633

 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:

  • IndexedDB has a larger capacity compared to Web Storage. It can store more than a GiB if the client's storage has enough space.
  • IndexedDB can store not only strings but also JavaScript objects.
  • Most of the operations in IndexedDB are asynchronous. Notifications of the completion or error of an operation are passed through event handlers or Promises.
  • Transactions are used to access data in IndexedDB. Transactional operations are either finished by 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.

Analysing Patch and Bug

 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:

 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.

  1. Create a transaction.
  2. Send a request, such as Get, Put, or Delete.
  3. Send a Commit request to actually execute the sequence of operations. (This is often run automatically, but the programmer can explicitly send a Commit request.)
  4. End the transaction.

 

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.

Operation on Browser Process

 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.

  1. A Commit request arrives, and the is_commit_pending_ flag of the transaction is set.
  2. The commit operation begins. Some transactions execute CommitPhaseOne first, while others skip it and execute CommitPhaseTwo.
  3. 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.

Use-after-Free: CommitPhaseOne

 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_
    • The instance of the IndexedDBExternalObjectChangeRecord class.
  • entry
    • A reference to a variable of type IndexedDBExternalObject.
  • mutable_external_objects():
    • Returns a reference to the member variable extern_objects_.
    • The type of 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.

Race Condition to Use-after-Free

 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.

PoC: Bug Reproduction

 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:

  1. Send a Put request with an IDBValue that has a file handle.
  2. Send a Commit request. (This request can be sent before step 1 as it may cause a delay.)
  3. Send another Put request with an IDBValue that has the same key but a different 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

Conclusion

 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.

1 comment: