All posts

Four libheif Defects You Can Catch Before Decoding

9 min read

Hacktron’s HEIF Heist research turned libheif into the most interesting image-parsing target of the year. Their team, led by Harsh Jaiswal with Mohan SRK, Rahul Maini and Sudhanshu Rajbhar, chained decoder bugs into remote code execution against OpenAI, Slack, Meta, GitHub Enterprise, Discourse and Next.js, all through ordinary image uploads and image optimization endpoints.

The OpenAI chain reads like a tour of how deep an image decoder sits in a modern stack: a HEIC file uploaded to community.openai.com, converted by ImageMagick, parsed by libheif 1.19.7, heap overflow, remote code execution, administrative access to the forum, then an SSO flaw that gave them an OpenAI employee account with Codex access to GitHub and, through it, the internal monorepo. OpenAI paid $6,500. The libheif bug they used carried no CVE at the time, because upstream had fixed it quietly.

Nobody runs libheif on purpose. It arrives through ImageMagick, libvips, sharp, GDK-PixBuf and container base images, four dependency hops below the application that accepts the upload. Version pinning and SBOM scanning only help for the defects that get an identifier.

Four of these defects are visible in the container metadata, before a single pixel is decoded. ELEGANTBOUNCER now screens for all four, the same way it screens DNG files for CVE-2025-43300 and CVE-2025-21043: describe the vulnerable code path in terms of what the file declares, then check the declaration against itself.

The item graph is the attack surface#

A HEIF or AVIF file is an ISOBMFF container whose meta box describes a graph of items. iinf names each item and its type, iloc says where its bytes live and how many there are, iprp carries properties like dimensions and bit depth, and iref wires items together with typed edges: auxl for an auxiliary image such as an alpha plane, dimg for a derived image such as a grid, an overlay or an identity derivation.

Most of what the decoder allocates comes from that metadata, not from the coded bitstream. Dimensions come from ispe, bit depth from mskC, pixi, hvcC, av1C or uncC, and buffer sizes follow. When a declared length disagrees with a declared geometry, the decoder allocates from one number and copies according to the other, which is the shape all four defects share.

That makes the class unusually friendly to pre-decode screening. Reading a few kilobytes of meta is enough. The mdat payload never has to be touched, and for mask items there is no codec involved at all.

CVE-2026-32741: the mask overflow#

MaskImageCodec::decode_mask_image() allocates a plane from ispe and then copies the item’s entire iloc extent into it:

if (data.size() < width * height) {
  return Error(..., "Mask image data is too short");   // only a lower bound
}

img->add_plane(heif_channel_Y, width, height, bits_per_pixel);
uint8_t* dst = img->get_plane(heif_channel_Y, &stride);

if (stride == width) {
  memcpy(dst, data.data(), data.size());               // whole extent, unbounded
}

The guard rejects short data and says nothing about long data. A 64x64 mask whose extent declares 64 KiB writes about 64 KiB past a 4 KiB allocation, with the length and the bytes both chosen by the file.

The iloc extent is copied whole into a plane sized from ispe, so 65521 bytes land in neighbouring heap

stride == width holds whenever the declared width is a multiple of 16 and at least 64, because ImagePlane::alloc() rounds mem_width up to 64 and aligns the stride to 16 bytes. The ispe height alone selects the allocator size class, which is what makes the corruption placeable.

Affected: libheif 1.21.2 and earlier, fixed in 1.22.0, CVSS 7.1.

The detection compares the summed iloc extents of every mski item against width * height * bytes_per_pixel:

CVE-2026-32741: mski item 1 is 64x64 8bpp but declares 69632 bytes of mask data
(4096 expected, plane allocation is 4111 bytes)
CVE-2026-32741: 65521 bytes land past the end of the mask plane

The 16-bit mask underfill, which has no CVE#

