Commit a95faa00 for tesseract

commit a95faa0078bcbb59357468f81e079814f8f28e74
Author: mardgez <mar@altivon.us>
Date:   Tue Oct 27 21:27:00 2026 +0700

    Fix use-after-free in ReplaceCurrentWord with several languages (#4667)

    With several languages (for example -l eng+chi_tra), a language whose
    recognizer returns no words for a word passes back its copy of the
    input word, made by WERD_RES::InitForRetryRecognition. That copy shares
    the input WERD without owning it. SelectBestWords can keep it beside
    the LSTM words of another language, and classify_word_and_language then
    passes them all to PAGE_RES_IT::ReplaceCurrentWord.

    ReplaceCurrentWord used the shared WERD's blob list as the source of
    the real blobs and as that word's destination at once, linked the WERD
    into the row a second time (STILL_LINKED in debug builds) and deleted
    it with the input word. For an input word that is a fuzzy-space
    combination made by ROW_RES, the WERD was deleted with the
    combination's WERD_RES. Either way the replacement kept pointing at
    freed memory, which recog_all_words then read (heap-use-after-free
    under ASan, a crash or the "!w_it.cycled_list()" assertion in
    DeleteCurrentWord otherwise).

    Give such a word a WERD of its own before the blobs are moved: a
    shallow copy of the input word with a fake blob at each of its blobs,
    owned by the word like the words from the LSTM recognizer.

    The new pageres_test covers both kinds of input word. The images from
    both issues crash on main under ASan with the language pairs given
    there (fast models) and run cleanly with this change.

    Fixes #4361
    Fixes #4362

    Co-authored-by: Mar <mardgez13@gmail.com>

diff --git a/Makefile.am b/Makefile.am
index fb69ab6f..63613275 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1208,6 +1208,7 @@ check_PROGRAMS += nthitem_test
 if !DISABLED_LEGACY_ENGINE
 check_PROGRAMS += osd_test
 endif # !DISABLED_LEGACY_ENGINE
+check_PROGRAMS += pageres_test
 check_PROGRAMS += pagesegmode_test
 if ENABLE_TRAINING
 check_PROGRAMS += pango_font_info_test
@@ -1473,6 +1474,10 @@ osd_test_CPPFLAGS = $(unittest_CPPFLAGS)
 osd_test_LDADD = $(TESS_LIBS) $(LEPTONICA_LIBS)
 endif # !DISABLED_LEGACY_ENGINE

+pageres_test_SOURCES = unittest/pageres_test.cc
+pageres_test_CPPFLAGS = $(unittest_CPPFLAGS)
+pageres_test_LDADD = $(TESS_LIBS)
+
 pagesegmode_test_SOURCES = unittest/pagesegmode_test.cc
 pagesegmode_test_CPPFLAGS = $(unittest_CPPFLAGS)
 pagesegmode_test_LDADD = $(TRAINING_LIBS) $(LEPTONICA_LIBS)
diff --git a/src/ccstruct/pageres.cpp b/src/ccstruct/pageres.cpp
index 1dcb536c..afe7552a 100644
--- a/src/ccstruct/pageres.cpp
+++ b/src/ccstruct/pageres.cpp
@@ -1387,6 +1387,27 @@ void PAGE_RES_IT::ReplaceCurrentWord(
     return;
   }
   WERD_RES *input_word = word();
+  // With several languages, a language whose recognizer returns no words
+  // passes back its copy of the input word (made by
+  // WERD_RES::InitForRetryRecognition), which shares the input WERD. That
+  // WERD is deleted below, so give such a word a WERD of its own, with a
+  // fake blob at each blob of the input word, like the words from LSTM.
+  for (size_t w = 0; w < words->size(); ++w) {
+    WERD_RES *word_w = (*words)[w];
+    if (word_w->word == input_word->word) {
+      WERD *new_word = input_word->word->shallow_copy();
+      new_word->set_flag(W_BOL, false);
+      new_word->set_flag(W_EOL, false);
+      C_BLOB_IT src_it(input_word->word->cblob_list());
+      C_BLOB_IT dest_it(new_word->cblob_list());
+      for (src_it.mark_cycle_pt(); !src_it.cycled_list(); src_it.forward()) {
+        dest_it.add_after_then_move(
+            C_BLOB::FakeBlob(src_it.data()->bounding_box()));
+      }
+      word_w->word = new_word;
+      word_w->combination = true; // It owns its word.
+    }
+  }
   // Set the BOL/EOL flags on the words from the input word.
   if (input_word->word->flag(W_BOL)) {
     (*words)[0]->word->set_flag(W_BOL, true);
diff --git a/unittest/pageres_test.cc b/unittest/pageres_test.cc
new file mode 100644
index 00000000..b0289ecf
--- /dev/null
+++ b/unittest/pageres_test.cc
@@ -0,0 +1,190 @@
+// 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
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "pageres.h"
+
+#include "boxword.h"
+#include "ocrblock.h"
+#include "ocrrow.h"
+#include "rect.h"
+#include "stepblob.h"
+#include "unicharset.h"
+#include "werd.h"
+
+#include "include_gunit.h"
+
+#include <set>
+#include <vector>
+
+namespace tesseract {
+
+namespace {
+
+// Makes a word of rectangular blobs, one per box.
+WERD *MakeWord(const std::vector<TBOX> &boxes) {
+  C_BLOB_LIST blobs;
+  C_BLOB_IT b_it(&blobs);
+  for (const auto &box : boxes) {
+    b_it.add_after_then_move(C_BLOB::FakeBlob(box));
+  }
+  return new WERD(&blobs, 1, nullptr);
+}
+
+// Adds a block to blocks, with one row holding the given words.
+void AddRow(BLOCK_LIST *blocks, const std::vector<WERD *> &words) {
+  int32_t xstarts[] = {0, 200};
+  double coeffs[] = {0.0, 0.0, 10.0};
+  auto *row = new ROW(1, xstarts, coeffs, 20.0f, 10.0f, -5.0f, 0, 10);
+  WERD_IT w_it(row->word_list());
+  for (auto *word : words) {
+    w_it.add_to_end(word);
+  }
+  row->recalc_bounding_box();
+  auto *block = new BLOCK("", true, 0, 0, 0, 0, 200, 50);
+  ROW_IT row_it(block->row_list());
+  row_it.add_to_end(row);
+  BLOCK_IT block_it(blocks);
+  block_it.add_to_end(block);
+}
+
+// Makes a word as the LSTM recognizer gives it: a combination that owns a
+// word with one fake blob per character.
+WERD_RES *MakeLSTMWord(const std::vector<TBOX> &boxes,
+                       const UNICHARSET &unicharset) {
+  auto *word_res = new WERD_RES(MakeWord(boxes));
+  word_res->combination = true;
+  word_res->SetupFake(unicharset);
+  return word_res;
+}
+
+// Makes the word a language gives back when its LSTM recognizer finds no
+// words: its copy of the input word (WordData::lang_words), with the fake
+// result of WERD_RES::SetupFake.
+WERD_RES *MakeWholeWord(const WERD_RES &input, const UNICHARSET &unicharset) {
+  auto *word_res = new WERD_RES;
+  word_res->InitForRetryRecognition(input);
+  word_res->SetupFake(unicharset);
+  return word_res;
+}
+
+// Checks that each word of the row has one owner: a combination owns its
+// word, any other word is on the row's word list once, and no two words of
+// the row share a WERD.
+void ExpectEachWordOwnedOnce(ROW_RES *row_res) {
+  WERD_LIST *row_words = row_res->row->word_list();
+  std::set<const WERD *> seen;
+  int non_combinations = 0;
+  WERD_RES_IT wr_it(&row_res->word_res_list);
+  for (wr_it.mark_cycle_pt(); !wr_it.cycled_list(); wr_it.forward()) {
+    const WERD_RES *word_res = wr_it.data();
+    EXPECT_TRUE(seen.insert(word_res->word).second);
+    int on_row = 0;
+    WERD_IT w_it(row_words);
+    for (w_it.mark_cycle_pt(); !w_it.cycled_list(); w_it.forward()) {
+      if (w_it.data() == word_res->word) {
+        ++on_row;
+      }
+    }
+    if (word_res->combination) {
+      EXPECT_EQ(0, on_row);
+    } else {
+      EXPECT_EQ(1, on_row);
+      ++non_combinations;
+    }
+  }
+  EXPECT_EQ(non_combinations, row_words->length());
+}
+
+const std::vector<TBOX> kInputBlobs = {
+    TBOX(10, 10, 20, 30), TBOX(25, 10, 35, 30), TBOX(40, 10, 50, 30)};
+
+// With several languages, classify_word_and_language can replace a word
+// with LSTM words of one language mixed with the whole input word of
+// another, which shares the input WERD. ReplaceCurrentWord must not link
+// that WERD into the row a second time and then delete it.
+TEST(PageResTest, ReplaceCurrentWordCopiesSharedInputWord) {
+  UNICHARSET unicharset;
+  BLOCK_LIST blocks;
+  WERD *input_werd = MakeWord(kInputBlobs);
+  input_werd->set_flag(W_BOL, true);
+  input_werd->set_flag(W_EOL, true);
+  AddRow(&blocks, {input_werd});
+  PAGE_RES page_res(false, &blocks, nullptr);
+  PAGE_RES_IT it(&page_res);
+  ASSERT_EQ(input_werd, it.word()->word);
+
+  WERD_RES *whole = MakeWholeWord(*it.word(), unicharset);
+  WERD_RES *lstm = MakeLSTMWord({TBOX(60, 10, 70, 30)}, unicharset);
+  PointerVector<WERD_RES> words;
+  words.push_back(whole);
+  words.push_back(lstm);
+  it.ReplaceCurrentWord(&words);
+
+  // The input WERD is deleted, so no replacement may still use it. Only the
+  // pointer values are compared, so this fails cleanly without the fix.
+  ASSERT_NE(input_werd, whole->word);
+  ExpectEachWordOwnedOnce(it.row());
+  EXPECT_FALSE(whole->combination);
+  EXPECT_FALSE(lstm->combination);
+  // The whole word gets the input blobs back, one per character.
+  EXPECT_EQ(3, whole->word->cblob_list()->length());
+  EXPECT_EQ(3u, whole->box_word->length());
+  EXPECT_EQ(TBOX(10, 10, 50, 30), whole->word->bounding_box());
+  EXPECT_TRUE(whole->word->flag(W_BOL));
+  EXPECT_FALSE(whole->word->flag(W_EOL));
+  EXPECT_FALSE(lstm->word->flag(W_BOL));
+  EXPECT_TRUE(lstm->word->flag(W_EOL));
+
+  it.restart_page();
+  EXPECT_EQ(whole, it.word());
+  EXPECT_EQ(lstm, it.forward());
+  EXPECT_EQ(nullptr, it.forward());
+}
+
+// The same, with an input word that is a combination of fuzzy-spaced words
+// (made by ROW_RES), which owns its WERD and deletes it with itself.
+TEST(PageResTest, ReplaceCurrentWordCopiesSharedCombinationWord) {
+  UNICHARSET unicharset;
+  BLOCK_LIST blocks;
+  WERD *first = MakeWord({kInputBlobs[0], kInputBlobs[1]});
+  WERD *second = MakeWord({kInputBlobs[2]});
+  second->set_flag(W_FUZZY_NON, true);
+  AddRow(&blocks, {first, second});
+  PAGE_RES page_res(false, &blocks, nullptr);
+  PAGE_RES_IT it(&page_res);
+  ASSERT_TRUE(it.word()->combination);
+  const WERD *input_werd = it.word()->word;
+
+  WERD_RES *whole = MakeWholeWord(*it.word(), unicharset);
+  WERD_RES *lstm = MakeLSTMWord({TBOX(60, 10, 70, 30)}, unicharset);
+  PointerVector<WERD_RES> words;
+  words.push_back(whole);
+  words.push_back(lstm);
+  it.ReplaceCurrentWord(&words);
+
+  // The input WERD is deleted, so no replacement may still use it.
+  ASSERT_NE(input_werd, whole->word);
+  ExpectEachWordOwnedOnce(it.row());
+  EXPECT_TRUE(whole->combination);
+  EXPECT_TRUE(lstm->combination);
+  EXPECT_EQ(3, whole->word->cblob_list()->length());
+  EXPECT_EQ(TBOX(10, 10, 50, 30), whole->word->bounding_box());
+  // The words of the fuzzy-spaced combination stay on the row.
+  EXPECT_EQ(2, it.row()->row->word_list()->length());
+
+  it.restart_page();
+  EXPECT_EQ(whole, it.word());
+  EXPECT_EQ(lstm, it.forward());
+  EXPECT_EQ(nullptr, it.forward());
+}
+
+} // namespace
+
+} // namespace tesseract