Skip to content

GHSA-xw34-mjcp-jqh8

CVE Information

Summary

libheif's HEIF sequence (track) support builds a per-track sample-timing table in Track::init_sample_timing_table() during Track::load(). The indefinite-duration and edit-list repeat paths compute m_num_output_samples from mvhd.duration/mdhd.duration and an elst repeat multiplier, then clamp only m_num_repetitions (used for an API duration query) to UINT32_MAX while leaving m_num_output_samples uncorrected. The decode loop in Track_Visual::decode_next_image_sample() and the raw-data path Track::get_next_sample_raw_data() compare a uint32_t loop counter against m_num_output_samples; when the latter is a uint64_t value near or above UINT32_MAX (or UINT64_MAX), the counter can never reach it, so end_of_sequence_reached() is always false and decoding never terminates. Separately, Box_stts::get_sample_duration() linearly walks the stts table from the start on every call while init_sample_timing_table() calls it once per sample, producing O(n x m) behavior, and several per-track allocations (Chunk::m_sample_ranges, Track::m_presentation_timeline) are not routed through MemoryHandle so max_total_memory is bypassed and there is no max_number_of_tracks limit to cap multi-track accumulation. The max_sequence_frames check is applied only to the physical stsz.sample_count, never to the logical m_num_output_samples after repeat amplification, so a file with a single physical sample passes the limit and then inflates to an effectively infinite output count.

Seven variants share this cluster. All seven are confirmed with POC:

Variant Defect CVSS
V1: stts O(n^2) linear traversal Box_stts::get_sample_duration walk per sample 7.5
V2: untracked per-track memory, fixed_sample_size bypass Chunk/SampleTiming vectors bypass MemoryHandle 5.5
V3: missing max_number_of_tracks, multi-track stacking no track-count limit, ~1 GB/track untracked 6.5
V4: indefinite-duration sentinel, m_num_output_samples uncorrected mvhd.duration=UINT64_MAX sentinel 7.5
V5: multiplier >= UINT32_MAX (non-sentinel), uint32 loop overflow mvhd.duration=0x100000001 5.5
V6: repeat mode bypasses max_sequence_frames scope limit checks physical count, not logical 5.5
V7: raw-data C API path, ignore_sequence_editlist unusable heif_track_get_next_raw_sequence_sample 7.5

We assess the cluster at roughly CVSS 3.1 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). This score is an automated estimate from our analysis and is provided for reference; the final score is for the maintainer to confirm. V2, V5, and V6 are scored AV:L/UI:R because their primary demonstrated trigger is local playback.

Tested versions

Version / revision Build Result
v1.23.1 (v1.23.1-7-g1a3583bc) ASan affected. Non-terminating loop / unbounded allocation confirmed via POC.
v1.23.1 non-ASan affected. CPU hang / OOM confirmed.

Source was not modified.

Root cause

