Commit 0d599d96 for tesseract
commit 0d599d9607f3b8a50fbb606b2ec7ea9dcfabd8bb
Author: Stefan Weil <sw@weilnetz.de>
Date: Tue Aug 25 21:55:54 2026 +0200
Make IndexMapBiDi index handling bounds-safe
IndexMapBiDi::DeSerialize used the compact and sparse indices read
from the file directly as subscripts of sparse_map_, so a crafted or
corrupt .traineddata with an out-of-range value performed a heap
out-of-bounds write during deserialization. DeSerialize now validates
every index before use and rejects such data, including
remaining_pairs with an odd element count, which previously read one
element past the end of the vector. It also rejects files in which a
sparse slot is claimed by more than one compact representative or
remaining pair: such a file can encode a master cycle (for example
sparse_map_ = [1, 0]) that would make MasterCompactIndex loop forever.
Copilot's review of this PR suggested the duplicate-claim check and
the empty base-map test case.
The public index accessors had the same unchecked-subscript pattern:
IndexMapBiDi::SparseToCompact and IndexMap::CompactToSparse read
outside their vectors for out-of-range indices, Merge subscripted both
maps with unchecked compact indices, MapFeatures read sparse_map_ with
unchecked feature indices, and IsCompactDeleted could chase an
out-of-range master index. They now return the not-mapped / not-merged
/ missed-feature result for out-of-range input instead of invoking
undefined behavior. The Merge(-1, index) merge-away sentinel used by
IntFeatureMap is still accepted. IndexMap::SparseToCompact no longer
reads compact_map_ when the map is empty.
Key changes:
- indexmapbidi.cpp: validate compact_map_ entries and remaining_pairs
in DeSerialize before subscripting sparse_map_, including duplicate
sparse slot claims that could encode a master cycle; guard Merge and
MapFeatures; reject an empty compact_map_ in IndexMap::SparseToCompact.
- indexmapbidi.h: bounds-check IndexMap::CompactToSparse,
IndexMapBiDi::SparseToCompact and IsCompactDeleted.
- unittest: add DeSerializeRejectsBadIndices (crafted blobs with
out-of-range, negative, odd-count, duplicate-claim and cyclic
indices) and AccessorsRejectBadIndices, which also covers the
empty-map guards in both SparseToCompact implementations, plus a
many-to-one serialize/deserialize round trip as a positive control.
On unpatched code the new tests die on ASan heap-buffer-overflow
write in DeSerialize and reads in SparseToCompact.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Assisted-by: OpenCode / qwen3.8-27b-thinking (Alibaba Cloud)
Signed-off-by: Stefan Weil <sw@weilnetz.de>
diff --git a/src/ccutil/indexmapbidi.cpp b/src/ccutil/indexmapbidi.cpp
index fb5d20e6..d5a8e2f3 100644
--- a/src/ccutil/indexmapbidi.cpp
+++ b/src/ccutil/indexmapbidi.cpp
@@ -31,6 +31,9 @@ IndexMap::~IndexMap() = default;
// Uses a binary search to find the result. For faster speed use
// IndexMapBiDi, but that takes more memory.
int IndexMap::SparseToCompact(int sparse_index) const {
+ if (compact_map_.empty()) {
+ return -1;
+ }
auto pos = std::upper_bound(compact_map_.begin(), compact_map_.end(), sparse_index);
if (pos > compact_map_.begin()) {
--pos;
@@ -142,6 +145,16 @@ void IndexMapBiDi::CopyFrom(const IndexMapBiDi &src) {
// the merges must be concluded by a call to CompleteMerges.
// Returns true if a merge was actually performed.
bool IndexMapBiDi::Merge(int compact_index1, int compact_index2) {
+ // A compact index may be -1 (meaning "merge away") or in [0, compact size).
+ const bool index1_ok =
+ compact_index1 == -1 ||
+ (compact_index1 >= 0 && static_cast<size_t>(compact_index1) < compact_map_.size());
+ const bool index2_ok =
+ compact_index2 == -1 ||
+ (compact_index2 >= 0 && static_cast<size_t>(compact_index2) < compact_map_.size());
+ if (!index1_ok || !index2_ok) {
+ return false;
+ }
// Find the current master index for index1 and index2.
compact_index1 = MasterCompactIndex(compact_index1);
compact_index2 = MasterCompactIndex(compact_index2);
@@ -241,14 +254,42 @@ bool IndexMapBiDi::DeSerialize(bool swap, FILE *fp) {
if (!tesseract::DeSerialize(swap, fp, remaining_pairs)) {
return false;
}
+ // The indices in the file are untrusted. Validate them before using them
+ // as subscripts, so that corrupt or crafted data is rejected instead of
+ // writing outside sparse_map_ (or leaving invalid compact indices behind).
+ // Each sparse slot may be claimed by at most one compact representative
+ // or one remaining pair, as in any map produced by Setup/CompleteMerges;
+ // duplicate claims would let a crafted file encode a master cycle that
+ // makes MasterCompactIndex loop forever.
+ const size_t sparse_size = static_cast<size_t>(sparse_size_);
+ std::vector<uint8_t> claimed(sparse_size, 0);
+ for (int32_t sparse_index : compact_map_) {
+ if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_size ||
+ claimed[sparse_index]) {
+ return false;
+ }
+ claimed[sparse_index] = 1;
+ }
+ if (remaining_pairs.size() % 2 != 0) {
+ return false;
+ }
+ for (size_t i = 0; i < remaining_pairs.size(); i += 2) {
+ const int32_t sparse_index = remaining_pairs[i];
+ const int32_t compact_index = remaining_pairs[i + 1];
+ if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_size ||
+ compact_index < 0 || static_cast<size_t>(compact_index) >= compact_map_.size() ||
+ claimed[sparse_index]) {
+ return false;
+ }
+ claimed[sparse_index] = 1;
+ }
sparse_map_.clear();
- sparse_map_.resize(sparse_size_, -1);
+ sparse_map_.resize(sparse_size, -1);
for (unsigned i = 0; i < compact_map_.size(); ++i) {
sparse_map_[compact_map_[i]] = i;
}
- for (size_t i = 0; i < remaining_pairs.size(); ++i) {
- int sparse_index = remaining_pairs[i++];
- sparse_map_[sparse_index] = remaining_pairs[i];
+ for (size_t i = 0; i < remaining_pairs.size(); i += 2) {
+ sparse_map_[remaining_pairs[i]] = remaining_pairs[i + 1];
}
return true;
}
@@ -264,7 +305,13 @@ int IndexMapBiDi::MapFeatures(const std::vector<int> &sparse, std::vector<int> *
int missed_features = 0;
int prev_good_feature = -1;
for (int f = 0; f < num_features; ++f) {
- int feature = sparse_map_[sparse[f]];
+ const int sparse_index = sparse[f];
+ if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_map_.size()) {
+ // A feature outside the sparse space cannot map to the compact space.
+ ++missed_features;
+ continue;
+ }
+ int feature = sparse_map_[sparse_index];
if (feature >= 0) {
if (feature != prev_good_feature) {
compact->push_back(feature);
diff --git a/src/ccutil/indexmapbidi.h b/src/ccutil/indexmapbidi.h
index e14ad12c..2526b4bc 100644
--- a/src/ccutil/indexmapbidi.h
+++ b/src/ccutil/indexmapbidi.h
@@ -53,6 +53,9 @@ public:
// CompactToSparse takes a compact index to the corresponding index in the
// sparse space.
int CompactToSparse(int compact_index) const {
+ if (compact_index < 0 || static_cast<size_t>(compact_index) >= compact_map_.size()) {
+ return -1;
+ }
return compact_map_[compact_index];
}
// The size of the sparse space.
@@ -130,6 +133,10 @@ public:
bool Merge(int compact_index1, int compact_index2);
// Returns true if the given compact index has been deleted.
bool IsCompactDeleted(int index) const {
+ // An index outside the compact space is not a live compact index.
+ if (index < 0 || static_cast<size_t>(index) >= compact_map_.size()) {
+ return true;
+ }
return MasterCompactIndex(index) < 0;
}
// Completes one or more Merge operations by further compacting the
@@ -138,6 +145,9 @@ public:
// SparseToCompact takes a sparse index to an index in the compact space.
int SparseToCompact(int sparse_index) const override {
+ if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_map_.size()) {
+ return -1;
+ }
return sparse_map_[sparse_index];
}
// The size of the sparse space.
diff --git a/unittest/indexmapbidi_test.cc b/unittest/indexmapbidi_test.cc
index a2502f77..60f1bd9b 100644
--- a/unittest/indexmapbidi_test.cc
+++ b/unittest/indexmapbidi_test.cc
@@ -13,7 +13,9 @@
#include <cstdio>
#include <string>
+#include "helpers.h"
#include "indexmapbidi.h"
+#include "serialis.h"
#include "include_gunit.h"
@@ -118,4 +120,154 @@ TEST_F(IndexMapBiDiTest, ManyToOne) {
EXPECT_EQ(1, map.SparseToCompact(11));
}
+// Writes a raw IndexMapBiDi serialization (sparse size, compact map,
+// remaining pairs) so crafted/invalid data can be fed to DeSerialize.
+static void WriteIndexMapBiDiBlob(const std::string &path, int32_t sparse_size,
+ const std::vector<int32_t> &compact_map,
+ const std::vector<int32_t> &remaining_pairs) {
+ FILE *fp = fopen(path.c_str(), "wb");
+ ASSERT_TRUE(fp != nullptr);
+ ASSERT_TRUE(tesseract::Serialize(fp, &sparse_size));
+ ASSERT_TRUE(tesseract::Serialize(fp, compact_map));
+ ASSERT_TRUE(tesseract::Serialize(fp, remaining_pairs));
+ fclose(fp);
+}
+
+// Indices read from a serialized map are untrusted. Out-of-range values must
+// be rejected instead of writing outside sparse_map_.
+TEST_F(IndexMapBiDiTest, DeSerializeRejectsBadIndices) {
+ // Positive control: a valid many-to-one map round-trips.
+ IndexMapBiDi valid;
+ valid.Init(13, false);
+ valid.SetMap(2, true);
+ valid.SetMap(4, true);
+ valid.SetMap(7, true);
+ valid.SetMap(9, true);
+ valid.SetMap(11, true);
+ valid.Setup();
+ valid.Merge(valid.SparseToCompact(2), valid.SparseToCompact(9));
+ valid.Merge(valid.SparseToCompact(4), valid.SparseToCompact(11));
+ valid.CompleteMerges();
+ const std::string good = OutputNameToPath("good.indexmap");
+ {
+ FILE *fp = fopen(good.c_str(), "wb");
+ ASSERT_TRUE(fp != nullptr);
+ ASSERT_TRUE(valid.Serialize(fp));
+ fclose(fp);
+ }
+ {
+ FILE *fp = fopen(good.c_str(), "rb");
+ ASSERT_TRUE(fp != nullptr);
+ IndexMapBiDi m;
+ ASSERT_TRUE(m.DeSerialize(false, fp));
+ fclose(fp);
+ EXPECT_EQ(13, m.SparseSize());
+ EXPECT_EQ(3, m.CompactSize());
+ EXPECT_EQ(0, m.SparseToCompact(2));
+ EXPECT_EQ(0, m.SparseToCompact(9));
+ }
+
+ auto reject = [this](const std::string &name, int32_t sparse_size,
+ const std::vector<int32_t> &compact_map,
+ const std::vector<int32_t> &remaining_pairs) {
+ const std::string path = OutputNameToPath(name);
+ WriteIndexMapBiDiBlob(path, sparse_size, compact_map, remaining_pairs);
+ FILE *fp = fopen(path.c_str(), "rb");
+ ASSERT_TRUE(fp != nullptr);
+ IndexMapBiDi m;
+ EXPECT_FALSE(m.DeSerialize(false, fp));
+ fclose(fp);
+ };
+
+ // compact_map_ entry outside the sparse space.
+ reject("bad1.indexmap", 2, {5}, {});
+ // Negative compact_map_ entry.
+ reject("bad2.indexmap", 2, {-1}, {});
+ // Remaining pair with a sparse index outside the sparse space.
+ reject("bad3.indexmap", 2, {0}, {5, 0});
+ // Remaining pair with a negative sparse index.
+ reject("bad4.indexmap", 2, {0}, {-1, 0});
+ // Remaining pair with a compact index outside the compact space.
+ reject("bad5.indexmap", 2, {0}, {0, 5});
+ // Remaining pair with a negative compact index.
+ reject("bad6.indexmap", 2, {0}, {0, -1});
+ // Odd number of remaining pairs.
+ reject("bad7.indexmap", 2, {0}, {0});
+ // Cyclic master mapping (0 <-> 1) that would make MasterCompactIndex
+ // loop forever.
+ reject("bad8.indexmap", 2, {0, 1}, {0, 1, 1, 0});
+ // Two compact representatives claiming the same sparse slot.
+ reject("bad9.indexmap", 2, {0, 0}, {});
+}
+
+// Public accessors must not read outside their maps when given bad indices.
+TEST_F(IndexMapBiDiTest, AccessorsRejectBadIndices) {
+ IndexMapBiDi map;
+ map.Init(4, false);
+ map.SetMap(1, true);
+ map.SetMap(3, true);
+ map.Setup();
+ // Sparse space is [0,4); compact space is [0,2) with sparse 1->0, 3->1.
+ EXPECT_EQ(4, map.SparseSize());
+ EXPECT_EQ(2, map.CompactSize());
+
+ // Out-of-range sparse index reports unmapped instead of reading OOB.
+ EXPECT_EQ(-1, map.SparseToCompact(-1));
+ EXPECT_EQ(-1, map.SparseToCompact(4));
+ EXPECT_EQ(-1, map.SparseToCompact(1324324));
+ // In-range behavior is unchanged.
+ EXPECT_EQ(0, map.SparseToCompact(1));
+ EXPECT_EQ(-1, map.SparseToCompact(0)); // unmapped.
+ EXPECT_EQ(1, map.SparseToCompact(3));
+
+ // Out-of-range compact index reports unmapped instead of reading OOB.
+ EXPECT_EQ(-1, map.CompactToSparse(-1));
+ EXPECT_EQ(-1, map.CompactToSparse(2));
+ EXPECT_EQ(1, map.CompactToSparse(0));
+ EXPECT_EQ(3, map.CompactToSparse(1));
+
+ // Out-of-range compact indices are not merged.
+ EXPECT_FALSE(map.Merge(2, 0));
+ EXPECT_FALSE(map.Merge(0, 2));
+ EXPECT_FALSE(map.Merge(0, 1324324));
+ EXPECT_FALSE(map.Merge(-5, 0));
+ // The map is unchanged by the rejected merges.
+ EXPECT_EQ(2, map.CompactSize());
+ EXPECT_EQ(0, map.SparseToCompact(1));
+ EXPECT_EQ(1, map.SparseToCompact(3));
+
+ // Out-of-range compact index is reported as deleted.
+ EXPECT_TRUE(map.IsCompactDeleted(-1));
+ EXPECT_TRUE(map.IsCompactDeleted(2));
+ EXPECT_TRUE(map.IsCompactDeleted(1324324));
+ EXPECT_FALSE(map.IsCompactDeleted(0));
+
+ // Out-of-range features count as missed; valid ones still map.
+ std::vector<int> compact;
+ const int missed = map.MapFeatures({-1, 0, 1, 3, 4}, &compact);
+ EXPECT_EQ(3, missed); // -1 (OOB), 0 (unmapped), 4 (OOB).
+ ASSERT_EQ(2, compact.size());
+ EXPECT_EQ(0, compact[0]);
+ EXPECT_EQ(1, compact[1]);
+
+ // The -1 sentinel (delete a compact index) is still permitted.
+ EXPECT_TRUE(map.Merge(-1, 1));
+ EXPECT_TRUE(map.IsCompactDeleted(1));
+
+ // Empty maps built via the public API must report unmapped for any
+ // index instead of reading past the end of their vectors. The plain
+ // IndexMap is the only way to reach the base-class binary-search
+ // implementation, since the IndexMapBiDi override handles its own
+ // empty check.
+ IndexMapBiDi empty_bidi;
+ empty_bidi.Init(0, false);
+ empty_bidi.Setup();
+ EXPECT_EQ(-1, empty_bidi.SparseToCompact(0));
+ EXPECT_EQ(-1, empty_bidi.CompactToSparse(0));
+ IndexMap empty_base;
+ empty_base.CopyFrom(empty_bidi);
+ EXPECT_EQ(-1, empty_base.SparseToCompact(0));
+ EXPECT_EQ(-1, empty_base.CompactToSparse(0));
+}
+
} // namespace tesseract