High chrome Input Validation 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in ANGLE
DescriptionImproper input validation in ANGLE
ComponentANGLE
Sub-componentD3D11
Bug ClassInput Validation
Tracker556750258
Fix commit906ad3fd3071 (angle/angle) +224/-81
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Files Changed

  • src/tests/gl_tests/WebGLCompatibilityTest.cpp
  • src/libANGLE/validationES.h
  • src/tests/gl_tests/TextureTest.cpp
  • src/libANGLE/validationES.cpp
  • src/libANGLE/validationES3.cpp
  • src/tests/angle_end2end_tests_expectations.txt
  • src/libANGLE/validationES2.cpp
  • src/libANGLE/renderer/d3d/d3d11/PixelTransfer11.cpp
From 906ad3fd30715befeb5a6fb01a4fabf1c9cdb15a Mon Sep 17 00:00:00 2001
From: Antonio Maiorano <amaiorano@google.com>
Date: Thu, 01 Oct 2026 15:29:41 -0400
Subject: [PATCH] D3D11: Fix UNPACK_ROW_LENGTH handling in fast PBO unpack

In PixelTransfer11::setBufferToTextureCopyParams, PixelsPerRow was set
to unpack.rowLength (when > 0) instead of destArea.width. Because
PixelsPerRow controls the number of destination columns drawn per row,
setting it to unpack.rowLength caused incomplete row writes when 0 <
unpack.rowLength < destArea.width and out-of-bounds row writes when
unpack.rowLength > destArea.width on partial-width updates.

Set PixelsPerRow to destArea.width and compute RowStride from
unpack.rowLength (when > 0) or destArea.width.

Also move PBO unpack validation and hardened-context unpack parameter
validation from ValidateES3TexImageParametersBase into
ValidImageDataSize so ES2 contexts with GL_EXT_unpack_subimage and
GL_NV_pixel_buffer_object enforce the same checks as ES3.

Add tests:
- Texture2DTestES3.TexImageUnpackRowLengthSmallerThanWidthAndSubRectPBO
- WebGL1CompatibilityTest.UnpackStateAndPBOValidation

Skip
Texture2DTestES3.TexImageUnpackRowLengthSmallerThanWidthAndSubRectPBO on
PIXEL10 GLES (PowerVR), where the native GLES driver fails with
GL_INVALID_OPERATION when unpacking overlapping rows (0 <
GL_UNPACK_ROW_LENGTH < width) from a PBO, matching existing skips under
crbug.com/480069042.

Bug: b/556750258
Change-Id: Idf1791c5e0eead05c9bbf2a1995cbc393b3e1127
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8502520
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Antonio Maiorano <amaiorano@google.com>
Reviewed-by: Ran Wang <wangra@google.com>
---
diff --git a/src/libANGLE/renderer/d3d/d3d11/PixelTransfer11.cpp b/src/libANGLE/renderer/d3d/d3d11/PixelTransfer11.cpp
index b806942..cf9097d 100644
--- a/src/libANGLE/renderer/d3d/d3d11/PixelTransfer11.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/PixelTransfer11.cpp
@@ -135,9 +135,10 @@
         (alignmentBytes <= bytesPerPixel ? 1 : alignmentBytes / bytesPerPixel);
 
     parametersOut->FirstPixelOffset = offset / bytesPerPixel;
-    parametersOut->PixelsPerRow =
-        static_cast<unsigned int>((unpack.rowLength > 0) ? unpack.rowLength : destArea.width);
-    parametersOut->RowStride    = roundUp(parametersOut->PixelsPerRow, alignmentPixels);
+    parametersOut->PixelsPerRow     = static_cast<unsigned int>(destArea.width);
+    parametersOut->RowStride        = roundUp(
+        static_cast<unsigned int>((unpack.rowLength > 0) ? unpack.rowLength : destArea.width),
+        alignmentPixels);
     parametersOut->RowsPerSlice = static_cast<unsigned int>(destArea.height);
     parametersOut->PositionOffset[0] =
         texelCenterX + (destArea.x / float(destSize.width)) * 2.0f - 1.0f;
diff --git a/src/libANGLE/validationES.cpp b/src/libANGLE/validationES.cpp
index d2a5a26..7e0b75f 100644
--- a/src/libANGLE/validationES.cpp
+++ b/src/libANGLE/validationES.cpp
@@ -1228,6 +1228,7 @@
                         GLsizei width,
                         GLsizei height,
                         GLsizei depth,
+                        bool isSubImage,
                         GLenum format,
                         GLenum type,
                         const void *pixels,
@@ -1273,12 +1274,82 @@
             ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kParamOverflow);
             return false;
         }
+
+        if (context->isHardenedContext() && pixelUnpackBuffer->hasTFBBindingConflict())
+        {
+            ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION,
+                                   kPixelUnpackBufferBoundForTransformFeedback);
+            return false;
+        }
+
+        // ...data is not evenly divisible into the number of bytes needed to store in memory a
+        // datum indicated by type.
+        {
+            size_t offset            = reinterpret_cast<size_t>(pixels);
+            size_t dataBytesPerPixel = static_cast<size_t>(GetTypeInfo(type).bytes);
+
+            if ((offset % dataBytesPerPixel) != 0)
+            {
+                ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kDataTypeNotAligned);
+                return false;
+            }
+        }
+
+        // ...the buffer object's data store is currently mapped but not persistently.
+        if (pixelUnpackBuffer->isMapped() && !pixelUnpackBuffer->isPersistentlyMapped())
+        {
+            ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kBufferMapped);
+            return false;
+        }
     }
     else if (outImageSize != nullptr)
     {
         *outImageSize = endByte;
     }
 
+    if (context->isHardenedContext())
+    {
+        // Define:
+        //   DataStoreWidth  = (GL_UNPACK_ROW_LENGTH ? GL_UNPACK_ROW_LENGTH : width)
+        //   DataStoreHeight = (GL_UNPACK_IMAGE_HEIGHT ? GL_UNPACK_IMAGE_HEIGHT : height)
+        //
+        // WebGL 2.0 / hardened contexts impose the following additional constraints:
+        //
+        // 1) texImage2D and texSubImage2D generate INVALID_OPERATION if:
+        //      GL_UNPACK_SKIP_PIXELS + width > DataStoreWidth
+        //    except for texImage2D if no GL_PIXEL_UNPACK_BUFFER is
+        //    bound and _pixels_ is null.
+        //
+        // 2) texImage3D and texSubImage3D generate INVALID_OPERATION if:
+        //      GL_UNPACK_SKIP_PIXELS + width > DataStoreWidth
+        //      GL_UNPACK_SKIP_ROWS + height > DataStoreHeight
+        //    except for texImage3D if no GL_PIXEL_UNPACK_BUFFER is
+        //    bound and _pixels_ is null.
+        if (!pixelUnpackBuffer && !pixels && !isSubImage)
+        {
+            // Exception case for texImage2D or texImage3D, above.
+        }
+        else
+        {
+            GLint dataStoreWidth = unpack.rowLength ? unpack.rowLength : width;
+            if (unpack.skipPixels + width > dataStoreWidth)
+            {
+                ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kInvalidUnpackParametersForWebGL);
+                return false;
+            }
+            if (texType == TextureType::_3D || texType == TextureType::_2DArray)
+            {
+                // Note that CubeMapArray is not supported as a texture target in WebGL.
+                GLint dataStoreHeight = unpack.imageHeight ? unpack.imageHeight : height;
+                if (unpack.skipRows + height > dataStoreHeight)
+                {
+                    ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kInvalidUnpackParametersForWebGL);
+                    return false;
+                }
+            }
+        }
+    }
+
     return true;
 }
 
diff --git a/src/libANGLE/validationES.h b/src/libANGLE/validationES.h
index 811244c..58fec91 100644
--- a/src/libANGLE/validationES.h
+++ b/src/libANGLE/validationES.h
@@ -79,6 +79,7 @@
                         GLsizei width,
                         GLsizei height,
                         GLsizei depth,
+                        bool isSubImage,
                         GLenum format,
                         GLenum type,
                         const void *pixels,
diff --git a/src/libANGLE/validationES2.cpp b/src/libANGLE/validationES2.cpp
index 40d9b54..95d89a2 100644
--- a/src/libANGLE/validationES2.cpp
+++ b/src/libANGLE/validationES2.cpp
@@ -1769,8 +1769,8 @@
     }
 
     GLenum sizeCheckFormat = isSubImage ? format : internalformat;
