skip to content
Back to GitHub.com
Home Bounties Research Advisories CodeQL Wall of Fame Get Involved Events
September 30, 2021

The fugitive in Java: Escaping to Java to escape the Chrome sandbox

Man Yue Mo

In this post, I’ll exploit CVE-2021-30528 (GHSL-2021-124), which is a use-after-free vulnerability in Chrome. The vulnerability was reported in May 2021 and fixed in Chrome version 91.0.4472.77 later in the month. Successful exploitation of this vulnerability allows a compromised renderer to escape the Chrome sandbox and gain the privilege of an untrusted app in Android.

A prerequisite of the vulnerability is that the user needs to have a credit card stored in their Google Account (possibly via the Easier payments with Chrome feature, although I’ve not verified this myself), but it can otherwise be triggered by a click to a malicious website (provided the malicious website is also able to compromise the renderer with a single click, which is the case for most renderer exploits). This somewhat limits its impact. Nevertheless, from the point of view of security research, the bug is interesting for a number of reasons:

  1. This issue demonstrates subtle object lifetime management issues between the C++ and Java code in Chrome.
  2. From version 89 onwards, Chrome has switched to using the PartitionAlloc as its memory allocator. As far as I am aware, this is the first public Chrome sandbox escape exploit since the switch.
  3. I’ll re-examine a technique by Mark Brand of Project Zero to place controlled content in predictable addresses to see how effective the mitigation is in 64-bit Android.

As the third point is trivial for 32-bit binaries, I’ll develop the exploit for the 64-bit version of Chrome (which is used on devices with at least 8GB of memory).

Chrome renderer process and sandbox

The renderers in Chrome are processes responsible for rendering the contents of web pages. This includes running JavaScript and executing functionalities of various web APIs. As these operations are inherently risky due to the complexity of the JavaScript engine and web APIs, renderer processes are sandboxed and have limited privilege. In Android, they are implemented as isolated processes. In contrast, the browser process runs with the full privilege of an untrusted app.

When exploiting Chrome, an attacker usually starts with a renderer vulnerability that allows remote code execution in the renderer process. Once an attacker has a compromised the renderer, there are different options. One option is to attack another high-privilege component in the OS that can be reached from within the renderer sandbox. (For example, the Typhoon Mangkhut bug of Hongli Han, Rong Jian, Xiaodong Wang, and Peng Zhou followed this path and attacked the binder driver in Android to gain root.) This has the advantage that fewer bugs are needed to compromise the system, but the attack surface is more limited. Alternatively, the attacker can use another vulnerability in the Chrome browser process (or another unsandboxed process in Chrome) to gain control over a higher privilege process in Chrome and continue the attack with a broader attack surface. (For example, “An Exploit Chain to Remotely Root Modern Android Devices” by Guang Gong followed this path.) In this post, I’ll assume that I have a compromised renderer and use it to compromise the browser process of Chrome (i.e., the second path).

Mojo IPC

The renderer process communicates with the browser process via two interprocess communication (IPC) mechanisms, the Mojo IPC and the Legacy IPC, although nowadays most communication is done via the Mojo IPC. The Mojo IPC exposes an IDL interface that allows the renderer process to communicate with the browser process to perform more privileged operations. In “Virtually Unlimited Memory: Escaping the Chrome Sandbox,” Mark Brand discovered that the Mojo IPC has a JavaScript binding that can be enabled by passing the --enable-blink-features=MojoJS flag when launching Chrome. This not only allows him to fuzz the Mojo IPC using JavaScript fuzzers, it also greatly simplifies the process of Chrome sandbox escape research. A lot of research that followed took advantage of this and used the --enable-blink-features=MojoJS flag to simulate a compromised renderer to make arbitrary Mojo IPC calls directly from JavaScript.

Beyond MojoJS

