Holding a stale frame hostage
Chrome 86 beta shipped a new payment method, secure-payment-confirmation, which wires a renderer-supplied payment request into the browser process through the PaymentRequest Mojo interface. The path ultimately lands in PaymentAppService::Create, where the browser instantiates an InternalAuthenticatorAndroid to check whether the device supports platform authentication for the requested payment method:
void SecurePaymentConfirmationAppFactory::Create(
base::WeakPtr<Delegate> delegate) {
...
for (const mojom::PaymentMethodDataPtr& method_data : spec->method_data()) {
if (method_data->supported_method == methods::kSecurePaymentConfirmation) {
...
std::unique_ptr<autofill::InternalAuthenticator> authenticator =
delegate->CreateInternalAuthenticator();
...
}
The relevant component here is the authenticator object itself. It is created as a unique_ptr and handed to the native implementation of IsUserVerifyingPlatformAuthenticatorAvailable, which parks the pointer in is_uvpaa_callback_:
void InternalAuthenticatorAndroid::
IsUserVerifyingPlatformAuthenticatorAvailable(
blink::mojom::Authenticator::
IsUserVerifyingPlatformAuthenticatorAvailableCallback callback) {
...
is_uvpaa_callback_ = std::move(callback);
Java_AuthenticatorImpl_isUserVerifyingPlatformAuthenticatorAvailableBridge(
env, obj);
}
That callback is dispatched only after the Java-side AuthenticatorImpl queries the Android Fido2ApiClient and receives a response. Because the response depends on an Android service rather than Chrome object lifetimes, the authenticator can easily outlive the RenderFrameHost that created it.
The only place InternalAuthenticatorAndroid touches its stored RenderFrameHost is in its destructor, where it calls the virtual GetRoutingID() as a sanity check for memory corruption:
InternalAuthenticatorAndroid::~InternalAuthenticatorAndroid() {
// This call exists to assert that |render_frame_host_| outlives this object.
// If this is violated, ASAN should notice.
DCHECK(render_frame_host_);
render_frame_host_->GetRoutingID();
}
That gives a straightforward, reliable primitive: if we can make the authenticator outlive its frame, the destructor will do a virtual call on freed memory.
Winning the race
Extending the authenticator’s lifetime hinges on how long the isUserVerifyingPlatformAuthenticatorAvailable call takes. Since this is an Android service shared across frames and apps, flooding it with requests can delay responses by seconds. Creating many PaymentRequest calls with the secure-payment-confirmation method would be the obvious approach, but on most devices that exhausts Java heap and crashes the browser. A more economical alternative is calling PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable from many frames through the Web Authentication API. Each call queues on the same mIsUserVerifyingPlatformAuthenticatorAvailableCallbackQueue, so a burst of calls can create a multi-second window during which our original authenticator stays alive.
One caveat: the Web Authentication API is not available on emulators or development builds that lack the WEB_AUTH feature, so the attack fails there. On production builds the delay is more than sufficient to delete the frame and then trigger the destructor at a chosen time.
The timing also needs to avoid a null pointer dereference: if the frame is destroyed before the authenticator uses it to obtain a WebContents early on in the payment flow, the browser crashes instead of giving us a controlled call. With the several-second window created by the flood of isUserVerifyingPlatformAuthenticatorAvailable calls, we can reliably sequence the two operations.
The resulting primitive mirrors the one from Tim Becker’s “Cleanly Escaping the Chrome Sandbox”: a use-after-free on a RenderFrameHost that lets us control the virtual call in the destructor.
Working around ASLR and Zygote
All Android processes fork from the Zygote, so any library loaded there maps at the same base address in every process. For system libraries like libhwui.so, renderer and browser share identical mappings, meaning once we compromise the renderer we can derive browser-process gadget addresses for those libraries:
$ out/86/bin/chrome_public_apk ps
W 0.117s TimeoutThread-1-for-MainThread Stale cache detected. Not using it.
9A261FFAZ009KQ (aosp_flame-userdebug 10 QQ3A.200805.001 eng.mmo.20210115.132601 test-keys):
org.chromium.chrome 9297
org.chromium.chrome:privileged_process0 9366
org.chromium.chrome:sandboxed_process0:org.chromium.content.app.SandboxedProcessService0:0 9352
flame:/ # cat /proc/9366/maps | grep libhwui
70a838c000-70a8512000 r--p 00000000 fd:00 2420 /system/lib64/libhwui.so
70a8512000-70a89a3000 --xp 00186000 fd:00 2420 /system/lib64/libhwui.so
70a89a3000-70a89a4000 rw-p 00617000 fd:00 2420 /system/lib64/libhwui.so
70a89a4000-70a89cb000 r--p 00618000 fd:00 2420 /system/lib64/libhwui.so
flame:/ # cat /proc/9352/maps | grep libhwui
70a838c000-70a8512000 r--p 00000000 fd:00 2420 /system/lib64/libhwui.so
70a8512000-70a89a3000 r-xp 00186000 fd:00 2420 /system/lib64/libhwui.so
70a89a3000-70a89a4000 rw-p 00617000 fd:00 2420 /system/lib64/libhwui.so
70a89a4000-70a89cb000 r--p 00618000 fd:00 2420 /system/lib64/libhwui.so
libchrome.so is a different story. It is not loaded in Zygote, so its base address is independently randomized in the renderer and browser; knowing one does not reveal the other:
flame:/ # cat /proc/9352/maps | grep chrome
6fb851d000-6fb8654000 r--s 00943000 fd:05 7311 /data/app/org.chromium.chrome-FJs_E5QD8BjRukkPI7RdDQ==/base.apk
...
701ed8e000-701ed9b000 r--s 00a79000 fd:05 7311 /data/app/org.chromium.chrome-FJs_E5QD8BjRukkPI7RdDQ==/base.apk
flame:/ # cat /proc/9366/maps | grep chrome
6fb6293000-6fb68c7000 r--s 00310000 fd:05 7311 /data/app/org.chromium.chrome-FJs_E5QD8BjRukkPI7RdDQ==/base.apk
...
701e764000-701e78e000 r--s 07a6c000 fd:05 7311 /data/app/org.chromium.chrome-FJs_E5QD8BjRukkPI7RdDQ==/base.apk
The gadgets used in the original “Cleanly Escaping” exploit live inside libchrome.so, so they are unusable here. Plenty of other libraries loaded via Zygote offer suitable gadgets, including well-known targets such as the Execute function in libwebp’s thread utilities, which gets compiled into libhwui.so.
Heap feng shui on a multi-threaded allocator
The more significant obstacle is heap layout control. The usual spray primitive for a Chrome sandbox escape is BlobRegistry::registerFromStream, first demonstrated in Mark Brand's "Virtually Unlimited Memory." It is attractive because it can both write arbitrary data to fake an object and read that data back after the fake object is used, which is essential for leaking a heap address once ASLR is partially defeated. For example, after triggering the bug, one can replace the freed InternalAuthenticatorAndroid with controlled data via this API, call a different virtual function, and then read the address of a field to obtain a heap leak for later construction of a fake vtable.
void foo() {
this.bar = new Bar();
}
The problem is that BlobRegistry operations run on the IO thread, while the target object, RenderFrameHost, is deleted on the UI thread. The browser process splits work across these two threads. On Android ≤ 10.0, the allocator is jemalloc, which is optimized for concurrency: each thread draws from its own arena and maintains a thread-local cache of recently freed chunks that are not immediately returned to other threads. This makes cross-thread replacement of a freed RenderFrameHost with a BlobRegistry spray very unreliable, particularly while also racing between the IO and UI threads. Spraying RenderFrameHost and Blob objects from the UI thread helps, but the race condition still makes it too fragile.
A more suitable primitive is needed, and it must allow data to be read back in order to leak a heap address.
Forcing a call through a static function pointer
On 32-bit binaries, a known trick introduced by Guang Gong and reused by Lucas P. in "Yet another RenderFrameHostImpl UAF" calls system from the PLT of llvm-glnext.so with a controlled argument. The freed object is replaced so that its first four bytes, the vtable pointer, point to the required PLT entry. A subsequent call to GetRoutingID then invokes system with the object itself as the argument. Because pointers are 32 bits, the first word of the object is the vtable pointer, which is unlikely to contain null bytes; the remainder of the object supplies the actual command string.
For 64-bit binaries, this specific technique fails because the canonical address space only uses 39 bits, guaranteeing null bytes in the upper part of any vtable pointer and truncating the argument. The exploit described here targets 64-bit builds, though it also works on 32-bit binaries if applied more elaborately.
Rather than jumping to system directly, we use the Execute gadget in libhwui.so. As noted by Mateusz Jurczyk in the MMS exploit write-up, this library embeds a static g_worker_interface variable (from the statically linked libwebp) that holds a pointer to the Execute function:
static void Execute(WebPWorker* const worker) {
if (worker->hook != NULL) {
worker->had_error |= !worker->hook(worker->data1, worker->data2);
}
}
If we forge a fake RenderFrameHost whose vtable pointer points to g_worker_interface, then the use-after-free’s GetRoutingID call will instead dispatch to Execute:
InternalAuthenticatorAndroid::~InternalAuthenticatorAndroid() {
...
//render_frame_host_ is already free'd
render_frame_host_->GetRoutingID();
}

At that moment, the freed render_frame_host_ is interpreted as a WebPWorker structure. By faking the hook, data1, and data2 fields of that structure, we can call an arbitrary function whose two arguments are pointers we control. But since those arguments are pointers, we still need a usable heap address at which to place our data, so leaking a heap address remains a prerequisite.
A clipboard-based spray that stays on the UI thread
Searching for other controllable, readable data types in src/content/browser, one promising candidate is ScopedClipboardWriter, used by the ClipboardHost Mojo interface for copying and pasting data. This allocates on the UI thread and allows a compromised renderer to write data and read it back. The text-based methods such as WriteText restrict input and output to valid UTF-16, which is too limiting. However, WriteImage accepts an arbitrary SkBitmap, and the data can be read back after calling CommitWrite followed by ReadImage.
One catch is the SkBitmap layout: it must have four channels (RGB plus alpha), with the pixel data grouped as R, G, B, A — one byte each. The bitmap also requires one of three alpha types, each of which transforms the data on read-back:
kOpaque_SkAlphaType— the alpha channel is reported as 255.kPremul_SkAlphaType— alpha divided by 255 becomes a float in [0, 1], and the other channels are multiplied by that value.kUnpremul_SkAlphaType— the opposite of premultiplied, potentially expanding channel values.
The written data itself remains raw; the conversion occurs when the bitmap is read. For example, with an input buffer, reading with kOpaque_SkAlphaType returns the raw RGB values but forces the alpha channel to 255, while premultiplied and unpremultiplied modes sacrifice precision in either the color channels or the alpha channel.
R|G|B|A|R|G|B|A|...|R|G|B|A|....
121|122|123|128|....|10|20|30|40|....
121|122|123|255|....|10|20|30|255|....
60|61|61|128|....|1|3|4|40|....
242|244|246|128|....|63|127|191|40|....
This loss of information can be worked around by leaking two nearby heap addresses with bitmaps that use different AlphaType settings, then combining the two results to reconstruct accurate values for all four bytes in each pixel. That way we retain both the ability to recreate arbitrary fake objects on the UI thread and to read back raw byte values for subsequent heap leaks.
A Heap Address Leak Gadget
Controlling the arguments of arbitrary function calls requires knowledge of a heap address pointing to controlled data. For this, I leverage VP8LBitWriterFinish from libhwui.so, whose pointer is stored in the library's plt section.
000000803e30 0fb400000402 R_AARCH64_JUMP_SL 00000000007c876c VP8LBitWriterFinish + 0
000000803e38 0b4d00000402 R_AARCH64_JUMP_SL 00000000007c8440 VP8LBitWriterWipeOut + 0
000000803e40 052c00000402 R_AARCH64_JUMP_SL 00000000007c8140 VP8BitWriterAppend + 0
000000803e48 0a4e00000402 R_AARCH64_JUMP_SL 00000000007b1568 VP8LEncDspInit + 0
Treating libhwui.so's plt section as a large vtable, I can set the fake RenderFrameHost's vtable to an offset within that section. When GetRoutingID is invoked, it will actually call VP8LBitWriterFinish instead.
Internally, VP8LBitWriterFinish triggers VP8LBitWriterResize, which allocates a new buffer based on the current buffer size and the extra_size parameter:
static int VP8LBitWriterResize(VP8LBitWriter* const bw, size_t extra_size) {
uint8_t* allocated_buf;
size_t allocated_size;
const size_t max_bytes = bw->end_ - bw->buf_;
const size_t current_size = bw->cur_ - bw->buf_;
const uint64_t size_required_64b = (uint64_t)current_size + extra_size;
const size_t size_required = (size_t)size_required_64b;
if (size_required != size_required_64b) {
bw->error_ = 1;
return 0;
}
if (max_bytes > 0 && size_required <= max_bytes) return 1;
allocated_size = (3 * max_bytes) >> 1;
if (allocated_size < size_required) allocated_size = size_required;
// make allocated size multiple of 1k
allocated_size = (((allocated_size >> 10) + 1) << 10); //<------ minimal allocation size is 1k
allocated_buf = (uint8_t*)WebPSafeMalloc(1ULL, allocated_size); //<------ allocates new buffer if needed, WebPSafeMalloc simply wraps malloc
...
WebPSafeFree(bw->buf_);
bw->buf_ = allocated_buf; //<---------- stores allocated buffer as a field
bw->cur_ = bw->buf_ + current_size;
bw->end_ = bw->buf_ + allocated_size;
return 1;
}
If the freed RenderFrameHost is treated as a VP8LBitWriter with a zeroed-out structure, a 1KB buffer is allocated and its pointer stored at offset 0x08 (the bw->buf_ field).
This serves a dual purpose: it enables reading the allocated buffer's address via the ClipboardHostImpl::ReadImage function, and it provides a large buffer in the relatively quiet size-1024 allocation bucket.
Refining the Leak with Two-Pass Spraying
To address the challenge of heap spraying with clipboard data, I first spray the size-1024 bucket using ClipboardHostImpl::WriteImage to fill existing holes. This makes subsequent allocations in this bucket adjacent. A small number of allocations (around 32) suffices due to the bucket's infrequent use.
After spraying, I free a buffer near the end of the contiguous allocation region, creating a hole. Triggering the bug next replaces the freed RenderFrameHost via ClipboardHostImpl::WriteImage with a kOpaque_SkAlphaType alphaType, and the VP8LBitWriterFinish allocation is likely to fill that hole.
However, the kOpaque_SkAlphaType masks the alpha channel byte (the fourth byte) with ff, obscuring part of the address. To recover the full address, I free the buffer adjacent to the one occupied by buf_, then trigger the bug again with alphaType set to kPremul_SkAlphaType. This time, the fourth byte is correct, though other bytes are corrupted.
Since the fourth byte tends to be identical between adjacent buffers, combining the results from both passes yields the full, correct address of the first buffer. Because buf_ is allocated by malloc, it even retains the data used in the initial size-1024 bucket spray, making the address guessable and the data there controllable.
Arbitrary Shell Command Execution
With fully controlled data at a predictable address, I can now control the arguments passed through WebPWorker's Execute function. This allows a direct call to system from libc.so, which is preloaded in Zygote:
$ out/86/bin/chrome_public_apk ps
W 0.117s TimeoutThread-1-for-MainThread Stale cache detected. Not using it.
9A261FFAZ009KQ (aosp_flame-userdebug 10 QQ3A.200805.001 eng.mmo.20210115.132601 test-keys):
org.chromium.chrome 9297
org.chromium.chrome:privileged_process0 9366
org.chromium.chrome:sandboxed_process0:org.chromium.content.app.SandboxedProcessService0:0 9352
flame:/ # cat /proc/9366/maps | grep libc.so
70aab66000-70aaba6000 r--p 00000000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
70aaba6000-70aac4d000 --xp 00040000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
70aac4d000-70aac50000 rw-p 000e7000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
70aac50000-70aac57000 r--p 000ea000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
flame:/ # cat /proc/9352/maps | grep libc.so
70aab66000-70aaba6000 r--p 00000000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
70aaba6000-70aac4d000 r-xp 00040000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
70aac4d000-70aac50000 rw-p 000e7000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
70aac50000-70aac57000 r--p 000ea000 07:02 107 /apex/com.android.runtime/lib64/bionic/libc.so
This executes any shell command as the Chrome browser process.
The complete exploit is available on GitHub with setup notes.
Conclusion
This sandbox escape on Chrome beta 86.0.4240.30 demonstrates that even when the usual BlobRegistry-based heap spraying is unavailable, alternative methods exist to place controlled data in fake objects. While Chrome's base address is randomized between renderer and browser processes, the preloaded Zygote libraries offer numerous gadgets that serve both to leak heap addresses and execute arbitrary functions with minimal effort. The one-per-boot ASLR applied to Zygote remains a fundamental weakness in Android, significantly undermining application sandboxing effectiveness.



