Vulnerability Report - CVE-2026-48029

CVE-2026-48029: Two grid-decode bugs in libheif

A single afternoon of fuzzing against libheif 1.21.2 produced two memory-safety bugs in the same function. The first is a NULL pointer dereference on a malformed grid dimg reference - deterministic denial of service on any consumer that calls heif_decode_image or heif_image_handle_decode_image_tile. The second is a uint32 underflow in the inverse-rotation tile arithmetic that feeds a debug-only assert in the grid index lookup; in NDEBUG release builds (the configuration typical distribution packages use) the assert is compiled out and the access becomes a heap out-of-bounds read with an attacker-influenced offset. Disclosed privately to the maintainer on 2026-05-02 and fixed in libheif 1.22.0, released 2026-05-19. Tracked as GHSA-6x5f-qchq-cxqv and assigned CVE-2026-48029.

Discovery Context

How the bug was found.

The libheif function ImageItem_Grid::decode_grid_tile with two memory-safety bugs landing two lines apart - F2 at line 586 (uint32 underflow into m_grid_tile_ids[idx], CWE-191 + CWE-125) and F1 at line 590 (NULL pointer dereference on missing grid tile, CWE-476). Three small thumbnails on the right depict a 2x2 grid with one missing tile reference, an irot rotation arrow over an asymmetric grid, and a release-vs-debug toggle showing the assert collapsed in NDEBUG.
Two memory-safety bugs in the same function, two lines apart. F1 is a missing null check after HeifContext::get_image(); F2 is a uint32 underflow upstream feeding an unchecked vector access gated only by a debug-only assert.

libheif is the de-facto open-source HEIF/HEIC/AVIF container library. If you have ever opened a .heic from an iPhone on Linux, viewed an AVIF in a desktop image viewer, processed an iPhone export with ImageMagick or libvips on a server, or hit an Android app that reads HEIC, you have very likely run libheif code. The container is intricate - ISO/IEC 23008-12 layered on top of the ISO Base Media File Format - the attack surface is exactly what you would expect for a media parser, and the consumer base is large.

This session was a single afternoon against the 1.21.2 release tag (commit 78638f4f), with two working assumptions. The first: the container layer runs before any codec back-end, so any container-side bug is reachable on hosts that do not have libde265 or libaom installed at all. The second: the derived-image layer - grid, overlay, iden, tiled, mask - sits on top of the container and mixes parsed header data with cross-references to other items in the file. Cross-references between two independent parsed data sources are historically a rich source of arithmetic and lifetime mistakes.

The harness that produced both findings - decode_grid_overlay - is a 200-line libFuzzer driver. It walks every top-level image, calls get_image_tiling with both process_xforms=0 and =1, probes seven (col, row) coordinate pairs per tiling layout (corners, centres, quarter-points), runs a per-tile decode, then a full-grid composition decode, then any auxiliary images. The same harness was linked against two libheif builds: a debug ASAN+UBSAN build with asserts on, and a release -O2 -DNDEBUG ASAN+UBSAN build with asserts compiled out. That second build is the choice that mattered most in this session.

Two grid-decode signatures fired in the first thirty seconds of the first sanity run. Both landed in ImageItem_Grid::decode_grid_tile, two lines apart, but with completely different failure modes and completely different root causes. Triage, root-cause derivation, sibling-class audit, suggested fixes, and the private report to the maintainer all went out the same day.

The bugs are not exotic. The workflow is the interesting part. Across May 2, a cold clone of the repository turned into two libheif builds, the harness suite, two minimised reproducers, the sibling-class audit table, and a coordinated private report on the maintainer's desk. The local campaign continued across the May 2/May 3 window with overnight runs that confirmed a second F2 sink, and culminated in a chaos object-graph campaign a few days later that produced 30.4 million executions and zero new crashes. The closing section of this page returns to the workflow that made that pace possible.

Impact Analysis
Severity
High
Heap out-of-bounds read with an attacker-influenced offset, reachable through standard libheif decode APIs. GHSA-6x5f-qchq-cxqv rated High.
Status
Public / Patched
Both bugs fixed in libheif 1.22.0, released 2026-05-19. 17 days from private report to fixed release.
CVE
CVE-2026-48029
Assigned via GitHub Security Advisory GHSA-6x5f-qchq-cxqv on 2026-05-20.
Reachability
Public decode APIs
heif_decode_image and heif_image_handle_decode_image_tile - the most common entry points for libheif consumers.
Technical Breakdown