While the JavaScript binding for the Mojo IPC is convenient, it does not cover all the possible IPC calls. One example is the associated interfaces. For instance, the vulnerability CVE-2021-21146 reported by Alison Huffman and Choongwoo Han used the BackForwardCacheControllerHost Mojo interface that cannot be called from JavaScript. This is an example of an associated interface that is associated with a RenderFrame. Many of these interfaces cannot be called using MojoJS. As IPC bugs reachable from MojoJS interfaces have now been studied extensively and researchers are beginning to explore new attack surfaces, many recent sandbox escape vulnerabilities are in IPC calls that cannot be reached via MojoJS.

Using associated interfaces generally involves calling the GetRemoteAssociatedInterfaces method and then calling GetInterface to bind the particular AssociatedRemote. The following example shows how to make IPC calls to the AutofillDriver, which is an associated interface of RenderFrame. A RenderFrame in Chrome represents a frame (main frame or child iframe) that is rendering a web page.

  blink::AssociatedInterfaceProvider* provider = frame->GetRemoteAssociatedInterfaces();
  mojo::AssociatedRemote<autofill::mojom::AutofillDriver> autofill_driver;
  provider->GetInterface(&autofill_driver);   //<------ binds the interface
  ...
  autofill_driver->QueryFormFieldAutofill(0, form, field, gfx::RectF(10,10), false); //<------ make IPC call `QueryFormFieldAutofillImpl`

The vulnerability that I’m going to discuss in this post involves the AutofillDriver. (I took the liberty of adapting Alison Huffmann and Choongwoo Han’s framework to patch the renderer.)

The vulnerability

The IPC call QueryFormFieldAutofill of the mojo::AutofillDriver interface (the call has since been renamed to AskForValuesToFill) accepts a FormData and a FormFieldData as arguments:

void BrowserAutofillManager::OnQueryFormFieldAutofillImpl(
    int query_id,
    const FormData& form,
    const FormFieldData& field,
    const gfx::RectF& transformed_box,
    bool autoselect_first_suggestion) {
  ...
  GetAvailableSuggestions(form, field, &suggestions, &context);
  ...

If the field of the form has attributes that are associated with credit cards (for example, cc-number), then an autofill lookup will be performed to check if there is any credit card information that can be used as an autofill suggestion. This includes both cards stored on the device and cards that are stored remotely (for example, those associated with the user’s account). If suggestions are available and, in particular, if a remote card exists, then in the CreditCardAccessManager::PrepareToFetchCreditCard method, the ServerCardsAvailable check will pass, and it will proceed to check platform support for web authentication by calling the IsUserVerifiable method:

void CreditCardAccessManager::PrepareToFetchCreditCard() {
#if !defined(OS_IOS)
  // No need to fetch details if there are no server cards.
  if (!ServerCardsAvailable())          //<------------- Check if remote card exists
    return;
  ...
    GetOrCreateFIDOAuthenticator()->IsUserVerifiable(base::BindOnce(     //<-------- Proceed to check platform support
        &CreditCardAccessManager::GetUnmaskDetailsIfUserIsVerifiable,
        weak_ptr_factory_.GetWeakPtr()));
  }
#endif
}

The ServerCardsAvailable check is the reason why this bug requires a credit card to be stored in the account associated with the user.

The checking of platform support of web authentication is done using the InternalAuthenticatorAndroid::IsUserVerifyingPlatformAuthenticatorAvailable method, which calls into the Java counterpart of this method (Java_InternalAuthenticator_isUserVerifyingPlatformAuthenticatorAvailable):

void InternalAuthenticatorAndroid::
    IsUserVerifyingPlatformAuthenticatorAvailable(
        blink::mojom::Authenticator::
            IsUserVerifyingPlatformAuthenticatorAvailableCallback callback) {
  JNIEnv* env = AttachCurrentThread();
  JavaRef<jobject>& obj = GetJavaObject();
  DCHECK(!obj.is_null());

  is_uvpaa_callback_ = std::move(callback);
  Java_InternalAuthenticator_isUserVerifyingPlatformAuthenticatorAvailable(env,
                                                                           obj);
}

Java objects in Chrome

The Android version of Chrome makes heavy use of Java code to access platform-specific resources. In some cases, this is necessary to access hardware resources (for example, when using NFC and Bluetooth) and in other cases, it allows the use of Android’s built-in UI, which saves reimplementing the complex UI framework and also comes with the benefit of memory safety. (For example, the UI of the payment API in Android uses Java implementation and is not affected by most of the memory corruption issues that other platforms had in the payment UI.) As accessing platform-specific resources is a privileged action, this code is often implemented in the browser process.

