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