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()duringTrack::load(). The indefinite-duration and edit-list repeat paths computem_num_output_samplesfrommvhd.duration/mdhd.durationand an elst repeat multiplier, then clamp onlym_num_repetitions(used for an API duration query) toUINT32_MAXwhile leavingm_num_output_samplesuncorrected. The decode loop inTrack_Visual::decode_next_image_sample()and the raw-data pathTrack::get_next_sample_raw_data()compare auint32_tloop counter againstm_num_output_samples; when the latter is auint64_tvalue near or aboveUINT32_MAX(orUINT64_MAX), the counter can never reach it, soend_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 whileinit_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 throughMemoryHandlesomax_total_memoryis bypassed and there is nomax_number_of_trackslimit to cap multi-track accumulation. Themax_sequence_framescheck is applied only to the physicalstsz.sample_count, never to the logicalm_num_output_samplesafter 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_durationwalk per sample7.5 V2: untracked per-track memory, fixed_sample_size bypass Chunk/SampleTimingvectors bypass MemoryHandle5.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_MAXsentinel7.5 V5: multiplier >= UINT32_MAX (non-sentinel), uint32 loop overflow mvhd.duration=0x1000000015.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_sample7.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:
m_num_output_samplesleft uncorrected in the indefinite/repeat branch (V4, V5, V6, V7). InTrack::init_sample_timing_table()(track.cc:1107-1114), whenis_sequence_duration_indefinite()is true or the elst repeat multiplier is large, the code setsm_num_repetitions = UINT32_MAXbut never clampsm_num_output_samples. With theUINT64_MAXindefinite sentinel (V4)m_num_output_samplesapproximatesUINT64_MAX; with a non-sentinelmvhd.duration = 0x100000001(V5) it exceedsUINT32_MAX. The decode loop counter isuint32_t, so it can never reach either value andend_of_sequence_reached()(track.cc:841) is always false. V7 shows the raw-data C APIheif_track_get_next_raw_sequence_sample()has the same root cause but takes nodecoding_options, so theignore_sequence_editlistmitigation that exists forheif_track_decode_next_image()is unreachable (confirmed by a// TODO: pass decoding optionscomment);--ignore-editlistis also ineffective on this path.
max_sequence_frameschecks the wrong count (V6). The limit is enforced only against the physicalstsz.sample_count(inBox_stsz::parseandTrack::load), never against the logicalm_num_output_samplesafter elst-repeat amplification. A file with one physical sample passes the limit, then repeat + indefinite duration inflates the output count toUINT64_MAX. The check also uses>rather than>=, so a count exactly equal to the limit passes.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), andinit_sample_timing_table()calls it once per sample; with 18M stts entries and 18M stsz samples this is ~1.62 x 10^14 operations. Whenstsz.fixed_sample_size != 0,Box_stsz::parse()skips the per-sample array allocation so tracked memory stays near zero whilem_sample_countstill reaches 18M;Chunk::Chunk()andinit_sample_timing_table()then allocate ~288 MB and ~864 MB per track of untrackedSampleFileRange/SampleTimingvectors. There is nomax_number_of_trackslimit (max_children_per_box=100only indirectly caps tracks), so multi-track stacking accumulates ~1.07 GB/track undetected.Affected files and functions: -
libheif/sequences/track.cc—Track::load,init_sample_timing_table,end_of_sequence_reached,get_next_sample_raw_data-libheif/sequences/track.h—m_num_output_samples,m_next_sample_to_be_output-libheif/sequences/seq_boxes.cc—Box_stts::get_sample_duration/parse,Box_stsz::parse,Box_mvhd::parse,Box_mdhd::parse-libheif/sequences/track_visual.cc—Track_Visual::decode_next_image_sample-libheif/sequences/chunk.cc—Chunk::Chunk-libheif/api/libheif/heif_sequences.cc—heif_track_get_next_raw_sequence_sample-libheif/context.cc—HeifContext::interpret_heif_file_sequences,get_sequence_duration-libheif/security_limits.cc—global_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 viaheif-dec --sequenceon aurimmetadata track with no decoder plugin required.- V7 cannot be mitigated by
ignore_sequence_editlist/--ignore-editlistbecause the raw-data API path does not accept decoding options.PoC
V1: O(N²) CPU exhaustion
Construct a HEIF sequence file with: -
sttsbox: N entries, each withsample_count=1, sample_delta=1-stszbox: N samples, each 1 byte -stsc/stco: 1 chunk containing all N samplesWhen libheif opens this file,
init_sample_timing_table()callsget_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-infostandard 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()computesmultiplier = UINT64_MAX / 1 = UINT64_MAX, setsm_num_output_samples = UINT64_MAX. The indefinite path setsm_num_repetitions = UINT32_MAXbut does not correctm_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 timeoutCPU: 100% sustained for 30+ seconds, RSS stable at ~7MB (pure CPU DoS, no memory growth). The loop can never terminate:
uint32_tcounter would need ~1.5 hours to overflow, and even after overflow the comparison0 >= UINT64_MAXremains false.Control: Same file structure but
mvhd.duration=1→m_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 ofheif_track_decode_next_image(). The raw API does not acceptheif_decoding_options*, soignore_sequence_editlist=truecannot 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 loopSecurity limits bypassed
max_sequence_frames = 18,000,000: does not prevent the infinite loop (V2/V3) —m_num_output_samplesisUINT64_MAX, far exceeding the limit, but the limit is not checked againstm_num_output_samplesmax_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(), clampm_num_output_samples(not justm_num_repetitions) to a safe range, and make the decode-loop counter the same width asm_num_output_samplesor bound the loop bymin(m_num_output_samples, max_sequence_frames).- Enforce
max_sequence_framesagainst the logicalm_num_output_samples(after elst-repeat amplification), not only the physicalstsz.sample_count, and use>=for the comparison.- Add a
max_number_of_trackssecurity-limit field, and routeChunk::m_sample_rangesandTrack::m_presentation_timelineallocations throughMemoryHandleso multi-track accumulation is bounded bymax_total_memory.- Replace the O(n) per-call
Box_stts::get_sample_duration()walk with a precomputed prefix-sum or cached cursor soinit_sample_timing_table()is O(n) rather than O(n x m).- Pass decoding options (including
ignore_sequence_editlist) intoheif_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