What is happening, in detail.

Both bugs live in libheif/image-items/grid.cc in the function ImageItem_Grid::decode_grid_tile. The relevant excerpt from libheif 1.21.2 is short:

grid.cc · 1.21.2
uint32_t idx = ty * m_grid_spec.get_columns() + tx;

assert(idx < m_grid_tile_ids.size());                  // line 586  -- F2 sink

heif_item_id tile_id = m_grid_tile_ids[idx];            // line 588  -- F2 OOB read
std::shared_ptr<const ImageItem> tile_item =
    get_context()->get_image(tile_id, true);            // line 589
if (auto error = tile_item->get_item_error()) {         // line 590  -- F1 NULL deref
  return error;
}

Four consecutive lines, two distinct memory-safety bugs.

F1 - NULL pointer deref on a missing grid tile reference

A HEIF grid is a derived image: a virtual item that says "to draw me, take these N tile items, lay them out in an R×C grid." The tile items are pointed to via an iref dimg (derived-image reference). At parse time the only consistency check is that dimg.size() == rows * cols. A file that declares a perfectly-sized dimg list whose entries point at item ids that do not exist - or at ids that exist but are not decodable images (an Exif metadata item, for example) - passes that check fine.

At decode time, HeifContext::get_image(id, only_images) returns an empty std::shared_ptr for any id that does not resolve to an image item in the registry. The very next line in decode_grid_tile calls tile_item->get_item_error() on it - a member call through a null shared_ptr. SIGSEGV. The dereferenced address is 0x0 plus a small constant vtable offset, so this is a deterministic crash, not a controlled write.

The sibling derived-image classes all do the missing null-check:

File and lineCalls get_image(_, true)Null-checks the result?
image-items/grid.cc:589yesno - this is the bug
image-items/overlay.cc:338yesyes (line 339)
image-items/iden.cc:86yesyes (line 87)
image-items/iden.cc:108yesyes (line 109)

The fix is structurally identical to the pattern at overlay.cc:339: five lines, one if (!tile_item) return Error{...};. That is what upstream commit e1b97646 shipped.

F2 - uint32 underflow into an unchecked vector index

F2 is the more interesting bug. The end of the chain is the line two above F1, on grid.cc:586-588: a uint32_t idx computed from the tile coordinates, an assert(idx < m_grid_tile_ids.size()), and then an unchecked std::vector::operator[] access.

In a debug build the assert fires and the process aborts. In a release build compiled with -DNDEBUG - the configuration typical distribution packages use - the assert is compiled to nothing and the next line walks a vector with an attacker-influenced index. ASAN reports:

AddressSanitizer release -O2 -DNDEBUG ASAN+UBSAN build of libheif 1.21.2
ASAN
==NN==ERROR: AddressSanitizer: SEGV on unknown address 0x610400000e00
==NN==The signal is caused by a READ memory access.
    #0 ImageItem_Grid::decode_grid_tile          grid.cc:588:26
    #1 ImageItem_Grid::decode_compressed_image   grid.cc:224
    #2 ImageItem::decode_image                   image_item.cc:747
    #3 HeifContext::decode_image                 context.cc:1404
    #4 heif_image_handle_decode_image_tile       heif_tiling.cc:108
SUMMARY: AddressSanitizer: SEGV grid.cc:588:26 in
  ImageItem_Grid::decode_grid_tile(...)

ASAN reports SEGV rather than heap-buffer-overflow only because idx is so large that m_grid_tile_ids.data() + idx*4 overshoots into unmapped memory. The bug class is still the same: an unchecked vector::operator[] access with an attacker-influenced index. The bytes read are a heif_item_id (4 bytes), used downstream as a lookup key in HeifContext::get_image().

The huge index comes from a uint32 underflow upstream. ImageItem::transform_requested_tile_position_to_original_tile_position performs subtractions like num_columns - 1 - tile_y and num_rows - 1 - tile_x on caller-supplied coordinates, against the pre-rotation grid extents. The caller's (tile_x, tile_y) are documented as being in the post-rotation (displayed) grid. When the rotation is 90° or 270° and rows ≠ columns, the displayed grid has its dimensions swapped relative to the file grid, and a legal post-rotation coordinate can exceed the smaller pre-rotation extent. The subtraction underflows to ~UINT32_MAX, the underflowed value flows into decode_grid_tile, the idx = ty * cols + tx arithmetic produces an enormous index, and the vector access walks off the heap.

