Commit ba47dddc for libheif

commit ba47dddc6f4f26586418d4ac0f06ded326bcf715
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Sep 19 15:38:13 2026 +0200

    Bound the JPEG 2000 'pclr' palette allocation (GHSA-9c75-9g8r-4728)

    The entry-count bound added in a44a3cd5 was skipped when the palette
    declared zero columns, because the per-entry byte count was zero. An
    11-byte 'pclr' box with NPC=0 and NE=65535 then allocated 65535 empty
    PaletteEntry vectors (about 1.5 MB), and nested 'j2kH' containers could
    repeat this hundreds of times within the child and nesting limits. A
    3 KB file reached about 330 MB RSS, none of it charged to the memory
    limits.

    Reject NPC=0 (ISO/IEC 15444-1 Table I.12 requires 1 to 255), make the
    byte bound unconditional, and charge the palette storage through a
    MemoryHandle so a tightened max_total_memory applies to it as well.

    While here, read and write the B_i field as the spec defines it: the
    low 7 bits hold the precision minus one (Table I.13), and each value
    occupies ceil(precision / 8) bytes. The parser and writer previously
    used the raw field as the precision, which misread 9-bit columns and
    accepted 17-bit ones. m_bitDepths now holds real precisions (1 to 16),
    so the writer emits precision - 1. Nothing in the library wrote 'pclr'
    before, so produced files are unaffected.

    Add the reporter's file as a fixture and parser tests for the zero
    column, byte bound, memory limit, precision and round-trip cases.

