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