CVE-2025-31217
Overview
Background
- ANGLE
- Almost Native Graphics Layer Engine, the library that translates OpenGL ES / WebGL calls onto native backends such as Metal, used by WebKit’s GPU process to implement WebGL.
- Mipmap generation
- Producing a chain of progressively downsampled versions of a texture, where each level is roughly half the size of the previous; glGenerateMipmap builds this chain.
- CPU mip fallback (generateMipmapCPU)
- ANGLE’s software path for generating mip levels when the GPU cannot, e.g. for non-renderable formats, invoking a per-format mipGenerationFunction to downsample level data in memory.
- 3D texture depth dimension
- A GL_TEXTURE_3D has width, height, and depth that each halve down their own mip chains, so depth can have more (or fewer) mip levels than width/height when they start at different sizes.
- Row/depth pitch
- The byte stride between consecutive rows (rowPitch) and consecutive depth slices (depthPitch) of a texture level’s data buffer, which the mip kernel uses with width/height/depth to index memory.
Root Cause Analysis
The vulnerability is in ANGLE’s Metal backend CPU mipmap generation, TextureMtl::generateMipmapCPU in TextureMtl.mm. When a texture format is non-renderable (so mip levels cannot be generated on the GPU), ANGLE falls back to a CPU path that, for each destination mip level, calls the format’s mipGenerationFunction to downsample the previous level.
The bug is that the depth argument passed to mipGenerationFunction was hard-coded to the constant 1 rather than the previous level’s actual depth (prevLevelDepth). For a 3D texture, mip levels are reduced in width, height, AND depth, but depth reduces on its own schedule; when the depth dimension has more mip levels than width/height (e.g. a 1x1x2 texture, where width and height are already 1 at level 0 but depth still halves to 1 at level 1), the previous level’s depth can be greater than 1. By telling the downsampling function that the source depth is 1 when it is actually 2 (or more), the generation function reads/writes the wrong number of depth slices relative to the real source and destination buffers described by prevLevelDepthPitch/dstDepthPitch, producing an out-of-bounds access (the previous level’s data buffer has more than one slice worth of data, but only one slice’s worth is accounted for, or the reduction across depth slices is skipped). The invariant violated is that the width/height/depth passed to the mip-generation kernel must describe the actual dimensions of the source level’s data buffer.
The fix replaces the literal 1 with prevLevelDepth so the depth passed matches the previous level’s real depth, restoring correct bounds and correct depth reduction.
The added regression test MipmapsForTexture3DNonRenderableMoreMipLevelsInDepthNoCrash creates exactly this shape: a 1x1x2 GL_ALPHA (non-renderable) 3D texture and calls glGenerateMipmap, asserting it does not crash and that level 1 contains the depth-averaged value (102 ~ average of 5 and 200), confirming the previous code both mis-bounded and failed to average across depth.
Attack Path
- Obtain a WebGL2 context From web content, create a WebGL2 (OpenGL ES 3.0) rendering context, which on Apple platforms is backed by ANGLE’s Metal renderer running in the GPU process.
- Create a 3D texture with a non-renderable format Call texImage3D with an internal format that is non-renderable in the Metal backend (the test uses GL_ALPHA) so mipmap generation must take the CPU fallback path generateMipmapCPU rather than a GPU render pass.
- Choose dimensions where depth outlives width/height in the mip chain Use a shape such as 1x1x2 (or more generally depth having more mip levels than width and height) so that at some level the previous level’s depth is greater than 1 while width/height are already 1.
- Call generateMipmap Invoke glGenerateMipmap(GL_TEXTURE_3D); the CPU path calls mipGenerationFunction with depth hard-coded to 1 while the source buffer actually holds prevLevelDepth (>1) slices, causing an out-of-bounds read/write against the level data buffers.
- Crash the GPU process The mismatched depth drives the downsampling kernel past the intended buffer bounds, producing the memory-safety fault that crashes the GPU process (the demonstrated Safari crash).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TextureMtl::generateMipmapCPUSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm |
modified | Changes the mipGenerationFunction call to pass prevLevelDepth instead of the literal 1 as the source depth, so the downsampling kernel uses the previous mip level's real depth for bounds and for reducing across depth slices. |
MipmapTestES3.MipmapsForTexture3DNonRenderableMoreMipLevelsInDepthNoCrashSource/ThirdParty/ANGLE/src/tests/gl_tests/MipmapTest.cpp |
added | Regression test creating a 1x1x2 GL_ALPHA (non-renderable) 3D texture and calling glGenerateMipmap, asserting no crash and verifying level-1 depth averaging (GLColor alpha ~102 from 5 and 200) to prove the depth dimension is now handled correctly. |
Files Changed
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mmSource/ThirdParty/ANGLE/src/tests/gl_tests/MipmapTest.cpp
Audit Directions
- Audit all mipGenerationFunction / downsampling call sites for hard-coded dimensionsIn TextureMtl.mm and sibling ANGLE backends grep for mipGenerationFunction and other downsample kernels, checking that width, height, and depth arguments are all taken from the source level (prevLevelWidth/Height/Depth) and never passed a literal 1 or a value that ignores the depth mip schedule.
- Check other 3D/array texture paths for depth vs width/height mip-count divergenceSearch TextureMtl.mm for GL_TEXTURE_3D / TextureType::_3D handling and computations of per-level width/height/depth and depthPitch; verify every place that iterates mip levels accounts for depth having a different number of levels than width/height (the ‘more mip levels in depth’ case).
- Compare Metal backend against other ANGLE backendsGrep the D3D, Vulkan, and GL ANGLE backends for their CPU mip fallbacks and confirm they pass the true source depth to the generation function, since a shared logic error or copied code could carry the same literal-1 depth bug.
- Look for analogous pitch/dimension mismatches in CPU texture copy/convert pathsBeyond mipmapping, audit ANGLE Metal CPU-side texture upload/convert/blit routines for cases that pass a fixed depth or slice count while the underlying buffer holds multiple depth slices (mismatch between depthPitch and the depth argument).