Three related root causes in the sequence timing/decode path:

  1. m_num_output_samples left uncorrected in the indefinite/repeat branch (V4, V5, V6, V7). In Track::init_sample_timing_table() (track.cc:1107-1114), when is_sequence_duration_indefinite() is true or the elst repeat multiplier is large, the code sets m_num_repetitions = UINT32_MAX but never clamps m_num_output_samples. With the UINT64_MAX indefinite sentinel (V4) m_num_output_samples approximates UINT64_MAX; with a non-sentinel mvhd.duration = 0x100000001 (V5) it exceeds UINT32_MAX. The decode loop counter is uint32_t, so it can never reach either value and end_of_sequence_reached() (track.cc:841) is always false. V7 shows the raw-data C API heif_track_get_next_raw_sequence_sample() has the same root cause but takes no decoding_options, so the ignore_sequence_editlist mitigation that exists for heif_track_decode_next_image() is unreachable (confirmed by a // TODO: pass decoding options comment); --ignore-editlist is also ineffective on this path.

  2. max_sequence_frames checks the wrong count (V6). The limit is enforced only against the physical stsz.sample_count (in Box_stsz::parse and Track::load), never against the logical m_num_output_samples after elst-repeat amplification. A file with one physical sample passes the limit, then repeat + indefinite duration inflates the output count to UINT64_MAX. The check also uses > rather than >=, so a count exactly equal to the limit passes.

  3. Algorithmic complexity and untracked memory (V1, V2, V3). Box_stts::get_sample_duration(sample_idx) linearly walks the stts table from the start each call (seq_boxes.cc:622), and init_sample_timing_table() calls it once per sample; with 18M stts entries and 18M stsz samples this is ~1.62 x 10^14 operations. When stsz.fixed_sample_size != 0, Box_stsz::parse() skips the per-sample array allocation so tracked memory stays near zero while m_sample_count still reaches 18M; Chunk::Chunk() and init_sample_timing_table() then allocate ~288 MB and ~864 MB per track of untracked SampleFileRange/SampleTiming vectors. There is no max_number_of_tracks limit (max_children_per_box=100 only indirectly caps tracks), so multi-track stacking accumulates ~1.07 GB/track undetected.

Affected files and functions: - libheif/sequences/track.ccTrack::load, init_sample_timing_table, end_of_sequence_reached, get_next_sample_raw_data - libheif/sequences/track.hm_num_output_samples, m_next_sample_to_be_output - libheif/sequences/seq_boxes.ccBox_stts::get_sample_duration/parse, Box_stsz::parse, Box_mvhd::parse, Box_mdhd::parse - libheif/sequences/track_visual.ccTrack_Visual::decode_next_image_sample - libheif/sequences/chunk.ccChunk::Chunk - libheif/api/libheif/heif_sequences.ccheif_track_get_next_raw_sequence_sample - libheif/context.ccHeifContext::interpret_heif_file_sequences, get_sequence_duration - libheif/security_limits.ccglobal_security_limits (max_sequence_frames, max_total_memory)

Security boundary and prerequisites

  • Attacker: remote, unauthenticated. Sends a crafted HEIF sequence file.
  • No authentication required (file parsing requires none).
  • Attack complexity: Low. A single malicious file triggers the issue. V4 and V7 need only one physical sample to bypass max_sequence_frames; V7 is reachable via heif-dec --sequence on a urim metadata track with no decoder plugin required.
  • V7 cannot be mitigated by ignore_sequence_editlist / --ignore-editlist because the raw-data API path does not accept decoding options.

PoC

V1: O(N²) CPU exhaustion

Construct a HEIF sequence file with: - stts box: N entries, each with sample_count=1, sample_delta=1 - stsz box: N samples, each 1 byte - stsc/stco: 1 chunk containing all N samples

When libheif opens this file, init_sample_timing_table() calls get_sample_duration(i) for each of N samples. Each call scans the stts table linearly from the beginning, resulting in O(N²) total operations.

Scaling verification (N doubles → time increases ~4×):

N File size CPU time N-doubling ratio
10,000 79 KB 63 ms
20,000 157 KB 242 ms 3.85×
50,000 391 KB 1,482 ms
100,000 782 KB 5,883 ms 3.98×
200,000 1,563 KB 23,492 ms 3.99×
500,000 3,907 KB 148,151 ms

Control comparison (same N=200,000 but stts has 1 entry with sample_count=200000):

File N stts entries CPU time
Exploit 200,000 200,000 23,492 ms
Control 200,000 1 18 ms

Same file size, same sample count — 1,278× difference in CPU time.

heif-info standard tool affected at file open stage: 784KB file → 5.9s CPU, 4MB file → 148s CPU.

V2: indefinite duration infinite loop

Construct a 652-byte HEIF sequence file with: - mvhd (v1): duration = 0xFFFFFFFFFFFFFFFF (UINT64_MAX, indefinite sentinel) - elst (v1, flags=1 [repeat]): 1 entry, segment_duration=1, media_time=0 - mdhd (v0): duration = 1 - stts: 1 entry (count=1, delta=1) - stsz: 1 sample, 3 bytes - mdat: 3 bytes (1×1 RGB pixel)

When libheif opens this file and processes the sequence track, init_sample_timing_table() computes multiplier = UINT64_MAX / 1 = UINT64_MAX, sets m_num_output_samples = UINT64_MAX. The indefinite path sets m_num_repetitions = UINT32_MAX but does not correct m_num_output_samples. The decode loop runs forever at 100% CPU.

Result (killed by timeout after 10 seconds):

Track ID: 1
Number of repetitions: 4294967295       ← UINT32_MAX
Sequence duration: 18446744073709551615  ← UINT64_MAX
Total: 2794731 samples in 10001ms        ← infinite loop
Exit code: 124                           ← killed by timeout

CPU: 100% sustained for 30+ seconds, RSS stable at ~7MB (pure CPU DoS, no memory growth). The loop can never terminate: uint32_t counter would need ~1.5 hours to overflow, and even after overflow the comparison 0 >= UINT64_MAX remains false.

Control: Same file structure but mvhd.duration=1m_num_output_samples=1, loop terminates after 1 sample, exit code 0.

V3: raw API path (no workaround)

Same 652-byte file as V2. Call heif_track_get_next_raw_sequence_sample() instead of heif_track_decode_next_image(). The raw API does not accept heif_decoding_options*, so ignore_sequence_editlist=true cannot be set. The infinite loop is unavoidable on this path.

GDB call stack verification (V2)

heif_context_read_from_file (heif_context.cc:60)
  → HeifContext::read_from_file (context.cc:264)
    → HeifContext::interpret_heif_file (context.cc:574)
      → HeifContext::interpret_heif_file_sequences (context.cc:2061)
        → Track::alloc_track (track.cc:735)
          → Track::load (track.cc:562)
            → Track::init_sample_timing_table (track.cc:1045)
              → m_num_output_samples = UINT64_MAX (BUG: not corrected)
              → end_of_sequence_reached() (track.cc:841)
                → m_next_sample_to_be_output (uint32_t) >= m_num_output_samples (uint64_t = UINT64_MAX)
                → always false → infinite loop

Security limits bypassed

  • max_sequence_frames = 18,000,000: does not prevent the infinite loop (V2/V3) — m_num_output_samples is UINT64_MAX, far exceeding the limit, but the limit is not checked against m_num_output_samples
  • max_memory_block_size / max_total_memory: do not prevent V1 (CPU DoS, no large memory allocation)
  • No CPU time limit exists in libheif

Impact

  • Availability: non-terminating decode loops (CPU DoS, A:H) and unbounded memory allocation (OOM, A:H). V1 consumes ~45 hours CPU from an 18M-entry file within all limits; V2/V3 allocate ~115 GB untracked from a ~581-byte / ~17 KB file; V4/V5/V7 loop forever.
  • Remote: exploitable by sending a crafted sequence file; no user interaction required for V1, V4, V7.

Suggested fix

  • In init_sample_timing_table(), clamp m_num_output_samples (not just m_num_repetitions) to a safe range, and make the decode-loop counter the same width as m_num_output_samples or bound the loop by min(m_num_output_samples, max_sequence_frames).
  • Enforce max_sequence_frames against the logical m_num_output_samples (after elst-repeat amplification), not only the physical stsz.sample_count, and use >= for the comparison.
  • Add a max_number_of_tracks security-limit field, and route Chunk::m_sample_ranges and Track::m_presentation_timeline allocations through MemoryHandle so multi-track accumulation is bounded by max_total_memory.
  • Replace the O(n) per-call Box_stts::get_sample_duration() walk with a precomputed prefix-sum or cached cursor so init_sample_timing_table() is O(n) rather than O(n x m).
  • Pass decoding options (including ignore_sequence_editlist) into heif_track_get_next_raw_sequence_sample() / get_next_sample_raw_data().

Reporter and coordination

  • Reporter(s): Yuqi Qiu & Xiang Li
  • Affiliation: Nankai University, AOSP Lab