Blog

The Cat Escaped from the Chrome Sandbox

Introduction

On 13th September 2021, Google published the security advisory for Google Chrome. That advisory states that Google is aware of two vulnerabilities exploited in the wild, CVE-2021-30632 as RCE and CVE-2021-30633 as Sandbox Escape.

In this post, I will talk about the bypass sandbox vulnerability CVE-2021-30633. Man Yue Mo had published a very detailed blog post explaining CVE-2021-30632, which is a Type Confusion bug that leads to RCE in Chrome.

In summary, the sandbox bypass is made possible because of a Use-After-Free (UAF) bug in the IndexedDB API, chained with a Out-of-Bounds (OOB) Write bug in V8, and triggered via Mojo IPC connection. As a disclaimer, this is not a bug that I had found. I made this post to help me organise my thoughts to understand the bug and the exploit. I will carry out a root cause analysis of the Sandbox Escape and discuss my observation and understanding of the full-chain exploit.

It has been 4 months since the bugs had been patched. I believe it is relatively safe to publish an analysis of it. For this blog post, we will be using the last stable 64-bit release of Desktop Chrome for Linux before this issue was fixed, 93.0.4577.0 on Stable channel or 95.0.4638.0 on Dev channel.

Based on the description in the following commits, we are able to establish the type of bugs that the attacker might have used.

This attack surface is interesting because it exposes complex functionality directly to Javascript with MojoJS bindings. To understand the bug, you have to understand how Mojo interface a fair bit. It will be great if readers have gone through the pre-requisites and have some basic understanding of Mojo.

The article(s) SBX Intro by Robert Chen and Cleanly Escaping the Chrome Sandbox are great write-ups that covers quite a bit on Mojo

A short glance at the Vulnerability

In order to exploit the bug, the vulnerability has to be studied in detail. CVE-2021-30633 is mentioned as Use after free in Indexed DB API and I have found 3 patches which might be related to this.

[IndexedDB] Add browser-side checks for committing transactions.

No new IPCs should come in for a transaction after it starts committing. This CL adds browser-side checks in addition to the existing renderer-side checks for this.

Bug: 1247766
Change-Id: If9d69d5a0320bfd3b615446710358dd439074795
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3149409
Commit-Queue: Marijn Kruisselbrink <mek@chromium.org>
Reviewed-by: Joshua Bell <jsbell@chromium.org>
Cr-Commit-Position: refs/heads/main@{#919898}
[IndexedDB] Don't ReportBadMessage for Commit calls.

We do seem to be getting commit calls quite a lot even after a transaction has already started to be committed or aborted, so for now just avoid killing the renderer until we figure out where these calls are coming from.

Bug: 1247766
Change-Id: If7a4d4b12574c894addddbfcaf336295bd90e0a3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3154398
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Commit-Queue: Marijn Kruisselbrink <mek@chromium.org>
Cr-Commit-Position: refs/heads/main@{#920304}
[IndexedDB] Don't kill the renderer for IPCs on finished transactions.

These checks incorrectly assumes that a transaction could only end up in a "finished" state as a result of an earlier renderer IPC. However it is possible for the browser process to initiate aborting a transaction as well. If that happens we should simply ignore incoming IPCs instead.

Ideally we'd still treat incoming IPCs that happen after a (renderer initiated) commit differently and do kill the renderer in those cases, but for now the safest thing to do seems to just never kill the renderer, as we don't currently track the state needed to make that distinction.

Bug: 1249439, 1247766
Change-Id: Ie1a39eade7505bd841230045031cd1eca5c6dbbd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3163121
Reviewed-by: Joshua Bell <jsbell@chromium.org>
Commit-Queue: Marijn Kruisselbrink <mek@chromium.org>
Cr-Commit-Position: refs/heads/main@{#921750}

After reviewing the patches, we noticed that the most significant part of the patches is the addition of the function IsAcceptingRequests.

  // Returns false if the transaction has been signalled to commit, is in the
  // process of committing, or finished committing or was aborted. Essentially
  // when this returns false no tasks should be scheduled that try to modify
  // the transaction.
  bool IsAcceptingRequests() {
    return !is_commit_pending_ && state_ != COMMITTING && state_ != FINISHED;
  }

We will look at the patch in the TransactionImpl::Put. The gist of the patch is summarized below along with the snippet being the patched version.

 void TransactionImpl::Put(
     int64_t object_store_id,
     blink::mojom::IDBValuePtr input_value,
     const blink::IndexedDBKey& key,
     blink::mojom::IDBPutMode mode,
     const std::vector<blink::IndexedDBIndexKeys>& index_keys,
     blink::mojom::IDBTransaction::PutCallback callback) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
   CHECK(dispatcher_host_);

   std::vector<IndexedDBExternalObject> external_objects;
   if (!input_value->external_objects.empty())
     CreateExternalObjects(input_value, &external_objects);

   if (!transaction_) {
     IndexedDBDatabaseError error(blink::mojom::IDBException::kUnknownError,
                                  "Unknown transaction.");
     std::move(callback).Run(
         blink::mojom::IDBTransactionPutResult::NewErrorResult(
             blink::mojom::IDBError::New(error.code(), error.message())));
     return;
   }

+  if (!transaction_->IsAcceptingRequests()) {
+    mojo::ReportBadMessage(
+        "Put was called after committing or aborting the transaction");
+    return;
+  }

   IndexedDBConnection* connection = transaction_->connection();
   if (!connection->IsConnected()) {
     IndexedDBDatabaseError error(blink::mojom::IDBException::kUnknownError,
                                  "Not connected.");
     std::move(callback).Run(
         blink::mojom::IDBTransactionPutResult::NewErrorResult(
             blink::mojom::IDBError::New(error.code(), error.message())));
     return;
   }

In short, it means in the process of committing, or finished committing or was aborted, the transaction can not be executed. The newly added IsAcceptingRequests function checks for the 3 states of the transactions i.e. is_commit_pending_, COMMITTING and FINISHED.

Simply put, we need to control at least 1 of the 3 states and then hook up that state which means we can call some other requests while the transaction is on that state.

From the source codes, we can see is_commit_pending_ and COMMITTING can be modified by IndexedDBTransaction::Commit() or TransactionImpl::Commit(). Both comes from transaction’s commit. FINISHED can be set by IndexedDBTransaction::Abort() or IndexedDBTransaction::CommitPhaseTwo().

However, both function will end at IndexedDBConnection::RemoveTransaction() and we can not control anything after the transaction is removed. The control flow of is_commit_pending_ also end at IndexedDBTransaction::Abort() We can ignore it for the time being and focus on the COMMITTING state.

While tracing the control flow inside IndexedDBTransaction::Commit(), we found an interesting piece of code in the function IndexedDBBackingStore::Transaction::WriteNewBlobs()

          blob_storage_context->WriteBlobToFile(
              std::move(pending_blob),
              backing_store_->GetBlobFileName(database_id_,
                                              entry.blob_number()),
              IndexedDBBackingStore::ShouldSyncOnCommit(durability_),
              last_modified, write_result_callback);
          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));

Transaction will call 2 other services, blob_storage or file_system_access, to write committing data into the disk. You can check this in DB Data Path.

The interesting part here is the other service is executed asynchronously which means when writing is completed then it will call write_result_callback and continue to commit execution.

  auto write_result_callback = base::BindRepeating(
      [](base::WeakPtr<Transaction> transaction,
         storage::mojom::WriteBlobToFileResult result) {
        if (!transaction)
          return;
        DCHECK_CALLED_ON_VALID_SEQUENCE(transaction->sequence_checker_);

        // This can be null if Rollback() is called.
        if (!transaction->write_state_)
          return;
        auto& write_state = transaction->write_state_.value();
        DCHECK(!write_state.on_complete.is_null());
        if (result != storage::mojom::WriteBlobToFileResult::kSuccess) {
          auto on_complete = std::move(write_state.on_complete);
          transaction->write_state_.reset();
          IDB_ASYNC_TRACE_END(
              "IndexedDBBackingStore::Transaction::WriteNewBlobs",
              transaction.get());
          std::move(on_complete).Run(BlobWriteResult::kFailure, result);
          return;
        }
        --(write_state.calls_left);
        if (write_state.calls_left == 0) {
          auto on_complete = std::move(write_state.on_complete);
          transaction->write_state_.reset();
          IDB_ASYNC_TRACE_END(
              "IndexedDBBackingStore::Transaction::WriteNewBlobs",
              transaction.get());
          std::move(on_complete)
              .Run(BlobWriteResult::kRunPhaseTwoAsync, result);
        }
      },
      weak_ptr_factory_.GetWeakPtr());

This happens in ::CommitPhaseOne() and after data has been written, it continues ::CommitPhaseTwo(). Fortunately, we can bind either blob_storage or file_system_access in renderer and hook up its operation, here is clone() request, you can see in the poc. In the clone() callback, we just hang the transaction at COMMITTING state and push some tasks into the transaction.

          // 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());

The next step, we will take over the other state of the transaction to raise the bug. To create an UAF, we thought it would be easy if we can free an object in clone() callback and then the continue committing will access freed object again but we are not able to find any object can be used in this pattern.

Luckily, I managed to find a cached raw object which can be used

          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));

entry which is an external object stored in external_objects_. It was cached as raw pointer in file_system_access callback request. So if we free entry in external_objects, object shall become a dangling pointer which we can do that by replacing external object of the same key in object store.

We can free old external object by putting another external object with the same key name. In the transaction committing process, it will register some callback within raw pointer while waiting external objects to be written. These callback was registered within raw pointer of external object. Due to this bug, we can call put operator while the transaction is committing - by hooking up clone request. As such, it means we can free the external object before the callback was triggered, that finally leads to UAF.

In summary, no tasks should be scheduled during the committing process of transaction. This has several implications. But to fully understand what the bug really means to us, it is necessary to understand some core concepts of IndexedDB and understand the database mechanism. We also need some official documentation about IndexedDB features, designs and it’s implementation in chromium. The information in following links are what we require.

