Skip to content

Commit bc102d6

Browse files
limqiyingmeta-codesync[bot]
authored andcommitted
Bound IVFPQ precomputed-table allocation at deserialization time (#5464)
Summary: Pull Request resolved: #5464 The `faiss_read_index_binary_fuzzer_ci_diff_time` health-check has been failing (T276499517). Reproduced at HEAD: libFuzzer reports `out-of-memory (malloc(2147483648))` with the stack `AlignedTableTightAlloc<float,32>::resize` <- `initialize_IVFPQ_precomputed_table` <- `read_ivfpq` <- `read_index_binary` (the `IBFf` path). Root cause: `read_ivfpq()` calls `IndexIVFPQ::precompute_table()`, which builds a derived table of `quantizer->ntotal * pq.M * pq.ksub` floats. That table is NOT stored in the serialized index — it is recomputed on load from attacker-controlled header fields — and faiss only capped it at `precomputed_table_max_bytes` (2 GB). Every other derived allocation in `index_read.cpp` is already bounded by the runtime-configurable `get_deserialization_vector_byte_limit()` (which the fuzzer sets to 128 MB), but this precompute path was not, so a tiny crafted header can drive a multi-GB allocation and OOM. Fix: bound `ntotal * pq.M * pq.ksub` by `get_deserialization_vector_byte_limit()` (overflow-safe via `mul_no_overflow`) before calling `precompute_table()`, mirroring the existing `read_ProductQuantizer` guard style. For normal library use the limit defaults to 1 TB, so realistic tables (<= 2 GB) still load exactly as before — no behavioral change. Also adds a deterministic regression test `ReadIndexDeserialize.IVFPQPrecomputedTableExceedsByteLimit` that serializes a real trained IVFPQ and confirms reading it back under a low byte limit throws instead of allocating. Reviewed By: mnorris11, alibeklfc Differential Revision: D111974816 fbshipit-source-id: 89b23905cd1b9406d957471c574afc0c2b1c91a9
1 parent 247527b commit bc102d6

2 files changed

Lines changed: 259 additions & 0 deletions

File tree

faiss/impl/index_read.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,6 +1547,45 @@ ArrayInvertedLists* set_array_invlist(
15471547
return result;
15481548
}
15491549

1550+
static void validate_ivfpq_precomputed_table_size(
1551+
const Index* quantizer,
1552+
const ProductQuantizer& pq) {
1553+
// The precomputed table is not stored; precompute_table() rebuilds it on
1554+
// load at a size derived from attacker-controlled header fields. Bound
1555+
// every table initialize_IVFPQ_precomputed_table() may allocate.
1556+
const size_t m_ksub =
1557+
mul_no_overflow(pq.M, pq.ksub, "IVFPQ precomputed_table");
1558+
// type 1: nlist (== quantizer->ntotal) * pq.M * pq.ksub.
1559+
size_t precompute_elems = mul_no_overflow(
1560+
static_cast<size_t>(quantizer->ntotal),
1561+
m_ksub,
1562+
"IVFPQ precomputed_table");
1563+
// type 2 (MultiIndexQuantizer coarse quantizer): cpq.ksub * pq.M * pq.ksub,
1564+
// plus a temporary quantizer->d * cpq.ksub centroid table. Both derive from
1565+
// the coarse PQ's ksub, which is independent of quantizer->ntotal, so the
1566+
// type-1 bound above does not cover them.
1567+
if (const auto* miq = dynamic_cast<const MultiIndexQuantizer*>(quantizer)) {
1568+
const size_t cpq_ksub = miq->pq.ksub;
1569+
const size_t type2_table =
1570+
mul_no_overflow(cpq_ksub, m_ksub, "IVFPQ precomputed_table");
1571+
const size_t type2_centroids = mul_no_overflow(
1572+
static_cast<size_t>(quantizer->d),
1573+
cpq_ksub,
1574+
"IVFPQ precomputed_table");
1575+
if (type2_table > precompute_elems) {
1576+
precompute_elems = type2_table;
1577+
}
1578+
if (type2_centroids > precompute_elems) {
1579+
precompute_elems = type2_centroids;
1580+
}
1581+
}
1582+
FAISS_THROW_IF_NOT_MSG(
1583+
precompute_elems <
1584+
get_deserialization_vector_byte_limit() / sizeof(float),
1585+
"IVFPQ precomputed_table allocation would exceed deserialization "
1586+
"byte limit");
1587+
}
1588+
15501589
static std::unique_ptr<IndexIVFPQ> read_ivfpq(
15511590
IOReader* f,
15521591
uint32_t h,
@@ -1564,6 +1603,8 @@ static std::unique_ptr<IndexIVFPQ> read_ivfpq(
15641603

15651604
std::vector<std::vector<idx_t>> ids;
15661605
read_ivf_header(ivpq.get(), f, legacy ? &ids : nullptr);
1606+
FAISS_THROW_IF_NOT_MSG(
1607+
ivpq->quantizer != nullptr, "IVFPQ coarse quantizer is null");
15671608
READ1_BOOL(ivpq->by_residual);
15681609
READ1(ivpq->code_size);
15691610
read_ProductQuantizer(&ivpq->pq, f);
@@ -1582,6 +1623,8 @@ static std::unique_ptr<IndexIVFPQ> read_ivfpq(
15821623
ivpq->use_precomputed_table = 0;
15831624
if (ivpq->by_residual) {
15841625
if ((io_flags & IO_FLAG_SKIP_PRECOMPUTE_TABLE) == 0) {
1626+
validate_ivfpq_precomputed_table_size(
1627+
ivpq->quantizer, ivpq->pq);
15851628
ivpq->precompute_table();
15861629
}
15871630
}
@@ -2636,6 +2679,9 @@ std::unique_ptr<Index> read_index_up(IOReader* f, int io_flags) {
26362679
} else if (h == fourcc("IwPf")) {
26372680
auto ivpq = std::make_unique<IndexIVFPQFastScan>();
26382681
read_ivf_header(ivpq.get(), f);
2682+
FAISS_THROW_IF_NOT_MSG(
2683+
ivpq->quantizer != nullptr,
2684+
"IVFPQFastScan coarse quantizer is null");
26392685
READ1_BOOL(ivpq->by_residual);
26402686
READ1(ivpq->code_size);
26412687
READ1(ivpq->bbs);
@@ -2644,6 +2690,7 @@ std::unique_ptr<Index> read_index_up(IOReader* f, int io_flags) {
26442690
READ1(ivpq->qbs2);
26452691
read_ProductQuantizer(&ivpq->pq, f);
26462692
read_InvertedLists(*ivpq, f, io_flags);
2693+
validate_ivfpq_precomputed_table_size(ivpq->quantizer, ivpq->pq);
26472694
ivpq->precompute_table();
26482695

26492696
const auto& pq = ivpq->pq;

tests/test_read_index_deserialize.cpp

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
#include <faiss/IndexIVFFlat.h>
2929
#include <faiss/IndexIVFIndependentQuantizer.h>
3030
#include <faiss/IndexIVFPQ.h>
31+
#include <faiss/IndexIVFPQFastScan.h>
3132
#include <faiss/IndexIVFPQR.h>
3233
#include <faiss/IndexRaBitQFastScan.h>
3334
#include <faiss/IndexScalarQuantizer.h>
@@ -3978,6 +3979,217 @@ TEST(ReadIndexDeserialize,
39783979
faiss::FaissException);
39793980
}
39803981

3982+
// -----------------------------------------------------------------------
3983+
// Test: read_ivfpq bounds the derived (not-stored) IVFPQ precomputed table
3984+
// by the deserialization vector byte limit before recomputing it. The table
3985+
// is quantizer->ntotal * pq.M * pq.ksub floats and is taken from
3986+
// attacker-controlled header fields, so a small crafted index can drive a
3987+
// large allocation (memory-amplification DoS reachable from read_index and
3988+
// the binary index fuzzer). With a low limit the read must throw rather than
3989+
// allocate.
3990+
// -----------------------------------------------------------------------
3991+
TEST(ReadIndexDeserialize, IVFPQPrecomputedTableExceedsByteLimit) {
3992+
const int d = 16;
3993+
const int nlist = 256;
3994+
const int M = 8;
3995+
const int nbits = 8; // ksub = 256
3996+
3997+
IndexFlatL2 quantizer(d);
3998+
IndexIVFPQ index(&quantizer, d, nlist, M, nbits);
3999+
ASSERT_TRUE(index.by_residual); // precompute_table() runs on read
4000+
4001+
const int nt = 10000;
4002+
std::vector<float> xt(size_t(nt) * d);
4003+
std::mt19937 rng(12345);
4004+
std::uniform_real_distribution<float> u(-1.0f, 1.0f);
4005+
for (auto& v : xt) {
4006+
v = u(rng);
4007+
}
4008+
index.train(nt, xt.data());
4009+
index.add(nt, xt.data());
4010+
4011+
VectorIOWriter writer;
4012+
write_index(&index, &writer);
4013+
4014+
// Recomputed table = ntotal(256) * M(8) * ksub(256) = 524288 floats (2 MB).
4015+
const size_t table_elems = size_t(nlist) * M * (size_t{1} << nbits);
4016+
const size_t old_limit = get_deserialization_vector_byte_limit();
4017+
4018+
// Cap the limit at 256 KB (65536 floats) so the precompute table is over
4019+
// the limit while the smaller derived reads (PQ centroids ~16 KB, inverted
4020+
// lists) stay under it. Pin the message so the throw can only come from the
4021+
// precompute-table guard, not some other byte-limit check.
4022+
set_deserialization_vector_byte_limit(size_t{1} << 18); // 256 KB
4023+
{
4024+
VectorIOReader reader;
4025+
reader.data = writer.data;
4026+
try {
4027+
read_index(&reader);
4028+
FAIL() << "expected FaissException";
4029+
} catch (const faiss::FaissException& e) {
4030+
EXPECT_NE(
4031+
std::string(e.what()).find("precomputed_table"),
4032+
std::string::npos)
4033+
<< "expected precompute-table guard, got: " << e.what();
4034+
}
4035+
}
4036+
4037+
// Just above the table size the index still loads (positive control).
4038+
set_deserialization_vector_byte_limit((table_elems + 1) * sizeof(float));
4039+
{
4040+
VectorIOReader reader;
4041+
reader.data = writer.data;
4042+
EXPECT_NO_THROW(read_index_up(&reader));
4043+
}
4044+
4045+
set_deserialization_vector_byte_limit(old_limit);
4046+
}
4047+
4048+
// -----------------------------------------------------------------------
4049+
// Test: the same precompute-table bound guards the IVFPQFastScan (IwPf) read
4050+
// path, whose precompute_table() call is unconditional. nbits=4 -> ksub=16, so
4051+
// the recomputed table = ntotal(256) * M(8) * ksub(16) = 32768 floats (128 KB).
4052+
// -----------------------------------------------------------------------
4053+
TEST(ReadIndexDeserialize, IVFPQFastScanPrecomputedTableExceedsByteLimit) {
4054+
const int d = 16;
4055+
const int nlist = 256;
4056+
const int M = 8;
4057+
const int nbits = 4; // ksub = 16
4058+
4059+
IndexFlatL2 quantizer(d);
4060+
IndexIVFPQFastScan index(&quantizer, d, nlist, M, nbits);
4061+
4062+
const int nt = 10000;
4063+
std::vector<float> xt(size_t(nt) * d);
4064+
std::mt19937 rng(12345);
4065+
std::uniform_real_distribution<float> u(-1.0f, 1.0f);
4066+
for (auto& v : xt) {
4067+
v = u(rng);
4068+
}
4069+
index.train(nt, xt.data());
4070+
index.add(nt, xt.data());
4071+
4072+
VectorIOWriter writer;
4073+
write_index(&index, &writer);
4074+
4075+
const size_t table_elems = size_t(nlist) * M * (size_t{1} << nbits);
4076+
const size_t old_limit = get_deserialization_vector_byte_limit();
4077+
4078+
// 64 KB (16384 floats) is below the 32768-float table but above the smaller
4079+
// derived reads. Pin the message so only the precompute-table guard
4080+
// matches.
4081+
set_deserialization_vector_byte_limit(size_t{1} << 16); // 64 KB
4082+
{
4083+
VectorIOReader reader;
4084+
reader.data = writer.data;
4085+
try {
4086+
read_index(&reader);
4087+
FAIL() << "expected FaissException";
4088+
} catch (const faiss::FaissException& e) {
4089+
EXPECT_NE(
4090+
std::string(e.what()).find("precomputed_table"),
4091+
std::string::npos)
4092+
<< "expected precompute-table guard, got: " << e.what();
4093+
}
4094+
}
4095+
4096+
// Just above the table size the index still loads (positive control).
4097+
set_deserialization_vector_byte_limit((table_elems + 1) * sizeof(float));
4098+
{
4099+
VectorIOReader reader;
4100+
reader.data = writer.data;
4101+
EXPECT_NO_THROW(read_index_up(&reader));
4102+
}
4103+
4104+
set_deserialization_vector_byte_limit(old_limit);
4105+
}
4106+
4107+
// -----------------------------------------------------------------------
4108+
// Test: read_ivfpq rejects a null coarse quantizer rather than dereferencing
4109+
// it. read_ivf_header deserializes the coarse quantizer via read_index, which
4110+
// returns nullptr for a crafted "null" fourcc. The explicit null check right
4111+
// after read_ivf_header must throw cleanly, before the precompute-table guard
4112+
// (or precompute_table itself) dereferences quantizer->ntotal.
4113+
// -----------------------------------------------------------------------
4114+
TEST(ReadIndexDeserialize, IVFPQNullQuantizerReadRejected) {
4115+
std::vector<uint8_t> buf;
4116+
push_fourcc(buf, "IwPQ");
4117+
// read_ivf_header: index_header (is_trained=true), nlist, nprobe,
4118+
// coarse quantizer, direct_map.
4119+
push_index_header(buf, /*d=*/4, /*ntotal=*/0, /*is_trained=*/true);
4120+
push_val<size_t>(buf, 1); // nlist
4121+
push_val<size_t>(buf, 1); // nprobe
4122+
push_fourcc(buf, "null"); // coarse quantizer -> read_index returns nullptr
4123+
push_empty_direct_map(buf);
4124+
// IVFPQ body: by_residual, code_size, ProductQuantizer, inverted lists.
4125+
push_val<bool>(buf, true); // by_residual
4126+
push_val<size_t>(buf, 1); // code_size
4127+
// Valid PQ: d=4, M=1, nbits=8 -> ksub=256, centroids = d*ksub = 1024.
4128+
push_pq(buf,
4129+
/*d=*/4,
4130+
/*M=*/1,
4131+
/*nbits=*/8,
4132+
std::vector<float>(4 * 256, 0.0f));
4133+
push_null_invlists(buf);
4134+
4135+
expect_read_throws_with(buf, "IVFPQ coarse quantizer is null");
4136+
}
4137+
4138+
// -----------------------------------------------------------------------
4139+
// Test: the precompute-table bound also covers the type-2 table used when the
4140+
// coarse quantizer is a MultiIndexQuantizer. That table is
4141+
// cpq.ksub * pq.M * pq.ksub floats, driven by the coarse PQ's ksub, which is
4142+
// decoupled from quantizer->ntotal. A crafted MultiIndexQuantizer with a tiny
4143+
// ntotal but a large ksub passes an ntotal-only bound yet forces a huge
4144+
// allocation, so the guard must reject it.
4145+
// -----------------------------------------------------------------------
4146+
TEST(ReadIndexDeserialize,
4147+
IVFPQMultiIndexQuantizerPrecomputedTableExceedsByteLimit) {
4148+
const int d = 8;
4149+
std::vector<uint8_t> buf;
4150+
push_fourcc(buf, "IwPQ");
4151+
push_index_header(buf, d, /*ntotal=*/0, /*is_trained=*/true);
4152+
push_val<size_t>(buf, 1); // nlist
4153+
push_val<size_t>(buf, 1); // nprobe
4154+
// Coarse quantizer = MultiIndexQuantizer ("Imiq") with a crafted tiny
4155+
// ntotal(1) but cpq.nbits=10 -> cpq.ksub=1024, decoupling the type-2 table
4156+
// size from ntotal.
4157+
push_fourcc(buf, "Imiq");
4158+
push_index_header(buf, d, /*ntotal=*/1, /*is_trained=*/true);
4159+
push_pq(buf, d, /*M=*/1, /*nbits=*/10, std::vector<float>(d * 1024, 0.0f));
4160+
push_empty_direct_map(buf);
4161+
push_val<bool>(buf, true); // by_residual
4162+
push_val<size_t>(buf, 1); // code_size
4163+
// Outer PQ: M=2, nbits=8 -> ksub=256, m_ksub=512, and pq.M % cpq.M == 0.
4164+
push_pq(buf, d, /*M=*/2, /*nbits=*/8, std::vector<float>(d * 256, 0.0f));
4165+
push_null_invlists(buf);
4166+
4167+
const size_t old_limit = get_deserialization_vector_byte_limit();
4168+
set_deserialization_vector_byte_limit(size_t{1} << 16); // 64 KB
4169+
// type-1 bound: ntotal(1) * 2 * 256 = 512 floats -- an ntotal-only guard
4170+
// would pass. type-2 table: cpq.ksub(1024) * 2 * 256 = 524288 floats --
4171+
// must be rejected.
4172+
expect_read_throws_with(buf, "precomputed_table");
4173+
set_deserialization_vector_byte_limit(old_limit);
4174+
}
4175+
4176+
// -----------------------------------------------------------------------
4177+
// Test: the IVFPQFastScan (IwPf) read path also rejects a null coarse quantizer
4178+
// at the explicit check right after read_ivf_header, before any
4179+
// quantizer->ntotal dereference.
4180+
// -----------------------------------------------------------------------
4181+
TEST(ReadIndexDeserialize, IVFPQFastScanNullQuantizerReadRejected) {
4182+
std::vector<uint8_t> buf;
4183+
push_fourcc(buf, "IwPf");
4184+
push_index_header(buf, /*d=*/4, /*ntotal=*/0, /*is_trained=*/true);
4185+
push_val<size_t>(buf, 1); // nlist
4186+
push_val<size_t>(buf, 1); // nprobe
4187+
push_fourcc(buf, "null"); // coarse quantizer -> read_index returns nullptr
4188+
push_empty_direct_map(buf);
4189+
4190+
expect_read_throws_with(buf, "IVFPQFastScan coarse quantizer is null");
4191+
}
4192+
39814193
// ============================================================
39824194
// SVS fourcc rejection / deserialization safety (Group F: T262015608)
39834195
// ============================================================

0 commit comments

Comments
 (0)