The fugitive in Java: Escaping to Java to escape the Chrome sandbox
In this post, I’ll exploit a use-after-free (CVE-2021-30528) in the Chrome browser process that I reported to escape the Chrome sandbox. This is a fairly interesting bug that shows some of the subtleties involved in the interactions between C++ and Java in the Android version of Chrome.
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:
- This issue demonstrates subtle object lifetime management issues between the C++ and Java code in Chrome.
- 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.
- 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:
- Between the freeing of the
InternalAuthenticatorAndroid
and theClipboard::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. - 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:
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:
- 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. - 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.
- Modify the
DataPipeConsumer/ProducerDispatcher
created in step one. In particular, replace theshared_ring_buffer_
with the file descriptors that were created for the shared memory region in step two and also modify some metadata of theshared_ring_buffer_
to reflect the new size of the shared memory region. - Send the
DataPipeConsumerDispatcher
handles via theBlobRegistry::RegisterFromStream
IPC call to the browser. For eachDataPipeConsumerDispatcher
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:
- The address range occupied is more or less the same within each boot, even after Chrome restarted, but it changes with each reboot.
- In general, there are a handful of different regions that get occupied between different boots, and these regions are more or less disjointed.
- 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:
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.
Tags:
Written by
Related posts
Uncovering GStreamer secrets
In this post, I’ll walk you through the vulnerabilities I uncovered in the GStreamer library and how I built a custom fuzzing generator to target MP4 files.
CodeQL zero to hero part 4: Gradio framework case study
Learn how I discovered 11 new vulnerabilities by writing CodeQL models for Gradio framework and how you can do it, too.
Attacking browser extensions
Learn about browser extension security and secure your extensions with the help of CodeQL.