# Four libheif Defects You Can Catch Before Decoding

URL: https://www.msuiche.com/posts/four-libheif-defects-you-can-catch-before-decoding/
Date: 2026-09-20
Author: Matt Suiche
Tags: CVE-2026-32741, CVE-2026-32882, CVE-2026-84383, libheif, HEIF, AVIF, ELEGANTBOUNCER, Detection


> Structural detection for four libheif memory-safety defects reachable through HEIF and AVIF item graphs, and how they show up in the container before any pixel is decoded

---


Hacktron's [HEIF Heist](https://heif-heist.com/) research turned libheif into the most interesting image-parsing target of the year. Their team, led by [Harsh Jaiswal](https://x.com/rootxharsh) with [Mohan SRK](https://x.com/S1r1u5_), 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](https://www.hacktron.ai/blog/hacking-openai) 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](https://www.msuiche.com/posts/elegantbouncer-when-you-cant-get-the-samples-but-still-need-to-catch-the-threat/) now screens for all four, the same way it screens DNG files for [CVE-2025-43300](https://www.msuiche.com/posts/detecting-cve-2025-43300-a-deep-dive-into-apples-dng-processing-vulnerability/) and [CVE-2025-21043](https://www.msuiche.com/posts/cve-2025-21043-when-dng-opcodes-become-attack-vectors/): 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:

```cpp
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](/images/libheif/mask-overflow.svg)

`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](https://osv.dev/vulnerability/DEBIAN-CVE-2026-32741), 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:

```cpp
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](/images/libheif/mask-underfill.svg)

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:

```cpp
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](/images/libheif/overlay-stride.svg)

Affected: libheif 1.21.2 and earlier, fixed in 1.22.0, CVSS 7.1. [CVE-2025-68431](https://nvd.nist.gov/vuln/detail/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](/images/libheif/duplicate-alpha.svg)

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](https://github.com/strukturag/libheif/releases) 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:

| Defect | Affected | Fixed |
|---|---|---|
| CVE-2026-32741, mask plane overflow | <= 1.21.2 | 1.22.0 |
| 16-bit mask underfill, no CVE | <= 1.21.2 | 1.22.0 |
| CVE-2026-32882, overlay alpha over-read | <= 1.21.2 | 1.22.0 |
| CVE-2026-84383, duplicate alpha overflow | 1.22.0 - 1.23.1 | 1.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

```bash
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:

| Condition | Verdict |
|---|---|
| 8-bit `mski`, extent > `width * height` | CVE-2026-32741 |
| 16-bit `mski`, `width * height` <= extent < `2 * width * height` | mask underfill disclosure |
| `iovl` child, colour sample wider than alpha sample | CVE-2026-32882 |
| two or more Alpha planes on one image | CVE-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

- [HEIF Heist](https://heif-heist.com/), by [Harsh Jaiswal](https://x.com/rootxharsh), [Mohan SRK](https://x.com/S1r1u5_), Rahul Maini and Sudhanshu Rajbhar
- [Hacking OpenAI](https://www.hacktron.ai/blog/hacking-openai), Hacktron
- [ELEGANTBOUNCER](https://github.com/msuiche/elegant-bouncer)
- GHSA-j3w5-7whq-p37q (CVE-2026-32741), CVE-2026-32882, CVE-2025-68431
- GHSA-g89c-p67h-r497 (CVE-2026-84383), GHSA-2xp9-vwfh-vxw4 (Next.js)
- GHSA-vhm9-85gw-x335 (Discourse), CVE-2026-19118 (GitHub Enterprise), GHSA-2jg2-4ch7-h545 (Meta)
- [libheif releases](https://github.com/strukturag/libheif/releases)