-    return ValidImageDataSize(context, entryPoint, texType, width, height, 1, sizeCheckFormat, type,
-                              pixels, outImageSize);
+    return ValidImageDataSize(context, entryPoint, texType, width, height, 1, isSubImage,
+                              sizeCheckFormat, type, pixels, outImageSize);
 }
 
 bool ValidateCompressedTexImage(const Context *context,
diff --git a/src/libANGLE/validationES3.cpp b/src/libANGLE/validationES3.cpp
index 21b661c..9528cf7 100644
--- a/src/libANGLE/validationES3.cpp
+++ b/src/libANGLE/validationES3.cpp
@@ -647,88 +647,14 @@
     }
 
     GLenum sizeCheckFormat = isSubImage ? format : internalformat;
-    if (!ValidImageDataSize(context, entryPoint, texType, width, height, depth, sizeCheckFormat,
-                            type, pixels, outImageSize))
+    if (!ValidImageDataSize(context, entryPoint, texType, width, height, depth, isSubImage,
+                            sizeCheckFormat, type, pixels, outImageSize))
     {
         return false;
     }
 
-    // Check for pixel unpack buffer related API errors
-    Buffer *pixelUnpackBuffer = context->getState().getTargetBuffer(BufferBinding::PixelUnpack);
-    if (pixelUnpackBuffer != nullptr)
-    {
-        if (context->isHardenedContext() && pixelUnpackBuffer->hasTFBBindingConflict())
-        {
-            ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION,
-                                   kPixelUnpackBufferBoundForTransformFeedback);
-            return false;
-        }
-
-        // ...data is not evenly divisible into the number of bytes needed to store in memory a
-        // datum
-        // indicated by type.
-        {
-            size_t offset            = reinterpret_cast<size_t>(pixels);
-            size_t dataBytesPerPixel = static_cast<size_t>(GetTypeInfo(type).bytes);
-
-            if ((offset % dataBytesPerPixel) != 0)
-            {
-                ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kDataTypeNotAligned);
-                return false;
-            }
-        }
-
-        // ...the buffer object's data store is currently mapped but not persistently.
-        if (pixelUnpackBuffer->isMapped() && !pixelUnpackBuffer->isPersistentlyMapped())
-        {
-            ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kBufferMapped);
-            return false;
-        }
-    }
-
     if (context->isHardenedContext())
     {
-        // Define:
-        //   DataStoreWidth  = (GL_UNPACK_ROW_LENGTH ? GL_UNPACK_ROW_LENGTH : width)
-        //   DataStoreHeight = (GL_UNPACK_IMAGE_HEIGHT ? GL_UNPACK_IMAGE_HEIGHT : height)
-        //
-        // WebGL 2.0 imposes the following additional constraints:
-        //
-        // 1) texImage2D and texSubImage2D generate INVALID_OPERATION if:
-        //      GL_UNPACK_SKIP_PIXELS + width > DataStoreWidth
-        //    except for texImage2D if no GL_PIXEL_UNPACK_BUFFER is
-        //    bound and _pixels_ is null.
-        //
-        // 2) texImage3D and texSubImage3D generate INVALID_OPERATION if:
-        //      GL_UNPACK_SKIP_PIXELS + width > DataStoreWidth
-        //      GL_UNPACK_SKIP_ROWS + height > DataStoreHeight
-        //    except for texImage3D if no GL_PIXEL_UNPACK_BUFFER is
-        //    bound and _pixels_ is null.
-        if (!pixelUnpackBuffer && !pixels && !isSubImage)
-        {
-            // Exception case for texImage2D or texImage3D, above.
-        }
-        else
-        {
-            const auto &unpack   = context->getState().getUnpackState();
-            GLint dataStoreWidth = unpack.rowLength ? unpack.rowLength : width;
-            if (unpack.skipPixels + width > dataStoreWidth)
-            {
-                ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kInvalidUnpackParametersForWebGL);
-                return false;
-            }
-            if (target == TextureTarget::_3D || target == TextureTarget::_2DArray)
-            {
-                // Note that CubeMapArray is not supported as a texture target in WebGL.
-                GLint dataStoreHeight = unpack.imageHeight ? unpack.imageHeight : height;
-                if (unpack.skipRows + height > dataStoreHeight)
-                {
-                    ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kInvalidUnpackParametersForWebGL);
-                    return false;
-                }
-            }
-        }
-
         if (!isSubImage &&
             !ValidateHardenedContextTextureLevelRedefine(context, entryPoint, texture, level, width,
                                                          height, depth, actualFormatInfo))
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 28d17ad..ba55695 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -1327,6 +1327,7 @@
 480069042 PIXEL10 GLES : Texture2DTestES3.DrawWithLevelZeroUndefined/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.DrawWithLevelsOutsideRangeUndefined/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.MipmapIncompleteMipmappingDisabled/* = SKIP
+480069042 PIXEL10 GLES : Texture2DTestES3.TexImageUnpackRowLengthSmallerThanWidthAndSubRectPBO/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.TextureFormatChangesWithBaseLevel/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.UnpackOverlappingRowsFromUnpackBuffer/* = SKIP
 480069042 PIXEL10 GLES : Texture3DTestES2.CopySubImageAlpha/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index a05dea1..e657db9 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -4324,6 +4324,74 @@
     EXPECT_PIXEL_COLOR_EQ(0, height - 1, GLColor::blue);
 }
 
+// Test that PBO unpack respects GL_UNPACK_ROW_LENGTH when 0 < rowLength < width
+// and when rowLength > subWidth for a partial-width glTexSubImage2D.
+TEST_P(Texture2DTestES3, TexImageUnpackRowLengthSmallerThanWidthAndSubRectPBO)
+{
+    const int width  = getWindowWidth();
+    const int height = getWindowHeight();
+
+    GLTexture tex2D;
+    glActiveTexture(GL_TEXTURE0);
+    glBindTexture(GL_TEXTURE_2D, tex2D);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Pre-fill the entire texture with red so unwritten texels are deterministically detected.
+    std::vector<GLColor> redPixels(width * height, GLColor::red);
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE,
+                    redPixels.data());
+
+    // 1. Unpack full texture from PBO with 0 < rowLength < width.
+    const int smallRowLength  = width / 2;
+    const int smallBufferSize = (height - 1) * smallRowLength + width;
+    std::vector<GLColor> greenPixels(smallBufferSize, GLColor::green);
+
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_UNPACK_BUFFER, smallBufferSize * sizeof(GLColor), greenPixels.data(),
+                 GL_STATIC_DRAW);
+
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, smallRowLength);
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    glUseProgram(mProgram);
+    glUniform1i(mTexture2DUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_GL_NO_ERROR();
+
+    // Both the left half [0, width/2) and right half [width/2, width) must be green.
+    EXPECT_PIXEL_RECT_EQ(0, 0, width, height, GLColor::green);
+
+    // 2. Unpack a partial-width sub-rectangle (subWidth = width / 4) with rowLength > subWidth.
+    const int subWidth        = width / 4;
+    const int largeRowLength  = width / 2;
+    const int largeBufferSize = (height - 1) * largeRowLength + subWidth;
+    std::vector<GLColor> bluePixels(largeBufferSize, GLColor::yellow);
+    for (int r = 0; r < height; ++r)
+    {
+        for (int c = 0; c < subWidth; ++c)
+        {
+            bluePixels[r * largeRowLength + c] = GLColor::blue;
+        }
+    }
+    glBufferData(GL_PIXEL_UNPACK_BUFFER, largeBufferSize * sizeof(GLColor), bluePixels.data(),
+                 GL_STATIC_DRAW);
+
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, largeRowLength);
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, subWidth, height, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_GL_NO_ERROR();
+
+    // [0, width/4) must be blue, and [width/4, width) must remain green.
+    EXPECT_PIXEL_RECT_EQ(0, 0, subWidth, height, GLColor::blue);
+    EXPECT_PIXEL_RECT_EQ(subWidth, 0, width - subWidth, height, GLColor::green);
+}
+
 // Test if the KHR debug label is set and passed to D3D correctly using glCopyTexImage2D.
 TEST_P(Texture2DTest, TextureKHRDebugLabelWithCopyTexImage2D)
 {
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index 0c21744..f304aa2 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -8164,6 +8164,81 @@
     test(GL_TEXTURE_3D, 1, 4, 1, 4, GL_INVALID_OPERATION);  // 1 + 4 > 4
 }
 
+// Tests that hardened-context unpack state validation and PBO validation are enforced on ES2
+// contexts when GL_EXT_unpack_subimage and GL_NV_pixel_buffer_object are enabled.
+TEST_P(WebGL1CompatibilityTest, UnpackStateAndPBOValidation)
+{
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_EXT_unpack_subimage"));
+
+    constexpr size_t kPixelCount = 8 * 8;
+    std::vector<GLColor> data(kPixelCount);
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    ASSERT_GL_NO_ERROR();
+
+    auto testClientMemory = [&](GLint skipPixels, GLint rowLength, GLenum expectedError) {
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, rowLength);
+        glPixelStorei(GL_UNPACK_SKIP_PIXELS_EXT, skipPixels);
+
+        // When no PBO is bound and pixels is nullptr, glTexImage2D must succeed regardless of
+        // unpack parameters.
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+        EXPECT_GL_NO_ERROR();
+
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, data.data());
+        EXPECT_GL_ERROR(expectedError);
+
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, 0);
+        glPixelStorei(GL_UNPACK_SKIP_PIXELS_EXT, 0);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, data.data());
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 28d17ad..ba55695 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -1327,6 +1327,7 @@
 480069042 PIXEL10 GLES : Texture2DTestES3.DrawWithLevelZeroUndefined/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.DrawWithLevelsOutsideRangeUndefined/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.MipmapIncompleteMipmappingDisabled/* = SKIP
+480069042 PIXEL10 GLES : Texture2DTestES3.TexImageUnpackRowLengthSmallerThanWidthAndSubRectPBO/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.TextureFormatChangesWithBaseLevel/* = SKIP
 480069042 PIXEL10 GLES : Texture2DTestES3.UnpackOverlappingRowsFromUnpackBuffer/* = SKIP
 480069042 PIXEL10 GLES : Texture3DTestES2.CopySubImageAlpha/* = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index a05dea1..e657db9 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -4324,6 +4324,74 @@
     EXPECT_PIXEL_COLOR_EQ(0, height - 1, GLColor::blue);
 }
 
+// Test that PBO unpack respects GL_UNPACK_ROW_LENGTH when 0 < rowLength < width
+// and when rowLength > subWidth for a partial-width glTexSubImage2D.
+TEST_P(Texture2DTestES3, TexImageUnpackRowLengthSmallerThanWidthAndSubRectPBO)
+{
+    const int width  = getWindowWidth();
+    const int height = getWindowHeight();
+
+    GLTexture tex2D;
+    glActiveTexture(GL_TEXTURE0);
+    glBindTexture(GL_TEXTURE_2D, tex2D);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Pre-fill the entire texture with red so unwritten texels are deterministically detected.
+    std::vector<GLColor> redPixels(width * height, GLColor::red);
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE,
+                    redPixels.data());
+
+    // 1. Unpack full texture from PBO with 0 < rowLength < width.
+    const int smallRowLength  = width / 2;
+    const int smallBufferSize = (height - 1) * smallRowLength + width;
+    std::vector<GLColor> greenPixels(smallBufferSize, GLColor::green);
+
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_UNPACK_BUFFER, smallBufferSize * sizeof(GLColor), greenPixels.data(),
+                 GL_STATIC_DRAW);
+
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, smallRowLength);
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    glUseProgram(mProgram);
+    glUniform1i(mTexture2DUniformLocation, 0);
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_GL_NO_ERROR();
+
+    // Both the left half [0, width/2) and right half [width/2, width) must be green.
+    EXPECT_PIXEL_RECT_EQ(0, 0, width, height, GLColor::green);
+
+    // 2. Unpack a partial-width sub-rectangle (subWidth = width / 4) with rowLength > subWidth.
+    const int subWidth        = width / 4;
+    const int largeRowLength  = width / 2;
+    const int largeBufferSize = (height - 1) * largeRowLength + subWidth;
+    std::vector<GLColor> bluePixels(largeBufferSize, GLColor::yellow);
+    for (int r = 0; r < height; ++r)
+    {
+        for (int c = 0; c < subWidth; ++c)
+        {
+            bluePixels[r * largeRowLength + c] = GLColor::blue;
+        }
+    }
+    glBufferData(GL_PIXEL_UNPACK_BUFFER, largeBufferSize * sizeof(GLColor), bluePixels.data(),
+                 GL_STATIC_DRAW);
+
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, largeRowLength);
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, subWidth, height, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    drawQuad(mProgram, "position", 0.5f);
+    EXPECT_GL_NO_ERROR();
+
+    // [0, width/4) must be blue, and [width/4, width) must remain green.
+    EXPECT_PIXEL_RECT_EQ(0, 0, subWidth, height, GLColor::blue);
+    EXPECT_PIXEL_RECT_EQ(subWidth, 0, width - subWidth, height, GLColor::green);
+}
+
 // Test if the KHR debug label is set and passed to D3D correctly using glCopyTexImage2D.
 TEST_P(Texture2DTest, TextureKHRDebugLabelWithCopyTexImage2D)
 {
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index 0c21744..f304aa2 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -8164,6 +8164,81 @@
     test(GL_TEXTURE_3D, 1, 4, 1, 4, GL_INVALID_OPERATION);  // 1 + 4 > 4
 }
 
+// Tests that hardened-context unpack state validation and PBO validation are enforced on ES2
+// contexts when GL_EXT_unpack_subimage and GL_NV_pixel_buffer_object are enabled.
+TEST_P(WebGL1CompatibilityTest, UnpackStateAndPBOValidation)
+{
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_EXT_unpack_subimage"));
+
+    constexpr size_t kPixelCount = 8 * 8;
+    std::vector<GLColor> data(kPixelCount);
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    ASSERT_GL_NO_ERROR();
+
+    auto testClientMemory = [&](GLint skipPixels, GLint rowLength, GLenum expectedError) {
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, rowLength);
+        glPixelStorei(GL_UNPACK_SKIP_PIXELS_EXT, skipPixels);
+
+        // When no PBO is bound and pixels is nullptr, glTexImage2D must succeed regardless of
+        // unpack parameters.
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+        EXPECT_GL_NO_ERROR();
+
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, data.data());
+        EXPECT_GL_ERROR(expectedError);
+
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, 0);
+        glPixelStorei(GL_UNPACK_SKIP_PIXELS_EXT, 0);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, data.data());
+        EXPECT_GL_NO_ERROR();
+
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, rowLength);
+        glPixelStorei(GL_UNPACK_SKIP_PIXELS_EXT, skipPixels);
+        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, data.data());
+        EXPECT_GL_ERROR(expectedError);
+    };
+
+    testClientMemory(0, 0, GL_NO_ERROR);
+    testClientMemory(0, 4, GL_NO_ERROR);           // 0 + 4 <= 4
+    testClientMemory(1, 5, GL_NO_ERROR);           // 1 + 4 <= 5
+    testClientMemory(0, 3, GL_INVALID_OPERATION);  // 0 + 4 > 3
+    testClientMemory(1, 0, GL_INVALID_OPERATION);  // 1 + 4 > 4
+    testClientMemory(1, 4, GL_INVALID_OPERATION);  // 1 + 4 > 4
+
+    if (EnsureGLExtensionEnabled("GL_NV_pixel_buffer_object"))
+    {
+        GLBuffer pbo;
+        glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
+        glBufferData(GL_PIXEL_UNPACK_BUFFER, kPixelCount * sizeof(GLColor), data.data(),
+                     GL_STATIC_DRAW);
+        ASSERT_GL_NO_ERROR();
+
+        // With a bound PBO, glTexImage2D(..., nullptr) must also reject overlapping unpack rows.
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, 1);
+        glPixelStorei(GL_UNPACK_SKIP_PIXELS_EXT, 0);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+        EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+        EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+        // Valid row length with PBO succeeds.
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, 4);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+        EXPECT_GL_NO_ERROR();
+
+        // Unaligned PBO offset for GL_UNSIGNED_SHORT_5_6_5 (2 bytes per datum) must fail.
+        glPixelStorei(GL_UNPACK_ROW_LENGTH_EXT, 0);
+        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 4, 4, 0, GL_RGB, GL_UNSIGNED_SHORT_5_6_5,
+                     reinterpret_cast<const void *>(1));
+        EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+        glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
+    }
+}
+
 // Tests that using cube map arrays is not supported in WebGL.
 TEST_P(WebGL2CompatibilityTest, CubeMapArrayNotSupported)
 {
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.