Hi OpenFHE team,
I would like to report a reproducible boundary-value bug in the public lattice assignment operators that accept signed integer vectors.
Summary
The public default-backend lattice types expose:
Poly& operator=(const std::vector<int64_t>& rhs);
Poly& operator=(const std::vector<int32_t>& rhs);
DCRTPoly& operator=(const std::vector<int64_t>& rhs) noexcept;
DCRTPoly& operator=(const std::vector<int32_t>& rhs) noexcept;
The two PolyImpl signed-vector operators handle negative entries by directly computing:
-rhs[j]
before converting that magnitude to an unsigned integer type.
If a caller supplies INT64_MIN or INT32_MIN, the corresponding positive magnitude is not representable in the same signed type, so this negation is a signed-overflow operation and therefore undefined behavior.
I dynamically reproduced this on the tested build through the public assignments for:
Poly = std::vector<int64_t>{INT64_MIN}Poly = std::vector<int32_t>{INT32_MIN}DCRTPoly = std::vector<int64_t>{INT64_MIN}DCRTPoly = std::vector<int32_t>{INT32_MIN}
The DCRTPoly paths delegate each signed vector to the per-tower Poly assignment, so they reach the same unsafe negation site.
On my UBSan-instrumented Clang 14 build, the continued Poly execution also produced coefficients that do not match full-domain modulo semantics after the UB:
- with modulus
73, assigningINT64_MINyieldedcoeff0 = 0, even thoughINT64_MIN mod 73is72 - with modulus
73, assigningINT32_MINyieldedcoeff0 = 0, even thoughINT32_MIN mod 73is57
I am not claiming those values are stable across builds; they are simply post-UB artifacts observed on this tested configuration. The unconditional finding is the signed-negation UB; whether the assignment operators should also support arbitrary signed values by reducing them modulo the polynomial modulus is an API-contract question.
Environment
- OpenFHE tested revision:
ed361af22049007db2107e7c69bcff209e8c420d - OS: Linux x86_64
- Compiler: Clang
14.0.0 - Sanitizers:
AddressSanitizer,UndefinedBehaviorSanitizer - Library configuration used for the dynamic probe:
BUILD_STATIC=ON,BUILD_SHARED=OFF,MATHBACKEND=4,NATIVE_SIZE=64
Minimal reproduction
The following probe exercises the four public assignment paths directly.
#include <cstdint>
#include <iostream>
#include <limits>
#include <memory>
#include <string>
#include <vector>
#include "lattice/lat-hal.h"
#include "openfhecore.h"
using namespace lbcrypto;
namespace {
std::shared_ptr<ILParams> MakePolyParams() {
return std::make_shared<ILParams>(8, BigInteger("73"), BigInteger("22"));
}
std::shared_ptr<ILDCRTParams<BigInteger>> MakeDCRTParams() {
const uint32_t m = 8;
std::vector<NativeInteger> moduli = {
NativeInteger("8353"),
NativeInteger("8369"),
NativeInteger("8513"),
};
std::vector<NativeInteger> rootsOfUnity = {
NativeInteger("8163"),
NativeInteger("6677"),
NativeInteger("156"),
};
return std::make_shared<ILDCRTParams<BigInteger>>(m, moduli, rootsOfUnity);
}
void RunPolyInt64Min() {
std::cout << "case=poly_int64_min\n";
Poly poly(MakePolyParams(), Format::COEFFICIENT, true);
poly = std::vector<int64_t>{std::numeric_limits<int64_t>::min()};
std::cout << "coeff0=" << poly[0] << '\n';
}
void RunPolyInt32Min() {
std::cout << "case=poly_int32_min\n";
Poly poly(MakePolyParams(), Format::COEFFICIENT, true);
poly = std::vector<int32_t>{std::numeric_limits<int32_t>::min()};
std::cout << "coeff0=" << poly[0] << '\n';
}
void RunDCRTInt64Min() {
std::cout << "case=dcrt_int64_min\n";
DCRTPoly poly(MakeDCRTParams(), Format::COEFFICIENT, true);
poly = std::vector<int64_t>{std::numeric_limits<int64_t>::min()};
std::cout << "tower0_coeff0=" << poly.GetAllElements()[0][0] << '\n';
}
void RunDCRTInt32Min() {
std::cout << "case=dcrt_int32_min\n";
DCRTPoly poly(MakeDCRTParams(), Format::COEFFICIENT, true);
poly = std::vector<int32_t>{std::numeric_limits<int32_t>::min()};
std::cout << "tower0_coeff0=" << poly.GetAllElements()[0][0] << '\n';
}
} // namespace
int main(int argc, char* argv[]) {
std::cout << std::unitbuf;
if (argc != 2) {
std::cerr << "usage: <poly_int64_min|poly_int32_min|dcrt_int64_min|dcrt_int32_min>\n";
return 1;
}
const std::string mode = argv[1];
if (mode == "poly_int64_min") {
RunPolyInt64Min();
return 0;
}
if (mode == "poly_int32_min") {
RunPolyInt32Min();
return 0;
}
if (mode == "dcrt_int64_min") {
RunDCRTInt64Min();
return 0;
}
if (mode == "dcrt_int32_min") {
RunDCRTInt32Min();
return 0;
}
std::cerr << "unknown mode: " << mode << '\n';
return 1;
}
Representative probe build:
clang++-14 -std=gnu++17 -fno-omit-frame-pointer -g -O0 \
-fsanitize=address,undefined \
openfhe_poly_signed_min_probe.cpp \
-Iopenfhe-development/third-party/include \
-Iopenfhe-development/third-party/cereal/include \
-Iopenfhe-development/src/core/include \
-Ibuild-openfhe-asan/src/core \
-Iopenfhe-development/src/core/lib \
build-openfhe-asan/lib/libOPENFHEcore_static.a \
-lpthread -ldl -lm \
-o openfhe_poly_signed_min_probe
Representative runs:
ASAN_OPTIONS=detect_leaks=0 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
./openfhe_poly_signed_min_probe poly_int64_min
ASAN_OPTIONS=detect_leaks=0 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
./openfhe_poly_signed_min_probe poly_int32_min
ASAN_OPTIONS=detect_leaks=0 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
./openfhe_poly_signed_min_probe dcrt_int64_min
ASAN_OPTIONS=detect_leaks=0 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
./openfhe_poly_signed_min_probe dcrt_int32_min
Actual behavior
For Poly = std::vector<int64_t>{INT64_MIN}, UBSan reports:
case=poly_int64_min
.../poly-impl.h:134:66: runtime error: negation of -9223372036854775808
cannot be represented in type 'long'
coeff0=0
If these public signed-vector assignments are intended to accept arbitrary representable signed values and reduce them modulo the polynomial modulus, the expected residue is:
INT64_MIN mod 73 = 72
The continued execution result does not match that full-domain modulo interpretation on this build. Independently of that policy question, the current implementation already invokes undefined behavior while evaluating -rhs[j].
For Poly = std::vector<int32_t>{INT32_MIN}, UBSan reports:
case=poly_int32_min
.../poly-impl.h:156:66: runtime error: negation of -2147483648 cannot be represented in type 'int'
coeff0=0
Under the same full-domain modulo interpretation, the expected residue is:
INT32_MIN mod 73 = 57
The continued execution result again does not match that interpretation after the UB.
For the DCRTPoly cases, UBSan reports the same unsafe negation site through the delegating tower assignments:
case=dcrt_int64_min
.../poly-impl.h:134:66: runtime error: negation of -9223372036854775808 ...
.../dcrtpoly-impl.h:487:11
and:
case=dcrt_int32_min
.../poly-impl.h:156:66: runtime error: negation of -2147483648 ...
.../dcrtpoly-impl.h:502:11
On this tested build, continued execution also produced the following post-UB first-tower coefficient values:
tower0_coeff0=9223372036854784161
tower0_coeff0=2147492001
Expected behavior
The public signed-vector assignment operators should not invoke undefined behavior while deciding how to handle a representable int32_t or int64_t input value.
If a signed minimum value is accepted, the implementation should compute its magnitude using a safe unsigned conversion path and, if full-domain support is intended, reduce that magnitude modulo the polynomial modulus before assigning the canonical residue.
If the intended contract excludes signed minimum values, that precondition should be documented explicitly. If runtime rejection is desired, it must occur before the unsafe negation. For the DCRTPoly overloads, any runtime rejection strategy also needs to account for their current noexcept signatures.
Cause analysis
The current PolyImpl signed-vector operators are:
(*m_values)[j] =
(rhs[j] < 0) ? m - Integer(static_cast<uint64_t>(-rhs[j]))
: Integer(static_cast<uint64_t>(rhs[j]));
When rhs[j] is INT64_MIN or INT32_MIN, the expression -rhs[j] itself already overflows in the source signed type before any cast to uint64_t happens.
The DCRTPolyImpl operators then forward the same signed vector into each ower:
for (auto& v : m_vectors) {
...
v = val;
}
So the DCRT path is not a separate root cause; it is a direct delegation path to the same unsafe PolyImpl logic.
Impact
This is a public core lattice boundary-value correctness and robustness bug.
The confirmed impact on the tested build is:
- UBSan-confirmed signed-overflow UB in the public signed-vector assignment operators
- coefficients after continued execution that do not match full-domain modulo semantics on the tested build
- propagation of the same issue into every tower of
DCRTPoly
If an application, protocol layer, or external task interface treats the full int32_t / int64_t range as acceptable input for these public assignment operators, then INT32_MIN or INT64_MIN can trigger the problem without requiring malformed serialization or internal-object corruption.
Relevant source locations
- Public
Polysigned-vector declarations:
openfhe-development/src/core/include/lattice/hal/default/poly.h at ed361af22049007db2107e7c69bcff209e8c420d · openfheorg/openfhe-development · GitHub PolyImplsigned-vector assignments:
openfhe-development/src/core/include/lattice/hal/default/poly-impl.h at ed361af22049007db2107e7c69bcff209e8c420d · openfheorg/openfhe-development · GitHub- Public
DCRTPolysigned-vector declarations:
openfhe-development/src/core/include/lattice/hal/default/dcrtpoly.h at ed361af22049007db2107e7c69bcff209e8c420d · openfheorg/openfhe-development · GitHub DCRTPolyImpldelegation into per-towerPolyassignments:
openfhe-development/src/core/include/lattice/hal/default/dcrtpoly-impl.h at ed361af22049007db2107e7c69bcff209e8c420d · openfheorg/openfhe-development · GitHub
Suggested direction
The negative branch should not negate a signed minimum value in the source type.
One reasonable direction is to compute the magnitude through an unsigned conversion path, for example:
template <class Signed>
static uint64_t AbsMagnitude(Signed x) {
using U = std::make_unsigned_t<Signed>;
if (x >= 0)
return static_cast<uint64_t>(static_cast<U>(x));
return static_cast<uint64_t>(static_cast<U>(-(x + 1))) + 1;
}
If full-domain signed-vector support is intended, that magnitude also needs to be reduced modulo the polynomial modulus before constructing the canonical residue. Conceptually:
mag = safe_unsigned_magnitude(x)
r = mag mod m
x >= 0 : r
x < 0 : (r == 0 ? 0 : m - r)
This is distinct from the current pattern of computing m - mag, which does not by itself define behavior for magnitudes larger than m.
If supporting the full signed range is not intended, that narrower input contract should be documented explicitly. If runtime rejection is desired, it must happen before the unsafe negation. For the DCRTPoly overloads, this should be done in a way that is consistent with their current noexcept API, or the API contract should be changed accordingly.
Question
Would you prefer these public signed-vector assignments to support the full int32_t / int64_t domain through safe unsigned magnitude handling, or to define a narrower documented input contract that excludes these minimum values?
Reported by Jiang Chao, Beijing University of Posts and Telecommunications