CVE-2026-9965
Overview
Files Changed
src/libANGLE/renderer/d3d/TextureD3D.cppsrc/libANGLE/renderer/gl/TextureGL.cpp
Patch
From 9234709fb457b211d778d8d85bdaa39c1f3bb6dc Mon Sep 17 00:00:00 2001
From: Antonio Maiorano <amaiorano@google.com>
Date: Thu, 30 Apr 2026 17:03:04 -0400
Subject: [PATCH] Fix possible OOB write copying to cube map in D3D11 and GL
When self-copying (glCopyTexImage2D) a cube map face with an OOB source
rectangle, the D3D11 and GL backends would early out without redefining
the destination texture to the new input dimensions, while the frontend
updated the image desc dimensions. This would allow for a future
glTexSubImage2D to copy a larger buffer to the un-resized staging
texture, potentially writing out of bounds. The test added here would
result in ASAN catching an OOB write without the fix.
Note that this self-copying code was added a couple months ago with
https://chromium-review.git.corp.google.com/c/angle/angle/+/7264430.
- Fixed the bug in D3D11 backend by making sure to redefine the
destination texture if the source rectangle is OOB.
- Moved up the setNeedsFlushBeforeDeleteTextures call in D3D11 to avoid
needing to call it multiple times due to early returns.
- Reworked the GL backend to mimic the D3D11 one, moving up the
self-copy check, and factoring out the self-copy handling to its own
function, handleCopyImageSelfCopyRedefine.
- Also simplified the original logic of this code based on Geoff's
review comments.
Bug: b/506377574
Change-Id: I8e926d644e8a8546a4b52ec5c2149fb66da199e0
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7807910
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Antonio Maiorano <amaiorano@google.com>
---
diff --git a/src/libANGLE/renderer/d3d/TextureD3D.cpp b/src/libANGLE/renderer/d3d/TextureD3D.cpp
index 7a8ba52..eefe51f 100644
--- a/src/libANGLE/renderer/d3d/TextureD3D.cpp
+++ b/src/libANGLE/renderer/d3d/TextureD3D.cpp
@@ -135,6 +135,8 @@
gl::Rectangle clippedArea;
if (!ClipRectangle(sourceArea, gl::Rectangle(0, 0, fbSize.width, fbSize.height), &clippedArea))
{
+ // We won't be copying, but redefine the destination texture in case sourceArea is larger
+ ANGLE_TRY(redefineDest(destExtents));
return angle::Result::Continue;
}
diff --git a/src/libANGLE/renderer/gl/TextureGL.cpp b/src/libANGLE/renderer/gl/TextureGL.cpp
index 2816918..ab342c0 100644
--- a/src/libANGLE/renderer/gl/TextureGL.cpp
+++ b/src/libANGLE/renderer/gl/TextureGL.cpp
@@ -696,6 +696,71 @@
return angle::Result::Continue;
}
+angle::Result TextureGL::handleCopyImageSelfCopyRedefine(const gl::Context *context,
+ GLenum internalFormat,
+ GLenum initTexFormat,
+ GLenum initTexType,
+ const gl::Rectangle &sourceArea,
+ bool outside,
+ const gl::ImageIndex &destIndex,
+ gl::Framebuffer *source)
+{
+ StateManagerGL *stateManager = GetStateManagerGL(context);
+ const FunctionsGL *functions = GetFunctionsGL(context);
+ gl::TextureTarget target = destIndex.getTarget();
+ size_t level = static_cast<size_t>(destIndex.getLevelIndex());
+
+ gl::Extents fbSize = source->getReadColorAttachment()->getSize();
+ gl::Rectangle clippedArea;
+ if (!ClipRectangle(sourceArea, gl::Rectangle(0, 0, fbSize.width, fbSize.height), &clippedArea))
+ {
+ // We won't be copying, but redefine the destination texture in case sourceArea is larger
+ stateManager->bindTexture(getType(), mTextureID);
+ ANGLE_GL_TRY_ALWAYS_CHECK(
+ context, functions->texImage2D(ToGLenum(target), static_cast<GLint>(level),
+ internalFormat, sourceArea.width, sourceArea.height, 0,
+ initTexFormat, initTexType, nullptr));
+ return angle::Result::Continue;
+ }
+
+ gl::Offset destOffset(clippedArea.x - sourceArea.x, clippedArea.y - sourceArea.y, 0);
+
+ // Avoid redefining the texture before the copy, as that would invalidate the
+ // source attachment. Copy through a temporary texture first.
+
+ GLuint tempTex = 0;
+ ANGLE_GL_TRY(context, functions->genTextures(1, &tempTex));
+
+ // Always use a 2D temp texture to keep the attachment complete (especially for
+ // cube maps in ES, which require all faces to be defined).
+ stateManager->bindTexture(gl::TextureType::_2D, tempTex);
+ ANGLE_GL_TRY(context, functions->copyTexImage2D(GL_TEXTURE_2D, 0, internalFormat, clippedArea.x,
+ clippedArea.y, clippedArea.width,
+ clippedArea.height, 0));
+
+ GLuint tempFBO = 0;
+ ANGLE_GL_TRY(context, functions->genFramebuffers(1, &tempFBO));
+ stateManager->bindFramebuffer(GL_FRAMEBUFFER, tempFBO);
+ ANGLE_GL_TRY(context, functions->framebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
+ GL_TEXTURE_2D, tempTex, 0));
+
+ // Redefine the destination texture after the temp framebuffer is set up.
+ stateManager->bindTexture(getType(), mTextureID);
+ ANGLE_GL_TRY_ALWAYS_CHECK(
+ context, functions->texImage2D(ToGLenum(target), static_cast<GLint>(level), internalFormat,
+ sourceArea.width, sourceArea.height, 0, initTexFormat,
+ initTexType, nullptr));
+
+ ANGLE_GL_TRY(context, functions->copyTexSubImage2D(ToGLenum(target), static_cast<GLint>(level),
+ destOffset.x, destOffset.y, 0, 0,
+ clippedArea.width, clippedArea.height));
+
+ stateManager->deleteFramebuffer(tempFBO);
+ stateManager->deleteTexture(tempTex);
+
+ return angle::Result::Continue;
+}
+
angle::Result TextureGL::copyImage(const gl::Context *context,
const gl::ImageIndex &index,
const gl::Rectangle &sourceArea,
@@ -720,6 +785,11 @@
nativegl::GetTexImageFormat(functions, features, internalFormat,
copyInternalFormatInfo.format, copyInternalFormatInfo.type);
+ if (features.flushBeforeDeleteTextureIfCopiedTo.enabled)
+ {
+ contextGL->setNeedsFlushBeforeDeleteTextures();
+ }
+
stateManager->bindTexture(getType(), mTextureID);
const FramebufferGL *sourceFramebufferGL = GetImplAs<FramebufferGL>(source);
@@ -730,6 +800,44 @@
sourceArea.x + sourceArea.width > fbSize.width ||
sourceArea.y + sourceArea.height > fbSize.height;
+ // If fbo's read buffer and the target texture are the same texture but different levels,
+ // and if the read buffer is a non-base texture level, then implementations glTexImage2D
+ // may change the target texture and make the original texture mipmap incomplete, which in
+ // turn makes the fbo incomplete.
+ // To avoid that, we clamp BASE_LEVEL and MAX_LEVEL to the same texture level as the fbo's
+ // read buffer attachment. See http://crbug.com/797235
+ bool isSourceTextureSame = false;
+ bool isSelfCopy = false;
+ const gl::FramebufferAttachment *readBuffer = source->getReadColorAttachment();
+ if (readBuffer && readBuffer->type() == GL_TEXTURE)
+ {
+ TextureGL *sourceTexture = GetImplAs<TextureGL>(readBuffer->getTexture());
+ if (sourceTexture && sourceTexture->mTextureID == mTextureID)
+ {
+ GLuint attachedTextureLevel = readBuffer->mipLevel();
+ if (attachedTextureLevel != mState.getEffectiveBaseLevel())
+ {
+ ANGLE_TRY(setBaseLevel(context, attachedTextureLevel));
+ ANGLE_TRY(setMaxLevel(context, attachedTextureLevel));
+ }
+ }
+ const bool isSameCubeFace = readBuffer->cubeMapFace() == gl::TextureTarget::InvalidEnum ||
+ readBuffer->cubeMapFace() == target;
+ isSourceTextureSame = sourceTexture && sourceTexture->mTextureID == mTextureID;
+ isSelfCopy = isSourceTextureSame && isSameCubeFace &&
+ readBuffer->mipLevel() == static_cast<GLint>(level);
+ }
+
+ if (isSelfCopy)
+ {
+ ANGLE_TRY(handleCopyImageSelfCopyRedefine(
+ context, copyTexImageFormat.internalFormat, initTexImageFormat.format,
+ initTexImageFormat.type, sourceArea, outside, index, source));
+
+ contextGL->markWorkSubmitted();
+ return angle::Result::Continue;
+ }
+
// TODO: Find a way to initialize the texture entirely in the gl level with ensureInitialized.
// Right now there is no easy way to pre-fill the texture when it is being redefined with
// partially uninitialized data.
@@ -769,40 +877,6 @@
gl::Rectangle clippedArea;
if (ClipRectangle(sourceArea, gl::Rectangle(0, 0, fbSize.width, fbSize.height), &clippedArea))
{
- // If fbo's read buffer and the target texture are the same texture but different levels,
- // and if the read buffer is a non-base texture level, then implementations glTexImage2D
- // may change the target texture and make the original texture mipmap incomplete, which in
- // turn makes the fbo incomplete.
- // To avoid that, we clamp BASE_LEVEL and MAX_LEVEL to the same texture level as the fbo's
- // read buffer attachment. See http://crbug.com/797235
- const gl::FramebufferAttachment *readBuffer = source->getReadColorAttachment();
- if (readBuffer && readBuffer->type() == GL_TEXTURE)
- {
- TextureGL *sourceTexture = GetImplAs<TextureGL>(readBuffer->getTexture());
- if (sourceTexture && sourceTexture->mTextureID == mTextureID)
- {
- GLuint attachedTextureLevel = readBuffer->mipLevel();
- if (attachedTextureLevel != mState.getEffectiveBaseLevel())
- {
- ANGLE_TRY(setBaseLevel(context, attachedTextureLevel));
- ANGLE_TRY(setMaxLevel(context, attachedTextureLevel));
Regression Test / PoC
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index 6df23a9..50b0ddc 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -3434,4 +3434,81 @@
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(CopyTextureTestES3);
ANGLE_INSTANTIATE_TEST_ES3(CopyTextureTestES3);
+class BasicCopyTextureTest : public ANGLETest<>
+{
+ protected:
+ BasicCopyTextureTest()
+ {
+ setWindowWidth(64);
+ setWindowHeight(64);
+ setConfigRedBits(8);
+ setConfigGreenBits(8);
+ setConfigBlueBits(8);
+ setConfigAlphaBits(8);
+ }
+};
+
+// Test that self-copying a cube map face with an OOB source rectangle doesn't cause lead to an OOB
+// write in texSubImage2D. See https:crbug.com/506377574
+TEST_P(BasicCopyTextureTest, SelfCopyOOBWrite)
+{
+ const int kSmallSize = 8;
+ const int kBigSize = 4096;
+ GLenum faces[] = {
+ GL_TEXTURE_CUBE_MAP_POSITIVE_X, GL_TEXTURE_CUBE_MAP_NEGATIVE_X,
+ GL_TEXTURE_CUBE_MAP_POSITIVE_Y, GL_TEXTURE_CUBE_MAP_NEGATIVE_Y,
+ GL_TEXTURE_CUBE_MAP_POSITIVE_Z, GL_TEXTURE_CUBE_MAP_NEGATIVE_Z,
+ };
+ // Cube texture with all six faces at kSmallSize x kSmallSize.
+ // Initialize with data so each face's ImageDesc.initState == Initialized.
+ GLTexture tex;
+ glBindTexture(GL_TEXTURE_CUBE_MAP, tex);
+ std::vector<GLubyte> initData(kSmallSize * kSmallSize * 4, 0x11);
+ for (GLenum f : faces)
+ {
+ glTexImage2D(f, 0, GL_RGBA, kSmallSize, kSmallSize, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ initData.data());
+ }
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ ASSERT_GL_NO_ERROR();
+
+ // Attach NEGATIVE_X as the read FBO color attachment.
+ GLFramebuffer fb;
+ glBindFramebuffer(GL_FRAMEBUFFER, fb);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_NEGATIVE_X,
+ tex, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+ ASSERT_GL_NO_ERROR();
+
+ // Self-copy NEGATIVE_X -> NEGATIVE_X with source rect entirely outside
+ // the kSmallSize x kSmallSize framebuffer.
+ //
+ // The bug that was found was happening because when handleCopyImageSelfCopyRedefine was called,
+ // it would early return when 'ClipRectangle' returned false, without making sure to redefine
+ // the destination texture to fit the larger kBigSize x kBigSize, while 'setImageDesc' would get
+ // called and update it's supposed size to kBigSize x kBigSize, desyncing the values between the
+ // frontend and backend. When the call to glTexSubImage2D below would attempt to copy a kBigSize
+ // x kBigSize buffer to this texture via a memory-mapped buffer, it would map the smaller buffer
+ // and write beyond its bounds.
+ glCopyTexImage2D(GL_TEXTURE_CUBE_MAP_NEGATIVE_X, 0, GL_RGBA, 1000, 1000, kBigSize, kBigSize, 0);
+ ASSERT_GL_NO_ERROR();
+
+ // Force the Image11::loadData path: redefine a *different* face at a mismatched size. In the
+ // D3D11 backend, this releases the storage and marks all images as dirty. The stale NEGATIVE_X
+ // Image11 is not resized.
+ glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, 0, GL_RGBA, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ nullptr);
+ ASSERT_GL_NO_ERROR();
+
+ // Potential OOB write: this is where Image11::loadData would map the kSmallSize x kSmallSize
+ // staging texture, and memcpy kBigSize x kBigSize data to it, writing OOB.
+ std::vector<GLubyte> payload(kBigSize * kBigSize * 4, 0x41);
+ glTexSubImage2D(GL_TEXTURE_CUBE_MAP_NEGATIVE_X, 0, 0, 0, kBigSize, kBigSize, GL_RGBA,
+ GL_UNSIGNED_BYTE, payload.data());
+
+ ASSERT_GL_NO_ERROR();
+}
+ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(BasicCopyTextureTest);
+
} // namespace angle
Original Bug Report
Potential Heap OOB write via size desynchronization in ANGLE D3D self-copy
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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A WebGL validation logic flaw in ANGLE allows an invalid self-copy operation on cube map faces to proceed. If the source area is entirely out-of-bounds, an early return in the D3D backend skips resizing the underlying storage while the frontend inflates its internal dimensions. A subsequent sub-image update using the inflated dimensions causes a linear heap buffer overflow when writing to the un-resized backend staging buffer in the GPU process.
Affected files:
third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cppthird_party/angle/src/libANGLE/renderer/d3d/d3d11/Image11.cppthird_party/angle/src/libANGLE/Texture.cppthird_party/angle/src/libANGLE/validationES.cppthird_party/angle/src/libANGLE/Framebuffer.cpp
Estimated timestamp from git blame: 2026-01-04
Overview
There is a potential high-severity heap out-of-bounds (OOB) write vulnerability in ANGLE’s D3D backend. The issue arises from a combination of a WebGL validation bypass regarding texture self-copying and a state desynchronization between the ANGLE frontend and the D3D backend during out-of-bounds copy operations.
Potential Steps to Reproduce
Note: These steps are derived from static code analysis and represent a potential execution path. A working proof-of-concept has not been run.
1. WebGL Validation Bypass
An attacker creates a WebGL context, a 1x1 Cube Map texture, and a framebuffer. The attacker binds a cube map face other than POSITIVE_X (e.g., NEGATIVE_X, which corresponds to layer index 1) to the active framebuffer’s color attachment. They then call gl.copyTexImage2D with the target TEXTURE_CUBE_MAP_NEGATIVE_X to initiate a self-copy.
In third_party/angle/src/libANGLE/validationES2.cpp line 5262, ValidateCopyTexImage2D calls ValidateES3CopyTexImage2DParameters with a hardcoded zoffset (layer index) of 0:
return ValidateES3CopyTexImage2DParameters(context, entryPoint, target, level, internalformat,
false, 0, 0, 0, x, y, width, height, border);
When checking for feedback loops, Framebuffer::formsCopyingFeedbackLoopWith (third_party/angle/src/libANGLE/Framebuffer.cpp:2407) compares the true read attachment layer (1) against this hardcoded copyTextureLayer (0). The check fails (1 != 0), bypassing WebGL’s protection against reading and writing to the same texture.
2. Frontend/Backend Size Desynchronization
The attacker intentionally provides a sourceArea that is completely outside the bounds of the 1x1 source framebuffer (e.g., 1000x1000 at a large offset).
In the frontend (third_party/angle/src/libANGLE/Texture.cpp:1532), Texture::copyImage calculates the new target size (1000x1000) and dispatches the call to the backend.
In the D3D backend (third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:123), TextureD3D::handleCopyImageSelfCopyRedefine intercepts the self-copy. Because the source area is out-of-bounds, ClipRectangle fails:
if (!ClipRectangle(sourceArea, gl::Rectangle(0, 0, fbSize.width, fbSize.height), &clippedArea))
{
return angle::Result::Continue;
}
This early return skips the redefineDest callback on line 147, leaving the backend Image11 storage at its original 1x1 size. However, because the backend returned angle::Result::Continue (success), the frontend unconditionally updates its state to the new inflated size (mState.setImageDesc(...) at Texture.cpp:1600).
3. Heap Out-of-Bounds Write
The attacker calls gl.texSubImage2D on the NEGATIVE_X face, providing a large 1000x1000 payload and an update area of 1000x1000.
The frontend validates this against its inflated state and passes the call to the backend. In third_party/angle/src/libANGLE/renderer/d3d/d3d11/Image11.cpp:274, Image11::loadData maps a staging texture. Because the backend size was never updated, a tiny 1x1 staging buffer is allocated and mapped.
Image11::loadData then invokes a pixel-copying function (e.g., LoadToNative3To4 in third_party/angle/src/image_util/loadimage.inc:80) passing the mapped tiny buffer as output but using the frontend’s inflated area.width and area.height (1000x1000) as the loop limits:
for (size_t y = 0; y < height; y++) // height is 1000
{
// ...
type *dest = priv::OffsetDataPointer<type>(output, y, z, outputRowPitch, outputDepthPitch);
// ... memcpy into dest ...
}
This linear copy loops far past the end of the mapped 1x1 staging buffer, causing a heap out-of-bounds write of attacker-controlled data in the GPU process.
Suggested Fix
- Fix Validation Bypass: In
ValidateCopyTexImage2D(validationES2.cpp), do not hardcodezoffsetto0when callingValidateES3CopyTexImage2DParameters. Instead, extract the correct layer index from thetargetparameter (e.g., usingTextureTargetToLayer(target)for cube maps). - Fix Desynchronization: In
TextureD3D::handleCopyImageSelfCopyRedefine(TextureD3D.cpp), ensure that the backend texture is resized even ifClipRectanglefails. Alternatively, if the copy is aborted, return an error code or ensure the frontend does not unconditionally inflate its size on an aborted out-of-bounds copy.
Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.