Two grids side by side: a 1x4 pre-rotation grid and a 4x1 post-rotation displayed grid, with caller coordinates flowing through case-270 subtraction against pre-rotation rows and producing a uint32 underflow to UINT32_MAX, then into m_grid_tile_ids[idx] without a release-build bound check.
The caller supplies (tile_x, tile_y) in the displayed grid; the inverse-rotation arithmetic subtracts against the pre-rotation extents. When the rotation swaps rows and columns and the grid is non-square, one coordinate exceeds the smaller pre-rotation dimension and the uint32 subtraction underflows to ~UINT32_MAX. That value is then used as a vector index, gated only by a debug-only assert.

This is a CWE-191 (integer underflow) feeding a CWE-125 (out-of-bounds read). The primitive is not trivially-leaking into an external observer - the 4 bytes read are consumed as a hash-map lookup key - but it is structurally a memory-safety bug, not a robustness assertion. The advisory states exactly that scope and nothing more.

Why two builds matter

The same source code, the same input file, two different categorisations depending on whether -DNDEBUG is set:

Build configurationF2 result
Debug ASAN+UBSAN, asserts onSIGABRT via assert(idx < m_grid_tile_ids.size()) at grid.cc:586 - looks like a robustness assert
Release -O2 -DNDEBUG ASAN+UBSANAddressSanitizer: SEGV grid.cc:588:26 READ - honest heap OOB read

Release-style builds with assertions disabled are what most downstream packages ship, so that configuration is what defines the security envelope. A fuzzing setup that only runs against debug-with-asserts will look at the SIGABRT from F2 and file a hardening PR. The release-build re-run is what turns it into a memory-safety report.

Two fixes, not one

The patch above shows the F1 null check and the F2 sink-side runtime bounds check. A third commit (e523ec0b, authored by Dirk Farin) is the structurally correct F2 source-side fix - process transformations on the tiling upfront so the arithmetic operates on post-rotation dimensions, then reject out-of-range caller coordinates before any subtraction. The in-code comment matches the advisory almost word-for-word: the displayed grid has its columns and rows swapped relative to the file; using the file dims both let out-of-range coordinates through and produced unsigned underflows inside the inverse-rotation formulas.

Attribution and parallel discovery

An attribution note worth making explicit: two of the three upstream commits in this area - e1b97646 and 518bd95f - were authored by Anthony Hurtado with a "Found by: AFL++ fuzzing with custom harness" trailer. My private report and reproducers were sent to the maintainer before I knew those commits existed; when the maintainer eventually checked my PoC against v1.22.0, he confirmed it was already fixed - probably from his own fuzzer runs. The cleanest framing is parallel discovery and overlapping validation, not a claim that my report alone caused every upstream patch. The third commit, e523ec0b - the structurally correct F2 source-side fix - was authored by the maintainer (Dirk Farin) the following day, and its in-code comment matches the underflow scenario described in my advisory almost word-for-word. The advisory itself (GHSA-6x5f-qchq-cxqv) was accepted, published, and credited to me as reporter after I mirrored the report to GitHub on 2026-05-20.

Patch libheif/image-items/grid.cc - upstream fixes in v1.22.0
diff
@@ ImageItem_Grid::decode_grid_tile @@   uint32_t idx = ty * m_grid_spec.get_columns() + tx;-  assert(idx < m_grid_tile_ids.size());+  if (idx >= m_grid_tile_ids.size()) {+    return Error{heif_error_Invalid_input,+                 heif_suberror_Missing_grid_images,+                 "Grid tile coordinate out of range"};+  }   heif_item_id tile_id = m_grid_tile_ids[idx];   std::shared_ptr<const ImageItem> tile_item =       get_context()->get_image(tile_id, true);+  if (!tile_item) {+    return Error{heif_error_Invalid_input,+                 heif_suberror_Missing_grid_images,+                 "Grid tile references a non-existent item"};+  }   if (auto error = tile_item->get_item_error()) {     return error;   }
Reachability and Impact