In the Android-specific part of the code, it is typical to find C++ wrappers of Java objects, such as InternalAuthenticatorAndroid. These objects typically create a Java object in their constructor and then take ownership of it via a ScopedJavaGlobalRef:

InternalAuthenticatorAndroid::InternalAuthenticatorAndroid(
    content::RenderFrameHost* render_frame_host)
    : render_frame_host_id_(render_frame_host->GetGlobalFrameRoutingId()) {
  JNIEnv* env = AttachCurrentThread();
  java_internal_authenticator_ref_ = Java_InternalAuthenticator_create(   //<---- `java_internal_authenticator_ref_` is a `ScopedJavaGlobalRef`
      env, reinterpret_cast<intptr_t>(this),
      render_frame_host->GetJavaRenderFrameHost());
}

In this case, a Java object named InternalAuthenticator is created using the Java_InternalAuthenticator_create JNI (Java Native Interface) method. This essentially maps to the create method of the InternalAuthenticator class in Java:

@CalledByNative
public static InternalAuthenticator create(
        long nativeInternalAuthenticatorAndroid, RenderFrameHost renderFrameHost) {
    return new InternalAuthenticator(nativeInternalAuthenticatorAndroid, renderFrameHost);
}

Note that the InternalAuthenticator Java object takes a raw pointer, nativeInternalAuthenticatorAndroid, and stores it as a member. This would normally be safe, as the created InternalAuthenticator is “owned” by the InternalAuthenticatorAndroid object, which holds a raw reference via a ScopedJavaGlobalRef: Once the InternalAuthenticatorAndroid object is destroyed, its java_internal_authenticator_ref_ will not have any reference left, and the InternalAuthenticator Java object will not be accessible and will be destroyed. When isUserVerifyingPlatformAuthenticatorAvailable is called, however, the situation is different:

@CalledByNative
public void isUserVerifyingPlatformAuthenticatorAvailable() {
    ...
    mAuthenticator.isUserVerifyingPlatformAuthenticatorAvailable(
            (isUVPAA)
                    -> InternalAuthenticatorJni.get()
                               .invokeIsUserVerifyingPlatformAuthenticatorAvailableResponse(
                                       mNativeInternalAuthenticatorAndroid, isUVPAA));
}

Note that mNativeInternalAuthenticatorAndroid is now passed to a callback in the method mAuthenticator::isUserVerifyingPlatformAuthenticatorAvailable, which ends up calling the isUserVerifyingPlatformAuthenticatorAuthenticator method of a Fido2ApiClient with callback::onIsUserVerifyingPlatformAuthenticatorAvailableResponse being the lambda function that was passed in:

public void handleIsUserVerifyingPlatformAuthenticatorAvailableRequest(
        RenderFrameHost frameHost, IsUvpaaResponseCallback callback) {
    ...
    Task<Boolean> result =
            mFido2ApiClient.isUserVerifyingPlatformAuthenticatorAvailable()
                    .addOnSuccessListener((isUVPAA) -> {
                        callback.onIsUserVerifyingPlatformAuthenticatorAvailableResponse(
                                isUVPAA);
                    });
}

This essentially makes an asynchronous call to an Android service and calls invokeIsUserVerifyingPlatformAuthenticatorAvailableResponse when the call returns.

Note that although the lambda function appears to store the value of a raw pointer, mNativeInternalAuthenticatorAndroid, in Java, it is actually taking a reference of the enclosing object (which is the InternalAuthenticator object). So if I trigger isUserVerifyingPlatformAuthenticatorAvailable by making a QueryFormFieldAutofillImpl IPC call and then destroy the InternalAuthenticatorAndroid object that mNativeInternalAuthenticatorAndroid refers to, the lambda function will keep the InternalAuthenticator object alive and when the asynchronous call returns, invokeIsUserVerifyingPlatformAuthenticatorAvailableResponse will be called with the freed mNativeinternalAuthenticatorAndroid. As InternalAuthenticatorAndroid is owned by a RenderFrameHost, which can be destroyed by closing the frame (either the main frame or a child iframe) where the IPC call is made, destroying the InternalAuthenticatorAndroid is easily achievable by making the IPC call in a child iframe and then closing it. And even though there is a race condition, in practice, this can be won almost every time by simply making the IPC call and then closing the iframe.