The same function accepts mskC.bits_per_pixel == 16, and the length guard still compares data.size() against width * height bytes without scaling by depth. The per-row copy then moves width bytes into rows that are 2 * width bytes wide:

for (uint32_t i = 0; i < height; i++) {
  memcpy(dst + i * stride, data.data() + i * width, width);
}

At 16 bits per pixel, stride == width is arithmetically impossible, so this branch always runs. The plane comes from new (std::nothrow) uint8_t[] and is never zeroed, the function returns Error::Ok, and the right half of every row reaches the caller as image data. A 512x512 mask hands back roughly 256 KiB of uninitialised heap per decode.

At 16 bits per pixel each row is 2 times width bytes, but only width bytes are copied, so the right half of every row is uninitialised heap returned as pixels

No CVE, no advisory, no Dependabot alert. The same 1.22.0 change that fixed the overflow closed it, by scaling both the guard and the copy length by bytes_per_pixel. Only the version number gives it away, which is exactly the situation the HEIF Heist write-up describes for the bug used against OpenAI.

Detection flags a 16-bit mask whose extent clears the buggy guard but underfills the plane, width * height <= extent < 2 * width * height. Anything below that bound never reaches the copy, so it is not an attempt.

CVE-2026-32882: the overlay over-read#

HeifPixelImage::overlay() fetches the overlay image’s alpha plane once, then blends every colour channel:

uint8_t in_val    = in_p[in_x0 + y * in_stride + x];
uint8_t alpha_val = alpha_p[in_x0 + y * in_stride + x];   // colour stride, not alpha_stride

in_stride belongs to the colour plane. Give an iovl child 10-bit colour and an 8-bit alpha and the colour plane carries two bytes per sample while the alpha plane carries one, so the alpha pointer advances twice as fast as its own rows and walks off the end of the buffer. The advisory quotes about 3,123 bytes for a 100x50 image. Those bytes get blended into the output and survive into whatever the server re-encodes and returns.

The alpha rows are 112 bytes apart but overlay reads at multiples of the 208 byte colour stride, leaving the allocation at row 27

Affected: libheif 1.21.2 and earlier, fixed in 1.22.0, CVSS 7.1. CVE-2025-68431 is the same function below 1.21.0.

Detection resolves each iovl child, follows an iden child to its source, and compares the declared colour depth against the declared alpha depth, reading either from the codec configuration (hvcC byte 17, av1C bit flags), from pixi, or from an uncompressed item’s uncC component list mapped through cmpd. The verdict fires only when the colour sample is wider than the alpha sample, the direction that over-reads.

Worth knowing before you act on a hit: whether a given decode path reaches the over-read also depends on the colour conversion in ImageItem_Overlay::decode. A child already in RGB 4:4:4 goes into overlay() with its declared depths intact, while a YCbCr child gets converted first, and that conversion can normalise the two depths. I decoded a 10-bit 4:2:0 HEVC child with an 8-bit alpha under an AddressSanitizer build of libheif 1.20.2 and it came out clean, because the conversion ran. Files of that shape are still malformed, since no encoder writes them, so ELEGANTBOUNCER reports them and names the depths and items involved.

CVE-2026-84383: two alpha planes, one allocation#

libheif 1.22.0 replaced std::map<heif_channel, ImagePlane> with a std::vector<ComponentStorage>, so a pixel image can now hold two entries for the same channel. Four behaviours then line up. transfer_channel_from_image_as() never checks whether the destination channel already exists. find_storage_for_channel() returns the first match, so every getter describes the first Alpha. scale_nearest_neighbor() allocates the destination from that first entry’s bit depth and then iterates all storage entries, sending a deeper second Alpha down the HDR branch to write uint16_t samples into an 8-bit plane. And iden items let a nested graph produce the duplicate, because ImageItem_iden::check_decoded_image_size() returns Ok unconditionally.

The published proof of concept builds five items:

Item 3 is an iden derivation of item 1 and inherits its 8-bit alpha, while item 4 attaches a 10-bit alpha to item 3, so the image for item 5 carries two Alpha planes