What an attacker can actually do.

The realistic blast radius is wider than for the average codec bug, because both bugs live in the container layer - which runs before any codec back-end - and are reachable through libheif's most-used public APIs. Any consumer that opens a malicious HEIF or HEIC file and asks libheif to decode either the primary image or a single tile reaches the vulnerable function.

SurfaceVerdictWhy
heif_decode_image on a malicious grid HEIFConfirmed reachableFull-grid composition iterates per-tile and lands in decode_grid_tile. Either F1 or F2 fires depending on the file shape. ASAN reproducers in both release and debug builds.
heif_image_handle_decode_image_tileConfirmed reachableDirect single-tile decode path. F2 fires here when the file carries an irot property and rows ≠ columns; F1 fires here when the dimg list references missing or non-image items.
heif_image_handle_get_grid_image_tile_idReachable for F2Invokes the same transform; the second F2 sink confirmed during the post-disclosure overnight fuzz run.
Image viewers and thumbnailers on Linux/BSD (gThumb, geeqie, Nautilus thumbnailers, KIO)Plausible direct exposureThese tools call heif_decode_image or its convenience wrappers on user-supplied files. A malicious file in a downloads folder is enough to crash the previewer or thumbnailer.
Server-side image pipelines that link libheif (ImageMagick, libvips when built with HEIF support, custom transcoders)Plausible direct exposureAny pipeline that accepts user-uploaded HEIC and runs heif_decode_image can be crashed on demand. F2's OOB read does not leak into an external observer by itself, but reliable DoS-on-decode is enough to matter for upload pipelines.
Browsers (Chrome, Firefox, Safari, Edge) opening AVIF/HEIC images directlyReachability not confirmedMainstream browsers ship their own AVIF stacks (dav1d, libavif) rather than libheif. WebAssembly-shipped libheif builds in some image-editor apps would be reachable, but the WASM sandbox contains the OOB read to the module's linear memory - it does not directly compromise the browser process.
Read-only consumers that never call a decode APINot affectedThe bugs are in the decode path. Container-only inspection (metadata enumeration, item listing) does not reach decode_grid_tile.

Exploitability

  • F1 - reliable DoS. Any consumer that calls heif_decode_image or heif_image_handle_decode_image_tile on a malicious file with a malformed dimg reference SIGSEGVs. The dereferenced address is 0x0 plus a small constant vtable offset - deterministic across runs and platforms, not attacker-controllable.
  • F2 - DoS plus a heap OOB read primitive. The 4 bytes read at the attacker-influenced offset are consumed as a heif_item_id lookup key in HeifContext::get_image(). By itself this is not a trivially-leaking primitive into an external observer; in a larger gadget chain (combined with an info-leak side channel via caching behaviour or timing) it could matter. Exploitability beyond the sanitizer report was not pursued.
  • Memory write. Neither bug provides a write primitive.
  • Sandbox guidance. Out-of-process decode workers are the right mitigation for downstream consumers that cannot immediately update to 1.22.0. Both bugs result in SIGSEGV in release builds, which a try/catch in the consumer process will not catch.
Disclosure Timeline

