|
28 | 28 | #include <faiss/IndexIVFFlat.h> |
29 | 29 | #include <faiss/IndexIVFIndependentQuantizer.h> |
30 | 30 | #include <faiss/IndexIVFPQ.h> |
| 31 | +#include <faiss/IndexIVFPQFastScan.h> |
31 | 32 | #include <faiss/IndexIVFPQR.h> |
32 | 33 | #include <faiss/IndexRaBitQFastScan.h> |
33 | 34 | #include <faiss/IndexScalarQuantizer.h> |
@@ -3978,6 +3979,217 @@ TEST(ReadIndexDeserialize, |
3978 | 3979 | faiss::FaissException); |
3979 | 3980 | } |
3980 | 3981 |
|
| 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 | + |
3981 | 4193 | // ============================================================ |
3982 | 4194 | // SVS fourcc rejection / deserialization safety (Group F: T262015608) |
3983 | 4195 | // ============================================================ |
|
0 commit comments