With all this in mind, let’s take a look at the potential impact of the use-after-free (UAF) vulnerability. The invokeIsUserVerifyingPlatformAuthenticatorAvailableResponse method in the callback is a JNI method that is implemented in InternalAuthenticatorAndroid:

void InternalAuthenticatorAndroid::
    InvokeIsUserVerifyingPlatformAuthenticatorAvailableResponse(
        JNIEnv* env,
        jboolean is_uvpaa) {
  std::move(is_uvpaa_callback_).Run(static_cast<bool>(is_uvpaa));
}

In the context of the callback, the this pointer is the mNativeInternalAuthenticatorAndroid pointer that is passed to the callback function in isUserVerifyingPlatformAuthenticatorAvailable. So with this UAF, I’ll be able to control the is_uvpaa_callback_, which is a OnceCallback<void(bool)> object:

using IsUserVerifyingPlatformAuthenticatorAvailableCallback = base::OnceCallback<void(bool)>;

A OnceCallback in Chrome is like a functor: it stores a pointer to a BindState object that contains information about the function to execute, as well as the arguments to use. When the Run method is called, the function stored as the polymorphic_invoke_ field of the BindState will be executed with the BindState itself being the first argument:

  R Run(Args... args) && {
    ...
    OnceCallback cb = std::move(*this);
    PolymorphicInvoke f =
        reinterpret_cast<PolymorphicInvoke>(cb.polymorphic_invoke());
    return f(cb.bind_state_.get(), std::forward<Args>(args)...);
  }

If I can replace the freed InternalAuthenticatorAndroid object with a fake object so that the bind_state_ pointer of its is_uvpaa_callback_ points to some other data that I control, then I’ll be able to run is_uvpaa_callback_ with a fake BindState and control both the function and the first argument.

Exploiting the bug

There are some problems that need solving when trying to exploit the bug. First, the object that I need to replace here, InternalAuthenticatorAndroid, is very small. It is of size 48 in the 64-bit binary and 24 in the 32-bit binary, and the bucket where the object lives is relatively noisy, which makes it difficult to reclaim the object. The second problem is that, even if I can reclaim the freed InternalAuthenticatorAndroid object, to make an arbitrary function call I still need the pointer to the BindState in is_uvpaa_callback_ to point to a valid address and to an object that I can control. Otherwise, the UAF will just crash Chrome.

I’ll now explain how to overcome these issues.

Object replacement

Since version 89 of Chrome, the PartitionAlloc is used in all Chrome processes. The PartitionAlloc is already used extensively as the allocator for most non-garbage collected objects in the renderer, and a notable security feature is that it supports partitioning of the heap. In blink (Chrome’s renderer), different types of objects are placed in different partitions to make it more difficult to replace objects with controlled data. For example, the backing store of an ArrayBuffer is placed in its own partition, which makes it difficult to use ArrayBuffer to corrupt the object vtable or to fake objects with controlled data with the backing store of ArrayBuffer. At the time of writing, however, the PartitionAlloc that is used outside of the renderer process contains only a single partition, so as far as memory corruption exploitation is concerned, it’s not much different from jemalloc. It is mostly a bucket-based allocator with minimal per-thread cache. (See the Architecture Detail section in this post.) Freeing an object and then allocating another one in the same bucket on the same thread immediately will cause the new object to replace the freed object.