diff --git a/libheif/codecs/jpeg2000_boxes.cc b/libheif/codecs/jpeg2000_boxes.cc
index ad2291b5..739be4b0 100644
--- a/libheif/codecs/jpeg2000_boxes.cc
+++ b/libheif/codecs/jpeg2000_boxes.cc
@@ -181,13 +181,29 @@ Error Box_pclr::parse(BitstreamRange& range, const heif_security_limits* limits)
 {
   uint16_t num_entries = range.read16();
   uint8_t num_palette_columns = range.read8();
+
+  // ISO/IEC 15444-1 (Table I.12) requires NPC to be in the range 1 to 255.
+  // A palette without columns carries no entry data, so the per-entry byte
+  // count below would be zero and could not bound num_entries. The entry loop
+  // then allocated up to 65535 empty PaletteEntry vectors from an 11-byte box,
+  // and nested 'j2kH' containers could repeat this hundreds of times without
+  // any of it being charged to the memory limits (GHSA-9c75-9g8r-4728).
+  if (num_palette_columns == 0) {
+    return Error(heif_error_Invalid_input,
+                 heif_suberror_Invalid_J2K_codestream,
+                 "pclr box declares zero palette columns");
+  }
+
   for (uint8_t i = 0; i < num_palette_columns; i++) {
-    uint8_t bit_depth = range.read8();
-    if (bit_depth & 0x80) {
+    // B_i (Table I.13): the high bit marks signed values, the low 7 bits hold
+    // the column precision minus one (0..37 for 1..38 bits).
+    uint8_t b = range.read8();
+    if (b & 0x80) {
       return Error(heif_error_Unsupported_feature,
                    heif_suberror_Unsupported_data_version,
                    "pclr with signed data is not supported");
     }
+    uint8_t bit_depth = static_cast<uint8_t>((b & 0x7F) + 1);
     if (bit_depth > 16) {
       return Error(heif_error_Unsupported_feature,
                    heif_suberror_Unsupported_data_version,
@@ -195,21 +211,33 @@ Error Box_pclr::parse(BitstreamRange& range, const heif_security_limits* limits)
     }
     m_bitDepths.push_back(bit_depth);
   }
-  // Number of bytes each palette entry occupies in the box. Used to bound
-  // num_entries by the data actually present, so a small header cannot force
-  // a large allocation (analogous to the 'cdef'/'j2kL' checks).
+  // Number of bytes each palette entry occupies in the box: each C_ji value is
+  // padded to a whole number of bytes (I.5.3.4). Used to bound num_entries by
+  // the data actually present, so a small header cannot force a large
+  // allocation (analogous to the 'cdef'/'j2kL' checks). The precision is at
+  // least 1 bit, so every entry occupies at least one byte.
   size_t bytes_per_entry = 0;
   for (uint8_t bd : m_bitDepths) {
-    bytes_per_entry += (bd <= 8) ? 1 : 2;
+    bytes_per_entry += (bd + 7) / 8;
   }

-  if (bytes_per_entry != 0 &&
-      num_entries > range.get_remaining_bytes() / bytes_per_entry) {
+  if (num_entries > range.get_remaining_bytes() / bytes_per_entry) {
     return Error(heif_error_Invalid_input,
                  heif_suberror_End_of_data,
                  "pclr box declares more entries than the box contains");
   }

+  // Each entry is stored as its own vector, so the palette costs noticeably
+  // more memory than the bytes it occupies in the file. Charge that storage
+  // to the memory limits before allocating it.
+  size_t bytes_per_stored_entry = sizeof(PaletteEntry) + num_palette_columns * sizeof(uint16_t);
+  if (auto err = m_memory_handle.alloc(num_entries, bytes_per_stored_entry,
+                                       limits, "the 'pclr' palette")) {
+    return err;
+  }
+
+  m_entries.reserve(num_entries);
+
   for (uint16_t j = 0; j < num_entries; j++) {
     PaletteEntry entry;
     for (size_t i = 0; i < m_bitDepths.size(); i++) {
@@ -256,8 +284,8 @@ Error Box_pclr::write(StreamWriter& writer) const

   writer.write16(get_num_entries());
   writer.write8(get_num_columns());
-  for (uint8_t b : m_bitDepths) {
-    writer.write8(b);
+  for (uint8_t bd : m_bitDepths) {
+    writer.write8(static_cast<uint8_t>(bd - 1));  // B_i stores the precision minus one
   }
   for (PaletteEntry entry : m_entries) {
     for (unsigned long int i = 0; i < entry.columns.size(); i++) {
diff --git a/libheif/codecs/jpeg2000_boxes.h b/libheif/codecs/jpeg2000_boxes.h
index a9f53703..5d1e61c5 100644
--- a/libheif/codecs/jpeg2000_boxes.h
+++ b/libheif/codecs/jpeg2000_boxes.h
@@ -23,6 +23,7 @@

 #include "box.h"
 #include "file.h"
+#include "security_limits.h"
 #include "context.h"
 #include <cstdint>
 #include <string>
@@ -211,6 +212,9 @@ public:
     /**
      * Get the bit depths for the columns in this palette box.
      *
+     * These are the column precisions in bits (1 to 16), not the raw B_i
+     * field of the box, which stores the precision minus one.
+     *
      * @return the bit depths as a read-only vector.
      */
     const std::vector<uint8_t>& get_bit_depths() const
@@ -241,7 +245,7 @@ public:
      * This will reset any existing columns and entries.
      *
      * @param num_columns the number of columns (e.g. 3 for RGB)
-     * @param bit_depth the bit depth for each column (e.g. 8 for 24-bit RGB)
+     * @param bit_depth the bit depth for each column in bits, 1 to 16 (e.g. 8 for 24-bit RGB)
      */
     void set_columns(uint8_t num_columns, uint8_t bit_depth);

@@ -251,6 +255,7 @@ protected:
 private:
     std::vector<uint8_t> m_bitDepths;
     std::vector<PaletteEntry> m_entries;
+    MemoryHandle m_memory_handle;
 };


diff --git a/tests/data/pclr_zero_columns.heic b/tests/data/pclr_zero_columns.heic
new file mode 100644
index 00000000..71536148
Binary files /dev/null and b/tests/data/pclr_zero_columns.heic differ
diff --git a/tests/jpeg2000.cc b/tests/jpeg2000.cc
index fa7e384c..ead451e6 100644
--- a/tests/jpeg2000.cc
+++ b/tests/jpeg2000.cc
@@ -27,8 +27,13 @@
 #include "catch_amalgamated.hpp"
 #include "libheif/heif.h"
 #include "codecs/jpeg2000_boxes.h"
+#include "security_limits.h"
+#include "test-config.h"
 #include <cstdint>
 #include <iostream>
+#include <memory>
+#include <string>
+#include <vector>


 TEST_CASE( "cdef" )
@@ -131,7 +136,7 @@ TEST_CASE( "pclr" )
     Error err = pclr->write(writer);
     REQUIRE(err.error_code == heif_error_Ok);
     const std::vector<uint8_t> bytes = writer.get_data();
-    std::vector<uint8_t> expected = {0x00, 0x00, 0x00, 0x14, 'p', 'c', 'l', 'r', 0x00, 0x02, 0x03, 0x08, 0x08, 0x08, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD};
+    std::vector<uint8_t> expected = {0x00, 0x00, 0x00, 0x14, 'p', 'c', 'l', 'r', 0x00, 0x02, 0x03, 0x07, 0x07, 0x07, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD};
     REQUIRE(bytes == expected);
     Indent indent;
     std::string dump_output = pclr->dump(indent);
@@ -159,7 +164,7 @@ TEST_CASE( "pclr 12 bit" )
     Error err = pclr->write(writer);
     REQUIRE(err.error_code == heif_error_Ok);
     const std::vector<uint8_t> bytes = writer.get_data();
-    std::vector<uint8_t> expected = {0x00, 0x00, 0x00, 0x1A, 'p', 'c', 'l', 'r', 0x00, 0x02, 0x03, 0x0C, 0x0C, 0x0C, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0x0F, 0xFF, 0x0F, 0xFE, 0x0F, 0xFD};
+    std::vector<uint8_t> expected = {0x00, 0x00, 0x00, 0x1A, 'p', 'c', 'l', 'r', 0x00, 0x02, 0x03, 0x0B, 0x0B, 0x0B, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0x0F, 0xFF, 0x0F, 0xFE, 0x0F, 0xFD};
     REQUIRE(bytes == expected);
     Indent indent;
     std::string dump_output = pclr->dump(indent);
@@ -727,3 +732,198 @@ TEST_CASE( "codestream - COD + SIZ + CAP other" )
     REQUIRE(uut.get_precision(0) == 8);
     REQUIRE(uut.hasHighThroughputExtension() == false);
 }
+
+
+// --- 'pclr' parsing (GHSA-9c75-9g8r-4728) ----------------------------------
+//
+// A 'pclr' box with zero palette columns declares no entry data, so the entry
+// count was not bounded by the box size and each declared entry allocated an
+// empty vector. Nested 'j2kH' containers repeated this hundreds of times from
+// a few KB of input. The parser now rejects zero columns, bounds the entry
+// count by the box payload, and charges the palette to the memory limits.
+
+// Parses a single box from raw bytes, as it would be encountered inside a
+// 'j2kH' container.
+static Error parse_box(const std::vector<uint8_t>& bytes,
+                       const heif_security_limits* limits,
+                       std::shared_ptr<Box>* box)
+{
+  auto reader = std::make_shared<StreamReader_memory>(bytes.data(), bytes.size(), false);
+  BitstreamRange range(reader, bytes.size());
+  return Box::read(range, box, limits);
+}
+
+// Box header (size, 'pclr') followed by the payload.
+static std::vector<uint8_t> pclr_box(std::vector<uint8_t> payload)
+{
+  uint32_t size = static_cast<uint32_t>(8 + payload.size());
+  std::vector<uint8_t> bytes{static_cast<uint8_t>(size >> 24), static_cast<uint8_t>(size >> 16),
+                             static_cast<uint8_t>(size >> 8), static_cast<uint8_t>(size),
+                             'p', 'c', 'l', 'r'};
+  bytes.insert(bytes.end(), payload.begin(), payload.end());
+  return bytes;
+}
+
+
+TEST_CASE("pclr parse")
+{
+  // NE=2, NPC=2, B={7,11} (8 and 12 bits), entries (0x10, 0x0123) and (0x20, 0x0456)
+  auto bytes = pclr_box({0x00, 0x02, 0x02, 0x07, 0x0b,
+                         0x10, 0x01, 0x23,
+                         0x20, 0x04, 0x56});
+  std::shared_ptr<Box> box;
+  Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+  REQUIRE(err == Error::Ok);
+
+  auto pclr = std::dynamic_pointer_cast<Box_pclr>(box);
+  REQUIRE(pclr);
+  REQUIRE(pclr->get_bit_depths() == std::vector<uint8_t>{8, 12});
+  REQUIRE(pclr->get_entries().size() == 2);
+  REQUIRE(pclr->get_entries()[0].columns == std::vector<uint16_t>{0x10, 0x0123});
+  REQUIRE(pclr->get_entries()[1].columns == std::vector<uint16_t>{0x20, 0x0456});
+}
+
+
+TEST_CASE("pclr bit depth field is precision minus one")
+{
+  // Table I.13: B_i = 0 is a 1-bit column stored in one byte, B_i = 15 is a
+  // 16-bit column stored in two bytes. NE=1, NPC=2, entry (1, 0xFFFF).
+  auto bytes = pclr_box({0x00, 0x01, 0x02, 0x00, 0x0f,
+                         0x01, 0xff, 0xff});
+  std::shared_ptr<Box> box;
+  Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+  REQUIRE(err == Error::Ok);
+
+  auto pclr = std::dynamic_pointer_cast<Box_pclr>(box);
+  REQUIRE(pclr);
+  REQUIRE(pclr->get_bit_depths() == std::vector<uint8_t>{1, 16});
+  REQUIRE(pclr->get_entries().size() == 1);
+  REQUIRE(pclr->get_entries()[0].columns == std::vector<uint16_t>{1, 0xffff});
+}
+
+
+TEST_CASE("pclr rejects unsupported bit depths")
+{
+  SECTION("17 bits does not fit the 16-bit entry storage") {
+    // NE=1, NPC=1, B=16 (17 bits), entry 0x000001
+    auto bytes = pclr_box({0x00, 0x01, 0x01, 0x10, 0x00, 0x00, 0x01});
+    std::shared_ptr<Box> box;
+    Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+    REQUIRE(err.error_code == heif_error_Unsupported_feature);
+  }
+
+  SECTION("signed columns") {
+    // NE=1, NPC=1, B=0x87 (signed 8 bits), entry 0x01
+    auto bytes = pclr_box({0x00, 0x01, 0x01, 0x87, 0x01});
+    std::shared_ptr<Box> box;
+    Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+    REQUIRE(err.error_code == heif_error_Unsupported_feature);
+  }
+}
+
+
+TEST_CASE("pclr one-bit columns still bound the entry count")
+{
+  // The smallest possible column (B_i = 0, 1 bit) still occupies one byte per
+  // entry, so the byte bound must reject NE=65535 with no entry data. This is
+  // the case a naive ceil(B_i / 8) would let through.
+  auto bytes = pclr_box({0xff, 0xff, 0x01, 0x00});
+  std::shared_ptr<Box> box;
+  Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+  REQUIRE(err.error_code == heif_error_Invalid_input);
+  REQUIRE(err.sub_error_code == heif_suberror_End_of_data);
+}
+
+
+TEST_CASE("pclr write and parse round trip")
+{
+  auto pclr = std::make_shared<Box_pclr>();
+  pclr->set_columns(3, 12);
+  Box_pclr::PaletteEntry entry0;
+  entry0.columns = {0x001, 0x002, 0xfff};
+  pclr->add_entry(entry0);
+  Box_pclr::PaletteEntry entry1;
+  entry1.columns = {0x800, 0x000, 0x7ff};
+  pclr->add_entry(entry1);
+
+  StreamWriter writer;
+  REQUIRE(pclr->write(writer) == Error::Ok);
+  const std::vector<uint8_t> bytes = writer.get_data();
+
+  std::shared_ptr<Box> box;
+  Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+  REQUIRE(err == Error::Ok);
+
+  auto parsed = std::dynamic_pointer_cast<Box_pclr>(box);
+  REQUIRE(parsed);
+  REQUIRE(parsed->get_bit_depths() == pclr->get_bit_depths());
+  REQUIRE(parsed->get_entries().size() == 2);
+  REQUIRE(parsed->get_entries()[0].columns == entry0.columns);
+  REQUIRE(parsed->get_entries()[1].columns == entry1.columns);
+}
+
+
+TEST_CASE("pclr zero columns rejected")
+{
+  // NE=65535, NPC=0: the 11-byte box from the advisory.
+  auto bytes = pclr_box({0xff, 0xff, 0x00});
+  std::shared_ptr<Box> box;
+  Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+  REQUIRE(err.error_code == heif_error_Invalid_input);
+  REQUIRE(std::dynamic_pointer_cast<Box_pclr>(box) == nullptr);
+}
+
+
+TEST_CASE("pclr entry count bounded by box payload")
+{
+  // NE=65535, NPC=1, B=7 (8 bits), but no entry data at all.
+  auto bytes = pclr_box({0xff, 0xff, 0x01, 0x07});
+  std::shared_ptr<Box> box;
+  Error err = parse_box(bytes, heif_get_global_security_limits(), &box);
+  REQUIRE(err.error_code == heif_error_Invalid_input);
+  REQUIRE(err.sub_error_code == heif_suberror_End_of_data);
+}
+
+
+TEST_CASE("pclr palette charged to memory limits")
+{
+  // NE=256, NPC=1, B=7 (8 bits), with all 256 entry bytes present. This is a
+  // valid palette, but it costs 256 PaletteEntry vectors in memory.
+  std::vector<uint8_t> payload{0x01, 0x00, 0x01, 0x07};
+  for (int i = 0; i < 256; i++) {
+    payload.push_back(static_cast<uint8_t>(i));
+  }
+  auto bytes = pclr_box(payload);
+
+  heif_security_limits limits = *heif_get_global_security_limits();
+  TotalMemoryTracker tracker(&limits);
+
+  SECTION("accepted within the budget") {
+    std::shared_ptr<Box> box;
+    Error err = parse_box(bytes, &limits, &box);
+    REQUIRE(err == Error::Ok);
+    auto pclr = std::dynamic_pointer_cast<Box_pclr>(box);
+    REQUIRE(pclr);
+    REQUIRE(pclr->get_entries().size() == 256);
+  }
+
+  SECTION("rejected when it exceeds max_total_memory") {
+    limits.max_total_memory = 1024;
+    std::shared_ptr<Box> box;
+    Error err = parse_box(bytes, &limits, &box);
+    REQUIRE(err.error_code == heif_error_Memory_allocation_error);
+    REQUIRE(err.sub_error_code == heif_suberror_Security_limit_exceeded);
+  }
+}
+
+
+TEST_CASE("pclr zero columns in nested j2kH rejects the file")
+{
+  // The advisory's reproducer: 200 zero-column 'pclr' boxes spread over
+  // nested 'j2kH' properties. It used to parse successfully at ~330 MB RSS.
+  heif_context* ctx = heif_context_alloc();
+  std::string path = tests_data_directory + "/pclr_zero_columns.heic";
+  heif_error err = heif_context_read_from_file(ctx, path.c_str(), nullptr);
+  REQUIRE(err.code == heif_error_Invalid_input);
+  heif_context_free(ctx);
+}