Decoding item 5 hands item 3 its source’s 8-bit alpha, transfers the 10-bit alpha on top, and then scales 64x64 up to 128x128. About 16 KiB lands past a 16 KiB allocation, with the write length set by the output geometry and the values by the deeper bitstream.

Affected: libheif 1.22.0 through 1.23.1, fixed in 1.23.2 on 25 August 2026, CVSS 9.8. The same defect carries a Next.js advisory, because the Image Optimization API decodes attacker-named AVIF bytes through sharp: Next.js 10.0.0 through 15.5.23 and 16.x below 16.3.3, with sharp 0.35.4 shipping the fixed libheif.

Detection counts how many Alpha planes each item’s decoded image would carry. An auxl edge from an item whose auxC property holds one of the three alpha URNs libheif accepts contributes one plane. An iden item passes its source’s alpha through, and a grid canvas is cloned from a tile with its alpha channel included. Several auxl edges pointing at the same master do not count as duplicates, because set_alpha_channel() keeps the last one:

CVE-2026-84383: item 3 carries 2 alpha planes - item 2 (8-bit), item 4 (10-bit);
it is the alpha of item 5 (64x64 against 128x128), so the planes are scaled before use

The upgrade trap#

Three of these defects sit below 1.22.0 and the fourth sits above it:

DefectAffectedFixed
CVE-2026-32741, mask plane overflow<= 1.21.21.22.0
16-bit mask underfill, no CVE<= 1.21.21.22.0
CVE-2026-32882, overlay alpha over-read<= 1.21.21.22.0
CVE-2026-84383, duplicate alpha overflow1.22.0 - 1.23.11.23.2

Upgrading out of the first range and into the second trades three bugs for a CVSS 9.8. Only 1.23.2 and later clears both. Since libheif usually arrives transitively, the version that matters is the one inside the image that serves your uploads, not the one in your package manifest, and sharp.versions.heif or vips_version() is the number to assert on in CI.

Scanning#

cargo install --git https://github.com/msuiche/elegant-bouncer
elegantbouncer --scan suspicious.avif
elegantbouncer --scan -r -e heic,heif,avif,hif /path/to/uploads

The scanner reads the top-level boxes, buffers meta alone, and reconstructs the item graph from iinf, iloc, iprp and iref. It refuses to parse anything that does not start with a valid ftyp box, handles iloc versions 0 through 2, infe versions 2 and 3, both ipma index widths, multi-extent items, idat-backed extents and 64-bit box sizes, and never reads mdat. Scanning stays in the hundreds of files per second on a debug build.

Each rule was written against the vulnerable code path rather than against sample bytes, so the verdicts describe geometry rather than signatures:

ConditionVerdict
8-bit mski, extent > width * heightCVE-2026-32741
16-bit mski, width * height <= extent < 2 * width * heightmask underfill disclosure
iovl child, colour sample wider than alpha sampleCVE-2026-32882
two or more Alpha planes on one imageCVE-2026-84383

Short 8-bit mask data, bit depths other than 8 and 16, an alpha deeper than its colour, a depth map declared with auxid:2, and a depth mismatch in a file with no iovl item all stay clean, because none of them reaches the vulnerable branch.

Against the published proofs of concept for the mask overflow, the mask underfill and the duplicate alpha graph, in both HEVC and AV1 form and under both heic and avif brands, every variant is flagged and every benign control stays clean. libheif’s own test and fuzzing corpus, 212 files including overlay, identity, auxiliary-alpha and uncompressed items, produces zero detections.

One honest caveat. The mask and duplicate-alpha rules are validated against real proof-of-concept files that crash sanitiser builds. The overlay rule is validated against files I built to the advisory’s stated root cause, since no public proof of concept exists for it, and my own attempt to trigger the over-read in a sanitiser build ran into the colour conversion described above.

References#