Commit 1bda5079 for tesseract

commit 1bda5079b1c8a7e25f523486837426903d29ce84
Author: Stefan Weil <sw@weilnetz.de>
Date:   Mon Aug 24 13:39:22 2026 +0200

    Limit unichar extraction in ReadNormProtos to the buffer size

    Classify::ReadNormProtos parsed each normproto line with
    `stream >> unichar >> NumProtos` into a char unichar[2 * UNICHAR_LEN + 1]
    stack buffer, but char* extraction from an istream has no length limit
    (the stream width was never set). A crafted TESSDATA_NORMPROTO component
    in a .traineddata file whose first proto-line token exceeds 60
    characters (the 100-byte line buffer allows up to 99) overflows the
    stack buffer during legacy engine initialization (CWE-121).

    Toolchain note: Apple's libc++ provides a C++20 array overload of
    operator>>(basic_istream&, char(&)[N]) that implicitly bounds the
    extraction to the array size, so builds against that standard library
    are incidentally protected. Standard libraries without that overload
    (e.g. libstdc++) still take the unbounded char* overload, so the
    explicit width limit below makes the behavior defined on all
    toolchains.

    Key changes:
    - normmatch.cpp: read the unichar token with
      std::setw(2 * UNICHAR_LEN + 1); char* extraction takes at most
      width - 1 characters, which fits the buffer exactly. Overlong
      tokens are truncated and the line is rejected like any other
      unparseable line.
    - unittest: add normproto_test covering a 99-character token (the
      maximum a 100-byte line can hold), a token of exactly 2 *
      UNICHAR_LEN characters, and a well-formed component. A minimal
      reproduction of the unbounded extraction crashes an ASan build
      with a stack-buffer-overflow.

    Reported-by: Tristan Madani <tristan@talencesecurity.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 c17cf7fd..cb206915 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1185,6 +1185,7 @@ check_PROGRAMS += mastertrainer_test
 endif # !DISABLED_LEGACY_ENGINE
 check_PROGRAMS += matrix_test
 check_PROGRAMS += networkio_test
+check_PROGRAMS += normproto_test
 if ENABLE_TRAINING
 check_PROGRAMS += normstrngs_test
 endif # ENABLE_TRAINING
@@ -1395,6 +1396,10 @@ networkio_test_SOURCES = unittest/networkio_test.cc
 networkio_test_CPPFLAGS = $(unittest_CPPFLAGS)
 networkio_test_LDADD = $(TESS_LIBS)

+normproto_test_SOURCES = unittest/normproto_test.cc
+normproto_test_CPPFLAGS = $(unittest_CPPFLAGS)
+normproto_test_LDADD = $(TESS_LIBS)
+
 normstrngs_test_SOURCES = unittest/normstrngs_test.cc
 normstrngs_test_CPPFLAGS = $(unittest_CPPFLAGS)
 normstrngs_test_LDADD = $(TRAINING_LIBS) $(ICU_I18N_LIBS) $(ICU_UC_LIBS)
diff --git a/src/classify/normmatch.cpp b/src/classify/normmatch.cpp
index d5bd7e6a..1ce7c928 100644
--- a/src/classify/normmatch.cpp
+++ b/src/classify/normmatch.cpp
@@ -28,6 +28,7 @@

 #include <cmath>
 #include <cstdio>
