Skip to content

[Bug report] CKKSEncoder::encode(int64_t) misencodes large negative integers #757

Description

@CCYJ1014

Hi SEAL team,
I would like to report a correctness bug in Microsoft SEAL's CKKS integer encoding path.

On a local v4.4.3 build, I reproduced a public C++ API sequence where CKKSEncoder::encode(std::int64_t, ...) accepts a negative integer input but produces a plaintext that decodes to the wrong numeric value. This is not just an approximation issue: for some inputs the decoded result has the wrong sign and many orders of magnitude of error.

I originally found the same behavior on SEAL v4.4.0. I rebuilt and reran the reproducer against the latest SEAL v4.4.3 release, and the same wrong-result behavior is still present.

Summary

CKKSEncoder::encode(int64_t, ...) handles negative inputs differently from the floating-point CKKS path.

For value < 0, it currently does:

uint64_t tmp = static_cast<uint64_t>(value);
tmp += coeff_modulus[j].value();
tmp = barrett_reduce_64(tmp, coeff_modulus[j]);

This only yields the intended residue when the magnitude of value is at most the current coefficient modulus.

For larger negative values, the conversion to uint64_t has already produced the value modulo 2^64. The subsequent single addition of
coeff_modulus[j].value() does not reconstruct the intended residue (-|value|) mod q_j; instead, barrett_reduce_64 reduces the wrong 64-bit word.

As a result, the integer CKKS overload can silently encode the wrong plaintext for negative integers whose magnitude exceeds one of the coefficient moduli in the chain.

With the parameter set used in the reproducer:

  • poly_modulus_degree = 8192
  • coeff_modulus = { 60, 40, 40, 60 }

the chain contains 40-bit primes. In my reproducer:

  • -2^39 still decodes correctly;
  • -2^40 decodes to a huge incorrect negative value;
  • on the tested build, INT64_MIN was also accepted and decoded as a large positive value, but I treat that only as an observed post-UB symptom because the preceding llabs(INT64_MIN) computation is itself undefined.

For -2^40, the floating-point CKKS overload encode(double, scale = 1.0, ...) decodes correctly on the same context, which isolates the bug to the int64_t encoding path.

Separately, the same function computes:

get_significant_bit_count(static_cast<uint64_t>(llabs(value))) + 2

which is also a source-level hazard for INT64_MIN, whose magnitude is not representable as a signed long long. The confirmed wrong-result bug below does not depend on that edge case: it already reproduces for -2^40.

Environment

Current dynamic reproduction:

  • SEAL release/tag: v4.4.3
  • Tested revision: 356f2e6dcc0520dc9fc14e98674d9a56cba6018c
  • OS: Linux x86_64
  • Compiler: Clang 14.0.0
  • Build type: Debug

Original discovery:

  • SEAL release/tag: v4.4.0
  • Tested revision: 04d53b99ce745efc26bb4965be609b9894755227
  • The same reproducer first showed the bug on this release.

Minimal reproduction on v4.4.3

The following reproducer uses only public C++ APIs.

It:

  1. creates a valid CKKS context;
  2. calls CKKSEncoder::encode(int64_t, ...) on several negative integers;
  3. decodes the resulting plaintexts; and
  4. for exactly representable values, compares the result against the floating-point overload encode(double, scale = 1.0, ...).
#include "seal/seal.h"

#include <complex>
#include <cstdint>
#include <iostream>
#include <limits>
#include <vector>

using namespace seal;

namespace
{
EncryptionParameters MakeCkksParms()
{
    EncryptionParameters parms(scheme_type::ckks);
    parms.set_poly_modulus_degree(8192);
    parms.set_coeff_modulus(CoeffModulus::Create(8192, { 60, 40, 40, 60 }));
    return parms;
}
} // namespace

int main()
{
    auto parms = MakeCkksParms();
    SEALContext context(parms);
    if (!context.parameters_set())
    {
        std::cerr << "context not set\n";
        return 1;
    }

    CKKSEncoder encoder(context);

    std::vector<std::int64_t> values = {
        -(static_cast<std::int64_t>(1) << 39),
        -(static_cast<std::int64_t>(1) << 40),
        std::numeric_limits<std::int64_t>::min(),
        std::numeric_limits<std::int64_t>::min() + 1,
        -(static_cast<std::int64_t>(1) << 62),
        -5,
        0,
        5
    };

    for (auto value : values)
    {
        Plaintext plain;
        std::cout << "about to encode value=" << value << '\n';
        encoder.encode(value, context.first_parms_id(), plain);
        std::cout << "plain_coeff_count=" << plain.coeff_count() << '\n';

        std::vector<std::complex<double>> decoded;
        encoder.decode(plain, decoded);
        std::cout << "decoded0_real=" << decoded[0].real() << '\n';
        std::cout << "decoded0_imag=" << decoded[0].imag() << '\n';

        if (value >= -(static_cast<std::int64_t>(1) << 53) &&
            value <= (static_cast<std::int64_t>(1) << 53))
        {
            Plaintext plain_double;
            encoder.encode(static_cast<double>(value), context.first_parms_id(), 1.0, plain_double);
            std::vector<std::complex<double>> decoded_double;
            encoder.decode(plain_double, decoded_double);
            std::cout << "double_control_real=" << decoded_double[0].real() << '\n';
        }
    }

    return 0;
}

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_ckks_int64min_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_ckks_int64min_probe