From discovery to coordinated disclosure.

  1. 2026-05-02, morning Cloned libheif 1.21.2 (commit 78638f4f). Read the container, derived-image, and metadata code top-down. Wrote a short attack-surface note prioritising the container and derived-image layers because they run before any codec back-end and are reachable from every consumer.
  2. 2026-05-02, afternoon Five small libFuzzer harnesses written: decode_primary, decode_metadata, decode_thumbnail, decode_grid_overlay, decode_sequence. Built libheif twice - debug ASAN+UBSAN, and release -O2 -DNDEBUG ASAN+UBSAN. The second build is what later turned a robustness assert into a memory-safety report.
  3. 2026-05-02, afternoon First sanity-fuzz run on decode_grid_overlay. Within 30 seconds, two distinct signatures fire: a NULL deref at grid.cc:590 and an assertion at grid.cc:586.
  4. 2026-05-02, evening Triage complete on both signatures. Sibling-class audit shows overlay.cc, iden.cc all do the missing null-check; grid.cc is the outlier. The grid.cc:586 assertion is debug-only - the release-NDEBUG build re-run confirms an ASAN-reported heap OOB read.
  5. 2026-05-02, evening Private security report sent to Dirk Farin (libheif maintainer) by email. Bundle includes a 303-byte minimised reproducer for F1, a 16,389-byte reproducer for F2, root-cause writeup, suggested fixes for both, and a request to coordinate fix and embargo through the advisory.
  6. 2026-05-03 to 2026-05-06 Local campaign continued across the May 2/May 3 window and beyond: post-disclosure overnight fuzz runs confirmed a second F2 sink (also reachable via heif_image_handle_get_grid_image_tile_id), then a chaos object-graph campaign (612 chaos seeds, 50 size-stress seeds, 200 box-level mutations). 30.4 million executions, zero new crashes. Coverage on decode_grid_overlay rose from 25,807 to 28,884 edges - a strong negative result.
  7. 2026-05-17, 22:01 -05:00 Upstream commit e1b97646 ("grid: fix NULL deref in decode_grid_tile on missing tile reference") authored by Anthony Hurtado. Trailer: "Found by: AFL++ fuzzing with custom harness". The fix shape matches the sibling-class pattern recommended in the advisory.
  8. 2026-05-17, 22:02 -05:00 Upstream commit 518bd95f ("grid: fix OOB read in decode_grid_tile when grid exceeds tile count") authored by Anthony Hurtado. Replaces the debug-only assert with a runtime error return - the F2 sink-side fix.
  9. 2026-05-18, 17:46 +02:00 Upstream commit e523ec0b ("fix tile coordinates validation in rotated images") authored by Dirk Farin (libheif maintainer). The structurally correct F2 source-side fix: process transformations on the tiling upfront, validate caller coordinates against post-rotation bounds, track current dimensions through the property chain. The in-code comment matches the underflow scenario described in the advisory almost word-for-word.
  10. 2026-05-19, 19:06 +02:00 libheif v1.22.0 tagged. All three fix commits included.
  11. 2026-05-20, 00:23 Follow-up email to maintainer (the original disclosure had been overlooked in the email triage). Reply 14 minutes later: "I just checked your PoC and found that it is already fixed. Probably from my own fuzzer runs. It just went into the release v1.22.0 today." Mirrored the report to GitHub as GHSA-6x5f-qchq-cxqv.
  12. 2026-05-20, 18:25 Maintainer confirms CVE requested through GitHub. CVE-2026-48029 assigned shortly after.
  13. 2026-05-21 Reproducers re-run against a fresh v1.22.0 release-NDEBUG ASAN+UBSAN build with codec back-ends off. Both reproducers exit clean - no sanitizer signal in release or debug. Public writeup published.
Takeaways

Lessons.

  1. Asserts are not bounds checks. Anywhere a parsed-from-the-file integer is used as an array index or a pointer offset, the check must be a runtime if that returns an error in release builds. assert() is documentation, not protection. F2 looked like a robustness signal in the debug build and a memory-safety report in the release build - same source, same input, same line, different categorisation. Build and fuzz the release-mode ASAN configuration explicitly.
  2. Cross-references between two parsed sources are bug factories. The grid header says "R×C tiles." A separate parsed list says "and here are the N tile item ids." A separate part of the file says "and the image is rotated 270 degrees." The decode path has to reconcile all three with the caller's coordinates - which themselves arrive through a public API. F2 is exactly the pattern of one parsed value flowing into arithmetic against a different parsed value. Whenever a header declares a count or a shape and a separate parsed list provides the elements, validate at parse time that the two agree and re-check at use time.
  3. Trust boundaries include caller-supplied API arguments. heif_image_handle_decode_image_tile(handle, tile_x, tile_y) is a public API. The coordinates come from the consumer, not the file - but consumers typically derive them from parsed-from-the-file dimensions returned by get_image_tiling(). Either way, libheif has to validate them itself against the displayed grid before they enter inverse-rotation arithmetic. The source-side fix (commit e523ec0b) closes exactly this gap.
  4. Sibling-class audit before reporting. The most effective sentence in a security report is "you already do this everywhere else, except here." For F1, the four sibling derived-image classes (overlay.cc, two sites in iden.cc) already do the missing null-check. That makes the fix a five-line patch with a structural precedent rather than a debate about whether the input was valid in the first place. The audit takes ten minutes; it saves the maintainer hours and the reporter a round-trip.
  5. Structurally-aware seeds beat random mutation on framed formats. ISO BMFF box framing eats random byte mutations alive - most mutants produce invalid lengths and stop parsing before reaching anything interesting. A 25-seed generator that produces deliberately-invalid-but-parseable HEIFs (grids pointing at missing items, grids pointing at non-image items, size mismatches, irot+imir combinations, extreme dimensions) hit F1's exact 303-byte shape within thirty seconds. The bug class wants a structurally valid container with an inconsistent cross-reference - that is exactly what targeted seeds produce.
