tesseract: upgrade 5.5.3 -> 5.5.4

Dropped patches that are part of the upstream version.

Changelog:
https://github.com/tesseract-ocr/tesseract/releases/tag/5.5.4

Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
Signed-off-by: Khem Raj <khem.raj@oss.qualcomm.com>
This commit is contained in:
Ankur Tyagi authored and Khem Raj committed 2026-10-29 06:51:18 -07:00
1 parent 7d5620e42a
commit 32c4929292
8 files changed
+1 -1607

No files matched your search

@@ -1,225 +0,0 @@
From e974684acbe3aeb6bc6192b06a048537c345b713 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Mon, 24 Aug 2026 13:39:22 +0200
Subject: [PATCH] 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>
(cherry picked from commit 1bda5079b1c8a7e25f523486837426903d29ce84)
CVE: CVE-2026-88047
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/1bda5079b1c8a7e25f523486837426903d29ce84]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 ++
src/classify/normmatch.cpp | 6 +-
unittest/CMakeLists.txt | 1 +
unittest/normproto_test.cc | 111 +++++++++++++++++++++++++++++++++++++
4 files changed, 122 insertions(+), 1 deletion(-)
create mode 100644 unittest/normproto_test.cc
diff --git a/Makefile.am b/Makefile.am
index 63ca6686..cc748455 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1184,6 +1184,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
@@ -1388,6 +1389,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 a660c9b1..4c677a60 100644
--- a/unittest/CMakeLists.txt
+++ b/unittest/CMakeLists.txt
@@ -59,6 +59,7 @@ set(LEGACY_TESTS
indexmapbidi_test.cc
intfeaturemap_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
@@ -1,227 +0,0 @@
From b2fbc8282c34f0a5a426622a9eb6a79ad0df0fe4 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Fri, 21 Aug 2026 15:23:59 +0200
Subject: [PATCH] Validate FullyConnected weight matrix dimensions at load
FullyConnected::DeSerialize read the WeightMatrix without checking
that its dimensions match the layer's declared ni/no. MatrixDotVector
drives the dot product from the matrix dimensions (writes w.dim1()
results, reads w.dim2()-1 inputs) while the scratch buffers are sized
from no_ and ni_, so a crafted .traineddata with a mismatched matrix
performed a heap out-of-bounds write (up to 65535 rows) and out-of-
bounds read on the first recognition step (crash, or heap corruption
with attacker-influenced size and content).
Key changes:
- weightmatrix.h: add Dim1()/Dim2() accessors for the active weight
matrix (int or float), alongside the existing NumOutputs().
- fullyconnected.cpp: reject the layer in DeSerialize unless
Dim1() == no_ and Dim2() == ni_ + 1 (the second dimension
includes the bias column), the layout that InitWeightsFloat
always produces.
- unittest: new fullyconnected_test that builds a Softmax network
with mismatched matrix dimensions and expects CreateFromFile to
return nullptr; on unpatched code the test reaches Forward and
ASan catches the out-of-bounds write in MatrixDotVector. A
second test verifies that matching dimensions are still accepted.
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>
(cherry picked from commit 103dc134eb36411ddc6833ec20aa2c76795bd0ff)
CVE: CVE-2026-88048
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/103dc134eb36411ddc6833ec20aa2c76795bd0ff]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 ++
src/lstm/fullyconnected.cpp | 11 ++-
src/lstm/weightmatrix.h | 7 ++
unittest/fullyconnected_test.cc | 115 ++++++++++++++++++++++++++++++++
4 files changed, 137 insertions(+), 1 deletion(-)
create mode 100644 unittest/fullyconnected_test.cc
diff --git a/Makefile.am b/Makefile.am
index 22c2c7a0..284f8b53 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1158,6 +1158,7 @@ if !DISABLED_LEGACY_ENGINE
check_PROGRAMS += equationdetect_test
endif # !DISABLED_LEGACY_ENGINE
check_PROGRAMS += fileio_test
+check_PROGRAMS += fullyconnected_test
check_PROGRAMS += heap_test
check_PROGRAMS += imagedata_test
if !DISABLED_LEGACY_ENGINE
@@ -1290,6 +1291,10 @@ fileio_test_SOURCES = unittest/fileio_test.cc
fileio_test_CPPFLAGS = $(unittest_CPPFLAGS)
fileio_test_LDADD = $(TRAINING_LIBS)
+fullyconnected_test_SOURCES = unittest/fullyconnected_test.cc
+fullyconnected_test_CPPFLAGS = $(unittest_CPPFLAGS)
+fullyconnected_test_LDADD = $(TESS_LIBS)
+
heap_test_SOURCES = unittest/heap_test.cc
heap_test_CPPFLAGS = $(unittest_CPPFLAGS)
heap_test_LDADD = $(TESS_LIBS)
diff --git a/src/lstm/fullyconnected.cpp b/src/lstm/fullyconnected.cpp
index a0ee1b02..d999f575 100644
--- a/src/lstm/fullyconnected.cpp
+++ b/src/lstm/fullyconnected.cpp
@@ -121,7 +121,16 @@ bool FullyConnected::Serialize(TFile *fp) const {
// Reads from the given file. Returns false in case of error.
bool FullyConnected::DeSerialize(TFile *fp) {
- return weights_.DeSerialize(IsTraining(), fp);
+ if (!weights_.DeSerialize(IsTraining(), fp)) {
+ return false;
+ }
+ // The weight matrix must match the declared sizes (the second dimension
+ // includes the bias column); otherwise Forward would read or write
+ // outside the scratch buffers sized from ni_ and no_.
+ if (weights_.Dim1() != no_ || weights_.Dim2() != ni_ + 1) {
+ return false;
+ }
+ return true;
}
// Runs forward propagation of activations on the input line.
diff --git a/src/lstm/weightmatrix.h b/src/lstm/weightmatrix.h
index eaca3ffb..6c5ab236 100644
--- a/src/lstm/weightmatrix.h
+++ b/src/lstm/weightmatrix.h
@@ -107,6 +107,13 @@ public:
int NumOutputs() const {
return int_mode_ ? wi_.dim1() : wf_.dim1();
}
+ // The dimensions of the active weight matrix (wi_ in int mode, else wf_).
+ int Dim1() const {
+ return int_mode_ ? wi_.dim1() : wf_.dim1();
+ }
+ int Dim2() const {
+ return int_mode_ ? wi_.dim2() : wf_.dim2();
+ }
// Provides one set of weights. Only used by peep weight maxpool.
const TFloat *GetWeights(int index) const {
return wf_[index];
diff --git a/unittest/fullyconnected_test.cc b/unittest/fullyconnected_test.cc
new file mode 100644
index 00000000..f9697de4
--- /dev/null
+++ b/unittest/fullyconnected_test.cc
@@ -0,0 +1,115 @@
+///////////////////////////////////////////////////////////////////////
+// File: fullyconnected_test.cc
+// Description: Tests that a FullyConnected (softmax) network layer with
+// weight-matrix dimensions that do not match the declared
+// ni/no is rejected at load. Without the check,
+// MatrixDotVector writes w.dim1() results into a scratch
+// buffer sized from no_ and reads w.dim2()-1 inputs from
+// a buffer sized from ni_ (heap out-of-bounds write/read)
+// on the first recognition step.
+//
+// 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 "network.h" // for Network, NetworkType
+#include "networkio.h"
+#include "networkscratch.h"
+#include "serialis.h" // for TFile
+
+#include <cstdint>
+#include <vector>
+
+namespace tesseract {
+namespace {
+
+// Appends raw little-endian values to a byte buffer.
+class ByteWriter {
+public:
+ void PutU8(uint32_t v) { data_.push_back(static_cast<char>(v & 0xFF)); }
+ void PutU32(uint32_t v) {
+ for (int i = 0; i < 4; ++i) {
+ data_.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
+ }
+ }
+ void PutS32(int32_t v) { PutU32(static_cast<uint32_t>(v)); }
+ const std::vector<char> &data() const { return data_; }
+
+private:
+ std::vector<char> data_;
+};
+
+void PutDoubleLE(ByteWriter *w, double d) {
+ union {
+ double d;
+ uint64_t u;
+ } conv;
+ conv.d = d;
+ w->PutU32(static_cast<uint32_t>(conv.u & 0xFFFFFFFF));
+ w->PutU32(static_cast<uint32_t>(conv.u >> 32));
+}
+
+// Builds a serialized NT_SOFTMAX network: header with the given ni/no,
+// then a float-mode WeightMatrix with the given (corrupt) dimensions.
+// The matrix is stored as doubles on disk (see WeightMatrix::DeSerialize).
+std::vector<char> MakeSoftmaxNetwork(int ni, int no, int32_t dim1, int32_t dim2) {
+ ByteWriter w;
+ w.PutU8(static_cast<uint32_t>(NT_SOFTMAX));
+ w.PutU8(0); // training: TS_DISABLED
+ w.PutU8(0); // needs_to_backprop
+ w.PutU32(0); // network_flags
+ w.PutU32(static_cast<uint32_t>(ni));
+ w.PutU32(static_cast<uint32_t>(no));
+ w.PutU32(0); // num_weights (not cross-checked, kept consistent anyway)
+ w.PutU32(0); // name (empty string)
+ // WeightMatrix::DeSerialize:
+ w.PutU8(128); // mode: kDoubleFlag, float mode
+ w.PutS32(dim1);
+ w.PutS32(dim2);
+ PutDoubleLE(&w, 0.0); // empty_ cell
+ for (int32_t i = 0; i < dim1 * dim2; ++i) {
+ PutDoubleLE(&w, 0.0); // weight data
+ }
+ return w.data();
+}
+
+// A FullyConnected layer whose weight matrix does not match the declared
+// sizes must be rejected by CreateFromFile; on unpatched code the test
+// reaches Forward, where MatrixDotVector performs the out-of-bounds
+// write this regression test guards against.
+TEST(FullyconnectedTest, RejectsWeightMatrixDimensionMismatch) {
+ // ni_=no_=1 but dim1=3 (OOB write of 3 results into a 1-result buffer)
+ // and dim2=5 (OOB read of 4 inputs from a 1-input buffer).
+ std::vector<char> bytes = MakeSoftmaxNetwork(1, 1, 3, 5);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ Network *net = Network::CreateFromFile(&fp);
+ if (net == nullptr) {
+ return; // Fixed: the mismatched layer is rejected at load.
+ }
+ NetworkIO input;
+ input.Resize2d(false, /*width=*/1, /*num_features=*/1);
+ NetworkScratch scratch;
+ NetworkIO output;
+ net->Forward(false, input, nullptr, &scratch, &output);
+ delete net;
+ FAIL() << "crafted FullyConnected layer with mismatched weight matrix was accepted";
+}
+
+// Consistent dimensions must still be accepted.
+TEST(FullyconnectedTest, AcceptsMatchingDimensions) {
+ std::vector<char> bytes = MakeSoftmaxNetwork(1, 2, 2, 2);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ Network *net = Network::CreateFromFile(&fp);
+ ASSERT_NE(net, nullptr);
+ delete net;
+}
+
+} // namespace
+} // namespace tesseract
@@ -1,315 +0,0 @@
From 62fabbca097d127e7d5a8fe3869102d2e4918b80 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Fri, 21 Aug 2026 16:41:51 +0200
Subject: [PATCH] Validate LSTM gate matrix dimensions against na_/no_ at load
LSTM::DeSerialize read na_ from the untrusted TESSDATA_LSTM component
and derived ns_ from the CI gate matrix's dim1, but never checked that
the deserialized dimensions were mutually consistent. The forward pass
sizes its buffers from na_, no_ and ns_ while the gate matrices drive
their own dimensions, so a crafted .traineddata performed heap
out-of-bounds writes and reads during the first recognition step, e.g.
WriteTimeStepPart writing ns_ floats at offset ni_+nf_ into a source_
buffer sized from na_ (up to ~256 KB with an attacker-chosen gate
matrix dim1).
The bounds assertions added by 2f4d2f4 (CVE-2026-73066) covered only
NetworkIO::CopyTimeStepGeneral and Randomize, leaving
WriteTimeStepPart and AddTimeStepPart unguarded.
Key changes:
- weightmatrix.h: add Dim1()/Dim2() accessors for the active weight
matrix (int or float), alongside the existing NumOutputs().
- lstm.cpp: reject the layer in DeSerialize unless na_ ==
ni_ + nf_ + (is_2d_ ? 2 : 1) * ns_, every deserialized gate has
Dim1() == ns_ and Dim2() == na_ + 1 (the layout InitWeightsFloat
always produces), ns_ == no_ for plain NT_LSTM/NT_LSTM_SUMMARY, and
the softmax layer's sizes match ns_/no_ for the softmax variants.
- networkio.cpp: add the same defense-in-depth bounds assertions to
WriteTimeStepPart and AddTimeStepPart as 2f4d2f4 added to
CopyTimeStepGeneral and Randomize.
- unittest: add lstm_layer_test with crafted NT_LSTM layers for the
na_ mismatch, gate dim1 mismatch, and gate dim2 mismatch cases
(each rejected at load; on unpatched code the tests reach Forward
and ASan catches the out-of-bounds write in WriteTimeStepPart),
plus a positive control that a consistent layer loads and runs.
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>
(cherry picked from commit b494ac18925f9d9aff9ef5815475de9943ab19bf)
CVE: CVE-2026-88049
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/b494ac18925f9d9aff9ef5815475de9943ab19bf]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 +
src/lstm/lstm.cpp | 20 ++++
src/lstm/networkio.cpp | 2 +
unittest/lstm_layer_test.cc | 177 ++++++++++++++++++++++++++++++++++++
4 files changed, 204 insertions(+)
create mode 100644 unittest/lstm_layer_test.cc
diff --git a/Makefile.am b/Makefile.am
index 284f8b53..4bcc858b 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1171,6 +1171,7 @@ check_PROGRAMS += layout_test
check_PROGRAMS += ligature_table_test
check_PROGRAMS += linlsq_test
check_PROGRAMS += list_test
+check_PROGRAMS += lstm_layer_test
if ENABLE_TRAINING
check_PROGRAMS += lstm_recode_test
check_PROGRAMS += lstm_squashed_test
@@ -1352,6 +1353,10 @@ loadlang_test_SOURCES = unittest/loadlang_test.cc
loadlang_test_CPPFLAGS = $(unittest_CPPFLAGS)
loadlang_test_LDADD = $(TESS_LIBS) $(LEPTONICA_LIBS)
+lstm_layer_test_SOURCES = unittest/lstm_layer_test.cc
+lstm_layer_test_CPPFLAGS = $(unittest_CPPFLAGS)
+lstm_layer_test_LDADD = $(TESS_LIBS)
+
lstm_recode_test_SOURCES = unittest/lstm_recode_test.cc
lstm_recode_test_CPPFLAGS = $(unittest_CPPFLAGS)
lstm_recode_test_LDADD = $(TRAINING_LIBS)
diff --git a/src/lstm/lstm.cpp b/src/lstm/lstm.cpp
index 7bfd19b5..bfffa5de 100644
--- a/src/lstm/lstm.cpp
+++ b/src/lstm/lstm.cpp
@@ -274,12 +274,32 @@ bool LSTM::DeSerialize(TFile *fp) {
is_2d_ = na_ - nf_ == ni_ + 2 * ns_;
}
}
+ // The deserialized dimensions must be mutually consistent: the forward
+ // pass sizes its buffers from na_, no_ and ns_ while the gate matrices
+ // drive their own dimensions.
+ if (na_ != ni_ + nf_ + (is_2d_ ? 2 : 1) * ns_) {
+ return false;
+ }
+ for (int w = 0; w < WT_COUNT; ++w) {
+ if (w == GFS && !Is2D()) {
+ continue;
+ }
+ if (gate_weights_[w].Dim1() != ns_ || gate_weights_[w].Dim2() != na_ + 1) {
+ return false;
+ }
+ }
+ if ((type_ == NT_LSTM || type_ == NT_LSTM_SUMMARY) && ns_ != no_) {
+ return false;
+ }
delete softmax_;
if (type_ == NT_LSTM_SOFTMAX || type_ == NT_LSTM_SOFTMAX_ENCODED) {
softmax_ = static_cast<FullyConnected *>(Network::CreateFromFile(fp));
if (softmax_ == nullptr) {
return false;
}
+ if (softmax_->NumInputs() != ns_ || softmax_->NumOutputs() != no_) {
+ return false;
+ }
} else {
softmax_ = nullptr;
}
diff --git a/src/lstm/networkio.cpp b/src/lstm/networkio.cpp
index 8e1c6679..7a31034b 100644
--- a/src/lstm/networkio.cpp
+++ b/src/lstm/networkio.cpp
@@ -641,6 +641,7 @@ void NetworkIO::AddTimeStep(int t, TFloat *inout) const {
// Adds part of a single timestep to floats.
void NetworkIO::AddTimeStepPart(int t, int offset, int num_features, float *inout) const {
+ ASSERT_HOST(offset + num_features <= NumFeatures());
if (int_mode_) {
const int8_t *line = i_[t] + offset;
for (int i = 0; i < num_features; ++i) {
@@ -662,6 +663,7 @@ void NetworkIO::WriteTimeStep(int t, const TFloat *input) {
// Writes a single timestep from floats in the range [-1, 1] writing only
// num_features elements of input to (*this)[t], starting at offset.
void NetworkIO::WriteTimeStepPart(int t, int offset, int num_features, const TFloat *input) {
+ ASSERT_HOST(offset + num_features <= NumFeatures());
if (int_mode_) {
int8_t *line = i_[t] + offset;
for (int i = 0; i < num_features; ++i) {
diff --git a/unittest/lstm_layer_test.cc b/unittest/lstm_layer_test.cc
new file mode 100644
index 00000000..2c874995
--- /dev/null
+++ b/unittest/lstm_layer_test.cc
@@ -0,0 +1,177 @@
+///////////////////////////////////////////////////////////////////////
+// File: lstm_layer_test.cc
+// Description: Tests that an NT_LSTM network layer with mutually
+// inconsistent deserialized dimensions is rejected at
+// load. The forward pass sizes its buffers from na_, no_
+// and ns_ while the gate weight matrices drive their own
+// dimensions, so a crafted .traineddata performs heap
+// out-of-bounds writes/reads during the first
+// recognition step (e.g. WriteTimeStepPart writing ns_
+// floats into a source_ buffer sized from na_).
+//
+// 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 "network.h" // for Network, NetworkType
+#include "networkio.h"
+#include "networkscratch.h"
+#include "serialis.h" // for TFile
+#include "stridemap.h"
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+namespace tesseract {
+namespace {
+
+// Appends raw little-endian values to a byte buffer.
+class ByteWriter {
+public:
+ void PutU8(uint32_t v) { data_.push_back(static_cast<char>(v & 0xFF)); }
+ void PutU32(uint32_t v) {
+ for (int i = 0; i < 4; ++i) {
+ data_.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
+ }
+ }
+ void PutS32(int32_t v) { PutU32(static_cast<uint32_t>(v)); }
+ const std::vector<char> &data() const { return data_; }
+
+private:
+ std::vector<char> data_;
+};
+
+void PutDoubleLE(ByteWriter *w, double d) {
+ union {
+ double d;
+ uint64_t u;
+ } conv;
+ conv.d = d;
+ w->PutU32(static_cast<uint32_t>(conv.u & 0xFFFFFFFF));
+ w->PutU32(static_cast<uint32_t>(conv.u >> 32));
+}
+
+// A serialized float-mode WeightMatrix with the given dimensions,
+// all weight data zeroed.
+void PutGateMatrix(ByteWriter *w, int32_t dim1, int32_t dim2) {
+ w->PutU8(128); // mode: kDoubleFlag, float mode
+ w->PutS32(dim1);
+ w->PutS32(dim2);
+ PutDoubleLE(w, 0.0); // empty_ cell
+ for (int32_t i = 0; i < dim1 * dim2; ++i) {
+ PutDoubleLE(w, 0.0);
+ }
+}
+
+// A serialized 1-D NT_LSTM network: header with the given ni/no, na_,
+// then the four gates CI, GI, GF1, GO (GFS is not serialized for 1-D).
+std::vector<char> MakeLstmNetwork(int ni, int no, int32_t na,
+ const int32_t gate_dim1[4], const int32_t gate_dim2[4]) {
+ ByteWriter w;
+ w.PutU8(static_cast<uint32_t>(NT_LSTM));
+ w.PutU8(0); // training: TS_DISABLED
+ w.PutU8(0); // needs_to_backprop
+ w.PutU32(0); // network_flags
+ w.PutU32(static_cast<uint32_t>(ni));
+ w.PutU32(static_cast<uint32_t>(no));
+ w.PutU32(0); // num_weights
+ w.PutU32(0); // name (empty string)
+ w.PutS32(na);
+ for (int g = 0; g < 4; ++g) {
+ PutGateMatrix(&w, gate_dim1[g], gate_dim2[g]);
+ }
+ return w.data();
+}
+
+// Builds the input a standalone LSTM layer would receive: one row of
+// the given width with ni features.
+NetworkIO MakeInput(int ni, int width) {
+ StrideMap stride_map;
+ stride_map.SetStride({{1, width}});
+ NetworkIO input;
+ input.ResizeToMap(false, stride_map, ni);
+ return input;
+}
+
+// Runs Forward on the loaded network; on unpatched code the out-of-
+// bounds access this regression test guards against fires here.
+void RunForward(Network *net, int ni, int width) {
+ NetworkIO input = MakeInput(ni, width);
+ NetworkScratch scratch;
+ NetworkIO output;
+ net->Forward(false, input, nullptr, &scratch, &output);
+ delete net;
+}
+
+// na_ must equal ni_ + nf_ + ns_ for a 1-D LSTM; here na_=2 but the
+// CI matrix makes ns_=64, so the layer must be rejected. On unpatched
+// code Forward writes 64 floats at offset ni_=1 into a source_ buffer
+// sized for na_=2 (heap out-of-bounds write).
+TEST(LstmLayerTest, RejectsInconsistentNa) {
+ const int32_t dim1[4] = {64, 64, 64, 64};
+ const int32_t dim2[4] = {3, 3, 3, 3};
+ std::vector<char> bytes = MakeLstmNetwork(1, 1, 2, dim1, dim2);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ Network *net = Network::CreateFromFile(&fp);
+ if (net == nullptr) {
+ return; // Fixed: the inconsistent layer is rejected at load.
+ }
+ RunForward(net, 1, 2);
+ FAIL() << "crafted LSTM layer with inconsistent na_ was accepted";
+}
+
+// All gate matrices must have dim1 == ns_; here the GI matrix has
+// dim1=9 while ns_=5. On unpatched code the GI gate dot product writes
+// 9 results into a temp line sized for 5 (heap out-of-bounds write).
+TEST(LstmLayerTest, RejectsGateDim1Mismatch) {
+ const int32_t dim1[4] = {5, 9, 5, 5};
+ const int32_t dim2[4] = {7, 7, 7, 7};
+ std::vector<char> bytes = MakeLstmNetwork(1, 5, 6, dim1, dim2);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ Network *net = Network::CreateFromFile(&fp);
+ if (net == nullptr) {
+ return; // Fixed: the inconsistent layer is rejected at load.
+ }
+ RunForward(net, 1, 2);
+ FAIL() << "crafted LSTM layer with inconsistent gate dim1 was accepted";
+}
+
+// All gate matrices must have dim2 == na_ + 1; here the GI matrix has
+// dim2=9 while na_=6. On unpatched code the GI gate dot product reads
+// 8 inputs from a buffer sized for 6 (heap out-of-bounds read).
+TEST(LstmLayerTest, RejectsGateDim2Mismatch) {
+ const int32_t dim1[4] = {5, 5, 5, 5};
+ const int32_t dim2[4] = {7, 9, 7, 7};
+ std::vector<char> bytes = MakeLstmNetwork(1, 5, 6, dim1, dim2);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ Network *net = Network::CreateFromFile(&fp);
+ if (net == nullptr) {
+ return; // Fixed: the inconsistent layer is rejected at load.
+ }
+ RunForward(net, 1, 2);
+ FAIL() << "crafted LSTM layer with inconsistent gate dim2 was accepted";
+}
+
+// A fully consistent 1-D LSTM layer must still be accepted and usable.
+TEST(LstmLayerTest, AcceptsConsistentLayer) {
+ const int32_t dim1[4] = {5, 5, 5, 5};
+ const int32_t dim2[4] = {7, 7, 7, 7};
+ std::vector<char> bytes = MakeLstmNetwork(1, 5, 6, dim1, dim2);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ Network *net = Network::CreateFromFile(&fp);
+ ASSERT_NE(net, nullptr);
+ RunForward(net, 1, 2);
+}
+
+} // namespace
+} // namespace tesseract
@@ -1,209 +0,0 @@
From 5426c23fe7fe32b62bd5694f68f71772f273c821 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Fri, 21 Aug 2026 16:56:51 +0200
Subject: [PATCH] Reject recoder code values outside the sane range at load
RecodedCharID::DeSerialize (hardened by 82727cc to check length_
only) still read the individual code values as raw signed int32.
UnicharCompress::ComputeCodeRange computes code_range_ as 1 plus the
maximum code using a signed > comparison, so a code value of -1
never raises the maximum and yields code_range_ = 0. SetupDecoder
then resizes is_valid_start_ to 0 and writes is_valid_start_[code(0)]
on the size-0 vector<bool>, an out-of-bounds write at a wild wrapped
index (deterministic crash) on LSTMRecognizer load. A code value of
INT32_MAX instead wraps code_range_ negative and makes resize()
throw.
Key changes:
- unicharcompress.h: validate each deserialized code value to be
within [0, UINT16_MAX), the same arbitrary cap used elsewhere for
.traineddata counts; reject the recoder otherwise.
- unicharcompress.h/.cpp: add defense-in-depth bounds assertions to
IsValidFirstCode and SetupDecoder, matching the style of the
NetworkIO assertions from 2f4d2f4.
- unittest: add recoder_test, which feeds a crafted UnicharCompress
with a -1 code and an INT32_MAX code and expects DeSerialize to
fail (on unpatched code the -1 case dies on the SEGV in
SetupDecoder, the huge case on the uncaught length_error), plus a
positive control that valid codes load and answer
code_range()/IsValidFirstCode() correctly.
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>
(cherry picked from commit c94a5532ee04db5a4919542832fd94caee5ea58f)
CVE: CVE-2026-88050
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/c94a5532ee04db5a4919542832fd94caee5ea58f]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 ++
src/ccutil/unicharcompress.cpp | 1 +
src/ccutil/unicharcompress.h | 13 ++++-
unittest/recoder_test.cc | 91 ++++++++++++++++++++++++++++++++++
4 files changed, 109 insertions(+), 1 deletion(-)
create mode 100644 unittest/recoder_test.cc
diff --git a/Makefile.am b/Makefile.am
index 4bcc858b..63ca6686 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1202,6 +1202,7 @@ endif # !DISABLED_LEGACY_ENGINE
check_PROGRAMS += progress_test
check_PROGRAMS += qrsequence_test
check_PROGRAMS += recodebeam_test
+check_PROGRAMS += recoder_test
check_PROGRAMS += rect_test
check_PROGRAMS += resultiterator_test
check_PROGRAMS += scanutils_test
@@ -1435,6 +1436,10 @@ recodebeam_test_SOURCES = unittest/recodebeam_test.cc
recodebeam_test_CPPFLAGS = $(unittest_CPPFLAGS)
recodebeam_test_LDADD = $(TRAINING_LIBS) $(ICU_I18N_LIBS) $(ICU_UC_LIBS)
+recoder_test_SOURCES = unittest/recoder_test.cc
+recoder_test_CPPFLAGS = $(unittest_CPPFLAGS)
+recoder_test_LDADD = $(TESS_LIBS)
+
rect_test_SOURCES = unittest/rect_test.cc
rect_test_CPPFLAGS = $(unittest_CPPFLAGS)
rect_test_LDADD = $(TESS_LIBS)
diff --git a/src/ccutil/unicharcompress.cpp b/src/ccutil/unicharcompress.cpp
index f909f76e..b3dd440f 100644
--- a/src/ccutil/unicharcompress.cpp
+++ b/src/ccutil/unicharcompress.cpp
@@ -400,6 +400,7 @@ void UnicharCompress::SetupDecoder() {
for (unsigned c = 0; c < encoder_.size(); ++c) {
const RecodedCharID &code = encoder_[c];
decoder_[code] = c;
+ ASSERT_HOST(code(0) >= 0 && code(0) < code_range_);
is_valid_start_[code(0)] = true;
RecodedCharID prefix = code;
uint32_t len = code.length();
diff --git a/src/ccutil/unicharcompress.h b/src/ccutil/unicharcompress.h
index 9b696d03..6919b7d2 100644
--- a/src/ccutil/unicharcompress.h
+++ b/src/ccutil/unicharcompress.h
@@ -84,7 +84,17 @@ public:
if (length_ > kMaxCodeLen) {
return false;
}
- return fp->DeSerialize(&code_[0], length_);
+ if (!fp->DeSerialize(&code_[0], length_)) {
+ return false;
+ }
+ // Code values index arrays sized from the maximum code; reject values
+ // that are out of the sane range for a recoded alphabet.
+ for (uint32_t i = 0; i < length_; ++i) {
+ if (code_[i] < 0 || code_[i] >= static_cast<int32_t>(UINT16_MAX)) {
+ return false;
+ }
+ }
+ return true;
}
bool operator==(const RecodedCharID &other) const {
if (length_ != other.length_) {
@@ -190,6 +200,7 @@ public:
int DecodeUnichar(const RecodedCharID &code) const;
// Returns true if the given code is a valid start or single code.
bool IsValidFirstCode(int code) const {
+ ASSERT_HOST(code >= 0 && code < code_range_);
return is_valid_start_[code];
}
// Returns a list of valid non-final next codes for a given prefix code,
diff --git a/unittest/recoder_test.cc b/unittest/recoder_test.cc
new file mode 100644
index 00000000..48dfa7ea
--- /dev/null
+++ b/unittest/recoder_test.cc
@@ -0,0 +1,91 @@
+///////////////////////////////////////////////////////////////////////
+// File: recoder_test.cc
+// Description: Tests that a UnicharCompress (LSTM recoder) with code
+// values outside the sane range is rejected at load.
+// Negative code values leave code_range_ at zero, so
+// SetupDecoder writes is_valid_start_[code(0)] out of
+// bounds on a size-0 vector<bool>; huge code values wrap
+// code_range_ and make resize() throw.
+//
+// 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 "unicharcompress.h"
+
+#include <cstdint>
+#include <vector>
+
+namespace tesseract {
+namespace {
+
+// Appends raw little-endian values to a byte buffer.
+class ByteWriter {
+public:
+ void PutU8(uint32_t v) { data_.push_back(static_cast<char>(v & 0xFF)); }
+ void PutU32(uint32_t v) {
+ for (int i = 0; i < 4; ++i) {
+ data_.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
+ }
+ }
+ void PutS32(int32_t v) { PutU32(static_cast<uint32_t>(v)); }
+ const std::vector<char> &data() const { return data_; }
+
+private:
+ std::vector<char> data_;
+};
+
+// A serialized UnicharCompress with one length-1 RecodedCharID per
+// given code value (self-normalizing).
+std::vector<char> MakeRecoder(const std::vector<int32_t> &codes) {
+ ByteWriter w;
+ w.PutU32(codes.size());
+ for (int32_t code : codes) {
+ w.PutU8(1); // self_normalized_
+ w.PutU32(1); // length_
+ w.PutS32(code); // code_[0]
+ }
+ return w.data();
+}
+
+// A recoder code of -1 keeps code_range_ at 0, so on unpatched code
+// SetupDecoder performs an out-of-bounds write into the size-0
+// is_valid_start_ vector<bool>.
+TEST(RecoderTest, RejectsNegativeCode) {
+ std::vector<char> bytes = MakeRecoder({-1});
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ UnicharCompress recoder;
+ EXPECT_FALSE(recoder.DeSerialize(&fp));
+}
+
+// A recoder code of INT32_MAX wraps code_range_ to a negative value,
+// so on unpatched code SetupDecoder's resize() throws.
+TEST(RecoderTest, RejectsHugeCode) {
+ std::vector<char> bytes = MakeRecoder({INT32_MAX});
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ UnicharCompress recoder;
+ EXPECT_FALSE(recoder.DeSerialize(&fp));
+}
+
+// A valid recoder must still be accepted and usable.
+TEST(RecoderTest, AcceptsValidCodes) {
+ std::vector<char> bytes = MakeRecoder({0, 1});
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ UnicharCompress recoder;
+ ASSERT_TRUE(recoder.DeSerialize(&fp));
+ EXPECT_EQ(recoder.code_range(), 2);
+ EXPECT_TRUE(recoder.IsValidFirstCode(0));
+ EXPECT_TRUE(recoder.IsValidFirstCode(1));
+}
+
+} // namespace
+} // namespace tesseract
@@ -1,184 +0,0 @@
From 79bf602b0cd5216c125f86eaf3c768daae256930 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Fri, 21 Aug 2026 20:29:37 +0200
Subject: [PATCH] Validate vector counts in GenericVector::read
The callback form of GenericVector::read read two independent int32
fields from the file: reserved sized the allocation via reserve(),
while size_used_ drove the element loop. Neither was capped and no
size_used_ <= reserved invariant was checked, so a crafted
.traineddata (e.g. the fontinfo table of a version >= 4 inttemp
component) performed a heap out-of-bounds write during legacy
engine initialization.
Key changes:
- genericvector.h: reject negative or over-limit reserved
(matching the 50000000 cap of the DeSerialize overloads) and
reject size_used_ < 0 or size_used_ > reserved before entering
the read loop. Legit files always satisfy size_used_ <= reserved,
as write() persists size_reserved_ first.
- unittest: add genericvector_test covering size_used_ beyond
reserved (on unpatched code the ASan build dies on a
heap-buffer-overflow in the read loop), negative counts, and a
consistent vector that must still load.
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>
(cherry picked from commit 56e09ca12e751623fe796ce1554ce704bffd2ef0)
CVE: CVE-2026-88051
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/56e09ca12e751623fe796ce1554ce704bffd2ef0]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 ++
src/ccutil/genericvector.h | 10 ++++
unittest/genericvector_test.cc | 91 ++++++++++++++++++++++++++++++++++
3 files changed, 106 insertions(+)
create mode 100644 unittest/genericvector_test.cc
diff --git a/Makefile.am b/Makefile.am
index cc748455..c67f0f63 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1159,6 +1159,7 @@ check_PROGRAMS += equationdetect_test
endif # !DISABLED_LEGACY_ENGINE
check_PROGRAMS += fileio_test
check_PROGRAMS += fullyconnected_test
+check_PROGRAMS += genericvector_test
check_PROGRAMS += heap_test
check_PROGRAMS += imagedata_test
if !DISABLED_LEGACY_ENGINE
@@ -1298,6 +1299,10 @@ fullyconnected_test_SOURCES = unittest/fullyconnected_test.cc
fullyconnected_test_CPPFLAGS = $(unittest_CPPFLAGS)
fullyconnected_test_LDADD = $(TESS_LIBS)
+genericvector_test_SOURCES = unittest/genericvector_test.cc
+genericvector_test_CPPFLAGS = $(unittest_CPPFLAGS)
+genericvector_test_LDADD = $(TESS_LIBS)
+
heap_test_SOURCES = unittest/heap_test.cc
heap_test_CPPFLAGS = $(unittest_CPPFLAGS)
heap_test_LDADD = $(TESS_LIBS)
diff --git a/src/ccutil/genericvector.h b/src/ccutil/genericvector.h
index 4a5bbe12..cbe1e203 100644
--- a/src/ccutil/genericvector.h
+++ b/src/ccutil/genericvector.h
@@ -654,10 +654,20 @@ bool GenericVector<T>::read(TFile *f, const std::function<bool(TFile *, T *)> &c
if (f->FReadEndian(&reserved, sizeof(reserved), 1) != 1) {
return false;
}
+ // Arbitrarily limit the number of elements to protect against bad data.
+ const uint32_t limit = 50000000;
+ if (reserved < 0 || static_cast<uint32_t>(reserved) > limit) {
+ return false;
+ }
reserve(reserved);
if (f->FReadEndian(&size_used_, sizeof(size_used_), 1) != 1) {
return false;
}
+ // size_used_ is an independent file field; without this check the reads
+ // below land past the end of the buffer sized from reserved.
+ if (size_used_ < 0 || size_used_ > reserved) {
+ return false;
+ }
if (cb != nullptr) {
for (int i = 0; i < size_used_; ++i) {
if (!cb(f, data_ + i)) {
diff --git a/unittest/genericvector_test.cc b/unittest/genericvector_test.cc
new file mode 100644
index 00000000..269b00f0
--- /dev/null
+++ b/unittest/genericvector_test.cc
@@ -0,0 +1,91 @@
+///////////////////////////////////////////////////////////////////////
+// File: genericvector_test.cc
+// Description: Tests that the callback form of GenericVector::read
+// rejects vectors whose size_used_ exceeds reserved (or
+// whose counts are negative). reserved sizes the buffer
+// while size_used_ is an independent file field driving
+// the element loop, so a crafted .traineddata (e.g. the
+// fontinfo table of a version >= 4 inttemp component)
+// performs a heap out-of-bounds write 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 "genericvector.h"
+#include "serialis.h" // for TFile
+
+#include <cstdint>
+#include <vector>
+
+namespace tesseract {
+namespace {
+
+// Appends raw little-endian values to a byte buffer.
+class ByteWriter {
+public:
+ void PutS32(int32_t v) {
+ uint32_t u = static_cast<uint32_t>(v);
+ for (int i = 0; i < 4; ++i) {
+ data_.push_back(static_cast<char>((u >> (8 * i)) & 0xFF));
+ }
+ }
+ const std::vector<char> &data() const { return data_; }
+
+private:
+ std::vector<char> data_;
+};
+
+// A serialized vector header (reserved, size_used_) followed by the
+// given number of int32 elements.
+std::vector<char> MakeVector(int32_t reserved, int32_t size_used, int32_t num_elements) {
+ ByteWriter w;
+ w.PutS32(reserved);
+ w.PutS32(size_used);
+ for (int32_t i = 0; i < num_elements; ++i) {
+ w.PutS32(i);
+ }
+ return w.data();
+}
+
+// reserved=4 but size_used_=0x10000: on unpatched code the callback
+// loop writes 65536 ints past the 4-int buffer (heap out-of-bounds
+// write).
+TEST(GenericVectorTest, RejectsSizeUsedBeyondReserved) {
+ std::vector<char> bytes = MakeVector(4, 0x10000, 0x10000);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ GenericVector<int> v;
+ EXPECT_FALSE(v.read(&fp, [](TFile *f, int *p) { return f->DeSerialize(p); }));
+}
+
+// Negative counts must be rejected; on unpatched code the read
+// "succeeds" and leaves size_used_ negative.
+TEST(GenericVectorTest, RejectsNegativeCounts) {
+ std::vector<char> bytes = MakeVector(-1, -1, 0);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ GenericVector<int> v;
+ EXPECT_FALSE(v.read(&fp, [](TFile *f, int *p) { return f->DeSerialize(p); }));
+}
+
+// A consistent vector must still be accepted.
+TEST(GenericVectorTest, AcceptsConsistentVector) {
+ std::vector<char> bytes = MakeVector(4, 2, 2);
+ TFile fp;
+ ASSERT_TRUE(fp.Open(bytes.data(), bytes.size()));
+ GenericVector<int> v;
+ ASSERT_TRUE(v.read(&fp, [](TFile *f, int *p) { return f->DeSerialize(p); }));
+ EXPECT_EQ(v.size(), 2);
+ EXPECT_EQ(v[0], 0);
+ EXPECT_EQ(v[1], 1);
+}
+
+} // namespace
+} // namespace tesseract
@@ -1,165 +0,0 @@
From c60ad589ff3a645a3fdf13cb24fe719313145a06 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Fri, 21 Aug 2026 18:34:33 +0200
Subject: [PATCH] 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>
(cherry picked from commit 2d04d640db2e8c7e3bab2369d599343b5a8b8443)
CVE: CVE-2026-88052
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/2d04d640db2e8c7e3bab2369d599343b5a8b8443]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 +++
src/ccutil/unicharset.cpp | 12 ++++++
unittest/unicharset_load_test.cc | 64 ++++++++++++++++++++++++++++++++
3 files changed, 81 insertions(+)
create mode 100644 unittest/unicharset_load_test.cc
diff --git a/Makefile.am b/Makefile.am
index bec9ab0e..22c2c7a0 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
@@ -1,274 +0,0 @@
From 83bbac0975f52a5171776fddcc800a2aaa5206a3 Mon Sep 17 00:00:00 2001
From: Stefan Weil <sw@weilnetz.de>
Date: Fri, 21 Aug 2026 14:38:55 +0200
Subject: [PATCH] Reject empty network stacks in LSTM .traineddata
deserialization
Plumbing::DeSerialize read the network stack size from the untrusted
TESSDATA_LSTM component and only rejected size > 10000. A crafted
.traineddata with a top-level Series/Parallel/Reversed layer with an
empty stack survived load; LSTMRecognizer initialization then called
network_->CacheXScaleFactor(network_->XScaleFactor()), which
dereferences stack_[0] on the empty vector:
- Series: Series::CacheXScaleFactor (series.cpp)
- Parallel/Reversed: inherited Plumbing::XScaleFactor
The virtual call through the wild pointer crashed the process at
initialization (deterministic denial of service).
Key changes:
- plumbing.cpp: reject size == 0 for all plumbing types, and
size < 2 for NT_SERIES (Series::Forward requires two or more
networks and always aborts on one).
- tessedit.cpp: fail the language load gracefully when the LSTM model
cannot be loaded, instead of aborting via ASSERT_HOST, so
TessBaseAPI::Init returns -1 on corrupt traineddata.
- unittest: add plumbing_test, which builds a minimal traineddata
with empty/undersized LSTM plumbing stacks and expects a graceful
init failure. On unpatched code the tests die on the original
SEGV in Series::CacheXScaleFactor / Plumbing::XScaleFactor.
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>
(cherry picked from commit 552771236b0d80cbdb0c7dd856120fa21a4672e5)
CVE: CVE-2026-88054
Upstream-Status: Backport [https://github.com/tesseract-ocr/tesseract/commit/552771236b0d80cbdb0c7dd856120fa21a4672e5]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
Makefile.am | 5 ++
src/ccmain/tessedit.cpp | 7 +-
src/lstm/plumbing.cpp | 6 ++
unittest/plumbing_test.cc | 165 ++++++++++++++++++++++++++++++++++++++
4 files changed, 182 insertions(+), 1 deletion(-)
create mode 100644 unittest/plumbing_test.cc
diff --git a/Makefile.am b/Makefile.am
index c67f0f63..a6ee0931 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1198,6 +1198,7 @@ if ENABLE_TRAINING
check_PROGRAMS += pango_font_info_test
endif # ENABLE_TRAINING
check_PROGRAMS += paragraphs_test
+check_PROGRAMS += plumbing_test
if !DISABLED_LEGACY_ENGINE
check_PROGRAMS += params_model_test
endif # !DISABLED_LEGACY_ENGINE
@@ -1427,6 +1428,10 @@ paragraphs_test_SOURCES = unittest/paragraphs_test.cc
paragraphs_test_CPPFLAGS = $(unittest_CPPFLAGS)
paragraphs_test_LDADD = $(TESS_LIBS)
+plumbing_test_SOURCES = unittest/plumbing_test.cc
+plumbing_test_CPPFLAGS = $(unittest_CPPFLAGS)
+plumbing_test_LDADD = $(TESS_LIBS)
+
if !DISABLED_LEGACY_ENGINE
params_model_test_SOURCES = unittest/params_model_test.cc
params_model_test_CPPFLAGS = $(unittest_CPPFLAGS)
diff --git a/src/ccmain/tessedit.cpp b/src/ccmain/tessedit.cpp
index 7ec89bd9..722ed09b 100644
--- a/src/ccmain/tessedit.cpp
+++ b/src/ccmain/tessedit.cpp
@@ -169,7 +169,12 @@ bool Tesseract::init_tesseract_lang_data(const std::string &language, OcrEngineM
#endif // ndef DISABLED_LEGACY_ENGINE
if (mgr->IsComponentAvailable(TESSDATA_LSTM)) {
lstm_recognizer_ = new LSTMRecognizer(language_data_path_prefix.c_str());
- ASSERT_HOST(lstm_recognizer_->Load(this->params(), lstm_use_matrix ? language : "", mgr));
+ if (!lstm_recognizer_->Load(this->params(), lstm_use_matrix ? language : "", mgr)) {
+ delete lstm_recognizer_;
+ lstm_recognizer_ = nullptr;
+ tprintf("Error: Failed to load the LSTM model from %s\n", tessdata_path.c_str());
+ return false;
+ }
} else {
#ifdef DISABLED_LEGACY_ENGINE
// The legacy engine is compiled out, so we cannot fall back to it.
diff --git a/src/lstm/plumbing.cpp b/src/lstm/plumbing.cpp
index f0133148..15cf3d62 100644
--- a/src/lstm/plumbing.cpp
+++ b/src/lstm/plumbing.cpp
@@ -226,6 +226,12 @@ bool Plumbing::DeSerialize(TFile *fp) {
if (size > 10000) {
return false;
}
+ // Reject empty stacks: XScaleFactor, CacheXScaleFactor and other methods
+ // unconditionally dereference stack_[0] during network initialization.
+ // A Series needs at least two networks (see Series::Forward).
+ if (size == 0 || (type() == NT_SERIES && size == 1)) {
+ return false;
+ }
for (uint32_t i = 0; i < size; ++i) {
Network *network = CreateFromFile(fp);
if (network == nullptr) {
diff --git a/unittest/plumbing_test.cc b/unittest/plumbing_test.cc
new file mode 100644
index 00000000..27486f86
--- /dev/null
+++ b/unittest/plumbing_test.cc
@@ -0,0 +1,165 @@
+///////////////////////////////////////////////////////////////////////
+// File: plumbing_test.cc
+// Description: Tests that a corrupt TESSDATA_LSTM component in a
+// .traineddata file is rejected without crashing. A
+// plumbing layer (Series/Parallel/Reversed) with an
+// empty or undersized network stack would make
+// XScaleFactor/CacheXScaleFactor dereference stack_[0]
+// during 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 <tesseract/baseapi.h>
+
+#include "network.h" // for NetworkType
+#include "tessdatamanager.h" // for TessdataManager, TESSDATA_LSTM
+
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <string>
+#include <vector>
+
+namespace tesseract {
+namespace {
+
+// Minimal unicharset with the special codes (space, Joined, Broken) that
+// UNICHARSET::load_from_file expects, as embedded in the TESSDATA_LSTM
+// component by LSTMRecognizer::Serialize.
+const char kMinUnicharset[] =
+ "3\n"
+ "NULL 0 NULL 0\n"
+ "Joined 7 0,69,188,255,486,1218,0,30,486,1188 Latin 26 0 98 Joined\n"
+ "|Broken|0|1 f 0,69,186,255,892,2138,0,80,892,2058 Common 84 10 84 |Broken|0|1\n";
+
+// Appends raw little-endian values to a byte buffer.
+class ByteWriter {
+public:
+ void PutU8(uint32_t v) { data_.push_back(static_cast<char>(v & 0xFF)); }
+ void PutU32(uint32_t v) {
+ for (int i = 0; i < 4; ++i) {
+ data_.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
+ }
+ }
+ void PutS32(int32_t v) { PutU32(static_cast<uint32_t>(v)); }
+ void PutString(const char *s) {
+ PutU32(static_cast<uint32_t>(std::strlen(s)));
+ data_.insert(data_.end(), s, s + std::strlen(s));
+ }
+ void PutRaw(const char *s) { data_.insert(data_.end(), s, s + std::strlen(s)); }
+ const std::vector<char> &data() const { return data_; }
+
+private:
+ std::vector<char> data_;
+};
+
+// Serialized network header as written by Network::Serialize:
+// int8 type, int8 training, int8 needs_to_backprop, int32 network_flags,
+// int32 ni, int32 no, int32 num_weights, string name.
+void AppendNetworkHeader(ByteWriter *w, NetworkType type) {
+ w->PutU8(static_cast<uint32_t>(type));
+ w->PutU8(0); // training: TS_DISABLED
+ w->PutU8(0); // needs_to_backprop
+ w->PutU32(0); // network_flags
+ w->PutU32(0); // ni
+ w->PutU32(0); // no
+ w->PutU32(0); // num_weights
+ w->PutString(""); // name
+}
+
+// A minimal valid child network (NT_INPUT with a 1x1x1x1 shape).
+void AppendInputChild(ByteWriter *w) {
+ AppendNetworkHeader(w, NT_INPUT);
+ w->PutS32(1); // batch
+ w->PutS32(1); // height
+ w->PutS32(1); // width
+ w->PutS32(1); // depth
+ w->PutS32(0); // loss type
+}
+
+// Builds a TESSDATA_LSTM component whose top-level network is a plumbing
+// layer of the given type with the given (corrupt) stack size, followed by
+// the remaining fields of LSTMRecognizer::DeSerialize.
+std::vector<char> MakeLstmComponent(NetworkType type, uint32_t stack_size) {
+ ByteWriter w;
+ AppendNetworkHeader(&w, type);
+ w.PutU32(stack_size); // Plumbing::DeSerialize reads this as uint32
+ for (uint32_t i = 0; i < stack_size; ++i) {
+ AppendInputChild(&w);
+ }
+ w.PutRaw(kMinUnicharset); // unicharset (raw text, no recoder/unicharset components)
+ w.PutString(""); // network_str_
+ w.PutS32(0); // training_flags_
+ w.PutS32(0); // training_iteration_
+ w.PutS32(0); // sample_iteration_
+ w.PutS32(0); // null_char_
+ w.PutU32(0); // adam_beta_ (float 0.0)
+ w.PutU32(0); // learning_rate_ (float 0.0)
+ w.PutU32(0); // momentum_ (float 0.0)
+ return w.data();
+}
+
+// Writes a traineddata file with the given (corrupt) LSTM component to
+// dir/eng.traineddata.
+bool WriteCorruptTraineddata(const std::string &dir, const std::vector<char> &lstm) {
+ TessdataManager mgr;
+ mgr.OverwriteEntry(TESSDATA_LSTM, lstm.data(), static_cast<int>(lstm.size()));
+ return mgr.SaveFile((dir + "/eng.traineddata").c_str(), nullptr);
+}
+
+class PlumbingTest : public testing::Test {
+protected:
+ void SetUp() override {
+ tmpl_ = "/tmp/tess_plumbing_test_XXXXXX";
+ char *dir = mkdtemp(tmpl_.data());
+ ASSERT_NE(dir, nullptr);
+ dir_ = dir;
+ }
+ void TearDown() override {
+ std::remove((dir_ + "/eng.traineddata").c_str());
+ rmdir(dir_.c_str());
+ }
+ // Expects the LSTM engine to reject the corrupted traineddata
+ // gracefully (init failure) instead of crashing.
+ void ExpectInitFails(const std::vector<char> &lstm) {
+ ASSERT_TRUE(WriteCorruptTraineddata(dir_, lstm));
+ tesseract::TessBaseAPI api;
+ EXPECT_EQ(api.Init(dir_.c_str(), "eng", tesseract::OEM_LSTM_ONLY), -1);
+ }
+ std::string dir_;
+ std::string tmpl_;
+};
+
+// Empty NT_SERIES stack: Series::CacheXScaleFactor would dereference
+// stack_[0] on the empty vector during initialization.
+TEST_F(PlumbingTest, RejectsEmptySeriesStack) {
+ ExpectInitFails(MakeLstmComponent(NT_SERIES, 0));
+}
+
+// Empty NT_PARALLEL stack: Plumbing::XScaleFactor would dereference
+// stack_[0] on the empty vector during initialization.
+TEST_F(PlumbingTest, RejectsEmptyParallelStack) {
+ ExpectInitFails(MakeLstmComponent(NT_PARALLEL, 0));
+}
+
+// Empty NT_XREVERSED stack: same crash as the parallel case.
+TEST_F(PlumbingTest, RejectsEmptyReversedStack) {
+ ExpectInitFails(MakeLstmComponent(NT_XREVERSED, 0));
+}
+
+// A Series with a single network: Series::Forward requires at least two
+// networks, so such a model can never work.
+TEST_F(PlumbingTest, RejectsSingleNetworkSeries) {
+ ExpectInitFails(MakeLstmComponent(NT_SERIES, 1));
+}
+
+} // namespace
+} // namespace tesseract
@@ -5,15 +5,8 @@ BUGTRACKER = "https://github.com/tesseract-ocr/tesseract/issues"
LICENSE = "Apache-2.0"
LIC_FILES_CHKSUM = "file://LICENSE;md5=3b83ef96387f14655fc854ddc3c6bd57"
SRCREV = "db0ec62f81b0737fbbe184d8fea40af5738f8eef"
SRCREV = "7e548e6eff172f38f97664afe7f7ce40db36c8d6"
SRC_URI = "git://github.com/${BPN}-ocr/${BPN}.git;branch=main;protocol=https;tag=${PV} \
file://CVE-2026-88052.patch \
file://CVE-2026-88048.patch \
file://CVE-2026-88049.patch \
file://CVE-2026-88050.patch \
file://CVE-2026-88047.patch \
file://CVE-2026-88051.patch \
file://CVE-2026-88054.patch \
"
DEPENDS = "leptonica"