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.
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_imagereturns success and hands back an image that reports primary 2x2 and Y plane 2x2 but Alpha plane 256x256 withhas_alphatrue. Feed that image intoheif_context_encode_imagewith 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.libheif/libheif/sequences/track_visual.cc
Lines 294 to 302 in bd114ed
transfer_channel_from_image_asmoves the plane in as is and keeps its size.libheif/libheif/image/pixelimage.cc
Line 1020 in bd114ed
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.libheif/libheif/codecs/uncompressed/unc_encoder_component_interleave.cc
Lines 190 to 217 in bd114ed
The size calculation that only looks at the primary dimensions is
compute_tile_data_size_bytes.libheif/libheif/codecs/uncompressed/unc_encoder_component_interleave.cc
Lines 159 to 187 in bd114ed
The two public entry points are
heif_track_decode_next_imagelibheif/libheif/api/libheif/heif_sequences.cc
Line 164 in bd114ed
and
heif_context_encode_imagelibheif/libheif/api/libheif/heif_encoding.cc
Line 677 in bd114ed
Root cause
There are two layers and both matter.
First layer is the decoder.
decode_next_image_sampleassociates the alpha track by itstref('auxl')reference and its alphaauxiURN and then callstransfer_channel_from_image_aswithout comparing the alpha frame size to the main frame size. The sequence path does not runcheck_decoded_image_sizeso nothing rejects the result. The decodedHeifPixelImageends up with a primary size that disagrees with its alpha plane size.Second layer is the encoder.
encode_tiletrusts that decoded object. It computes the output size withcompute_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 usingget_component_width(comp.component_id)andget_component_height(comp.component_id)which are the real plane sizes. When a component is larger than the primary imageout_posruns 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:
The consistent control file re-encodes with
encode -> OKand 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 insideheif_context_encode_imagebefore 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_alphatrue but the alpha plane is only 2x2. A normal exporter that writes one RGBA pixel per primary pixel readsalpha[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_imageindexes every plane with the primary dimensions inscale_nearest_neighborso scaling the frame reads past the small alpha plane in library code atpixelimage.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
POC Code: export_read_poc.c
gen_poc.pybuilds 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.heifshas a 64x64 main track and a 64x64 alpha track and is expected to re-encode cleanly.exploit_reencode_overflow.heifshas a 2x2 main track and a 256x256 alpha track and is expected to overflow.Secondary read case.
control_same_alpha.heifsis 256x256 main and 256x256 alpha and exports cleanly.exploit_small_alpha.heifsis 256x256 main and 2x2 alpha and reads out of bounds on export.Build and run everything:
The script builds libheif with AddressSanitizer and the uncompressed codec only. It then runs the write control and exploit through
transcode_write_pocand the read control and exploit throughexport_read_poc.Expected on the primary exploit:
Expected on the secondary exploit:
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_createfor a 2x2 image and thenheif_image_add_plane(img, heif_channel_Alpha, 256, 256, 8)and pass that toheif_context_encode_image.heif_image_add_planedoes not validate the plane size against the image size so the same write atencode_tile:213fires. 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_samplereject or resize an alpha auxiliary frame whose dimensions differ from the main frame before callingtransfer_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_bytesandencode_tileuse the same per component dimensions. This also closes the directheif_image_add_planeroute.It is also worth enforcing the size invariant centrally in
HeifPixelImageso 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 HighA 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 ModerateThe 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.