Hi SEAL team,
I would like to report a Plaintext state-consistency bug in Microsoft SEAL's C API.
I originally found this on SEAL v4.4.0. I rebuilt and reran the reproducer against the latest SEAL v4.4.3 release, and the issue is still present.
Plaintext_SwapData replaces a Plaintext's internal DynArray buffer, but it does not update coeff_count_ to match the new buffer length. This allows a public C API call to create a Plaintext whose logical coefficient count is larger than the backing array. Later C API helpers such as Plaintext_SignificantCoeffCount and Plaintext_ToString then read past the end of the heap buffer.
Summary
Plaintext_SwapData currently constructs a new DynArray<uint64_t> of the caller-provided count and swaps it into the Plaintext:
DynArray<uint64_t> new_array(plain->pool());
new_array.resize(count);
copy_n(new_data, count, new_array.begin());
ph::swap_data(plain, new_array);
The private helper only swaps data_:
swap(plain->data_, new_data);
It does not update coeff_count_. Starting from Plaintext plain(4), calling:
Plaintext_SwapData(&plain, 1, replacement);
leaves coeff_count_ == 4 while the backing DynArray has size 1. SEAL's own plaintext buffer-validity predicate explicitly defines this state as invalid:
in.coeff_count() == in.dyn_array().size()
In the companion C API validation probe, ValCheck_Plaintext_IsValidFor consequently reports the object as invalid. However, Plaintext_SignificantCoeffCount and Plaintext_ToString do not check that invariant before using the stale coeff_count_, so they can read out of bounds. Source inspection also shows that Plaintext_NonZeroCoeffCount follows the same stale-count scan pattern, although I have not dynamically exercised that third sink in this report.
The same invalid object is also observable across the C API:
ValCheck_Plaintext_IsValidFor returns S_OK with is_valid = false;
Plaintext_SaveSize and Plaintext_Save still succeed on the invalid object;
Plaintext_Load later rejects the emitted blob with 0x80131509.
The OOB reads are the primary confirmed memory-safety issue. The save/load behavior is a secondary state-consistency symptom: SEAL can serialize a Plaintext state that its own checked load path rejects.
Environment
Current release reproduction:
- SEAL release/tag:
v4.4.3
- Tested revision:
356f2e6dcc0520dc9fc14e98674d9a56cba6018c
- OS: Linux x86_64
- Compiler: Clang
14.0.0
- Sanitizers: AddressSanitizer and UndefinedBehaviorSanitizer
Original discovery:
- SEAL release/tag:
v4.4.0
- Tested revision:
04d53b99ce745efc26bb4965be609b9894755227
Minimal Reproduction
The reproducer starts with a Plaintext of coefficient count 4, replaces its data buffer with a length-1 array through Plaintext_SwapData, and then calls a consumer that still trusts the stale coefficient count.
#include "seal/c/plaintext.h"
#include "seal/plaintext.h"
#include <cstdint>
using namespace seal;
int main()
{
Plaintext plain(4);
plain[0] = 1;
plain[1] = 2;
plain[2] = 3;
plain[3] = 4;
std::uint64_t replacement[1] = { 7 };
auto hr = Plaintext_SwapData(&plain, 1, replacement);
if (hr != 0)
{
return static_cast<int>(hr);
}
std::uint64_t sig = 0;
return static_cast<int>(Plaintext_SignificantCoeffCount(&plain, &sig));
}
The full local probe also contains companion modes for:
swap-shorter-then-tostring
swap-shorter-then-valcheck
swap-shorter-save-then-load
Those modes exercise the second confirmed OOB sink and the validation / serialization observations shown below.
Expected Behavior
At minimum, Plaintext_SwapData must not return S_OK while leaving coeff_count_ inconsistent with the installed DynArray.
The most direct fix appears to be updating coeff_count_ to count together with data_. Alternatively, if retaining the old logical coefficient count is intentional API behavior, mismatched sizes should be rejected before mutation.
In either policy, a successful public C API call should not leave a Plaintext in a state that SEAL's own is_buffer_valid(Plaintext) predicate
rejects and that subsequently invoked read-only helpers can consume out of bounds.
Actual Behavior
After:
Plaintext_SwapData(&plain, 1, replacement);
later helpers still use the old logical coefficient count and read beyond the new buffer.
ASan report for Plaintext_SignificantCoeffCount:
ERROR: AddressSanitizer: heap-buffer-overflow
#0 seal::util::get_significant_uint64_count_uint(...) at native/src/seal/util/uintcore.h:269
#1 seal::Plaintext::significant_coeff_count() const at native/src/seal/plaintext.h:535
#2 Plaintext_SignificantCoeffCount(...) at native/src/seal/c/plaintext.cpp:377
ASan report for Plaintext_ToString:
ERROR: AddressSanitizer: heap-buffer-overflow
#5 seal::util::is_zero_uint(...) at native/src/seal/util/uintcore.h:137
#6 seal::util::poly_to_hex_string(...) at native/src/seal/util/polycore.h:40
#7 seal::Plaintext::to_string() const at native/src/seal/plaintext.h:576
#8 Plaintext_ToString(...) at native/src/seal/c/plaintext.cpp:230
Plaintext_NonZeroCoeffCount appears affected by the same stale-count pattern by source inspection, because it also scans data_.cbegin() using coeff_count_. I have not dynamically exercised that third sink here.
The companion validation / serialization mode prints:
Plaintext_SwapData returned hr=0
ValCheck_Plaintext_IsValidFor returned hr=0 is_valid=0
Plaintext_SaveSize returned hr=0 save_size=96
Plaintext_Save returned hr=0 out_bytes=96
Plaintext_Load returned hr=2148734217 in_bytes=0
So the current behavior is:
- invalid state can be constructed through
Plaintext_SwapData;
- C API validation reports the state as invalid;
- OOB-reading helpers can still consume that state;
- serialization still emits a blob for that invalid state;
- checked deserialization rejects that blob later.
Cause Analysis
Plaintext_SwapData builds a replacement array whose length is controlled by the C API caller:
DynArray<uint64_t> new_array(plain->pool());
new_array.resize(count);
copy_n(new_data, count, new_array.begin());
ph::swap_data(plain, new_array);
The helper that installs the replacement array only swaps data_:
static void swap_data(seal::Plaintext *plain, seal::DynArray<uint64_t> &new_data)
{
swap(plain->data_, new_data);
}
It does not update coeff_count_. This breaks the private Plaintext invariant expressed by SEAL's own buffer-validity check:
bool is_buffer_valid(const Plaintext &in)
{
if (in.coeff_count() != in.dyn_array().size())
{
return false;
}
return true;
}
The neighboring helper used by Plaintext_Set4 does update the same metadata, and the standard Plaintext::resize() implementation also resizes data_ and then assigns coeff_count_. That makes swap_data an outlier among the nearby mutation paths that change plaintext storage size.
Plaintext::significant_coeff_count() then trusts coeff_count_:
return util::get_significant_uint64_count_uint(data_.cbegin(), coeff_count_);
and Plaintext::to_string() also trusts coeff_count_:
return util::poly_to_hex_string(data_.cbegin(), coeff_count_, 1);
Plaintext::nonzero_coeff_count() uses the same pattern with get_nonzero_uint64_count_uint(...).
If the swapped-in array is shorter than the old logical coefficient count, the dynamically exercised helpers pass a pointer to a length-1 allocation together with a stale count of 4, causing out-of-bounds reads.
The serialization inconsistency follows from the same stale metadata: save_members() serializes coeff_count_ and data_ independently, while checked loading later rejects the reconstructed object when is_buffer_valid fails.
Impact
This is a C API state-consistency and memory-safety bug. A public C API call can create a Plaintext state that SEAL's own buffer-validity predicate rejects, and subsequently invoked read-only C API helpers can then read past the end of the replacement buffer. This is a confirmed out-of-bounds read consistent with CWE-125; the security impact is deployment-dependent.
Relevant Source Locations
Current release v4.4.3:
native/src/seal/c/plaintext.cpp
native/src/seal/c/valcheck.cpp
native/src/seal/plaintext.h
native/src/seal/plaintext.cpp
native/src/seal/valcheck.cpp
native/src/seal/valcheck.h
native/src/seal/util/uintcore.h
native/src/seal/util/polycore.h
Suggested Direction
A fix should make Plaintext_SwapData preserve the full Plaintext state invariant. Possible policies are:
- treat the supplied
count as the new logical coefficient count and update coeff_count_ together with data_;
- if changing logical length is not intended, reject any
count that differs from the current coeff_count_ before mutating the object;
Reported by Jiang Chao, Beijing University of Posts and Telecommunications
Hi SEAL team,
I would like to report a
Plaintextstate-consistency bug in Microsoft SEAL's C API.I originally found this on SEAL
v4.4.0. I rebuilt and reran the reproducer against the latest SEALv4.4.3release, and the issue is still present.Plaintext_SwapDatareplaces aPlaintext's internalDynArraybuffer, but it does not updatecoeff_count_to match the new buffer length. This allows a public C API call to create aPlaintextwhose logical coefficient count is larger than the backing array. Later C API helpers such asPlaintext_SignificantCoeffCountandPlaintext_ToStringthen read past the end of the heap buffer.Summary
Plaintext_SwapDatacurrently constructs a newDynArray<uint64_t>of the caller-providedcountand swaps it into thePlaintext:The private helper only swaps
data_:swap(plain->data_, new_data);It does not update
coeff_count_. Starting fromPlaintext plain(4), calling:leaves
coeff_count_ == 4while the backingDynArrayhas size 1. SEAL's own plaintext buffer-validity predicate explicitly defines this state as invalid:In the companion C API validation probe,
ValCheck_Plaintext_IsValidForconsequently reports the object as invalid. However,Plaintext_SignificantCoeffCountandPlaintext_ToStringdo not check that invariant before using the stalecoeff_count_, so they can read out of bounds. Source inspection also shows thatPlaintext_NonZeroCoeffCountfollows the same stale-count scan pattern, although I have not dynamically exercised that third sink in this report.The same invalid object is also observable across the C API:
ValCheck_Plaintext_IsValidForreturnsS_OKwithis_valid = false;Plaintext_SaveSizeandPlaintext_Savestill succeed on the invalid object;Plaintext_Loadlater rejects the emitted blob with0x80131509.The OOB reads are the primary confirmed memory-safety issue. The save/load behavior is a secondary state-consistency symptom: SEAL can serialize a
Plaintextstate that its own checked load path rejects.Environment
Current release reproduction:
v4.4.3356f2e6dcc0520dc9fc14e98674d9a56cba6018c14.0.0Original discovery:
v4.4.004d53b99ce745efc26bb4965be609b9894755227Minimal Reproduction
The reproducer starts with a
Plaintextof coefficient count 4, replaces its data buffer with a length-1 array throughPlaintext_SwapData, and then calls a consumer that still trusts the stale coefficient count.The full local probe also contains companion modes for:
Those modes exercise the second confirmed OOB sink and the validation / serialization observations shown below.
Expected Behavior
At minimum,
Plaintext_SwapDatamust not returnS_OKwhile leavingcoeff_count_inconsistent with the installedDynArray.The most direct fix appears to be updating
coeff_count_tocounttogether withdata_. Alternatively, if retaining the old logical coefficient count is intentional API behavior, mismatched sizes should be rejected before mutation.In either policy, a successful public C API call should not leave a
Plaintextin a state that SEAL's ownis_buffer_valid(Plaintext)predicaterejects and that subsequently invoked read-only helpers can consume out of bounds.
Actual Behavior
After:
later helpers still use the old logical coefficient count and read beyond the new buffer.
ASan report for
Plaintext_SignificantCoeffCount:ASan report for
Plaintext_ToString:Plaintext_NonZeroCoeffCountappears affected by the same stale-count pattern by source inspection, because it also scansdata_.cbegin()usingcoeff_count_. I have not dynamically exercised that third sink here.The companion validation / serialization mode prints:
So the current behavior is:
Plaintext_SwapData;Cause Analysis
Plaintext_SwapDatabuilds a replacement array whose length is controlled by the C API caller:The helper that installs the replacement array only swaps
data_:It does not update
coeff_count_. This breaks the privatePlaintextinvariant expressed by SEAL's own buffer-validity check:The neighboring helper used by
Plaintext_Set4does update the same metadata, and the standardPlaintext::resize()implementation also resizesdata_and then assignscoeff_count_. That makesswap_dataan outlier among the nearby mutation paths that change plaintext storage size.Plaintext::significant_coeff_count()then trustscoeff_count_:return util::get_significant_uint64_count_uint(data_.cbegin(), coeff_count_);and
Plaintext::to_string()also trustscoeff_count_:Plaintext::nonzero_coeff_count()uses the same pattern withget_nonzero_uint64_count_uint(...).If the swapped-in array is shorter than the old logical coefficient count, the dynamically exercised helpers pass a pointer to a length-1 allocation together with a stale count of 4, causing out-of-bounds reads.
The serialization inconsistency follows from the same stale metadata:
save_members()serializescoeff_count_anddata_independently, while checked loading later rejects the reconstructed object whenis_buffer_validfails.Impact
This is a C API state-consistency and memory-safety bug. A public C API call can create a
Plaintextstate that SEAL's own buffer-validity predicate rejects, and subsequently invoked read-only C API helpers can then read past the end of the replacement buffer. This is a confirmed out-of-bounds read consistent with CWE-125; the security impact is deployment-dependent.Relevant Source Locations
Current release
v4.4.3:native/src/seal/c/plaintext.cppPlaintextPrivateHelper::swap_dataPlaintextPrivateHelper::setupdatingcoeff_count_Plaintext_ToStringPlaintext_SignificantCoeffCountPlaintext_NonZeroCoeffCountPlaintext_SwapDataPlaintext_SaveSize/Plaintext_SavePlaintext_Loadnative/src/seal/c/valcheck.cppValCheck_Plaintext_IsValidFornative/src/seal/plaintext.hPlaintext::resizeupdatingdata_andcoeff_count_Plaintext::significant_coeff_countPlaintext::nonzero_coeff_countPlaintext::to_stringPlaintext::save_sizePlaintext::loadchecked byte-buffer overloadcoeff_count_anddata_membersnative/src/seal/plaintext.cppPlaintext::save_membersPlaintext::load_membersbuffer-validity checknative/src/seal/valcheck.cppis_buffer_valid(const Plaintext&)native/src/seal/valcheck.his_valid_for(const Plaintext&, const SEALContext&)native/src/seal/util/uintcore.hget_significant_uint64_count_uintget_nonzero_uint64_count_uintnative/src/seal/util/polycore.hpoly_to_hex_stringSuggested Direction
A fix should make
Plaintext_SwapDatapreserve the fullPlaintextstate invariant. Possible policies are:countas the new logical coefficient count and updatecoeff_count_together withdata_;countthat differs from the currentcoeff_count_before mutating the object;Reported by Jiang Chao, Beijing University of Posts and Telecommunications