The heap spray technique to replace objects for Chrome sandbox escape is well-documented. For example the BlobRegistry::registerFromStream IPC call in “Virtually Unlimited Memory: Escaping the Chrome Sandbox” by Mark Brand or the Clipboard::WriteImage IPC call that I used in this post can be used for reclaiming freed objects with controlled data. Both of these calls can allocate arbitrary size data in the browser process and then fill it with data supplied by the IPC call. As the allocation pattern of the PartitionAlloc is similar to the jemalloc, these techniques still apply to the PartitionAlloc. Due to per-thread cache, however, the ClipboardHost::WriteImage IPC is a better choice because the allocations it makes are on the same thread as the InternalAuthenticatorAndroid.

When trying to reclaim the freed InternalAuthenticatorAndroid object with objects created by the Clipboard::WriteImage IPC, however, I got a very low success rate. It’s almost impossible to replace the freed InternalAuthenticatorAndroid object. There are a couple of potential reasons for this:

  1. Between the freeing of the InternalAuthenticatorAndroid and the Clipboard::WriteImage IPC call, other memory allocations that I cannot control (for example, from other IPC calls going on in the background that I did not make) are made and it is simply not possible to reliably reclaim the object due to the background noise in memory allocations.
  2. The IPC call itself allocates other objects in the same bucket of InternalAuthenticatorAndroid, which prevents the freed object from being replaced.

If point one is the problem, then it’ll be very difficult to create a reliable exploit, whereas if point two is the problem, then it may be resolvable by finding a different IPC call for heap spraying. To test this, I modified the implementation of the Clipboard::WriteImage IPC call to have it allocate multiple objects of the size of InternalAuthenticatorAndroid so I could make fewer IPC calls when trying to replace the object and reduce the noise due to the IPC call itself. The result was positive. By allocating multiple objects in a single IPC call, I can replace the freed InternalAuthenticatorAndroid with nearly 100% success rate. This means that all I need to do is to find an IPC call that can allocate multiple objects with controlled size and data in a single call.

Fortunately, I don’t have to go far to find the suitable IPC call. The Clipboard::WriteCustomData IPC call also in Clipboard allows me to send a map of strings (map<mojo_base.mojom.String16, mojo_base.mojom.BigString16>) as data, which will in turn allocate String16 data for each key and value in the map. Moreover, the String16 data can contain any special character, including a null character. (The length of the string is determined by the size of its backing store rather than by null termination.) The only restriction is that the last two bytes in the backing store of each String16 will be set to zero, and the backing store size has to be even (each character in the string is a uint16_t). So for example, to spray an object of size 48, a String16 of length 23 can be used, which will give a backing store of size 48, with 46 (23 x 2) bytes of controlled data and the last two bytes zero. By using the Clipboard::WriteCustomData IPC with a large map, I was able to replace the freed InternalAuthenticatorAndroid object nearly every time.

Virtually unlimited memory, mobile edition

Now that I can replace the freed InternalAuthenticatorAndroid object with controlled data, I need to find a way to ensure that the bind_state_ pointer in the is_uvpaa_callback_ of the fake InternalAuthenticatorAndroid object points to some other data that I control. To recap, I’d like to create the following situation:

figure

While this can normally be achieved by leaking a heap address, it isn’t obvious how to leak a heap address in the current situation without an extra vulnerability. Another alternative is to do a partial replacement and try to replace the pointed to BindState object in is_uvpaa_callback_ directly. If I can do that without replacing the freed InternalAuthenticatorAndroid, then its is_uvpaa_callback_ will still point to the fake BindState that I managed to replace and there is no need to leak a heap address. Unfortunately, the BindState object in this case is in the same bucket as the InternalAuthenticatorAndroid object, so this path doesn’t seem viable either.

The current situation is very similar to the one encountered by Mark Brand in “Virtually Unlimited Memory: Escaping the Chrome Sandbox.” In that post, a technique is developed to put controlled data in a predictable address in the browser process. If I can apply that technique here, then I’ll be able to create a fake BindState in a predictable address and have my bind_state_ in the fake is_uvpaa_callback_ point to it. First, let me briefly describe the technique:

