Commit ff7bf3ce for libheif
commit ff7bf3ce4e63c911d2f769164250bdb653dcb547
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sun Sep 20 22:46:10 2026 +0200
Reject a mismatched Cb/Cr plane size (GHSA-j2rv-58fh-w8pw)
A Cb/Cr plane must have exactly the chroma-subsampled size (round_up) of the
logical image, the invariant has_standard_plane_sizes() and check_plane_layout()
enforce downstream. Nothing stopped a caller from building an inconsistent image
through the public API (heif_image_create() + heif_image_add_plane()) by adding a
Cb/Cr plane at any other size, and that mismatch is a memory-safety hazard: an
oversized plane fooled the per-row fill in extend_to_size_with_zero() into an
unsigned underflow and a ~4 GB out-of-bounds write (GHSA-j2rv-58fh-w8pw), and an
undersized one caused out-of-bounds reads during RGB conversion (issue #1796).
Reject the mismatch in add_channel() so the inconsistent image can never be built.
As defense in depth, extend_to_size_with_zero() also gains a per-plane preflight
that rejects, before modifying any plane, a target that would shrink any plane so
a rejected request cannot leave the image partially modified; this backstops the
GHSA-hqc2-cx5m-g6ff logical-size guard, whose whole-image check does not see an
individually oversized chroma plane. Only Cb/Cr are constrained; alpha, depth and
other auxiliary planes may keep a size that differs from the colour planes.
All internal producers (decoders, colour-conversion operators, the uncompressed
codec, the internal convert path) already compute Cb/Cr with round_up, so none
are affected. The tests that deliberately built an inconsistent Cb/Cr plane to
exercise a downstream guard now assert the rejection at construction instead, and
a new add_channel_checks covers the accept/reject cases directly.
This is only reachable by an application calling the public API with an
out-of-contract image; no HEIF/AVIF file triggers it.
diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index 443082c0..ab9b251c 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -381,6 +381,28 @@ Error HeifPixelImage::add_channel(heif_channel channel, uint32_t width, uint32_t
"Use an interleaved format with alpha (e.g. heif_chroma_interleaved_RGBA) instead."};
}
+ // A Cb/Cr plane must have exactly the chroma-subsampled size of the logical image
+ // (round_up), the invariant has_standard_plane_sizes() and check_plane_layout()
+ // enforce elsewhere. Building one at a different size makes the plane's actual
+ // allocation disagree with the size that code derives from the logical image
+ // dimensions, and that mismatch is a memory-safety hazard: an oversized plane
+ // fooled the per-row fill in extend_to_size_with_zero() into an unsigned underflow
+ // and a ~4 GB out-of-bounds write (GHSA-j2rv-58fh-w8pw), an undersized one caused
+ // out-of-bounds reads during RGB conversion (issue #1796). Reject the mismatch at
+ // construction so the inconsistent image cannot be built in the first place. Only
+ // Cb/Cr are constrained; alpha, depth, disparity and other auxiliary planes may
+ // legitimately have a size that differs from the colour planes.
+ if (channel == heif_channel_Cb || channel == heif_channel_Cr) {
+ uint32_t expected_width, expected_height;
+ get_subsampled_size(m_width, m_height, channel, m_chroma, &expected_width, &expected_height);
+
+ if (width != expected_width || height != expected_height) {
+ return {heif_error_Usage_error,
+ heif_suberror_Invalid_parameter_value,
+ "A Cb/Cr plane must have the chroma-subsampled size of the image."};
+ }
+ }
+
// for backwards compatibility, allow for 24/32 bits for RGB/RGBA interleaved chromas
if (m_chroma == heif_chroma_interleaved_RGB && bit_depth == 24) {
@@ -673,6 +695,27 @@ Error HeifPixelImage::extend_to_size_with_zero(uint32_t width, uint32_t height,
return Error::Ok;
}
+ // Preflight: no plane may shrink. Even when the logical target is not smaller
+ // than the image, get_subsampled_size() can map it to a per-component size that
+ // is smaller than a plane's current size, e.g. a Cb/Cr plane that was added
+ // larger than the target's subsampled chroma extent. The per-row right-edge fill
+ // below would then compute its length as (subsampled_width - old_width), underflow
+ // uint32_t, and write past the end of the plane. Validate every plane before
+ // touching any so a rejected request cannot leave the image partially modified
+ // (GHSA-j2rv-58fh-w8pw, a follow-up to GHSA-hqc2-cx5m-g6ff whose logical-size
+ // guard above does not cover an individually oversized chroma plane).
+ for (const auto& component : m_storage) {
+ uint32_t subsampled_width, subsampled_height;
+ get_subsampled_size(width, height, component.m_channel, m_chroma,
+ &subsampled_width, &subsampled_height);
+
+ if (subsampled_width < component.m_width || subsampled_height < component.m_height) {
+ return Error{heif_error_Usage_error,
+ heif_suberror_Invalid_parameter_value,
+ "Cannot extend an image to a size smaller than an existing plane."};
+ }
+ }
+
for (auto& component : m_storage) {
// See extend_padding_to_size(): get_subsampled_size() assumes a non-Cb/Cr
// component has the full logical image size, so we cannot compute a correct
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 4df9937f..1e15a2ea 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -56,6 +56,7 @@ else()
add_libheif_test(crop_plane_checks)
add_libheif_test(extract_area_plane_checks)
add_libheif_test(extend_to_size_checks)
+ add_libheif_test(add_channel_checks)
add_libheif_test(jpeg2000)
add_libheif_test(avc_box)
add_libheif_test(hevc_sps)
diff --git a/tests/add_channel_checks.cc b/tests/add_channel_checks.cc
new file mode 100644
index 00000000..c38ce4b6
--- /dev/null
+++ b/tests/add_channel_checks.cc
@@ -0,0 +1,114 @@
+/*
+ libheif unit tests
+
+ MIT License
+
+ Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+*/
+
+#include "image/pixelimage.h"
+#include "catch_amalgamated.hpp"
+
+// Regression tests for the Cb/Cr size check in HeifPixelImage::add_channel().
+//
+// A Cb/Cr plane must have exactly the chroma-subsampled size of the logical image
+// (round_up). Building one at a different size makes the plane's allocation disagree
+// with the size other code derives from the logical dimensions. An oversized plane
+// let extend_to_size_with_zero() underflow its per-row fill length and write ~4 GB
+// past the plane (GHSA-j2rv-58fh-w8pw); an undersized plane caused out-of-bounds
+// reads during RGB conversion (issue #1796). add_channel() now rejects the mismatch
+// at construction. Only Cb/Cr are constrained; alpha/depth/... may differ.
+
+TEST_CASE("add_channel rejects an oversized Cb plane (GHSA-j2rv-58fh-w8pw)") {
+ auto* limits = heif_get_global_security_limits();
+
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_420);
+ REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+
+ // The subsampled Cb size of a 12x8 4:2:0 image is 6x4; 32x8 is far too large.
+ Error err = image->add_channel(heif_channel_Cb, 32, 8, 8, limits);
+ REQUIRE(err.error_code == heif_error_Usage_error);
+ REQUIRE(err.sub_error_code == heif_suberror_Invalid_parameter_value);
+}
+
+TEST_CASE("add_channel rejects an undersized (round-down) chroma plane") {
+ auto* limits = heif_get_global_security_limits();
+
+ // Odd dimensions: the round_up subsampled size is 7x5, so a floor-division 6x4
+ // plane (as an out-of-spec producer might compute) must be rejected.
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(13, 9, heif_colorspace_YCbCr, heif_chroma_420);
+ REQUIRE(image->add_channel(heif_channel_Y, 13, 9, 8, limits).error_code == heif_error_Ok);
+
+ REQUIRE(image->add_channel(heif_channel_Cb, 6, 4, 8, limits).error_code == heif_error_Usage_error);
+ REQUIRE(image->add_channel(heif_channel_Cr, 6, 4, 8, limits).error_code == heif_error_Usage_error);
+}
+
+TEST_CASE("add_channel accepts correctly-subsampled chroma planes") {
+ auto* limits = heif_get_global_security_limits();
+
+ SECTION("4:2:0, even dimensions") {
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_420);
+ REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cb, 6, 4, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cr, 6, 4, 8, limits).error_code == heif_error_Ok);
+ }
+
+ SECTION("4:2:0, odd dimensions round up") {
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(13, 9, heif_colorspace_YCbCr, heif_chroma_420);
+ REQUIRE(image->add_channel(heif_channel_Y, 13, 9, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cb, 7, 5, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cr, 7, 5, 8, limits).error_code == heif_error_Ok);
+ }
+
+ SECTION("4:2:2 subsamples width only") {
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_422);
+ REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cb, 6, 8, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cr, 6, 8, 8, limits).error_code == heif_error_Ok);
+ }
+
+ SECTION("4:4:4 chroma is full size") {
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_444);
+ REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cb, 12, 8, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cr, 12, 8, 8, limits).error_code == heif_error_Ok);
+ }
+}
+
+TEST_CASE("add_channel does not constrain non-chroma auxiliary planes") {
+ auto* limits = heif_get_global_security_limits();
+
+ // The size rule is Cb/Cr only. An auxiliary plane such as depth may legitimately
+ // have a size unrelated to the colour planes, so add_channel must still accept it.
+ auto image = std::make_shared<HeifPixelImage>();
+ image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_420);
+ REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cb, 6, 4, 8, limits).error_code == heif_error_Ok);
+ REQUIRE(image->add_channel(heif_channel_Cr, 6, 4, 8, limits).error_code == heif_error_Ok);
+
+ REQUIRE(image->add_channel(heif_channel_depth, 5, 5, 8, limits).error_code == heif_error_Ok);
+}
diff --git a/tests/plane_layout.cc b/tests/plane_layout.cc
index 1c760c44..883d06ae 100644
--- a/tests/plane_layout.cc
+++ b/tests/plane_layout.cc
@@ -168,14 +168,22 @@ TEST_CASE("check_plane_layout rejects non-canonical layouts")
}
SECTION("chroma plane with the wrong size") {
+ // add_channel() now refuses to build a Cb/Cr plane whose size is not the
+ // chroma-subsampled size of the image, so the inconsistent state that
+ // check_plane_layout()'s "Cb plane has size" branch used to catch can no
+ // longer be constructed. Verify the rejection happens at construction.
auto img = std::make_shared<HeifPixelImage>();
img->create(W, H, heif_colorspace_YCbCr, heif_chroma_420);
REQUIRE(!img->add_channel(heif_channel_Y, W, H, 8, nullptr));
- REQUIRE(!img->add_channel(heif_channel_Cb, W, H, 8, nullptr)); // should be W/2 x H/2
- REQUIRE(!img->add_channel(heif_channel_Cr, W / 2, H / 2, 8, nullptr));
- Error err = img->check_plane_layout();
+
+ Error err = img->add_channel(heif_channel_Cb, W, H, 8, nullptr); // should be W/2 x H/2
REQUIRE(err);
- CHECK(mentions(err, "Cb plane has size"));
+ CHECK(err.error_code == heif_error_Usage_error);
+
+ // The correctly-sized planes are still accepted.
+ REQUIRE(!img->add_channel(heif_channel_Cb, W / 2, H / 2, 8, nullptr));
+ REQUIRE(!img->add_channel(heif_channel_Cr, W / 2, H / 2, 8, nullptr));
+ CHECK(!img->check_plane_layout());
}
SECTION("alpha next to a filter array") {
diff --git a/tests/uncompressed_encode.cc b/tests/uncompressed_encode.cc
index ecc6994c..d979f876 100644
--- a/tests/uncompressed_encode.cc
+++ b/tests/uncompressed_encode.cc
@@ -1130,15 +1130,18 @@ TEST_CASE("Add tile rejects images that do not match the unci configuration")
};
SECTION("component plane larger than the tile") {
- heif_image *prototype = createImage_YCbCr_customPlanes(TW, TH, heif_chroma_420, TW / 2, TH / 2, TW / 2, TH / 2);
- // Declares the correct tile size, but its Cb plane is far larger than the
- // subsampled size the encoder allocates for.
- heif_image *tile = createImage_YCbCr_customPlanes(TW, TH, heif_chroma_420, 200, 200, TW / 2, TH / 2);
+ // A tile whose Cb plane is far larger than the subsampled size can no longer be
+ // built: heif_image_add_plane() now rejects a Cb/Cr plane that is not the
+ // chroma-subsampled size of the image (GHSA-j2rv-58fh-w8pw), so such a tile can
+ // never reach the encoder's per-tile guard. Verify the rejection at construction.
+ heif_image *tile;
+ REQUIRE(heif_image_create(TW, TH, heif_colorspace_YCbCr, heif_chroma_420, &tile).code == heif_error_Ok);
+ REQUIRE(heif_image_add_plane(tile, heif_channel_Y, TW, TH, 8).code == heif_error_Ok);
- REQUIRE(add_tile(prototype, tile).code != heif_error_Ok);
+ // Cb should be TW/2 x TH/2; 200 x 200 is far too large.
+ REQUIRE(heif_image_add_plane(tile, heif_channel_Cb, 200, 200, 8).code != heif_error_Ok);
heif_image_release(tile);
- heif_image_release(prototype);
}
SECTION("chroma format differs from the prototype") {