GPURenderBundle externalTexture lifetime tracking bypass causes GPU-process use-after-free
Categories
(Core :: Graphics: WebGPU, defect, P1)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox151 | --- | wontfix |
| firefox152 | + | fixed |
| firefox153 | + | fixed |
People
(Reporter: 5up3rh3i, Assigned: aleiserson)
References
Details
(4 keywords, Whiteboard: [client-bounty-form][adv-main152+])
Attachments
(2 files, 1 external file)
Title
[Graphics/WebGPU] Security vulnerability in Firefox: GPURenderBundle externalTexture lifetime tracking bypass causes GPU-process use-after-free
Product / Component
Firefox / Graphics: WebGPU (dom/webgpu, gfx/wgpu_bindings, third_party/rust/wgpu-core, Metal backend)
Affected Versions
Tested on Firefox Nightly 152.0a1.
- Source revision:
cf6415620d92de30e1e1a413ef98b58684eb160e - Build ID:
20260514203623 - ASAN binary:
obj-asan/dist/NightlyDebug.app/Contents/MacOS/firefox
Also tested on:
- Earlier local ASAN Nightly build
BuildID=20260513010200, where the same bug was reproducible. - A release build was used only as an additional behavioral check for GPU/device-loss behavior; the primary security evidence is from the ASAN build above.
Environment
- OS: macOS 26.4.1, Darwin 25.4.0, arm64
- Hardware/backend: Apple M4, WebGPU Metal backend
- Build type: local Firefox Nightly ASAN debug/optimized build
- ASAN/Debug/Release: ASAN + debug,
--enable-address-sanitizer,--enable-debug,--enable-optimize=-O1,--disable-jemalloc - Relevant prefs used by the testcase:
dom.webgpu.enabled=truedom.webgpu.external-texture.enabled=truedom.webgpu.allow-present-without-readback=truegfx.webgpu.ignore-blocklist=truedom.webgpu.wgpu-backend="metal"media.autoplay.default=0
Summary
Firefox WebGPU tracks GPUExternalTexture lifetime for direct render passes, but the same lifetime dependency is lost when the external texture is first captured inside a GPURenderBundle.
A web page can import an external texture from a video source, bind it in a render bundle, finish the bundle, then tear down the video/external texture source before executing the bundle in a later render pass. The direct render-pass path correctly rejects the stale external texture with External texture is expired. The render-bundle path bypasses this validation and reaches the Metal backend with a stale texture object, causing a GPU-process use-after-free in objc_retain while wgpu_hal::metal::command::Encoder::set_texture binds the texture.
This is a remotely reachable WebGPU memory-safety issue from web content when WebGPU/external texture are enabled.
Steps To Reproduce
- Build Firefox with ASAN enabled.
- Start Firefox with the WebGPU prefs listed above.
- Save the attached PoC file as
webgpu_renderbundle_externaltexture_lifetime.htmland serve it over HTTP from any local directory. - Open the PoC with the query parameter
mode=bundle, for example:
http://127.0.0.1:8000/webgpu_renderbundle_externaltexture_lifetime.html?mode=bundle&source=video&loop=1&batch=1&readbackEvery=0&deferSubmitMs=100&teardownVideo=1&close=0&beacon=1
- For the direct-path control, open the same PoC with
mode=direct:
http://127.0.0.1:8000/webgpu_renderbundle_externaltexture_lifetime.html?mode=direct&source=video&loop=1&batch=1&readbackEvery=0&deferSubmitMs=100&teardownVideo=1&close=0&beacon=1
Expected Result
Firefox should apply the same GPUExternalTexture lifetime validation to render bundles and direct render passes. If an external texture source has expired before submit, both paths should reject the command with a WebGPU validation error and should not pass stale backend texture objects to Metal/wgpu.
Actual Result
The direct render-pass path reports the expected WebGPU validation error:
RESULT=EXPIRED_VALIDATION
JavaScript warning: Uncaptured WebGPU error: External texture is expired
The render-bundle path bypasses the expired-external-texture validation and crashes in the GPU process:
RESULT=ASAN_BUS
SUMMARY: AddressSanitizer: BUS (/usr/lib/libobjc.A.dylib:arm64+0x791c) in objc_retain+0x10
Crash / ASAN / Sanitizer Output
Short top stack from the latest ASAN build:
AddressSanitizer:DEADLYSIGNAL
==51263==ERROR: AddressSanitizer: BUS on unknown address
==51263==The signal is caused by a READ memory access.
#0 objc_retain
#1 wgpu_hal::metal::command::Encoder::set_texture
#2 wgpu_hal::metal::command::<impl CommandEncoder>::update_bind_group_state
#3 wgpu_hal::metal::command::<impl CommandEncoder>::set_bind_group
#4 wgpu_core::command::bundle::RenderBundle::execute
#5 wgpu_core::command::render::encode_render_pass
#6 wgpu_core::command::CommandEncoder::finish
#7 wgpu_core::global::Global::command_encoder_finish
#8 wgpu_bindings::server::process_message
#9 wgpu_server_messages
#10 mozilla::webgpu::WebGPUParent::RecvMessages
#11 mozilla::webgpu::PWebGPUParent::OnMessageReceived
#12 mozilla::gfx::PCanvasManagerParent::OnMessageReceived
#13 mozilla::ipc::MessageChannel::DispatchAsyncMessage
Earlier NSZombie-assisted runs also showed the same object-lifetime failure more directly:
-[AGXG16GFamilyTexture retain]: message sent to deallocated instance
Root Cause Analysis
Affected files/functions:
dom/webgpu/RenderBundleEncoder.cppRenderBundleEncoder::SetBindGroupRenderBundleEncoder::Finish
dom/webgpu/RenderBundle.hRenderBundle
dom/webgpu/RenderPassEncoder.cppRenderPassEncoder::ExecuteBundlesRenderPassEncoder::End
dom/webgpu/CommandEncoder.cppCommandEncoder::EndRenderPassCommandEncoder::Finish
dom/webgpu/Queue.cppQueue::Submit
dom/webgpu/ExternalTexture.cppExternalTexture::MaybeDestroy- external texture source destruction path
gfx/wgpu_bindings/src/server.rs- external texture creation / backend resource binding
third_party/rust/wgpu-core/src/command/bundle.rsRenderBundle::execute
third_party/rust/wgpu-hal/src/metal/command.rs- Metal texture binding
Code path:
- A page imports a
GPUExternalTexturefrom a video source. - The page creates a bind group containing that external texture.
- The page records
setBindGroup()into aGPURenderBundleEncoder. RenderBundleEncoder::Finish()creates aRenderBundle.- The external texture source is torn down after bundle creation but before bundle execution/submission.
- The page executes the render bundle inside a later render pass.
- The command encoder is finished and sent to the WebGPU parent/GPU process.
- wgpu/Metal executes the bundle and attempts to bind the stale texture object.
Why the input reaches the vulnerable state:
Direct render passes retain enough external-texture metadata for CommandEncoder / CommandBuffer / Queue::Submit to detect that the external texture is expired. In that path, Queue::Submit rejects the command and reports External texture is expired.
The render-bundle path drops this dependency:
RenderBundleEncoder::SetBindGroup()records the bind group while encoding the bundle.RenderBundleEncoder::Finish()transfers only canvas-context usage into the resultingRenderBundle.RenderBundlestoresmUsedCanvasContexts, but does not store theExternalTextureobjects or their source ids.RenderPassEncoder::ExecuteBundles()merges only canvas-context dependencies from each bundle.RenderPassEncoder::End()collects external textures only from direct render-pass bind groups, not from bind groups captured inside executed bundles.CommandEncoder::EndRenderPass()andCommandEncoder::Finish()therefore create a command buffer without the bundle's external-texture lifetime dependency.Queue::Submit()only validates the external textures present in the command buffer metadata, so the bundle's stale external texture is not rejected.
As a result, the JavaScript-visible GPUExternalTexture source can expire while the backend bundle still contains a stale Metal texture binding. The stale object is later used by wgpu_core::command::bundle::RenderBundle::execute, reaching wgpu_hal::metal::command::Encoder::set_texture and crashing in objc_retain.
Security Impact
Potential impact:
- GPU-process use-after-free / use-after-destroy of a Metal texture object.
- Memory corruption reachable from web content through WebGPU render bundle commands.
- The crash occurs in the GPU/WebGPU parent-side execution path, not as a JavaScript exception in content.
Exploitability notes:
- The attacker controls the render bundle, shader/bind group layout, external texture lifetime timing, submit order, and additional GPU allocation pressure around the stale object.
- The direct path provides a strong control case: the same stale external texture is normally rejected, so the crash is caused by the render-bundle lifetime tracking gap.
- The ASAN stack and NSZombie output identify a freed Objective-C Metal texture object being retained during backend command execution.
Attacker requirements:
- Ability to run web content with WebGPU and external texture support enabled.
- No browser extension or privileged script is required.
- The observed testcase uses the Metal backend on macOS.
Attachments
webgpu_renderbundle_externaltexture_lifetime.html
Suggested Fix Direction
Render bundles should preserve the same external-texture lifetime dependencies as direct render passes.
Recommended fix direction:
- Store external texture dependencies, or source ids, in
RenderBundle. - Transfer external texture dependencies from
RenderBundleEncoder::Finish()into the producedRenderBundle. - Merge external texture dependencies from executed bundles in
RenderPassEncoder::ExecuteBundles()orRenderPassEncoder::End(). - Ensure
CommandEncoder::EndRenderPass()/CommandEncoder::Finish()include bundle-introduced external textures in the command buffer. - Keep
Queue::Submit()as the final validation point for both direct and bundle paths. - Add a regression test that records an external texture into a render bundle, expires the source, executes the bundle, and verifies that Firefox returns
External texture is expiredinstead of crashing.
Updated•5 months ago
|
| Assignee | ||
Comment 2•5 months ago
|
||
Seems like the render bundle path is definitely broken, but I'm also dubious of this comment on code that is involved in handling this for regular render passes:
A compromised content process could skip this step, but equally it could skip setting the external texture's expired state even if this check were performed on the server side.
My conclusion from this is that we need a way for the server side to independently ensure the texture is still valid, not that it justifies putting additional checks on the client side.
| Assignee | ||
Updated•5 months ago
|
| Assignee | ||
Comment 4•5 months ago
|
||
Not sure the best way to track this, so I'll note it here: the CTS appears to be missing coverage for the case where a submission fails because a render bundle references an expired external texture. (webgpu:api,validation,gpu_external_texture_expiration:* covers the render pass case, but not the render bundle case.)
The code path where the UAF occurs is actually not submission -- it's when the render bundle is being executed, i.e., replayed onto the primary command encoder. So adding a submit time "external texture is expired" check will not be sufficient to prevent that. This also means that there isn't really an equivalent case for render passes, so my concern in comment 2 may not be a problem. I did do some experiments modifying the content process to omit that check and submit the render pass anyways, I was not able to get a crash, but, I'd want to do some more investigation to be confident that path is okay.
Currently there is logic in the Firefox bindings to manage destruction of external textures, I think some of this could be moved into wgpu to be more similar to the way it is handled for textures and buffers and avoid scheduling additional onSubmittedWorkDone callbacks to track when external textures can be expired. But in the interest of getting a minimal fix in place, I do not plan to tackle that now. I'm just planning to add detection to prevent executing a render bundle that uses a destroyed external texture, and also make sure that the submit time checks are correct for both passes and bundles.
| Assignee | ||
Comment 5•5 months ago
|
||
https://github.com/gfx-rs/wgpu/pull/9577 has a fix.
I have held back part of the test. The version of the test in the PR doesn't actually fail before the change. To unleash the chaos, the test has to clear the buffer/texture after creation and before creating the bind group (and flush the command buffer with the clear). But that version of the test fails with Vulkan validation errors and segfaults that could call attention to this being a security issue. I can of course share it privately if desired.
The other thing that's not called out explicitly in the PR, is the detail that external texture planes don't get added to texture_init_actions (f.k.a. used_texture_ranges). Because textures (regular, not external) can be discarded, texture_init_actions is completely populated even if the texture is already initialized when the bind group is created. The cases where we get in to trouble are buffers (which get an init action only if uninitialized when the bind group is created) and external textures (which never get an init action).
I think it's debatable whether it makes more sense to omit the init actions for external textures, or declare them anyways, and rely on the fact that they will be determined not necessary for the external texture planes created with create_texure_from_hal. But not doing anything with that now.
Initially I was going to add logic to BindGroup::try_raw that checked whether external textures in the bind group are destroyed. But that's redundant with the changes above.
Updated•5 months ago
|
| Assignee | ||
Comment 6•5 months ago
|
||
I can no longer reproduce with the attached PoC in nightly 20260529091611.
(There are still two follow-ups: merging the piece of the test that I held back, and either adding tracking of external textures in render bundles forward to submission in dom/webgpu, or removing tracking of external textures in submissions from dom/webgpu entirely and doing it in wgpu instead. The latter is no longer a security issue after the fix that was made.)
:superhei, would appreciate your confirmation of the fix if you have an opportunity.
Also, something I didn't state explicitly before: there was an additional broken case unrelated to external textures that is also covered by the fix. If a buffer was initialized before creating a bind group using that buffer, the buffer was not included in used_buffer_ranges for the bind group, which resulted in a similar ability to perform some operations with the bind group after the buffer was destroyed. This resulted in Vulkan validation errors with my unit test.
I tested Nightly 20260529091611 with the PoC on macOS/Metal.
I could not get the original source=video variant to progress past media metadata loading in my automated local session, but I
confirmed the same external-texture lifetime path with source=frame.
The direct path now reports:
External texture is expired
The render-bundle path no longer crashes and reports:
In a execute_bundle command, caused by: Buffer with '' label has been destroyed
No GPU-process crash or ASAN-style stale Metal texture behavior was observed. This confirms that the wgpu liveness fix blocks
the security issue. The remaining difference looks like the follow-up semantic tracking issue you mentioned.
| Assignee | ||
Updated•5 months ago
|
Updated•5 months ago
|
Comment 8•5 months ago
|
||
Given the sec rating, I think a Beta-specific cherry-pick patch is needed as well.
| Assignee | ||
Comment 9•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Comment 11•5 months ago
|
||
| uplift | ||
Updated•5 months ago
|
| Assignee | ||
Comment 12•5 months ago
|
||
Small adjustment to repro instructions:
- Get the file
dom/media/test/red-46x48.mp4from the Firefox source and save it alongside the PoC HTML. - Use the URL
http://127.0.0.1:8080/webgpu_renderbundle_externaltexture_lifetime.html?mode=bundle&source=video&loop=1&batch=1&readbackEvery=0&deferSubmitMs=100&teardownVideo=1&close=0&beacon=1&videoUrl=/red-46x48.mp4(addsvideoUrl=/red-46x48.mp4)
Updated•5 months ago
|
Updated•4 months ago
|
Comment 13•4 months ago
|
||
Updated•4 months ago
|
Updated•1 month ago
|
Description
•