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
};