Methodology

A two-model research loop.

The work that produced this disclosure was not one agent grinding through libheif alone. It was a deliberate two-model loop with a human in the conductor seat: one model for execution, one for critique, the human choosing what to commit to.

The execution model (Claude) did the work that takes wall-clock time. Reading the libheif source top-down. Drafting the attack-surface note. Writing the five harnesses. Building the debug and release-NDEBUG libraries. Running the sanity fuzzes. Triaging the two grid signatures. Composing the sibling-class audit table. Minimising F1 to 303 bytes. Authoring the suggested-fix diffs. Drafting the private security report. End to end.

A two-model research loop with a human in the conductor seat. The human node (sage-filled circle) sits at the top labeled 'intent and judgment, picks the target, commits to actions, makes disclosure call'. Below left is the execution model (Claude) with bullets reads source, writes harnesses, triages crashes. Below right is the critic model (ChatGPT) with bullets challenges conclusions, proposes missed angles, polishes disclosure. Solid arrows connect human-to-execution (intent), execution-to-critic (results / draft reports), and critic-to-execution (pushback); a dashed arrow runs from critic back up to human (review).
The execution model does the work that takes wall-clock time. The critic model is kept off the execution path on purpose, so its pushback is independent of the work it is critiquing. The human picks the target and commits to actions.

The critic model (ChatGPT) was kept off the execution path on purpose - its job was to challenge conclusions, not author them. The most useful intervention in this campaign was the moment the execution side declared libheif "saturated" after a clean overnight run. The critic pushed back: structured harness fuzzing is complete, but you have not tested chaotic cross-object composition - semi-valid containers with conflicting item references, derived-image-of-derived-image, transforms stacked on the wrong items, iloc extents pointing into parseable-but-weird regions. It produced a full Phase 7 plan with an explicit stopping criterion. The execution side built it: 612 chaos seeds plus 50 size-stress seeds plus 200 box-level mutations, run as smoke, short, and overnight tracks. The result was zero new crashes across 30.4 million executions - but with measurable coverage gain over the prior best, decode_grid_overlay edges climbing from 25,807 to 28,884 (+11.9%) and decode_primary from 25,399 to 27,289 (+7.4%). A strong negative result. Without the critic, that phase would have been rationalised away as diminishing returns and the saturation claim would have rested on absence of evidence rather than evidence of absence.

The same loop ran during disclosure. The first email to the maintainer was drafted by the execution side; the critic edited it for tone (less apologetic, more practical) and recommended the shorter of two phrasings for the CVE-request follow-up. Small edits, real effect on the response.

The human role in the loop is narrower than it sounds and harder than it looks: pick the target, accept or reject the critic's pushback, commit to the actions the execution side proposes, decide when to disclose and how. The models are fast; the judgment about which question to ask is the part that does not scale yet.

This research was done over a weekend, on my own free time, outside work hours, and not as part of my job.

Closing thought

AI is an amplifier of intent.

What collapsed is not the difficulty of finding bugs. It is the cost of acting on intent. Each step of vulnerability research - reading code top-down, hypothesising where a bug class lives, building a harness, triaging a crash, doing a sibling-class audit, drafting a clean report with a suggested fix - used to be a manual sequence punctuated by hours of human attention. Now most of those steps are reasoning-over-actions that an agent can execute in seconds against a fully-grounded view of the codebase. The intent here was mine; the wall-clock cost was a fraction of what the same intent used to cost.

The uncomfortable corollary is that today's models are the worst versions of these models we will ever use again. Whatever the bottleneck looks like at this point in 2026, it will be smaller next quarter and smaller still the quarter after. For maintainers, that means the bar for "we would have caught this in code review" is rising faster than the codebase. For defenders, it means the inventory of latent bugs in mature libraries is going to be drained faster than the disclosure infrastructure was designed to handle. For researchers, the leverage now sits less in the act of finding a bug and more in the choice of where to point. The disclosure cadence libheif demonstrated here - 17 days from private report to fixed release, with the maintainer himself authoring the structurally correct F2 source-side fix - is the kind of pace the rest of the ecosystem should be optimising toward.

The bugs are arithmetic mistakes. The story is the timeline.

Realistic Exposure

Who is actually at risk?

libheif is the canonical open-source HEIF/HEIC/AVIF container library. Direct downstream consumers include ImageMagick, libvips, GIMP, KDE's KImageFormats, GNOME's image viewers and thumbnailers, and a long tail of Linux desktop image apps. The ecosystem reach is large; the realistic exposure to these specific bugs is narrower, because four gates must all be true: (1) the consumer links a libheif at or before 1.21.2 with the grid-decode path enabled; (2) it actually calls a decode API on user-supplied input; (3) for F2, the input can carry an irot property and a non-square grid; (4) the host has not already received a libheif update through its distribution. Most distributions track libheif closely, so the practical exposure window is the time between v1.22.0 reaching their mirrors and downstream consumers picking up the rebuilt package.

Platform / categoryAssessmentWhy
Linux desktop image viewers and thumbnailers (gThumb, geeqie, Nautilus, KIO, GNOME image viewer, KDE Gwenview)Plausible direct exposureThese tools link libheif and call heif_decode_image on user-supplied files. A malicious HEIC in a downloads folder is enough to crash the previewer or thumbnailer. DoS-on-decode is the realistic outcome.
Server-side image pipelines (ImageMagick + HEIF delegate, libvips with HEIF support, custom transcoders)Plausible direct exposureAny pipeline that accepts user-uploaded HEIC and runs libheif's decode APIs can be crashed on demand. F2's OOB read does not leak into an external observer by itself, but reliable DoS-on-decode of an upload worker still matters for upload-driven services.
Mobile platforms (iOS Photos, Android Gallery, vendor camera apps)Not affectediOS uses its own ImageIO/HEIF stack. Android relies on platform decoders that do not link libheif. The HEIC ecosystem on mobile bypasses libheif entirely.
Major browsers (Chrome, Firefox, Safari, Edge)Reachability not confirmedMainstream browsers ship libavif/dav1d for AVIF and platform decoders for HEIC, not libheif. Image-editor PWAs that ship libheif in WebAssembly would be reachable, but a WASM sandbox contains the OOB read to the module's linear memory - it does not directly compromise the browser process.
Linux distributions (Debian, Ubuntu, Fedora, Arch, openSUSE)Update path is the mitigationDistributions track libheif closely. The right action is to wait for the distro update that pulls 1.22.0 and rebuild any consumers that statically link libheif. Backporting the three fix commits (e1b97646, 518bd95f, e523ec0b) is small and local. See the packaging-status snapshot below for which distributions have caught up.
Custom builds of libheif compiled with -DCMAKE_BUILD_TYPE=DebugDifferent failure modeDebug builds with asserts on will SIGABRT instead of producing a heap OOB read on F2. Still a denial of service; not a memory-safety report. The decision to fuzz the release-NDEBUG configuration is what surfaced F2 as memory-safety.

Realistic exposure

This does not mean every browser or every phone is affected. Many mainstream browsers and mobile platforms use different AVIF/HEIF decode paths or platform media frameworks rather than libheif. The realistic exposure is Linux desktop software (image viewers, thumbnailers, file managers), server-side image pipelines and media-processing tools that link libheif, and distributions that package libheif directly. F2's heap OOB read primitive in release-style builds is structurally significant under standard exploit assumptions but does not by itself prove exploitation against any deployed application.

Packaging status as of 2026-05-21

The patch shipped two days ago. The table below is a static snapshot of Repology captured on the day of publication, frozen so it remains a faithful record of where the ecosystem stood when this advisory went out. Sage cells are patched (libheif 1.22.0); red cells are still vulnerable. Of 113 tracked distributions and repositories, 16 had picked up 1.22.0; 97 were still on a vulnerable version. The full list is shown below for record-keeping; the headline numbers above are the takeaway, and a future revision of this page may collapse the full list behind a "show all distributions" toggle.

DistributionVersionDistributionVersionDistributionVersion
Alpine Linux 3.211.19.5Fedora 431.20.2Parrot1.19.8
Alpine Linux 3.221.19.8Fedora 441.21.2PCLinuxOS1.15.1
Alpine Linux 3.231.21.2Fedora Rawhide1.21.2Pisi Linux1.21.2
Alpine Linux Edge1.21.2FreeBSD Ports1.21.2pkgsrc-2025Q41.20.2
ALT Linux p91.6.2Gentoo1.21.2pkgsrc-2026Q11.21.2
ALT Linux p101.19.5GNU Guix1.19.7pkgsrc current1.21.2
ALT Linux p111.21.2HaikuPorts master1.21.2PLD Linux1.21.2
ALT Sisyphus1.22.0Homebrew1.22.0PureOS amber1.3.2
AOSC1.21.2Kali Linux Rolling1.21.2PureOS byzantium1.11.0
Apertis v20251.15.1KaOS1.21.2PureOS landing1.19.8
Apertis v20261.19.8KaOS Build1.22.0Raspbian Oldstable1.15.1
Apertis v2027 Development1.19.8LiGurOS stable1.20.1Raspbian Stable1.19.8
Arch Linux1.22.0LiGurOS develop1.21.2Raspbian Testing1.21.2
ArchPOWER powerpc1.21.2MacPorts1.21.2Ravenports1.21.2
ArchPOWER powerpc64le1.21.2Mageia cauldron1.20.2Rosa 2021.11.12.0
ArchPOWER riscv641.19.7Manjaro Stable1.21.2Rosa 131.19.8
AUR1.22.0Manjaro Testing1.21.2RPM Fusion EL 81.7.0
Artix1.21.2Manjaro Unstable1.22.0Side Linux1.21.2
Chimera Linux1.20.2MSYS2 clang641.22.0SlackBuilds1.20.2
Chromebrew1.20.2MSYS2 clangarm641.22.0SliTaz Next1.3.2
ConanCenter1.20.1MSYS2 mingw1.22.0Solus1.21.2
CRUX 3.81.21.2MSYS2 ucrt641.22.0Spack1.12.0
Cygwin1.12.0MX Linux MX-21 Testing1.14.0stal/IX1.21.2
Debian 111.11.0MX Linux MX-23 Testing1.17.6stal/IX dev1.21.2
Debian 121.15.1nixpkgs stable 25.111.20.2T2 SDE1.22.0
Debian 12 Backports1.19.7nixpkgs unstable1.21.2Termux1.22.0
Debian 131.19.8OpenBSD Ports1.22.0Trisquel 11.01.12.0
Debian 141.21.2OpenIndiana packages1.20.2Ubuntu 18.041.1.0
Debian Unstable1.21.2openmamba1.22.0Ubuntu 20.041.6.1
deepin 201.3.2OpenMandriva 6.01.19.7Ubuntu 22.041.12.0
deepin 231.18.1OpenMandriva Rolling1.21.2Ubuntu 24.041.17.6
Devuan 4.01.11.0OpenMandriva Cooker1.21.2Ubuntu 25.101.20.2
Devuan Unstable1.21.2openSUSE Tumbleweed1.21.2Ubuntu 26.041.21.2
Endless OS master1.15.1openSUSE multimedia:libs Tumbleweed1.21.2Ubuntu 26.101.21.2
EPEL 91.16.1PackMan openSUSE Tumbleweed1.21.2Ubuntu 26.10 Proposed1.21.2
EPEL 101.17.6PackMan SLE 151.21.2Vcpkg1.21.2
Exherbo1.22.0Parabola1.22.0Void Linux x86_641.21.2
Fedora 421.19.8Pardus 211.11.0

The vast majority of tracked distributions and repositories in this snapshot still packaged a libheif version that contained both bugs. The patch is local to two functions and small enough to backport, but for most users the realistic path is to wait for the distribution update.

What to do

  • Update to libheif 1.22.0. The three fix commits are local to two functions and small enough to backport.
  • If you ship a static build of libheif, rebuild against 1.22.0 and re-link consumers.
  • For service operators: a libheif decode worker that crashes mid-request should restart cleanly, not poison a longer-lived shared process. Out-of-process decode is the right boundary regardless of this specific bug.