Hi OpenFHE team,
I would like to report a deserialization input-validation issue that can lead to an ASan-confirmed heap-buffer-overflow in RingGSWCryptoParams::PreCompute().
Summary
A malformed but still-parseable serialized BinFHEContext can reach RingGSWCryptoParams::PreCompute() during deserialization.
My original fuzz trigger was a corrupted binary archive, but I minimized it to a field-targeted JSON mutation that isolates the invariant break. In my local OpenFHE 1.5.1 TOY context, the relevant serialized values were:
bN = 512bQ = 134215681bmethod = 2(GINX)bparams.rd = 512bparams.co = 1024
Changing only bN from 512 to 513 while keeping bparams unchanged is enough to trigger the bug. The archive remains parseable, but RingGSWCryptoParams::load() then calls PreCompute() on internally inconsistent state.
I reproduced this locally under ASan/UBSan on OpenFHE 1.5.1.
Environment
- OpenFHE version:
1.5.1 - Compiler:
clang 14.0.0 - Sanitizers:
AddressSanitizer,UndefinedBehaviorSanitizer
Minimal reproduction
The reproducer below serializes a valid TOY BinFHEContext to JSON, changes only the bN field from 512 to 513, verifies that bparams still reports ring dimension 512 and cyclotomic order 1024, and then deserializes the mutated JSON:
#include "binfhecontext-ser.h"
#include <sstream>
#include <stdexcept>
#include <string>
using namespace lbcrypto;
static void ReplaceExactlyOnce(std::string& text, const std::string& from, const std::string& to) {
const auto first = text.find(from);
if (first == std::string::npos) {
throw std::runtime_error("target field not found");
}
if (text.find(from, first + from.size()) != std::string::npos) {
throw std::runtime_error("target field is not unique");
}
text.replace(first, from.size(), to);
}
int main() {
BinFHEContext cc;
cc.GenerateBinFHEContext(TOY);
std::stringstream ss;
Serial::Serialize(cc, ss, SerType::JSON);
std::string json = ss.str();
ReplaceExactlyOnce(json, "\"bN\": 512", "\"bN\": 513");
if (json.find("\"rd\": 512") == std::string::npos) {
throw std::runtime_error("unexpected ring-dimension change");
}
if (json.find("\"co\": 1024") == std::string::npos) {
throw std::runtime_error("unexpected cyclotomic-order change");
}
BinFHEContext restored;
std::stringstream in(json);
Serial::Deserialize(restored, in, SerType::JSON);
return 0;
}
Expected behavior
The parseable-but-internally-inconsistent archive should be rejected before PreCompute() runs.
At minimum, deserialization should not call PreCompute() unless:
m_polyParams != nullptrm_polyParams->GetRingDimension() == m_Nm_polyParams->GetModulus() == m_Q
Actual behavior
The process crashes with an ASan heap-buffer-overflow during deserialization.
Relevant excerpt from my run:
=================================================================
==792789==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x621000789d00
READ of size 8 at 0x621000789d00 thread T0
#0 ... NativeIntegerT<unsigned long>::ModAddFastEq(...) .../ubintnat.h:753:17
#1 ... lbcrypto::RingGSWCryptoParams::PreCompute(bool) .../rgsw-cryptoparameters.cpp:102:22
#2 ... void lbcrypto::RingGSWCryptoParams::load<cereal::JSONInputArchive>(...)
...
0x621000789d00 is located 0 bytes to the right of 4096-byte region
[0x621000788d00,0x621000789d00)
allocated by thread T0 here:
...
#11 ... lbcrypto::RingGSWCryptoParams::PreCompute(bool) .../rgsw-cryptoparameters.cpp:100:24
#12 ... void lbcrypto::RingGSWCryptoParams::load<cereal::JSONInputArchive>(...)
The sanitizer line numbers above refer to my local instrumented source tree; stable v1.5.1 source links are listed below.
Cause analysis
From what I can tell, the core issue is that the normal constructor and the deserialization path establish invariants differently.
The normal RingGSWCryptoParams constructor sets:
m_N = Nm_Q = Qm_polyParams = std::make_shared<ILNativeParams>(2 * N, Q)
and only then calls PreCompute(signEval).
By contrast, RingGSWCryptoParams::load() independently reads:
bN -> m_NbQ -> m_Qbq -> m_qbR -> m_baseRbG -> m_baseGbmethod -> m_methodbdigitsG -> m_digitsGbparams -> m_polyParams
and then immediately calls PreCompute() without validating the cross-field relationships.
In this reproducer, the loaded state becomes:
m_N = 513m_Q = 134215681m_polyParams->GetRingDimension() = 512m_polyParams->GetCyclotomicOrder() = 1024m_polyParams->GetModulus() = 134215681
So this is not a generic parse failure and not a modulus mismatch. It is a targeted ring-dimension inconsistency: m_N is larger than the actual polynomial ring dimension carried by m_polyParams.
Inside the GINX branch of PreCompute(), the code constructs:
NativePoly aPoly(m_polyParams, Format::COEFFICIENT, true);
which gets its storage shape from m_polyParams, but the loops use the independently loaded m_N:
for (uint32_t i = 0; i < m_N; ++i) {
...
aPoly[i].ModAddFastEq(one, m_Q);
}
and later:
for (uint32_t i = 0; i < m_N; ++i) {
...
aPoly[i].ModSubFastEq(one, m_Q);
}
With m_N = 513 and polynomial ring dimension 512, the first invalid coefficient index is i = 512.
Relevant source locations
src/binfhe/include/rgsw-cryptoparameters.hRingGSWCryptoParamsconstructor: lines 71-90- GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/binfhe/include/rgsw-cryptoparameters.h#L71-L90
RingGSWCryptoParams::load(): lines 183-200- GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/binfhe/include/rgsw-cryptoparameters.h#L183-L200
src/binfhe/lib/rgsw-cryptoparameters.cppRingGSWCryptoParams::PreCompute(bool): lines 33-121- GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/binfhe/lib/rgsw-cryptoparameters.cpp#L33-L121
- GINX monomial precomputation: lines 90-106
- GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/binfhe/lib/rgsw-cryptoparameters.cpp#L90-L106
- Relevant
aPoly[i]accesses: lines 94-96 and 101-103 - GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/binfhe/lib/rgsw-cryptoparameters.cpp#L94-L103
Suggested direction
It looks reasonable to add a dedicated post-load validation step before PreCompute() runs.
At minimum, the loaded object should reject archives where:
m_polyParams == nullptrm_polyParams->GetRingDimension() != m_Nm_polyParams->GetModulus() != m_Q
It may also be worth checking the canonical RingGSW relationship:
m_polyParams->GetCyclotomicOrder() == 2 * m_N
I think this validation should happen before PreCompute(), and ideally in one centralized helper so the constructor path and deserialization path do not drift apart.
It may also be safer to recompute derived fields such as m_digitsG from the loaded base parameters instead of fully trusting serialized derived values.
If the archive is inconsistent, it seems better to reject it cleanly than to try to continue with shortened loops or partially valid parameters.
Question
Is this considered expected behavior for malformed serialized input, or would you treat this as a missing post-deserialization validation bug in RingGSWCryptoParams::load()?
Reported by Jiang Chao, Beijing University of Posts and Telecommunications