Actual behavior on v4.4.3

The full reproducer prints the per-input trace. The key observations were:

input                  int64 overload output      double-control output
-5                     -5                         -5
-2^39                  -5.49756e+11               -5.49756e+11
-2^40                  -3.82576e+41               -1.09951e+12

The important points are:

  • -2^39 still decodes correctly;
  • -2^40 is accepted but decodes to -3.82576e+41 instead of -1.09951e+12;
  • small negative integers such as -5 decode correctly.

Separately, on this tested build, INT64_MIN produced 9.22337e+18; because execution has already passed through llabs(INT64_MIN), I treat that only as an observed post-UB symptom.

Expected behavior

CKKSEncoder::encode(int64_t, ...) should produce the same encoded integer value that the caller supplied, within ordinary CKKS decoding error.

In particular:

  • negative integers should not silently change sign;
  • a large negative integer accepted by the int64_t overload should not decode to a completely different magnitude;
  • the integer overload should be consistent with the floating-point overload on exactly representable integer inputs such as -2^40.

Cause analysis

The current negative branch in encode_internal(int64_t, ...) is:

uint64_t tmp = static_cast<uint64_t>(value);
tmp += coeff_modulus[j].value();
tmp = barrett_reduce_64(tmp, coeff_modulus[j]);

This only works for negative values whose magnitude fits under the current modulus q_j.

For example, with value = -5, the cast to uint64_t gives 2^64 - 5, and adding q_j wraps to q_j - 5, which is the correct representative.

But for a larger negative value such as value = -2^40, the intended coefficient is:

(-2^40) mod q_j = q_j - (2^40 mod q_j)

The current code does not compute that quantity. Instead, it reduces the already-converted unsigned value plus one extra q_j, which is a different 64-bit integer once |value| > q_j.

The first incorrect RNS limb appears once the input magnitude exceeds one of the coefficient moduli. In the supplied {60, 40, 40, 60} context, the 40-bit primes are below 2^40, so -2^40 already causes wrong residues in those limbs.

The floating-point CKKS path does not use this formula. It decomposes the rounded magnitude and then applies modular negation to each reduced residue, which is the mathematically correct approach.

Separately, this line is also problematic for INT64_MIN:

get_significant_bit_count(static_cast<uint64_t>(llabs(value))) + 2

because the magnitude of INT64_MIN is not representable as a signed long long. That special case should be hardened too, but it is not needed to demonstrate the confirmed wrong-result bug.

Impact

This is a result-integrity bug in a public encoding API.

Applications that use CKKSEncoder::encode(int64_t, ...) for large negative integers can obtain a plaintext encoding of the wrong value while the call still succeeds normally.

This can directly lead to:

  • incorrect encrypted computations;
  • decoded results with the wrong sign;
  • inconsistent behavior between the integer and floating-point CKKS encode overloads.

I have not established memory corruption, code execution, or key compromise.

Relevant source locations

Current dynamic reproduction target (v4.4.3):

Original discovery target (v4.4.0):

Related current unsigned-magnitude validation pattern (v4.4.3):

Suggested direction

The negative integer branch should compute the modular negation of the unsigned magnitude, rather than reducing a wrapped unsigned-conversion result.

A safer structure would compute a single unsigned magnitude first and then use ordinary modular negation for negative inputs:

uint64_t magnitude = (value < 0) ? (0 - static_cast<uint64_t>(value))
                                 : static_cast<uint64_t>(value);

int coeff_bit_count = get_significant_bit_count(magnitude) + 2;

for (size_t j = 0; j < coeff_modulus_size; j++)
{
    uint64_t reduced = barrett_reduce_64(magnitude, coeff_modulus[j]);
    uint64_t tmp = (value < 0)
        ? negate_uint_mod(reduced, coeff_modulus[j])
        : reduced;

    fill_n(destination.data() + (j * coeff_count), coeff_count, tmp);
}

This matches the intended residue regardless of whether |value| is smaller or larger than a particular coefficient modulus, and it also avoids the llabs(INT64_MIN) bit-count hazard. The unsigned-magnitude computation follows the existing validation pattern already used by BatchEncoder::encode for signed inputs, including its explicit handling of INT64_MIN.

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