Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebGL
DescriptionUse after free in WebGL
ComponentWebGL
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker523591974
Fix commitfeb103bc1c01 (chromium/src) +172/-2
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-13 (per vendor advisory)
Fix landed2026-06-18 (public git)
Disclosed2026-06-23
Patch gap5 days — fix public in source before the advisory

Background

`ClearLevelUsingGL`
helper in GLES2DecoderImpl that 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; glDeleteFramebuffersEXTFn frees 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.

Key insight
Deletion order relative to binding matters on drivers that hold an internal reference to the previously-bound FBO: an FBO must be unbound (by rebinding the prior target) before it is deleted, otherwise the driver dereferences freed state.

Attack Path

  1. Trigger a texture clear Craft WebGL content that causes the decoder to lazily clear an uninitialized texture level via ClearLevelUsingGL.
  2. Establish a prior binding Ensure a framebuffer binding is active on entry so the temporary fb becomes the effective binding that must later be restored.
  3. Force deletion while bound Reach RestoreClearState() and the subsequent glDeleteFramebuffersEXTFn(1, &fb) while fb is still the effectively-bound framebuffer.
  4. Provoke driver dereference On Imagination/PowerVR, the deletion frees FBO state the driver still references, and the following BindFramebuffer dereferences freed memory.
  5. 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

A use-after-free in the GPU process is reachable from WebGL, giving an attacker corruption of freed driver-internal FBO state on affected Imagination/PowerVR devices. Because the GPU process handles untrusted graphics commands, such corruption can lead to memory disclosure, denial of service, or potentially code execution within the GPU process sandbox. The condition is driver-dependent, so the practical attack surface is devices using the affected drivers.

Changed Functions

FunctionChangeNotes
GLES2DecoderEnsurePrevFboWorkaroundTest
gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
modified
if
gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
modified
TEST_P
gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
modified

Files Changed

  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc

Audit Directions

  • Delete-while-bound
    Audit all call sites of glDeleteFramebuffersEXTFn (and analogous glDelete* 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 coverage
    Check that paths gated by ensure_previous_framebuffer_not_deleted and similar Imagination/PowerVR workarounds are consistently applied wherever an FBO can be deleted while effectively bound.
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
Loading diff…

Regression Test / PoC

shipped with the fix
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";
Loading diff…

Original Bug Report

reported by [email protected]

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:

  1. A temporary FBO fb is created and bound using the BindFramebuffer wrapper.
  2. The uninitialized texture is attached and cleared using glClearFn.
  3. The flaw: api()->glDeleteFramebuffersEXTFn(1, &fb) is called while fb is still the currently bound FBO on the GL context.
  4. 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.
  5. Immediately after deletion, BindFramebuffer(fb_target, fb_service_id) is called to restore the original FBO binding.
  6. Inside the BindFramebuffer wrapper, the PowerVR workaround triggers, attempting to bind the internal complete_fbo_ first.
  7. This state transition from the deleted fb to complete_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.

  1. Heap Grooming: The attacker manipulates the GPU process heap via WebGL, creating and freeing FBOs or other driver objects to prepare the memory layout.
  2. Trigger Allocation: The attacker allocates a depth texture using WebGL (e.g., texImage2D with null data) on an ES2 context.
  3. 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, invoking ClearLevelUsingGL.
  4. Exploitation: The driver dereferences the freed FBO state during the subsequent BindFramebuffer call. If the freed memory has been replaced by attacker-controlled data, this can lead to code execution in the GPU process.

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.

View on issue tracker