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:
- creates a valid CKKS context;
- calls
CKKSEncoder::encode(int64_t, ...) on several negative integers;
- decodes the resulting plaintexts; and
- 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):
-
native/src/seal/ckks.cpp
-
native/src/seal/ckks.h
Original discovery target (v4.4.0):
Related current unsigned-magnitude validation pattern (v4.4.3):
native/src/seal/batchencoder.cpp
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
Hi SEAL team,
I would like to report a correctness bug in Microsoft SEAL's CKKS integer encoding path.
On a local
v4.4.3build, I reproduced a public C++ API sequence whereCKKSEncoder::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 SEALv4.4.3release, 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:This only yields the intended residue when the magnitude of
valueis at most the current coefficient modulus.For larger negative values, the conversion to
uint64_thas already produced the value modulo2^64. The subsequent single addition ofcoeff_modulus[j].value()does not reconstruct the intended residue(-|value|) mod q_j; instead,barrett_reduce_64reduces 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 = 8192coeff_modulus = { 60, 40, 40, 60 }the chain contains 40-bit primes. In my reproducer:
-2^39still decodes correctly;-2^40decodes to a huge incorrect negative value;INT64_MINwas also accepted and decoded as a large positive value, but I treat that only as an observed post-UB symptom because the precedingllabs(INT64_MIN)computation is itself undefined.For
-2^40, the floating-point CKKS overloadencode(double, scale = 1.0, ...)decodes correctly on the same context, which isolates the bug to theint64_tencoding path.Separately, the same function computes:
which is also a source-level hazard for
INT64_MIN, whose magnitude is not representable as a signedlong long. The confirmed wrong-result bug below does not depend on that edge case: it already reproduces for-2^40.Environment
Current dynamic reproduction:
v4.4.3356f2e6dcc0520dc9fc14e98674d9a56cba6018c14.0.0DebugOriginal discovery:
v4.4.004d53b99ce745efc26bb4965be609b9894755227Minimal reproduction on
v4.4.3The following reproducer uses only public C++ APIs.
It:
CKKSEncoder::encode(int64_t, ...)on several negative integers;encode(double, scale = 1.0, ...).Representative build command:
Actual behavior on
v4.4.3The full reproducer prints the per-input trace. The key observations were:
The important points are:
-2^39still decodes correctly;-2^40is accepted but decodes to-3.82576e+41instead of-1.09951e+12;-5decode correctly.Separately, on this tested build,
INT64_MINproduced9.22337e+18; because execution has already passed throughllabs(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:
int64_toverload should not decode to a completely different magnitude;-2^40.Cause analysis
The current negative branch in
encode_internal(int64_t, ...)is:This only works for negative values whose magnitude fits under the current modulus
q_j.For example, with
value = -5, the cast touint64_tgives2^64 - 5, and addingq_jwraps toq_j - 5, which is the correct representative.But for a larger negative value such as
value = -2^40, the intended coefficient is: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 below2^40, so-2^40already 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:because the magnitude of
INT64_MINis not representable as a signedlong 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:
I have not established memory corruption, code execution, or key compromise.
Relevant source locations
Current dynamic reproduction target (
v4.4.3):native/src/seal/ckks.cppCKKSEncoder::encode_internal(int64_t, ...): lines 232-282llabs(value)bit-count computation: line 253tmp += coeff_modulus[j].value(): lines 265-273native/src/seal/ckks.hencode(std::int64_t, ...)overloads: lines 363-379encode_internal(std::int64_t, ...)declaration: line 802https://github.com/microsoft/SEAL/blob/v4.4.3/native/src/seal/ckks.h#L802-L802
Original discovery target (
v4.4.0):native/src/seal/ckks.cppencode_internal(int64_t, ...)implementation was present: lines 232-282Related current unsigned-magnitude validation pattern (
v4.4.3):native/src/seal/batchencoder.cppBatchEncoder::encode(const vector<int64_t> &, ...): lines 150-172BatchEncoder::encode(gsl::span<const int64_t>, ...): lines 234-256https://github.com/microsoft/SEAL/blob/v4.4.3/native/src/seal/batchencoder.cpp#L234-L256
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:
This matches the intended residue regardless of whether
|value|is smaller or larger than a particular coefficient modulus, and it also avoids thellabs(INT64_MIN)bit-count hazard. The unsigned-magnitude computation follows the existing validation pattern already used byBatchEncoder::encodefor signed inputs, including its explicit handling ofINT64_MIN.Reported by Jiang Chao, Beijing University of Posts and Telecommunications