A Lifetime Bug Across the C++/Java Boundary
Chrome's renderer processes are sandboxed on Android as isolated processes, while the browser process runs with the full privilege of an untrusted app. An attacker who has already compromised a renderer typically has two paths forward: attack an OS-level component reachable from the sandbox, or find a second bug in a higher-privilege Chrome process. This article examines a vulnerability in the latter category—a use-after-free in Chrome's interaction between native C++ code and Java components.
CVE-2021-30528 is a use-after-free fixed in Chrome 91.0.4472.77. Exploiting it lets a compromised renderer escape the sandbox and gain browser-process privileges. One prerequisite limits the bug's real-world reach: the targeted user must have a credit card stored in their Google Account, likely through the "Easier payments with Chrome" feature. Beyond that, however, the bug is notable for three reasons:
- It highlights subtle object lifetime management problems between C++ and Java code inside Chrome.
- It is, as far as is publicly known, the first Chrome sandbox-escape exploit written since Chrome 89 switched to PartitionAlloc as its memory allocator.
- Exploiting it requires re-testing a technique from Project Zero's Mark Brand for placing controlled data at predictable addresses—specifically, how well that technique holds up against PartitionAlloc's mitigations in 64-bit Android builds. The technique is trivial on 32-bit binaries, so this exploit targets the 64-bit version of Chrome, which runs on devices with at least 8GB of RAM.
Renderer Communication: Mojo IPC and Its Limits
Renderers talk to the browser process through two IPC mechanisms: the older Legacy IPC and the now-dominant Mojo IPC. Mojo IPC exposes an IDL interface so the renderer can request privileged operations. In "Virtually Unlimited Memory: Escaping the Chrome Sandbox," Project Zero showed that passing --enable-blink-features=MojoJS at launch enables a JavaScript binding for Mojo IPC. That flag effectively simulated a compromised renderer from JavaScript, which greatly simplified sandbox-escape research—researchers could fuzz interfaces and make calls without first building a renderer exploit.
The convenience has limits. MojoJS does not cover every IPC call. Associated interfaces, in particular, are bound to a RenderFrame and generally cannot be triggered through the JavaScript binding. CVE-2021-21146, reported by Alison Huffman and Choongwoo Han, is one example: it used the BackForwardCacheControllerHost interface, which is unreachable from MojoJS. As the easier MojoJS-reachable interfaces have been picked over, researchers have moved toward these associated interfaces, and many recent sandbox escapes live in IPC calls that MojoJS cannot make.
Using an associated interface usually means calling GetRemoteAssociatedInterfaces, then calling GetInterface to bind the specific AssociatedRemote. The pattern is straightforward for, say, the AutofillDriver, which is an associated interface of RenderFrame—a RenderFrame being the object that renders a page's main frame or a child iframe:
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 at the center of this article sits in AutofillDriver. (The exploit framework used here adapts Huffman and Han's renderer-patching approach to reach this interface.)
Hitting the Java boundary from a compromised renderer
The route into this bug starts at the mojo::AutofillDriver IPC interface, where the QueryFormFieldAutofill call (renamed to AskForValuesToFill in later Chromium revisions) accepts a FormData and a FormFieldData:
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);
...
When the supplied field carries credit-card attributes such as cc-number, Chromium performs an autofill lookup that can surface both locally stored cards and cards synced to the user’s account. If a server-side card is available, the path in CreditCardAccessManager::PrepareToFetchCreditCard passes the ServerCardsAvailable gate and then asks the platform whether user verification is supported via IsUserVerifiable:
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 condition is what ties exploitability to having a card associated with the account. Platform support for web authentication is probed by InternalAuthenticatorAndroid::IsUserVerifyingPlatformAuthenticatorAvailable, which forwards to the Java 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 wrappers owned by C++
Chrome on Android leans on Java for platform-specific resources—NFC, Bluetooth, and native UI such as the payment sheet. Those privileged code paths typically run in the browser process, and they are wrapped on the C++ side by classes like InternalAuthenticatorAndroid. These wrappers construct a Java peer in their constructor and hold it through 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());
}
The JNI creation call Java_InternalAuthenticator_create maps to the create method of the Java class InternalAuthenticator:
@CalledByNative
public static InternalAuthenticator create(
long nativeInternalAuthenticatorAndroid, RenderFrameHost renderFrameHost) {
return new InternalAuthenticator(nativeInternalAuthenticatorAndroid, renderFrameHost);
}
The Java object receives a raw native pointer (nativeInternalAuthenticatorAndroid) and stores it in a member. Under normal ownership rules this is safe: the parent InternalAuthenticatorAndroid holds the only strong reference to its Java peer, and when the C++ side is destroyed, the peer loses its last reference and becomes unreachable.
The trouble appears in isUserVerifyingPlatformAuthenticatorAvailable, where that raw native pointer leaves the ownership scope. The method passes itself into an asynchronous Android service call via a lambda, wrapping mAuthenticator::isUserVerifyingPlatformAuthenticatorAvailable:
@CalledByNative
public void isUserVerifyingPlatformAuthenticatorAvailable() {
...
mAuthenticator.isUserVerifyingPlatformAuthenticatorAvailable(
(isUVPAA)
-> InternalAuthenticatorJni.get()
.invokeIsUserVerifyingPlatformAuthenticatorAvailableResponse(
mNativeInternalAuthenticatorAndroid, isUVPAA));
}
public void handleIsUserVerifyingPlatformAuthenticatorAvailableRequest(
RenderFrameHost frameHost, IsUvpaaResponseCallback callback) {
...
Task<Boolean> result =
mFido2ApiClient.isUserVerifyingPlatformAuthenticatorAvailable()
.addOnSuccessListener((isUVPAA) -> {
callback.onIsUserVerifyingPlatformAuthenticatorAvailableResponse(
isUVPAA);
});
}
Although the lambda’s Java body looks like it stores a raw pointer value, it is in fact capturing the enclosing InternalAuthenticator object. The native pointer becomes a dangling reference the moment the C++ owner is gone—and that owner, InternalAuthenticatorAndroid, sits on a RenderFrameHost. Any renderer can trigger the autofill IPC from an iframe and then close that frame, destroying the native object while the asynchronous Java call is still in flight. The race window exists, but in practice it can be won consistently with a rapid IPC-then-close sequence.
Turning a dangling callback into arbitrary invocation
When the Android service eventually responds, the JNI method invokeIsUserVerifyingPlatformAuthenticatorAvailableResponse is invoked on the freed native object:
void InternalAuthenticatorAndroid::
InvokeIsUserVerifyingPlatformAuthenticatorAvailableResponse(
JNIEnv* env,
jboolean is_uvpaa) {
std::move(is_uvpaa_callback_).Run(static_cast<bool>(is_uvpaa));
}
Inside that callback, the this pointer is the raw mNativeInternalAuthenticatorAndroid captured by the lambda, so the freed object’s is_uvpaa_callback_—a OnceCallback<void(bool)>—is now attacker-influenced memory:
using IsUserVerifyingPlatformAuthenticatorAvailableCallback = base::OnceCallback<void(bool)>;
Chrome’s OnceCallback is a functor backed by a BindState object that holds both the target function pointer (polymorphic_invoke_) and its bound arguments. Calling Run executes that function with the BindState itself as the first parameter:
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)...);
}
By reclaiming the freed InternalAuthenticatorAndroid with a fake object—one whose bind_state_ points to attacker-controlled data—an exploit can walk the OnceCallback machinery to execute an arbitrary function with a controlled first argument. That gives a compromised renderer a direct hand in the browser process, with no further sandbox hop required.
Filling the freed object
Replacing the freed InternalAuthenticatorAndroid object is the first hurdle. The object is small — 48 bytes on 64-bit builds and 24 bytes on 32-bit builds — and it sits in a noisy bucket. Background allocations from unrelated IPC traffic can easily claim the slot before any attempt to reclaim it. Even if the replacement succeeds, the is_uvpaa_callback_ field must point to a valid, attacker-controlled BindState or the use-after-free simply crashes the browser.
Chrome 89 moved all processes onto PartitionAlloc. In the renderer, object types are separated into partitions, which complicates cross-type replacement. Outside the renderer, however, PartitionAlloc operates as a single-partition, bucket-based allocator with a small per-thread cache — structurally similar to jemalloc for exploitation purposes. Free an object and immediately allocate another of the same size on the same thread, and the new allocation takes the old object's place. This makes classic heap-spray techniques viable, but with a caveat: the spray must come from the same thread as the freed object.
A common spray primitive is the Clipboard::WriteImage IPC, which allocates arbitrarily sized, attacker-controlled buffers in the browser process on the same thread that handles InternalAuthenticatorAndroid. In practice, this approach had a very low success rate. Two possible explanations emerged: either uncontrolled background allocations were stealing the slot, or the IPC itself was allocating other objects from the same bucket before the controlled buffer. To distinguish between the two, the Clipboard::WriteImage implementation was modified to allocate multiple controlled buffers in a single call. This change pushed the success rate to nearly 100%, indicating the problem lay with the IPC's own allocation pattern, not background noise.
The stock browser already offers a primitive that fits this requirement: Clipboard::WriteCustomData. This IPC accepts a map of String16 key/value pairs, and each key and value gets its own backing-store allocation in the browser. The strings can contain arbitrary bytes, including nulls, since length is determined by the backing store size. Two constraints exist: the backing store size must be even, and the final two bytes are always zeroed. To hit a 48-byte slot, a string of length 23 provides 46 controlled bytes plus the trailing zero. With a large map, this IPC reliably reclaims the freed InternalAuthenticatorAndroid.
A predictable address for the fake BindState
Reclaiming the object is only half the problem. The fake InternalAuthenticatorAndroid needs its bind_state_ pointer to reference a controlled BindState. Directly replacing the original BindState in place is impractical — it shares a bucket with InternalAuthenticatorAndroid, so reclaiming it would require not reclaiming the freed object. A heap address leak is another route, but that requires a second vulnerability.
Mark Brand's "Virtually Unlimited Memory" technique offers a path. The core idea: abuse the mojo data pipe machinery to map controlled data into predictable address ranges in the browser process.
The Mojo::createDataPipe API returns a producer/consumer pair backed by shared memory. The browser represents each end as a DataPipeProducerDispatcher or DataPipeConsumerDispatcher object. Passing such a handle to the browser through an IPC like BlobRegistry::RegisterFromStream triggers DataPipeConsumerDispatcher::Deserialize, which mmaps the associated shared memory region into the browser's address space.
Each mapping is created from a separate shared memory region. Creating enough distinct regions to fill address space hits memory limits quickly. Brand's refinement: create a small set of data pipes with minimal buffers, then swap each dispatcher's shared_ring_buffer_ to reference a single, large, attacker-filled shared memory region. Sending these modified handles to the browser produces many mappings of the same physical memory. The full procedure:
- Create several data pipes with small buffers so the initial shared memory footprint stays low.
- Allocate one large shared memory region, fill it with controlled data, and duplicate its file descriptor for each data pipe.
- Patch each
DataPipeConsumerDispatcher'sshared_ring_buffer_and its metadata to point at the duplicated descriptors with the correct size. - Send the handles via
BlobRegistry::RegisterFromStream; the browser maps the region for each handle, creating many mappings of the same underlying memory.
In JavaScript this is laborious — duplicating file descriptors and manipulating dispatcher internals is not directly exposed. Brand's original implementation handles these obstacles.
Chromium has since limited total shared memory mappings per process to 32GB. On 64-bit builds, that isn't enough to blanket the address space. A 1GB spray on a 32-bit build still covers a meaningful fraction, so the mitigation matters mainly for 64-bit. The question becomes whether the 32GB budget is enough to stake out a predictable address range rather than spray everywhere.
Making the guess deterministic
Testing empirically: the technique from steps one through four was implemented and run over roughly 20 boots on a Pixel 3a and a Samsung Galaxy A71, spraying about 30GB each boot. The results showed a pattern:
- Within a single boot, the occupied address range stayed stable across Chrome restarts, but shifted between reboots.
- Across boots, a handful of mostly disjoint ranges were occupied.
- The occupied ranges did not depend on the device model.
Choosing one of the likely ranges as a hardcoded target yielded roughly a one-in-three success rate — not enough for a reliable exploit. The range variation between boots suggested the memory layout depends on something global, likely inherited from the Zygote process from which all Android user-space processes fork. If the renderer shares that influence, its address space should hint at the browser's layout.
Comparing the address of the shared memory region visible to the renderer with the browser's occupied range confirmed a strong correlation. Subtracting a fixed offset (0x1000000000) from the renderer-side address reliably lands inside the browser's sprayed region. Using that signal raises the success rate to practically 100%.
From Binder gadget to shell command
With controlled reads and writes in the browser process, the remaining work is to turn an arbitrary function call into code execution. On Android, ASLR is not an obstacle: every user space process is forked from the Zygote process, so shared libraries loaded in Zygote appear at the same address base everywhere. Because the renderer is assumed compromised, gadget addresses can simply be read out of its memory as long as those gadgets live in a library that Zygote loads.
The gadget used here is WebPWorker::Execute, the same one described in One day short of a full chain: Part 2 – Chrome sandbox escape. It sits in libhwui.so, a Zygote-loaded library, and takes a pointer to a WebPWorker object:
static void Execute(WebPWorker* const worker) {
if (worker->hook != NULL) {
worker->had_error |= !worker->hook(worker->data1, worker->data2);
}
}
The function invokes the worker’s hook member with two arguments taken from the object. Constructing a fake bind_state_ and a fake InternalAuthenticatorAndroid as shown below yields a call to system from libc.so with an arbitrary command:
That construction reuses the fake BindState as the fake WebPWorker. When WebPWorker::Execute runs, it sees the BindState’s polymorphic_invoke_ as its entry point. Only polymorphic_invoke_, hook, and data1 need to be set, so the same object can legitimately serve both roles. The result is a call to system running a shell command with Chrome’s privileges.
Exploit code and setup notes are available in GitHub’s securitylab repository.
What CVE-2021-30528 shows
The vulnerability is a sandbox escape affecting stable Chrome. It comes down to object lifetime management across the C++ and Java boundary of the browser, where the common arrangement of a C++ object owning a Java object can break. The analysis also covered two mitigations:
- PartitionAlloc now used in the Chrome browser process is an infrastructure step toward hardening such as Miracle pointer, but it still has a single partition and does little to impede a UAF exploit in the browser process.
- The predictable-address technique from Virtually Unlimited Memory: Escaping the Chrome Sandbox places controlled data at known addresses. Even with the mitigation that was added, the technique remains viable on 64-bit Chrome for Android.
The intent of detailing these mitigations is to give engineers a concrete view of their limits, with the hope that it contributes to stronger Chrome security.



