Hi SEAL team,
I would like to report a validation bug in Microsoft SEAL's public C++ API.
On SEAL v4.4.3, Evaluator::add_many({ciphertext}, destination) can return success when the single input ciphertext is not valid for the evaluator's SEALContext. The implementation copies the first input to destination and then starts validation through add_inplace only for later inputs. For a one-element vector, that validation loop is never entered.
This means a documented invalid_argument condition is bypassed for the single-element case, and the successful output can be a ciphertext that is not valid for the current evaluator/decryptor context.
Summary
The public documentation for Evaluator::add_many says it throws:
std::invalid_argument if encrypteds are not valid for the encryption parameters
However, the implementation does:
destination = encrypteds[0];
for (size_t i = 1; i < encrypteds.size(); i++)
{
add_inplace(destination, encrypteds[i]);
}
add_inplace(...) performs the normal metadata and buffer validation, but a one-element vector never calls add_inplace. As a result, add_many({foreign_ciphertext}, destination) can succeed and copy a foreign ciphertext into destination.
Environment
- SEAL release/tag:
v4.4.3
- Tested revision:
356f2e6dcc0520dc9fc14e98674d9a56cba6018c
- OS: Linux x86_64
- Compiler: Clang
14.0.0
- Build type: local Debug build
Minimal reproduction
The following reproducer uses two valid BFV contexts with different coefficient modulus chains. A ciphertext is generated under the first context and then passed as the only input to add_many on an evaluator for the second context.
#include <seal/seal.h>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
using namespace seal;
namespace
{
EncryptionParameters MakeBfvParms(std::vector<int> coeff_bits)
{
EncryptionParameters parms(scheme_type::bfv);
parms.set_poly_modulus_degree(8192);
parms.set_coeff_modulus(CoeffModulus::Create(8192, coeff_bits));
parms.set_plain_modulus(PlainModulus::Batching(8192, 20));
return parms;
}
Ciphertext EncryptScalar(
Encryptor &encryptor, BatchEncoder &encoder, std::uint64_t value)
{
std::vector<std::uint64_t> slots(encoder.slot_count(), 0ULL);
slots[0] = value;
Plaintext plain;
encoder.encode(slots, plain);
Ciphertext encrypted;
encryptor.encrypt(plain, encrypted);
return encrypted;
}
bool TryDecrypt(
Decryptor &decryptor, BatchEncoder &encoder, const Ciphertext &encrypted,
std::string &error)
{
try
{
Plaintext plain;
decryptor.decrypt(encrypted, plain);
std::vector<std::uint64_t> slots;
encoder.decode(plain, slots);
return true;
}
catch (const std::exception &e)
{
error = e.what();
return false;
}
}
} // namespace
int main()
{
SEALContext foreign_context(MakeBfvParms({ 50, 50, 50 }));
SEALContext current_context(MakeBfvParms({ 40, 40, 40 }));
if (!foreign_context.parameters_set() || !current_context.parameters_set())
{
std::cerr << "context setup failed\n";
return 2;
}
KeyGenerator foreign_keygen(foreign_context);
PublicKey foreign_pk;
foreign_keygen.create_public_key(foreign_pk);
KeyGenerator current_keygen(current_context);
SecretKey current_sk = current_keygen.secret_key();
PublicKey current_pk;
current_keygen.create_public_key(current_pk);
BatchEncoder foreign_encoder(foreign_context);
Encryptor foreign_encryptor(foreign_context, foreign_pk);
Ciphertext foreign = EncryptScalar(foreign_encryptor, foreign_encoder, 11);
BatchEncoder current_encoder(current_context);
Encryptor current_encryptor(current_context, current_pk);
Decryptor current_decryptor(current_context, current_sk);
Evaluator current_evaluator(current_context);
Ciphertext destination = EncryptScalar(current_encryptor, current_encoder, 99);
std::cout << "foreign_parms_id_eq_current_first="
<< (foreign.parms_id() == current_context.first_parms_id()) << '\n';
std::cout << "foreign_metadata_valid_for_current="
<< is_metadata_valid_for(foreign, current_context) << '\n';
std::cout << "foreign_buffer_valid="
<< is_buffer_valid(foreign) << '\n';
std::string decrypt_error;
std::cout << "destination_decryptable_before="
<< TryDecrypt(current_decryptor, current_encoder, destination, decrypt_error)
<< '\n';
bool add_many_returned = false;
try
{
current_evaluator.add_many(std::vector<Ciphertext>{ foreign }, destination);
add_many_returned = true;
std::cout << "add_many_returned=1\n";
}
catch (const std::exception &e)
{
std::cout << "add_many_threw=" << \n';
}
std::cout << "destination_parms_id_eq_foreign="
<< (destination.parms_id() == foreign.parms_id()) << '\n';
std::cout << "destination_metadata_valid_for_current_after="
<< is_metadata_valid_for(destination, current_context) << '\n';
decrypt_error.clear();
std::cout << "destination_decryptable_after="
<< TryDecrypt(current_decryptor, current_encoder, destination, decrypt_error)
<< '\n';
if (!decrypt_error.empty())
{
std::cout << "destination_decrypt_after_error=" << decrypt_error << '\n';
}
return add_many_returned &&
destination.parms_id() == foreign.parms_id() &&
!is_metadata_valid_for(destination, current_context)
? 0
: 1;
}
Representative build command:
clang++ -std=c++17 -O0 -g \
-I/home/sht/agent-fuzzing/SEAL-v4.4.3-check/native/src \
-I/home/sht/agent-fuzzing/build-seal-v443-cpp/native/src \
/home/sht/agent-fuzzing/fuzz/seal_add_many_single_invalid_probe.cpp \
/home/sht/agent-fuzzing/build-seal-v443-cpp/lib/libseal-4.4.a \
/usr/lib/x86_64-linux-gnu/libz.so \
/usr/lib/x86_64-linux-gnu/libzstd.so.1 \
-pthread \
-o /home/sht/agent-fuzzing/fuzz/bin/seal_add_many_single_invalid_probe
Actual behavior
The program prints:
foreign_parms_id_eq_current_first=0
foreign_metadata_valid_for_current=0
foreign_buffer_valid=1
destination_decryptable_before=1
add_many_returned=1
destination_parms_id_eq_foreign=1
destination_metadata_valid_for_current_after=0
destination_decryptable_after=0
destination_decrypt_after_error=encrypted is not valid for encryption parameters
The important points are:
- the input ciphertext is metadata-invalid for the current evaluator context even though its own buffer is valid;
add_many nevertheless returns success for the one-element vector;
destination is overwritten with the foreign ciphertext;
- later decryption under the current context rejects
destination as invalid.
Expected behavior
If an input ciphertext is not valid for the evaluator's encryption parameters, Evaluator::add_many should throw std::invalid_argument, as documented.
The one-element case should not bypass the same ciphertext validation that multi-element calls reach through add_inplace.
Cause analysis
Evaluator::add_many only checks that the vector is non-empty and that destination is not one of the input objects:
if (encrypteds.empty())
{
throw invalid_argument("encrypteds cannot be empty");
}
for (size_t i = 0; i < encrypteds.size(); i++)
{
if (&encrypteds[i] == &destination)
{
throw invalid_argument("encrypteds must be different from destination");
}
}
It then copies the first ciphertext before any ciphertext validity check in this function:
destination = encrypteds[0];
for (size_t i = 1; i < encrypteds.size(); i++)
{
add_inplace(destination, encrypteds[i]);
}
The validation that would reject a ciphertext from the wrong context is inside add_inplace:
if (!is_metadata_valid_for(encrypted1, context_) || !is_buffer_valid(encrypted1))
{
throw invalid_argument("encrypted1 is not valid for encryption parameters");
}
if (!is_metadata_valid_for(encrypted2, context_) || !is_buffer_valid(encrypted2))
{
throw invalid_argument("encrypted2 is not valid for encryption parameters");
}
For encrypteds.size() == 1, the loop starts at i = 1 and is skipped, so add_inplace is never called. The documented validation is therefore bypassed.
Impact
This is an API contract and validation-consistency bug.
A caller can receive a successful return from add_many even though the sole input ciphertext is not valid for the evaluator's encryption parameters. The returned destination can then be an invalid ciphertext for the current context, and the error appears only in later operations.
Relevant source locations
Suggested direction
add_many should ensure that every ciphertext in encrypteds is validated for the evaluator's encryption parameters, including encrypteds[0] in the one-element case.
Possible approaches:
- explicitly validate
encrypteds[0] with the same metadata and buffer checks before destination = encrypteds[0];
- validate the whole vector up front before mutating
destination;
- for the one-element case, either return a validated copy or reject invalid ciphertexts consistently with the documented
invalid_argument contract.
Reported by Jiang Chao, Beijing University of Posts and Telecommunications
Hi SEAL team,
I would like to report a validation bug in Microsoft SEAL's public C++ API.
On SEAL
v4.4.3,Evaluator::add_many({ciphertext}, destination)can return success when the single input ciphertext is not valid for the evaluator'sSEALContext. The implementation copies the first input todestinationand then starts validation throughadd_inplaceonly for later inputs. For a one-element vector, that validation loop is never entered.This means a documented
invalid_argumentcondition is bypassed for the single-element case, and the successful output can be a ciphertext that is not valid for the current evaluator/decryptor context.Summary
The public documentation for
Evaluator::add_manysays it throws:However, the implementation does:
add_inplace(...)performs the normal metadata and buffer validation, but a one-element vector never callsadd_inplace. As a result,add_many({foreign_ciphertext}, destination)can succeed and copy a foreign ciphertext intodestination.Environment
v4.4.3356f2e6dcc0520dc9fc14e98674d9a56cba6018c14.0.0Minimal reproduction
The following reproducer uses two valid BFV contexts with different coefficient modulus chains. A ciphertext is generated under the first context and then passed as the only input to
add_manyon an evaluator for the second context.Representative build command:
Actual behavior
The program prints:
The important points are:
add_manynevertheless returns success for the one-element vector;destinationis overwritten with the foreign ciphertext;destinationas invalid.Expected behavior
If an input ciphertext is not valid for the evaluator's encryption parameters,
Evaluator::add_manyshould throwstd::invalid_argument, as documented.The one-element case should not bypass the same ciphertext validation that multi-element calls reach through
add_inplace.Cause analysis
Evaluator::add_manyonly checks that the vector is non-empty and thatdestinationis not one of the input objects:It then copies the first ciphertext before any ciphertext validity check in this function:
The validation that would reject a ciphertext from the wrong context is inside
add_inplace:For
encrypteds.size() == 1, the loop starts ati = 1and is skipped, soadd_inplaceis never called. The documented validation is therefore bypassed.Impact
This is an API contract and validation-consistency bug.
A caller can receive a successful return from
add_manyeven though the sole input ciphertext is not valid for the evaluator's encryption parameters. The returneddestinationcan then be an invalid ciphertext for the current context, and the error appears only in later operations.Relevant source locations
Evaluator::add_many()API contract inevaluator.h: https://github.com/microsoft/SEAL/blob/v4.4.3/native/src/seal/evaluator.h#L150-L163Evaluator::add_many()implementation: https://github.com/microsoft/SEAL/blob/v4.4.3/native/src/seal/evaluator.cpp#L242-L260Evaluator::add_inplace()ciphertext validity checks: https://github.com/microsoft/SEAL/blob/v4.4.3/native/src/seal/evaluator.cpp#L155-L165Suggested direction
add_manyshould ensure that every ciphertext inencryptedsis validated for the evaluator's encryption parameters, includingencrypteds[0]in the one-element case.Possible approaches:
encrypteds[0]with the same metadata and buffer checks beforedestination = encrypteds[0];destination;invalid_argumentcontract.Reported by Jiang Chao, Beijing University of Posts and Telecommunications