The Mojo::createDataPipe method is part of the Mojo IPC framework that can be used to create a data pipe for sending and receiving custom data between the different processes in Chrome and is used by various IPC calls, such as the BlobRegistry::RegisterFromStream. The Mojo::createDataPipe method will create a pair, ScopedDataPipeProducerHandle and ScopedDataPipeConsumerHandler, and return them to the caller. These represent the two ends of a data pipe, with the producer used for sending data and the consumer for receiving. The method Core::CreateDataPipe contains the actual implementation for creating the data pipe. The buffers for the data pipe are backed by shared memory regions that are created in Core::CreateDataPipe and are stored in the DataPipeConsumer/ProducerDispatcher objects. The dispatcher objects then get stored in the handles_ field of the global Mojo::Core object, and a handle is returned to the caller. This handle can be used to retrieve the actual dispatcher by using the Core::GetDispatcher method with the handle ID.

One interesting behaviour that Mark Brand noticed is that when using an IPC call that takes a handle of a DataPipeConsumer/Producer as an argument, for example the BlobRegistry::RegisterFromStream call in which the data argument is a handle<data_pipe_consumer>, the handle will be materialized in the browser process using the DataPipeConsumerDispatcher::Deserialize method. During the deserialization, a new DataPipeConsumerDispatcher object will be created and initialized with the InitializeNoLock call in the browser process. During initialization, the shared memory region created in Core::CreateDataPipe (represented as a file descriptor of an Android shared memory region) will be used to create virtual memory mappings via mmap. By sending DataPipeConsumer to the browser via IPC like BlobRegistry::RegisterFromStream, I’ll be able to create virtual memory regions in the browser process. If I can create a large number of memory mappings this way, to the point where most addresses are occupied (which is possible without running out of memory because virtual memory is only allocated when it is accessed), then I’ll be able to place data in almost any address. Of course, doing this by simply making the IPC calls will require an equal amount of shared memory regions to be created and filled with data, which will cause an out-of-memory error before long. To overcome this problem, Mark Brand modified the created DataPipeConsumer and replaced their buffers so that they all use the same shared memory region as the backing store. This way, only a small amount of shared memory needs to be allocated in order to map a large amount of virtual memory in the browser process, and when any of these regions is accessed they will contain the data that is in the shared memory backing store. The technique roughly contains the following steps:

  1. Create a small number of data pipes with Mojo::createDataPipe and set the size of the buffer to a small value. This will create some shared memory regions, but since the buffer size is small it shouldn’t consume much memory.
  2. Create a large shared memory region and fill it with controlled data. For each data pipe created in step one, make a duplicate of the file descriptor that is associated with this region.
  3. Modify the DataPipeConsumer/ProducerDispatcher created in step one. In particular, replace the shared_ring_buffer_ with the file descriptors that were created for the shared memory region in step two and also modify some metadata of the shared_ring_buffer_ to reflect the new size of the shared memory region.
  4. Send the DataPipeConsumerDispatcher handles via the BlobRegistry::RegisterFromStream IPC call to the browser. For each DataPipeConsumerDispatcher that the browser receives, a new virtual memory mapping will be created for the shared memory region. This will create a large amount of virtual memory with controlled data.

The actual implementation in “Virtually Unlimited Memory: Escaping the Chrome Sandbox” is more complicated because it was done in JavaScript, which doesn’t provide straightforward ways to create shared memory regions or to duplicate file descriptors.

Of course, this being a technique discovered by a member of Google’s Project Zero and the fact that the Chromium security team has good general security awareness, I’d expect there to be mitigations in place. This is indeed the case. Since the disclosure by Mark Brand, the Chromium security team has placed a limit of 32Gb on the amount of shared memory that can be mapped in every process in Chrome. So on 64-bit binaries, it’s no longer possible to use this technique to occupy a large proportion of the memory addresses in the browser process. On 32-bit binary, of course, this does not pose much of a constraint as a spray of 1GB is more than sufficient to occupy a large proportion of the available addresses. So in what follows, I’ll focus on bypassing this mitigation in the 64-bit version of Chrome.

In “Virtually Unlimited Memory: Escaping the Chrome Sandbox”, Mark Brand sprayed 16 terabytes of virtual memory in the browser process to place data in an address of his choice. This, however, may be much more than necessary in our case. After all, the goal is to place data in a predictable address, rather than to place data in any address. So if the address returned by mmap on Android follows a predictable pattern, then with sufficient heap spray to fill out some potential gaps, the addresses returned by mmap may still occupy a predictable range. If this is the case, then perhaps the technique can still be used.

