Skip to content

[Bug report] Plaintext_SwapData can desynchronize coeff_count_ from backing storage and trigger out-of-bounds reads #762

Description

@CCYJ1014

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:

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

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