{"repro_id":"REPRO-2026-00142","version":8,"title":"libheif: integer underflow out-of-bounds read crash via crafted HEIF stsc box","repro_type":"security","status":"published","severity":"medium","description":"`libheif` parses HEIF/HEIC sequence files. The `Chunk` constructor computes the\nlast sample index as `m_last_sample = first_sample + samples_per_chunk - 1`. A\ncrafted file whose `stsc` (sample-to-chunk) box sets `samples_per_chunk = 0`\nmakes this evaluate to `0 + 0 - 1`, which **underflows** the unsigned 32-bit\ncounter to `UINT32_MAX`.\n\nSubsequently accessing any sample indexes into an **empty `std::vector`** at\nindex `0`, dereferencing the null page — an out-of-bounds read that crashes the\nprocess with `SIGSEGV`. Any application that decodes attacker-supplied HEIF\nfiles is exposed to this **denial of service**.","root_cause":"# Root Cause Analysis: CVE-2026-32738\n\n## Summary\n\n`libheif` versions 1.21.2 and below contain an integer underflow vulnerability in the `Chunk` constructor during HEIF/HEIC sequence file parsing. When a crafted file's `stsc` (sample-to-chunk) box specifies `samples_per_chunk = 0`, the unsigned 32-bit expression `first_sample + num_samples - 1` underflows to `UINT32_MAX`. This causes all sequence samples to be mapped to an empty chunk. Subsequent sample access attempts to read index 0 of an empty `std::vector`, resulting in a guaranteed null-page read / SIGSEGV crash — a denial of service against any application that decodes attacker-supplied HEIF sequence files.\n\n## Impact\n\n- **Package**: `libheif` (C++ library for HEIF/HEIC/AVIF)\n- **Affected versions**: `<= 1.21.2`\n- **Fixed version**: `1.22.0`\n- **Risk level**: Medium (CVSS 3.1 base 6.5)\n- **Consequences**: Denial of service via process crash (SIGSEGV). Any application using libheif to decode HEIF sequences is exposed.\n\n## Root Cause\n\nThe vulnerability occurs in two locations in `libheif/sequences/chunk.cc`:\n\n1. **Missing validation in `Box_stsc::parse()`**: The `samples_per_chunk` field is read as a raw `uint32_t` from the file with no rejection of the value `0`, even though ISO 14496-12 requires this field to be ≥ 1.\n\n2. **Unsigned integer underflow in `Chunk::Chunk()` (line ~82)**:\n   ```cpp\n   m_last_sample = first_sample + num_samples - 1;\n   ```\n   When `num_samples = 0` and `first_sample = 0`, this evaluates to `0 + 0 - 1 = UINT32_MAX` due to unsigned underflow. The `m_sample_ranges` vector remains empty because the `for (i < 0)` loop never executes.\n\n3. **Out-of-bounds read in `Chunk::get_data_extent_for_sample()` (line ~112)**:\n   ```cpp\n   extent.set_file_range(m_ctx->get_heif_file(),\n                         m_sample_ranges[n - m_first_sample].offset,\n                         m_sample_ranges[n - m_first_sample].size);\n   ```\n   Since `m_sample_ranges` is empty, accessing index `0` dereferences a null pointer (empty vector `begin()`), causing a SEGV.\n\n4. **All samples mapped to the empty chunk**: In `Track::init_sample_timing_table()`, the loop condition `i > m_chunks[current_chunk]->last_sample_number()` is always false because `last_sample_number()` returns `UINT32_MAX`. Consequently, every sample is assigned to chunk 0, ensuring the crash is triggered on the first frame access.\n\nThe fix commit is `edc12502` (\"Validate stsc sample coverage against stsz/stts\") in the `v1.21.2..v1.22.0` history, which adds a validation check in `Track::load()` ensuring the total samples covered by `stsc` entries match the `stsz`/`stts` sample count. Additionally, `Box_stsc::parse()` or related logic in 1.22.0 explicitly rejects `samples_per_chunk = 0` with the controlled error message: `'stsc' box with zero samples per chunk entry`.\n\n## Reproduction Steps\n\nThe reproduction script is at `repro/reproduction_steps.sh`.\n\nWhat the script does:\n1. Installs build dependencies (`cmake`, `build-essential`, `git`, `libjpeg-dev`).\n2. Clones `libheif` from GitHub.\n3. Builds the **vulnerable** version `v1.21.2` and the **fixed** version `v1.22.0` with minimal codecs (only JPEG decoder and uncompressed codec enabled).\n4. Creates two tiny JPEG images and uses `heif-enc` to generate a valid 2-frame HEIF sequence.\n5. Binary-patches the `stsc` box in the sequence file, changing `samples_per_chunk` from `2` to `0`.\n6. Compiles a small C harness that opens the file and calls `heif_track_decode_next_image()`.\n7. Runs the harness against both library versions and captures logs.\n\nExpected evidence:\n- **Vulnerable (1.21.2)**: The harness crashes with `SIGSEGV` (exit code 139 / signal 11). AddressSanitizer builds show a clear null-page read in `Chunk::get_data_extent_for_sample()`.\n- **Fixed (1.22.0)**: The harness returns a controlled decode error: `Invalid input: Unspecified: 'stsc' box with zero samples per chunk entry.` No crash occurs.\n\n## Evidence\n\nLog files generated by the reproduction script:\n\n- `logs/vulnerable.log`:\n  ```\n  === Testing vulnerable libheif v1.21.2 ===\n  Track handler: pict\n  Vulnerable exit code: 139\n  ```\n  Exit code 139 = 128 + 11, confirming a `SIGSEGV` (signal 11) crash.\n\n- `logs/fixed.log`:\n  ```\n  === Testing fixed libheif v1.22.0 ===\n  Error reading file: Invalid input: Unspecified: 'stsc' box with zero samples per chunk entry.\n  Fixed exit code: 1\n  ```\n  The fixed version rejects the malformed file with a clean, controlled error instead of crashing.\n\nAddressSanitizer output from the ASAN-instrumented vulnerable build (captured during verification):\n```\nDEBUG: get_data_extent_for_sample called with n=0, m_first_sample=0, m_last_sample=4294967295, vector_size=0\nAddressSanitizer:DEADLYSIGNAL\n==12262==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000008\n    #0 Chunk::get_data_extent_for_sample(unsigned int) const\n    #1 Track_Visual::decode_next_image_sample(heif_decoding_options const&)\n    #2 heif_track_decode_next_image\n```\n\n## Recommendations / Next Steps\n\n1. **Upgrade to libheif 1.22.0 or later** — the fix adds explicit validation of `stsc` entries against `stsz`/`stts` sample counts and rejects zero `samples_per_chunk` values.\n2. **Add regression tests** — the reproduction file and harness can be incorporated into CI to prevent reintroduction of the bug.\n3. **Harden parsing** — consider adding more defensive checks in `Box_stsc::parse()` and `Chunk::Chunk()` to reject invalid zero values immediately, rather than relying on downstream validation.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` was run twice consecutively; both runs produced identical results (vulnerable crash, fixed clean rejection) and exited with code 0.\n- **Edge cases**: The crash is guaranteed as long as any accessed sample maps to a chunk with `samples_per_chunk = 0`. The reproduction uses a minimal 2-frame sequence, but the vulnerability scales to any sequence file where at least one `stsc` entry has a zero sample count.\n","ghsa_id":"GHSA-7f2h-cmpf-v9ww","cve_id":"CVE-2026-32738","cwe_id":"CWE-191 (Integer Underflow) / CWE-125 (Out-of-bounds Read)","source_url":"https://github.com/strukturag/libheif","package":{"name":"libheif","ecosystem":"c","affected_versions":"<= 1.21.2","fixed_version":"1.22.0"},"reproduced_at":"2026-05-22T10:44:46.710420+00:00","duration_secs":3030.0780577659607,"tool_calls":313,"turns":266,"handoffs":3,"total_cost_usd":2.8513029900000024,"agent_costs":{"repro":1.4203917700000004,"support":0.03510953,"vuln_variant":1.39580169},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":1.4203917700000004},"support":{"accounts/fireworks/models/kimi-k2p6":0.03510953},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.39580169}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T10:44:48.650839+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":6008,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7185,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7285,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7906,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":2724,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":717,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3218,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1319,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1157,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4379,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2780,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2705,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":695,"category":"other"},{"path":"logs/vulnerable.log","filename":"vulnerable.log","size":89,"category":"log"},{"path":"logs/fixed.log","filename":"fixed.log","size":151,"category":"log"},{"path":"logs/variant_evidence.log","filename":"variant_evidence.log","size":1192,"category":"log"}]}