Commit 49f78a57 for libheif

commit 49f78a57ab2b55c8c2f8b58613c4092766afd380
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Aug 24 21:41:22 2026 +0200

    make BitReader::skip_bytes() constant time

    skip_bytes() looped skip_bits(8) once per byte. Every skip distance in libheif
    comes from a 32-bit file field, so a malformed file could make it spin for
    billions of no-op iterations.

    Found by the OSS-Fuzz sequence_fuzzer: a 1072-byte unci sequence declaring
    row_align_size = 134217728 (128 MB) for a 16x16 YUV420 tile that holds only
    384 bytes. handleRowAlignment() then asks to skip ~134 M bytes per row over
    32 rows, which is ~4.3 billion iterations past the end of the data.

    skip_bytes() now consumes the buffered bits, advances the read pointer
    directly, and records any overshoot in nextbits_cnt. A negative nextbits_cnt
    past EOF is load-bearing rather than a bug: get_current_byte_index()
    subtracts nextbits_cnt/8, so the virtual position keeps advancing beyond the
    buffer end, which is what keeps the row and tile alignment arithmetic
    self-consistent. The new implementation reproduces that exactly, verified
    against the old one over 5616 combinations of buffer length, bit phase and
    skip distance.

    nextbits_cnt therefore has to be int64_t. As an int it overflowed for skips
    of 2^28 bytes or more past EOF, in refill()'s "64 - nextbits_cnt", which is
    the undefined behaviour predicted by the comment in mini.cc.

    get_current_byte_index() now computes in signed arithmetic. The previous
    size_t form converted the negative operand first, which trips the 'integer'
    sanitizer that the fuzzing preset enables with -fno-sanitize-recover.

diff --git a/libheif/bitstream.cc b/libheif/bitstream.cc
index c4cd290a..d061c616 100644
--- a/libheif/bitstream.cc
+++ b/libheif/bitstream.cc
@@ -653,10 +653,65 @@ int BitReader::peek_bits(int n)

 void BitReader::skip_bytes(uint32_t nBytes)
 {
-  // TODO: this is slow
-  while (nBytes) {
-    nBytes--;
-    skip_bits(8);
+  // This has to run in constant time. The number of bytes to skip is taken directly
+  // from 32-bit file fields (e.g. the 'uncC' row/tile alignment), so a byte-at-a-time
+  // loop spins for billions of iterations when a malformed file asks to skip far
+  // beyond the end of the data. MinimizedImageBox::parse() guards against the same
+  // failure mode by validating its declared chunk sizes up front.
+
+  uint64_t nBits = uint64_t{nBytes} * 8;
+
+  // --- consume the bits that are already buffered in 'nextbits'
+
+  if (nextbits_cnt > 0) {
+    uint64_t from_buffer = std::min(nBits, static_cast<uint64_t>(nextbits_cnt));
+
+    if (from_buffer >= 64) {
+      nextbits = 0;
+    }
+    else {
+#if AVOID_FUZZER_FALSE_POSITIVE
+      nextbits &= (0xffffffffffffffffULL >> from_buffer);
+#endif
+      nextbits <<= from_buffer;
+    }
+
+    nextbits_cnt -= static_cast<int64_t>(from_buffer);
+    nBits -= from_buffer;
+  }
+
+  if (nBits == 0) {
+    return;
+  }
+
+  // --- skip whole bytes directly in the input buffer, without pushing them
+  //     through the bit buffer
+
+  uint64_t whole_bytes = nBits / 8;
+  int residual_bits = static_cast<int>(nBits % 8);
+
+  if (whole_bytes >= bytes_remaining) {
+    // Skipping past the end of the data. Record the overshoot in 'nextbits_cnt' (which
+    // thereby goes negative) so that get_current_byte_index() keeps advancing exactly
+    // as it did with the previous bit-by-bit implementation.
+    uint64_t overshoot_bits = (whole_bytes - bytes_remaining) * 8 + static_cast<uint64_t>(residual_bits);
+
+    data += bytes_remaining;
+    bytes_remaining = 0;
+    nextbits = 0;
+    nextbits_cnt -= static_cast<int64_t>(overshoot_bits);
+    return;
+  }
+
+  data += static_cast<size_t>(whole_bytes);
+  bytes_remaining -= static_cast<size_t>(whole_bytes);
+  nextbits = 0;
+  nextbits_cnt = 0;
+
+  refill();
+
+  if (residual_bits > 0) {
+    skip_bits(residual_bits);
   }
 }

@@ -686,7 +741,7 @@ void BitReader::skip_bits_fast(int n)

 void BitReader::skip_to_byte_boundary()
 {
-  int nskip = (nextbits_cnt & 7);
+  int nskip = static_cast<int>(nextbits_cnt & 7);

 #if AVOID_FUZZER_FALSE_POSITIVE
   nextbits &= (0xffffffffffffffffULL >> nskip);
@@ -747,7 +802,13 @@ void BitReader::refill()
     nextbits |= newval;
   }
 #else
-  int shift = 64 - nextbits_cnt;
+  if (bytes_remaining == 0) {
+    // Nothing to refill. Returning early also keeps the shift below out of range
+    // when nextbits_cnt is far negative after skipping past the end of the data.
+    return;
+  }
+
+  int64_t shift = 64 - nextbits_cnt;

   while (shift >= 8 && bytes_remaining) {
     uint64_t newval = *data++;
diff --git a/libheif/bitstream.h b/libheif/bitstream.h
index da6acf1e..fb31316e 100644
--- a/libheif/bitstream.h
+++ b/libheif/bitstream.h
@@ -458,7 +458,13 @@ public:

   size_t get_current_byte_index() const
   {
-    return data_length - bytes_remaining - nextbits_cnt / 8;
+    // Computed in signed arithmetic: when we skipped past the end of the data,
+    // nextbits_cnt is negative and the index keeps growing beyond data_length (the
+    // 'uncC' alignment handling relies on that). Doing the subtraction in size_t
+    // would convert the negative operand first, which trips the 'integer' sanitizer.
+    // The result itself is always >= 0.
+    int64_t bytes_read = static_cast<int64_t>(data_length - bytes_remaining);
+    return static_cast<size_t>(bytes_read - nextbits_cnt / 8);
   }

   int64_t get_bits_remaining() const
@@ -473,7 +479,12 @@ private:
   size_t bytes_remaining;

   uint64_t nextbits; // left-aligned bits
-  int nextbits_cnt;
+
+  // Number of valid bits in 'nextbits'. Goes negative when we read or skip past the
+  // end of the data, in which case it holds the (negated) overshoot so that
+  // get_current_byte_index() keeps advancing. Has to be 64-bit because skip_bytes()
+  // may be asked to skip up to 2^32 bytes (= 2^35 bits) past the end.
+  int64_t nextbits_cnt;

   void refill(); // refill to at least 56+1 bits
 };