CVE-2026-13032
Overview
Background
- `ClearLevelUsingGL`
- helper in
GLES2DecoderImplthat clears a texture level by attaching it to a temporary framebuffer object (fb), issuing a GL clear, then tearing that FBO down. - FBO (framebuffer object)
- a GL container of attachment points;
glDeleteFramebuffersEXTFnfrees its service-side (driver) state, and deleting one that is still bound is undefined on some drivers. - UAF (use-after-free)
- reuse of memory after it is released; here freed driver-internal FBO state is dereferenced when the driver rebinds it.
- `ensure_previous_framebuffer_not_deleted`
- a GPU driver workaround flag acknowledging that some drivers keep an internal reference to the previously-bound FBO across bind transitions.
Root Cause Analysis
In ClearLevelUsingGL, after RestoreClearState() the code called glDeleteFramebuffersEXTFn(1, &fb) to destroy the temporary FBO before restoring the framebuffer binding that was active on entry. The temporary FBO (fb) was still the effectively-bound framebuffer at the moment of deletion, and only afterward did the code call BindFramebuffer(fb_target, fb_service_id) to restore the previous binding. On Imagination/PowerVR drivers, the driver retains an internal reference to the previously-bound FBO across bind transitions, so deleting fb while it is (effectively) still bound and then rebinding causes the driver to dereference the now-freed FBO state. This produces a use-after-free inside the GPU process driver code.
The fix reorders the operations so that BindFramebuffer restores the original binding first, and the temporary fb is deleted only once it is no longer the bound framebuffer.
Attack Path
- Trigger a texture clear
Craft WebGL content that causes the decoder to lazily clear an uninitialized texture level via
ClearLevelUsingGL. - Establish a prior binding
Ensure a framebuffer binding is active on entry so the temporary
fbbecomes the effective binding that must later be restored. - Force deletion while bound
Reach
RestoreClearState()and the subsequentglDeleteFramebuffersEXTFn(1, &fb)whilefbis still the effectively-bound framebuffer. - Provoke driver dereference
On Imagination/PowerVR, the deletion frees FBO state the driver still references, and the following
BindFramebufferdereferences freed memory. - Exploit the UAF Shape GPU-process allocations so the freed FBO state is reclaimed with attacker-influenced data, corrupting driver state in the GPU process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
GLES2DecoderEnsurePrevFboWorkaroundTestgpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc |
modified | |
ifgpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc |
modified | |
TEST_Pgpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder.ccgpu/command_buffer/service/gles2_cmd_decoder_unittest_base.hgpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
Audit Directions
- Delete-while-boundAudit all call sites of
glDeleteFramebuffersEXTFn(and analogousglDelete*for renderbuffers, textures, VAOs) to confirm the object is unbound or the prior binding is restored before deletion. - Ordering around `RestoreClearState`/`BindFramebuffer`Review temporary-object teardown sequences in the decoder to ensure restoration of prior GL state precedes freeing scratch resources.
- Driver-workaround coverageCheck that paths gated by
ensure_previous_framebuffer_not_deletedand similar Imagination/PowerVR workarounds are consistently applied wherever an FBO can be deleted while effectively bound.
Patch
From feb103bc1c01d1a1e62383f906346d23bb725fb1 Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> Date: Wed, 17 Jun 2026 18:06:08 -0700 Subject: [PATCH] Rebind original FBO earlier in ClearLevelUsingGL. Avoid triggering problems on some drivers when deleting an in-use FBO. Co-authored with jetski-cli. Fixed: 523591974 Change-Id: I17239a547a16d8d2b2a754c9141f37b7f0012956 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7953879 Reviewed-by: Brandon Jones <[email protected]> Commit-Queue: Kenneth Russell <[email protected]> Cr-Commit-Position: refs/heads/main@{#1648728} --- diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc index 3a9353d..6c51ee8 100644 --- a/gpu/command_buffer/service/gles2_cmd_decoder.cc +++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc @@ -12281,11 +12281,16 @@ result = true; } RestoreClearState(); - api()->glDeleteFramebuffersEXTFn(1, &fb); + // Restore the previous framebuffer binding *before* deleting the temporary + // FBO. Some Imagination/PowerVR drivers retain an internal reference to the + // previously-bound FBO across bind transitions; deleting it while bound and + // then rebinding can dereference freed driver state. See the + // ensure_previous_framebuffer_not_deleted workaround. Framebuffer* framebuffer = GetFramebufferInfoForTarget(fb_target); GLuint fb_service_id = framebuffer ? framebuffer->service_id() : GetBackbufferServiceId(); BindFramebuffer(fb_target, fb_service_id); + api()->glDeleteFramebuffersEXTFn(1, &fb); return result; } diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h index 87409811..9e4d6e3 100644 --- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h +++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h @@ -543,6 +543,7 @@ } protected: + virtual void SetupMockGLBehaviors(); static const int kBackBufferWidth = 128; static const int kBackBufferHeight = 64; @@ -787,7 +788,6 @@ GLuint bound_vertex_array_object_; }; // class MockGLStates - void SetupMockGLBehaviors(); GpuPreferences gpu_preferences_; ShaderTranslatorCache shader_translator_cache_; diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc index 826c27a8..407f6b9f 100644 --- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc +++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc @@ -3211,6 +3211,171 @@ EXPECT_EQ(GL_INVALID_OPERATION, GetGLError()); } +class GLES2DecoderEnsurePrevFboWorkaroundTest + : public GLES2DecoderManualInitTest { + public: + GLES2DecoderEnsurePrevFboWorkaroundTest() = default; + + void SetupMockGLBehaviors() override { + GLES2DecoderManualInitTest::SetupMockGLBehaviors(); + if (enable_workaround_expectations_) { + EXPECT_CALL(*gl_, GenTextures(1, _)) + .WillOnce(SetArgPointee<1>(kCompleteFboTextureId)) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, BindTexture(GL_TEXTURE_2D, kCompleteFboTextureId)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, + GL_UNSIGNED_BYTE, nullptr)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, GenFramebuffersEXT(1, _)) + .WillOnce(SetArgPointee<1>(kCompleteFboId)) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kCompleteFboId)) + .Times(3) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, FramebufferTexture2DEXT( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + kCompleteFboTextureId, 0)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, CheckFramebufferStatusEXT(GL_FRAMEBUFFER)) + .WillOnce(Return(GL_FRAMEBUFFER_COMPLETE)) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, DeleteTextures(1, Pointee(kCompleteFboTextureId))) + .Times(1) + .RetiresOnSaturation(); + } + } + + protected: + bool enable_workaround_expectations_ = false; + static constexpr GLuint kCompleteFboTextureId = 999; + static constexpr GLuint kCompleteFboId = 998; +}; + +// The test uses a depth texture to force GLES2DecoderImpl::ClearLevel to use +// the ClearLevelUsingGL path, which is required to trigger the FBO restoration +// workaround logic. For color textures, ClearLevel may fallback to the +// TexSubImage2D clear path which does not use a temporary FBO. +TEST_P(GLES2DecoderEnsurePrevFboWorkaroundTest, ClearLevelUsingGLOrder) { + enable_workaround_expectations_ = true; + gpu::GpuDriverBugWorkarounds workarounds; + workarounds.ensure_previous_framebuffer_not_deleted = true; + InitState init; + init.extensions = "GL_ANGLE_depth_texture"; + init.gl_version = "OpenGL ES 2.0"; + init.has_depth = true; + init.request_depth = true; + InitDecoderWithWorkarounds(init, workarounds); + + // Set up texture + DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId); + DoTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 1, 1, 0, + GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, 0, 0); + + TextureRef* texture_ref = + group().texture_manager()->GetTexture(client_texture_id_); + ASSERT_TRUE(texture_ref != nullptr); + Texture* texture = texture_ref->texture(); + + // Expectations for ClearLevel + const GLuint kTempFboId = 777; + InSequence seq; + + // 1. Gen temp FBO + EXPECT_CALL(*gl_, GenFramebuffersEXT(1, _)) + .WillOnce(SetArgPointee<1>(kTempFboId)) + .RetiresOnSaturation(); + + // 2. Bind temp FBO (wrapper triggers complete_fbo bind first) + EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kCompleteFboId)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kTempFboId)) + .Times(1) + .RetiresOnSaturation(); + + // 3. Attach texture + EXPECT_CALL(*gl_, + FramebufferTexture2DEXT(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_TEXTURE_2D, kServiceTextureId, 0)) + .Times(1) + .RetiresOnSaturation(); + + // 4. Check status + EXPECT_CALL(*gl_, CheckFramebufferStatusEXT(GL_FRAMEBUFFER)) + .WillOnce(Return(GL_FRAMEBUFFER_COMPLETE)) + .RetiresOnSaturation(); + + // 5. Clear calls + if (GetParam()) { + EXPECT_CALL(*gl_, ColorMask(true, true, true, true)) + .Times(1) + .RetiresOnSaturation(); + } + EXPECT_CALL(*gl_, ClearColor(0.0f, 0.0f, 0.0f, 0.0f)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, ClearStencil(0)).Times(1).RetiresOnSaturation(); + if (GetParam()) { + EXPECT_CALL(*gl_, StencilMaskSeparate(GL_FRONT, 0xFFFFFFFF)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, StencilMaskSeparate(GL_BACK, 0xFFFFFFFF)) + .Times(1) + .RetiresOnSaturation(); + } + EXPECT_CALL(*gl_, ClearDepth(1.0f)).Times(1).RetiresOnSaturation(); + if (GetParam()) { + EXPECT_CALL(*gl_, DepthMask(true)).Times(1).RetiresOnSaturation(); + } + EXPECT_CALL(*gl_, Enable(GL_SCISSOR_TEST)).Times(1).RetiresOnSaturation(); + EXPECT_CALL(*gl_, Scissor(0, 0, 1, 1)).Times(1).RetiresOnSaturation(); + EXPECT_CALL(*gl_, Clear(GL_DEPTH_BUFFER_BIT)).Times(1).RetiresOnSaturation(); + + // 6. Restore state + EXPECT_CALL(*gl_, ClearColor(0.0f, 0.0f, 0.0f, 0.0f)) + .Times(1) + .RetiresOnSaturation(); + EXPECT_CALL(*gl_, ClearStencil(0)).Times(1).RetiresOnSaturation(); + EXPECT_CALL(*gl_, ClearDepth(1.0f)).Times(1).RetiresOnSaturation(); + EXPECT_CALL(*gl_, Disable(GL_SCISSOR_TEST)).Times(1).RetiresOnSaturation(); + EXPECT_CALL(*gl_, + Scissor(0, 0, 128, 64)) // 128x64 is the default backbuffer size
Regression Test / PoC
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
index 87409811..9e4d6e3 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
@@ -543,6 +543,7 @@
}
protected:
+ virtual void SetupMockGLBehaviors();
static const int kBackBufferWidth = 128;
static const int kBackBufferHeight = 64;
@@ -787,7 +788,6 @@
GLuint bound_vertex_array_object_;
}; // class MockGLStates
- void SetupMockGLBehaviors();
GpuPreferences gpu_preferences_;
ShaderTranslatorCache shader_translator_cache_;
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
index 826c27a8..407f6b9f 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -3211,6 +3211,171 @@
EXPECT_EQ(GL_INVALID_OPERATION, GetGLError());
}
+class GLES2DecoderEnsurePrevFboWorkaroundTest
+ : public GLES2DecoderManualInitTest {
+ public:
+ GLES2DecoderEnsurePrevFboWorkaroundTest() = default;
+
+ void SetupMockGLBehaviors() override {
+ GLES2DecoderManualInitTest::SetupMockGLBehaviors();
+ if (enable_workaround_expectations_) {
+ EXPECT_CALL(*gl_, GenTextures(1, _))
+ .WillOnce(SetArgPointee<1>(kCompleteFboTextureId))
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, BindTexture(GL_TEXTURE_2D, kCompleteFboTextureId))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, GenFramebuffersEXT(1, _))
+ .WillOnce(SetArgPointee<1>(kCompleteFboId))
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kCompleteFboId))
+ .Times(3)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, FramebufferTexture2DEXT(
+ GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+ kCompleteFboTextureId, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, CheckFramebufferStatusEXT(GL_FRAMEBUFFER))
+ .WillOnce(Return(GL_FRAMEBUFFER_COMPLETE))
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, DeleteTextures(1, Pointee(kCompleteFboTextureId)))
+ .Times(1)
+ .RetiresOnSaturation();
+ }
+ }
+
+ protected:
+ bool enable_workaround_expectations_ = false;
+ static constexpr GLuint kCompleteFboTextureId = 999;
+ static constexpr GLuint kCompleteFboId = 998;
+};
+
+// The test uses a depth texture to force GLES2DecoderImpl::ClearLevel to use
+// the ClearLevelUsingGL path, which is required to trigger the FBO restoration
+// workaround logic. For color textures, ClearLevel may fallback to the
+// TexSubImage2D clear path which does not use a temporary FBO.
+TEST_P(GLES2DecoderEnsurePrevFboWorkaroundTest, ClearLevelUsingGLOrder) {
+ enable_workaround_expectations_ = true;
+ gpu::GpuDriverBugWorkarounds workarounds;
+ workarounds.ensure_previous_framebuffer_not_deleted = true;
+ InitState init;
+ init.extensions = "GL_ANGLE_depth_texture";
+ init.gl_version = "OpenGL ES 2.0";
+ init.has_depth = true;
+ init.request_depth = true;
+ InitDecoderWithWorkarounds(init, workarounds);
+
+ // Set up texture
+ DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+ DoTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 1, 1, 0,
+ GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, 0, 0);
+
+ TextureRef* texture_ref =
+ group().texture_manager()->GetTexture(client_texture_id_);
+ ASSERT_TRUE(texture_ref != nullptr);
+ Texture* texture = texture_ref->texture();
+
+ // Expectations for ClearLevel
+ const GLuint kTempFboId = 777;
+ InSequence seq;
+
+ // 1. Gen temp FBO
+ EXPECT_CALL(*gl_, GenFramebuffersEXT(1, _))
+ .WillOnce(SetArgPointee<1>(kTempFboId))
+ .RetiresOnSaturation();
+
+ // 2. Bind temp FBO (wrapper triggers complete_fbo bind first)
+ EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kCompleteFboId))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kTempFboId))
+ .Times(1)
+ .RetiresOnSaturation();
+
+ // 3. Attach texture
+ EXPECT_CALL(*gl_,
+ FramebufferTexture2DEXT(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
+ GL_TEXTURE_2D, kServiceTextureId, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+
+ // 4. Check status
+ EXPECT_CALL(*gl_, CheckFramebufferStatusEXT(GL_FRAMEBUFFER))
+ .WillOnce(Return(GL_FRAMEBUFFER_COMPLETE))
+ .RetiresOnSaturation();
+
+ // 5. Clear calls
+ if (GetParam()) {
+ EXPECT_CALL(*gl_, ColorMask(true, true, true, true))
+ .Times(1)
+ .RetiresOnSaturation();
+ }
+ EXPECT_CALL(*gl_, ClearColor(0.0f, 0.0f, 0.0f, 0.0f))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, ClearStencil(0)).Times(1).RetiresOnSaturation();
+ if (GetParam()) {
+ EXPECT_CALL(*gl_, StencilMaskSeparate(GL_FRONT, 0xFFFFFFFF))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, StencilMaskSeparate(GL_BACK, 0xFFFFFFFF))
+ .Times(1)
+ .RetiresOnSaturation();
+ }
+ EXPECT_CALL(*gl_, ClearDepth(1.0f)).Times(1).RetiresOnSaturation();
+ if (GetParam()) {
+ EXPECT_CALL(*gl_, DepthMask(true)).Times(1).RetiresOnSaturation();
+ }
+ EXPECT_CALL(*gl_, Enable(GL_SCISSOR_TEST)).Times(1).RetiresOnSaturation();
+ EXPECT_CALL(*gl_, Scissor(0, 0, 1, 1)).Times(1).RetiresOnSaturation();
+ EXPECT_CALL(*gl_, Clear(GL_DEPTH_BUFFER_BIT)).Times(1).RetiresOnSaturation();
+
+ // 6. Restore state
+ EXPECT_CALL(*gl_, ClearColor(0.0f, 0.0f, 0.0f, 0.0f))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, ClearStencil(0)).Times(1).RetiresOnSaturation();
+ EXPECT_CALL(*gl_, ClearDepth(1.0f)).Times(1).RetiresOnSaturation();
+ EXPECT_CALL(*gl_, Disable(GL_SCISSOR_TEST)).Times(1).RetiresOnSaturation();
+ EXPECT_CALL(*gl_,
+ Scissor(0, 0, 128, 64)) // 128x64 is the default backbuffer size
+ .Times(1)
+ .RetiresOnSaturation();
+
+ // 7. Restore FBO binding (CRITICAL: Must happen BEFORE delete)
+ // Wrapper triggers complete_fbo bind first
+ EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, kCompleteFboId))
+ .Times(1)
+ .RetiresOnSaturation();
+ EXPECT_CALL(*gl_, BindFramebufferEXT(GL_FRAMEBUFFER, 0))
+ .Times(1)
+ .RetiresOnSaturation();
+
+ // 8. Delete temp FBO
+ EXPECT_CALL(*gl_, DeleteFramebuffersEXT(1, Pointee(kTempFboId)))
+ .Times(1)
+ .RetiresOnSaturation();
+
+ // Call ClearLevel
+ EXPECT_TRUE(decoder_->ClearLevel(texture, GL_TEXTURE_2D, 0,
+ GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, 0, 0, 1,
+ 1));
+
+ DoDeleteTexture(client_texture_id_, kServiceTextureId);
+}
+
+// The boolean parameter controls whether ignore_cached_state_for_test is
+// enabled in the base fixture GLES2DecoderTestBase. This forces GL state
+// restoration and helps test state-dirtying paths.
+INSTANTIATE_TEST_SUITE_P(Service,
+ GLES2DecoderEnsurePrevFboWorkaroundTest,
+ ::testing::Bool());
+
TEST_P(GLES2DecoderManualInitTest, DrawWithGLImageExternal) {
InitState init;
init.extensions = "GL_OES_EGL_image_external";
Original Bug Report
Potential UAF in PowerVR GPU driver via FBO deletion order in ClearLevelUsingGL
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: An incorrect order of operations in GLES2DecoderImpl::ClearLevelUsingGL deletes a temporary Framebuffer Object (FBO) while it is actively bound. On Android devices with PowerVR GPUs, restoring the previous FBO subsequently triggers a driver bug, leading to a Use-After-Free (UAF) in the GPU process. This can be reached from malicious WebGL content.
Affected files:
gpu/command_buffer/service/gles2_cmd_decoder.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
Background
In the validating command decoder, GLES2DecoderImpl::ClearLevelUsingGL is used to clear uninitialized texture levels. It achieves this by creating a temporary Framebuffer Object (FBO), binding it, attaching the texture, clearing it, and then tearing down the temporary FBO.
Certain Imagination (PowerVR) GPU drivers contain a bug where they retain internal references to the ‘currently bound’ or ‘previously bound’ FBO across state transitions. To mitigate this, Chrome implements the ensure_previous_framebuffer_not_deleted workaround (enabled in gpu_driver_bug_list.json for Imagination.*). This workaround wraps FBO binding operations inside GLES2DecoderImpl::BindFramebuffer to ensure a known-good internal FBO (complete_fbo_) is bound during transitions, preventing the driver from accessing deleted state.
The Vulnerability
An incorrect sequence of OpenGL calls in GLES2DecoderImpl::ClearLevelUsingGL defeats this workaround, creating a condition that triggers the PowerVR driver bug.
The vulnerable code flow in gpu/command_buffer/service/gles2_cmd_decoder.cc is as follows:
- A temporary FBO
fbis created and bound using theBindFramebufferwrapper. - The uninitialized texture is attached and cleared using
glClearFn. - The flaw:
api()->glDeleteFramebuffersEXTFn(1, &fb)is called whilefbis still the currently bound FBO on the GL context. - Although the OpenGL ES spec mandates that deleting the bound FBO implicitly binds FBO 0, the buggy PowerVR driver fails to completely update its internal FBO state, leaving a dangling pointer to the now-freed
fb. - Immediately after deletion,
BindFramebuffer(fb_target, fb_service_id)is called to restore the original FBO binding. - Inside the
BindFramebufferwrapper, the PowerVR workaround triggers, attempting to bind the internalcomplete_fbo_first. - This state transition from the deleted
fbtocomplete_fbo_causes the driver to access its dangling pointer representing the previously bound FBO, resulting in a Use-After-Free (UAF).
Because the GPU process is unsandboxed on Android devices, memory corruption in the driver can lead to a direct sandbox escape.
Potential Attacker Steps (Theoretical)
Note: These are suggested theoretical steps as our tooling agent cannot execute code to verify a full chain.
- Heap Grooming: The attacker manipulates the GPU process heap via WebGL, creating and freeing FBOs or other driver objects to prepare the memory layout.
- Trigger Allocation: The attacker allocates a depth texture using WebGL (e.g.,
texImage2Dwithnulldata) on an ES2 context. - Trigger the Clear: The attacker issues a WebGL draw call (e.g.,
drawArrays) that relies on the uninitialized depth texture. The validation command decoder will intercept this and force a clear of the texture, invokingClearLevelUsingGL. - Exploitation: The driver dereferences the freed FBO state during the subsequent
BindFramebuffercall. If the freed memory has been replaced by attacker-controlled data, this can lead to code execution in the GPU process.
Recommended Fix
The restoration of the original FBO binding must occur before the temporary FBO is deleted. This ensures the driver transitions its internal state away from the temporary FBO while the FBO is still validly allocated, allowing the ensure_previous_framebuffer_not_deleted workaround to function correctly.
// gpu/command_buffer/service/gles2_cmd_decoder.cc - GLES2DecoderImpl::ClearLevelUsingGL
RestoreClearState();
// 1. Restore the old binding first
Framebuffer* framebuffer = GetFramebufferInfoForTarget(fb_target);
GLuint fb_service_id =
framebuffer ? framebuffer->service_id() : GetBackbufferServiceId();
BindFramebuffer(fb_target, fb_service_id);
// 2. Now it is safe to delete the temporary FBO
api()->glDeleteFramebuffersEXTFn(1, &fb);
return result;
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.