Commit 2d04d640 for tesseract

commit 2d04d640db2e8c7e3bab2369d599343b5a8b8443
Author: Stefan Weil <sw@weilnetz.de>
Date:   Fri Aug 21 18:34:33 2026 +0200

    Reject unicharset files whose inserts desync id from unichars

    UNICHARSET::load_via_fgets reads the unichar count via sscanf and
    trusts it as the loop bound, indexing the unichars vector with the
    loop index id via the unchecked set_* accessors. unichar_insert is a
    no-op for duplicate (or empty) representations, so once any insert
    no-ops, unichars.size() falls behind id and the subsequent
    set_*(id, ...) and unichars[id].properties writes land past the end
    of the vector - a deterministic heap out-of-bounds write (including a
    std::string assignment via set_normed) for every remaining line, on
    both the LSTM and legacy init paths. A malformed unicharset with a
    duplicate line (e.g. two identical entries) triggers it; a
    non-positive header count likewise loads an empty unicharset
    "successfully".

    Key changes:
    - unicharset.cpp: reject unicharset_size <= 0, and after each insert
      verify the vector actually grew to id + 1; on mismatch report the
      offending line and reject the file instead of writing out of bounds.
    - unittest: add unicharset_load_test with a duplicate-representation
      unicharset (on unpatched code the test dies on the
      container-overflow in load_via_fgets), a zero and a negative count,
      and a positive control that a valid unicharset still loads.

    Reported-by: Zhixi "Jace" Sun <g.mygenie@gmail.com>
    Assisted-by: OpenCode / qwen3.8-27b-thinking (Alibaba Cloud)
    Signed-off-by: Stefan Weil <sw@weilnetz.de>

diff --git a/Makefile.am b/Makefile.am
index 9ac4d26f..5df35d6f 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1220,6 +1220,7 @@ check_PROGRAMS += tfile_test
 if ENABLE_TRAINING
 check_PROGRAMS += unichar_test
 check_PROGRAMS += unicharcompress_test
+check_PROGRAMS += unicharset_load_test
 check_PROGRAMS += unicharset_test
 check_PROGRAMS += validate_grapheme_test
 check_PROGRAMS += validate_indic_test
@@ -1493,6 +1494,10 @@ unicharcompress_test_SOURCES = unittest/unicharcompress_test.cc
 unicharcompress_test_CPPFLAGS = $(unittest_CPPFLAGS)
 unicharcompress_test_LDADD = $(TRAINING_LIBS) $(ICU_UC_LIBS)

+unicharset_load_test_SOURCES = unittest/unicharset_load_test.cc
+unicharset_load_test_CPPFLAGS = $(unittest_CPPFLAGS)
+unicharset_load_test_LDADD = $(TESS_LIBS)
+
 unicharset_test_SOURCES = unittest/unicharset_test.cc
 unicharset_test_CPPFLAGS = $(unittest_CPPFLAGS)
 unicharset_test_LDADD = $(TRAINING_LIBS) $(ICU_UC_LIBS)
diff --git a/src/ccutil/unicharset.cpp b/src/ccutil/unicharset.cpp
index b29ec3b7..0e72ae48 100644
--- a/src/ccutil/unicharset.cpp
+++ b/src/ccutil/unicharset.cpp
@@ -791,6 +791,9 @@ bool UNICHARSET::load_via_fgets(
       sscanf(buffer, "%d", &unicharset_size) != 1) {
     return false;
   }
+  if (unicharset_size <= 0) {
+    return false;
+  }
   for (UNICHAR_ID id = 0; id < unicharset_size; ++id) {
     char unichar[256];
     unsigned int properties;
@@ -884,6 +887,15 @@ bool UNICHARSET::load_via_fgets(
     } else {
       this->unichar_insert_backwards_compatible(unichar);
     }
+    // A duplicate or empty representation makes the insert a no-op,
+    // desynchronizing id from the unichars vector; the set_* calls and
+    // unichars[id] below would then write out of bounds. The file is
+    // malformed, so reject it.
+    if (size() != static_cast<size_t>(id) + 1) {
+      fprintf(stderr, "%s:%d unichar %d has a duplicate or empty representation\n",
+              __FILE__, __LINE__, id);
+      return false;
+    }

     this->set_isalpha(id, properties & ISALPHA_MASK);
     this->set_islower(id, properties & ISLOWER_MASK);
diff --git a/unittest/unicharset_load_test.cc b/unittest/unicharset_load_test.cc
new file mode 100644
index 00000000..d775e233
--- /dev/null
+++ b/unittest/unicharset_load_test.cc
@@ -0,0 +1,64 @@
+///////////////////////////////////////////////////////////////////////
+// File:        unicharset_load_test.cc
+// Description: Tests that UNICHARSET::load_via_fgets rejects unicharset
+//              files whose insertions desynchronize the id loop index
+//              from the unichars vector (duplicate or empty
+//              representations), which would make the subsequent set_*
+//              calls write out of bounds, and non-positive size counts.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+///////////////////////////////////////////////////////////////////////
+
+#include "include_gunit.h"
+
+#include "serialis.h" // for TFile
+#include "unicharset.h"
+
+#include <cstring>
+
+namespace tesseract {
+namespace {
+
+// Loads the given unicharset text via the TFile-based loader.
+bool LoadUnicharset(const char *text, UNICHARSET *unicharset) {
+  TFile fp;
+  if (!fp.Open(text, std::strlen(text))) {
+    return false;
+  }
+  return unicharset->load_from_file(&fp, false);
+}
+
+// A duplicate representation makes the second insert a no-op, so on
+// unpatched code the set_* calls for the remaining lines write past
+// the end of the unichars vector (ASan container-overflow).
+TEST(UnicharsetLoadTest, RejectsDuplicateRepresentation) {
+  const char *text = "3\nA 0 Latin\nA 0 Latin\nB 0 Latin\n";
+  UNICHARSET unicharset;
+  EXPECT_FALSE(LoadUnicharset(text, &unicharset));
+}
+
+// A non-positive size count must be rejected; on unpatched code a
+// zero or negative count loads an empty unicharset successfully.
+TEST(UnicharsetLoadTest, RejectsNonPositiveCount) {
+  const char *texts[] = {"0\n", "-1\n"};
+  for (const char *text : texts) {
+    UNICHARSET unicharset;
+    EXPECT_FALSE(LoadUnicharset(text, &unicharset));
+  }
+}
+
+// A valid unicharset must still be accepted.
+TEST(UnicharsetLoadTest, AcceptsValidUnicharset) {
+  const char *text = "3\nA 0 Latin\nB 0 Latin\nC 0 Latin\n";
+  UNICHARSET unicharset;
+  ASSERT_TRUE(LoadUnicharset(text, &unicharset));
+  EXPECT_EQ(unicharset.size(), 3u);
+  EXPECT_STREQ(unicharset.id_to_unichar(1), "B");
+}
+
+} // namespace
+} // namespace tesseract