It will be great to read the following documentations in order to have a perspective on mojo, mojo binding and IndexedDB concept.

Exploitation

At this time, we got an UAF bug in browser side, exploit this bug could help we bypass the sandbox. We got two approach, the first is fake a virtual call like Virtually Unlimited Memory: Escaping the Chrome Sandbox and the second is force the browser creates new subprocess within --no-sandbox flag from Cleanly Escaping the Chrome Sandbox. In our exploit, as you can see, we can fake an entire object that could help us control rip as we want. But first, let’s see what we can do with the bug.

Use-After-Free

In short, we can free all external_objects_ in clone() callback and when return to write_result_callback, cached object was freed and trigger UAF. Like in snippet code above, after freed object only call:

object->set_file_system_access_token(serialized_token);
void IndexedDBExternalObject::set_file_system_access_token(
    std::vector<uint8_t> token) {
  DCHECK_EQ(object_type_, ObjectType::kFileSystemAccessHandle);
  file_system_access_token_ = std::move(token);
}

The function IndexedDBExternalObject::set_file_system_access_token is short as we can see here. It only sets value to file_system_access_token_ propety. file_system_access_token_ or token is a std::vector<uint8_t> which contains file_name. We can control the content and thus in this pattern, we can overwrite a vector.

Use-After-Free

Control Over A Vector

I’m not sure about how a vector is implemented, but we can see it contains three pointer which points to the heap that it control namely, begin, end and end_cap.

// https://github.com/llvm-mirror/libcxx/blob/master/include/vector
template <class _Tp, class _Allocator>
inline _LIBCPP_INLINE_VISIBILITY
__vector_base<_Tp, _Allocator>::__vector_base()
        _NOEXCEPT_(is_nothrow_default_constructible<allocator_type>::value)
    : __begin_(nullptr),
      __end_(nullptr),
      __end_cap_(nullptr)
{
}

We gather two observations from here. First, we can leak heap pointer of a vector. Second, we can fake a vector in file_system_access_token_ which will point to any heap we want. Because vector’s operator= will automatically delete vector container if it exist, it means if file_system_access_token_ not null, it will be freed. So we can free any heap memory address by set a faked vector in freed object.

We only can get some leaked pointer but freed object can’t be controlled anymore. In order to have more options, we can free leaked vector and any object located on that heap memory (remember we can free any address). We can then set-up another UAF on whatever object we want.

Exploiting

The following are the steps for the exploit:

  • Step 1: Spray faked external_object and get leaked token as p

Leak heap address of token p

  • Step 2: Free leaked address p

Free leaked address p

  • Step 3: Spray target object into p

Spray target object into p

  • Step 4: Free p again, which means free target object

Free p as BlobDataItem object

  • Step 5: Spray faked target object

Spray Faked BlobDataItem object

  • Step 6: Trigger a virtual call of target object => control rip

Trigger virtual call

Selecting Target Object and Control RIP

It’s quite compelling when we can create another UAF of any object, but this scenario comes with some issues as well.

In creating the target object, noisy objects can be created along with it, which will then make it difficult for us to spray target object into hole p.

In addition, target object should be easy to call a virtual method, so that we can control rip. In our exploit, we chose spray BlobDataItem object with size 504 (0x1f8). It is not difficult to spray and we can fake a BlobDataItem::DataHandle which can call ::GetSideDataSize() method as virtual call.

void BlobImpl::ReadSideData(ReadSideDataCallback callback) {
  handle_->RunOnConstructionComplete(base::BindOnce(
      [](BlobDataHandle handle, ReadSideDataCallback callback,
         BlobStatus status) {
        ...

        const auto& item = items[0];
        if (item->type() != BlobDataItem::Type::kReadableDataHandle) {
          std::move(callback).Run(absl::nullopt);
          return;
        }

        int32_t body_size = item->data_handle()->GetSideDataSize();
        if (body_size == 0) {
          std::move(callback).Run(absl::nullopt);
          return;
        }

Fullchain Exploit

To make a fullchain exploit, I tweaked the CVE-2021-03632 PoC made by @Zeusb0X as RCE part. We will be turning that poc into read/write arbitrary and set enabled_bindings_ flag to enable mojo binding and trigger this exploit to bypass sandbox.

At the time of fixing these bugs, javascript binding of IndexedDB was not generated in default, so you have to generate it by yourself.

During the process of making the demo, I used some fixed address for testing against the vulnerable version. If we were to write the exploit for different version, you will need to write the parts for finding global address and rop gadgets.

The entire exploit code can be found on our github repo.

Demo

It’s Demo Time.

Conclusion

Finally, I believe there are many ways to exploit this vulnerability. The above scenario can also be turned into read/write arbitrary.

We are thankful to Google Project Zero, Theori, Github for their blogposts that helped us in understanding how to piece everything together.

No cats were harmed during the process of this blog post.

Last but not least, the author would like to thank his teammates (Frances, Bruce, Sung & Jacob) for their proofreading as well as assistance provided during this period taken to write the exploit and the blog post. Special thanks to Sarah for the cute cats and images

References