+#include <iomanip> // for std::setw
 #include <sstream> // for std::istringstream

 namespace tesseract {
@@ -190,7 +191,10 @@ NORM_PROTOS *Classify::ReadNormProtos(TFile *fp) {
   while (fp->FGets(line, kMaxLineSize) != nullptr) {
     std::istringstream stream(line);
     stream.imbue(std::locale::classic());
-    stream >> unichar >> NumProtos;
+    // unichar holds at most 2 * UNICHAR_LEN characters; the width limit
+    // (width - 1 characters for char* extraction) keeps the extraction
+    // from overflowing the buffer on overlong lines.
+    stream >> std::setw(2 * UNICHAR_LEN + 1) >> unichar >> NumProtos;
     if (stream.fail()) {
       continue;
     }
diff --git a/unittest/CMakeLists.txt b/unittest/CMakeLists.txt
index c5294955..66b7f999 100644
--- a/unittest/CMakeLists.txt
+++ b/unittest/CMakeLists.txt
@@ -60,6 +60,7 @@ set(LEGACY_TESTS
     intfeaturemap_test.cc
     intproto_test.cc
     mastertrainer_test.cc
+    normproto_test.cc
     osd_test.cc
     params_model_test.cc
     shapetable_test.cc
diff --git a/unittest/normproto_test.cc b/unittest/normproto_test.cc
new file mode 100644
index 00000000..574b2f3d
--- /dev/null
+++ b/unittest/normproto_test.cc
@@ -0,0 +1,111 @@
+///////////////////////////////////////////////////////////////////////
+// File:        normproto_test.cc
+// Description: Tests that Classify::ReadNormProtos handles a normproto
+//              line whose first (unichar) token exceeds the
+//              unichar[2 * UNICHAR_LEN + 1] stack buffer. The
+//              istream extraction has no intrinsic length limit, so a
+//              crafted NORMPROTO component in a .traineddata file
+//              could overflow the stack buffer during legacy engine
+//              initialization.
+//
+// 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 "classify.h"
+#include "serialis.h" // for TFile
+
+#include <cstdio>
+#include <cstdlib>
+#include <string>
+#include <vector>
+
+namespace tesseract {
+namespace {
+
+// Minimal unicharset (space and 'a').
+const char kMinUnicharset[] =
+    "2\n"
+    "NULL 1 0,255,0,255,0,0,0,0,0,0 Latin 2 0 2\n"
+    "a 1 0,255,0,255,0,0,0,0,0,0 Latin 2 0 2\n";
+
+// Builds a normproto component: a sample-size line (5), five parameter
+// description lines, then the given raw proto lines.
+std::vector<char> MakeNormproto(const std::string &lines) {
+  std::string data = "5\n";
+  for (int i = 0; i < 5; ++i) {
+    data += "e e 0 1\n";
+  }
+  data += lines;
+  return std::vector<char>(data.begin(), data.end());
+}
+
+class NormprotoTest : public testing::Test {
+protected:
+  void SetUp() override {
+    tmpl_ = "/tmp/tess_normproto_test_XXXXXX";
+    char *dir = mkdtemp(tmpl_.data());
+    ASSERT_NE(dir, nullptr);
+    dir_ = dir;
+    std::string uc_path = dir_ + "/eng.unicharset";
+    FILE *f = fopen(uc_path.c_str(), "w");
+    ASSERT_NE(f, nullptr);
+    ASSERT_EQ(fwrite(kMinUnicharset, 1, sizeof(kMinUnicharset) - 1, f),
+              sizeof(kMinUnicharset) - 1);
+    fclose(f);
+    // Load the minimal unicharset into the classifier's inherited
+    // unicharset member.
+    ASSERT_TRUE(classifier_.unicharset.load_from_file(uc_path.c_str()));
+  }
+  void TearDown() override {
+    std::remove((dir_ + "/eng.unicharset").c_str());
+    rmdir(dir_.c_str());
+  }
+  std::string dir_;
+  std::string tmpl_;
+  Classify classifier_;
+};
+
+// A 99-character first token (the maximum FGets can return) overflows
+// unichar[2 * UNICHAR_LEN + 1] on unpatched code; the width-limited
+// extraction must reject the line instead.
+TEST_F(NormprotoTest, ToleratesOverlongUnicharToken) {
+  std::vector<char> bytes = MakeNormproto(std::string(99, 'A') + "\n");
+  TFile fp;
+  ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+  classifier_.NormProtos = classifier_.ReadNormProtos(&fp);
+  ASSERT_NE(classifier_.NormProtos, nullptr);
+  classifier_.FreeNormProtos();
+  EXPECT_EQ(classifier_.NormProtos, nullptr);
+}
+
+// A token of exactly 2 * UNICHAR_LEN characters is the maximum legitimate
+// size; it must not be truncated or rejected by the width limit.
+TEST_F(NormprotoTest, ToleratesMaxLenUnicharToken) {
+  std::vector<char> bytes = MakeNormproto(std::string(2 * UNICHAR_LEN, 'A') + " 0\n");
+  TFile fp;
+  ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+  classifier_.NormProtos = classifier_.ReadNormProtos(&fp);
+  ASSERT_NE(classifier_.NormProtos, nullptr);
+  classifier_.FreeNormProtos();
+  EXPECT_EQ(classifier_.NormProtos, nullptr);
+}
+
+// A well-formed normproto component must still parse.
+TEST_F(NormprotoTest, ReadsValidNormprotos) {
+  std::vector<char> bytes = MakeNormproto("a 0\n");
+  TFile fp;
+  ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+  classifier_.NormProtos = classifier_.ReadNormProtos(&fp);
+  ASSERT_NE(classifier_.NormProtos, nullptr);
+  classifier_.FreeNormProtos();
+  EXPECT_EQ(classifier_.NormProtos, nullptr);
+}
+
+} // namespace
+} // namespace tesseract