To find out, I reimplemented the technique in steps one to four above and recorded the addresses that were returned when the browser process mapped the shared memory regions from the data pipe. After about 20 boots across two different devices (Pixel 3a and Samsung Galaxy A71, spraying about 30GB each time), I discovered the following:

  1. The address range occupied is more or less the same within each boot, even after Chrome restarted, but it changes with each reboot.
  2. In general, there are a handful of different regions that get occupied between different boots, and these regions are more or less disjointed.
  3. The regions that were occupied do not seem to depend on the device.

With a few possible address regions occupied after spraying, I was able to spray controlled data at some hardcoded address with around a one-in-three chance of success.

Toward 100% success rate

The fact that the address range occupied by the heap spray depends on the boot suggests that some global mechanisms may affects all processes. For example, it may be something that is inherited from the memory layout of the Zygote process that all Android user space processes are forked from. If that is the case, then it should also affect the renderer process and there may be some hints that I can take from the renderer process to help me to make a more accurate guess of the address range. To test this, I recorded the address of the shared memory region in the renderer that I created for the heap spray and compared it to the address range that I got in the browser process. These addresses do indeed seem to be strongly correlated and in fact, subtracting a fixed offset (I used 0x1000000000) from the address that I got from the renderer process will almost certainly give me an address in the browser process that is occupied by the heap spray. With this approach, there is now an almost 100% success rate to exploit the bug.

Executing arbitrary shell command

At this point, it is just a matter of locating a gadget to turn the arbitrary function call into arbitrary code execution and to defeat ASLR. Readers who are familiar with the Zygote process model on Android will know that ASLR is not an issue here because all Android user space processes are spawned from the Zygote process, which means that all the shared libraries loaded in Zygote will have the same address base in all processes. In particular, the address base of these libraries are the same in both the browser process and the renderer processes. As the renderer is assumed to be compromised, I can simply use the addresses of the gadgets in the renderer, provided that they are in libraries loaded in Zygote.

This leaves the question of finding a gadget. For this I’ll use the WebPWorker::Execute gadget that I used in One day short of a full chain: Part 2 - Chrome sandbox escape.

static void Execute(WebPWorker* const worker) {
  if (worker->hook != NULL) {
    worker->had_error |= !worker->hook(worker->data1, worker->data2);
  }
}

This function is in the libhwui.so library that is loaded in Zygote and takes a pointer to a WebPWorker. It calls its member hook with two arguments stored in the WebPWorker. If I create a fake bind_state_ and InternalAuthenticatorAndroid using the following method, then I’ll be able to make a call to the system function in libc.so with arbitrary command:

figure

In the above, the fake BindState is also used as a fake WebPWorker when WebPWorker::Execute is called as the polymorphic_invoke_ of BindState. Since I only need to set polymorphic_invoke_ in BindState and hook, data1 in WebPWorker, I can completely control all these fields and use the fake object as both a BindState and a WebPWorker, which will enable me to call system to execute an arbitrary shell command with the privileges of Chrome.

The exploit can be found here with some set up notes.

Conclusion

In this post, I’ve gone through the exploit of CVE-2021-30528, a sandbox escape that affected stable versions of Chrome. The bug itself highlights some subtleties in object lifetime management between the C++ and Java part of Chrome and a scenario where a common pattern of having a C++ object managing a Java object can go wrong. I also took a brief look at the impact of the PartitionAlloc being used in the Chrome browser process and discovered that, while it is an essential step to enable mitigations like the Miracle pointer, it only has a single partition at the moment and does not pose much of a hurdle in exploiting UAF in the browser process. Finally, I also looked at a technique introduced in “Virtually Unlimited Memory: Escaping the Chrome Sandbox” for placing controlled data in predictable addresses and its mitigation and found that, even with the mitigation in place, the technique can still be used on 64-bit Chrome in Android successfully. I hope that by looking at these mitigations in detail, these findings will provide some insights and help to improve the security of Chrome.