Skip to content

[Bug report] Evaluator::add_many skips ciphertext validation for a one-element input vector #759

Description

@CCYJ1014

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions