Skip to content

Bounds and input-validation hardening across ECC, PKCS#7, EVP, TLS 1.2 and ECH - #11484

Open
Frauschi wants to merge 6 commits into
wolfSSL:masterfrom
Frauschi:mem_fixes
Open

Frauschi wants to merge 6 commits into
wolfSSL:masterfrom
Frauschi:mem_fixes

Conversation

@Frauschi

@Frauschi Frauschi commented Sep 17, 2026

Copy link
Copy Markdown
Member

Six independent robustness fixes, kept as separate commits. Each adds a missing bound or corrects an offset calculation, and each ships a regression test.

Changes

  • tls13: the ECH acceptance confirmation is computed from the handshake header rather than from the buffer the message arrived in. The two differ for a fragmented or coalesced ServerHello.
  • evp: the padded decrypt path no longer holds a stored block and a partial input block at the same time, keeping EVP_CipherUpdate output within the documented inl + block size. EVP_CipherFinal() also returns a stored block when padding is disabled, where it was previously dropped.
  • pkcs7: the AuthEnvelopedData authTag copy is bounded by the input remaining after the OCTET STRING header, and its bounds check is no longer compiled out in the default build. Two adjacent issues in the same state go with it: encOID was not restored on re-entry, and the re-buffer call bypassed the shared error handler.
  • tls: the client rejects a ServerHello cipher suite that is not usable at the negotiated version, which is the check VerifyServerSuite() already performs on the server, and stops offering TLS 1.3-only suites below TLS 1.3.
  • ecc: explicit EC domain parameters, and curves installed through wc_ecc_set_custom_curve(), are bounded by MAX_ECC_BYTES, which is what the fixed buffers in ecc.c are dimensioned for.
  • ecc: X9.63 ordinates must match the curve size, wolfSSL_EC_POINT_point2hex() refuses ordinates wider than the curve, and sp_ecc_is_point_*() in all eight SP back ends range checks its input so the back ends agree on which points are acceptable.

The sp_*.c files are generator output based on https://github.com/wolfSSL/scripts/pull/685.

Behavioral changes

  • X9.63 EC points must be exactly the curve's size. Shorter and longer encodings return ECC_BAD_ARG_E; they are non-canonical and OpenSSL rejects them too, so a conforming peer is unaffected.
  • SP builds return ECC_OUT_OF_RANGE_E for ordinates that are not less than the field prime, as the other math back ends already did.
  • EVP_DecryptUpdate() emits a completed block as soon as more input arrives rather than withholding it until Final, so out must hold at least inl + EVP_CIPHER_CTX_block_size() bytes and must not overlap in. The doxygen block and its example are corrected.
  • Explicit-parameter EC keys and certificates whose prime exceeds MAX_ECC_BYTES (66 bytes, 128 with SAKKE) fail with ASN_PARSE_E; every standard prime curve up to P-521 still decodes. In WOLFSSL_ECC_CURVE_STATIC builds this also refuses the Koblitz curves, whose order is a byte wider than their prime, in a build sized exactly to that prime.
  • A TLS 1.2 or DTLS 1.2 peer that selects a TLS 1.3 suite fails with UNSUPPORTED_SUITE at ServerHello.
  • PKCS#7 AuthEnvelopedData input ending inside the authTag returns WC_PKCS7_WANT_READ_E when streaming and BUFFER_E otherwise. A chunked delivery split inside the tag now completes once the rest arrives.
  • A fragmented ServerHello or HelloRetryRequest no longer spuriously rejects ECH.

… buffer

EchCalcAcceptance() hashes the message from its handshake header and
reads the confirmation bytes at an offset into it, so its input must
start at that header. DoTls13ServerHello() instead passed the buffer it
had been handed together with an index measured from that buffer's base.
The two coincide only when the ServerHello is the first, unfragmented
handshake message of its record; otherwise the trailing hash length it
computes is wrong.

Derive the message start as input + args->begin - headerSz, the
adjustment HashInput() already makes, and convert both the server-random
offset and the HelloRetryRequest confBuf offset to be relative to it.
That holds on every path reaching here: a record buffer, a reassembled
TLS message, and the DTLS 1.3 reassembly path, which writes the
handshake header immediately before the assembled message. The adjusted
value goes into a local rather than back into args->acceptOffset, so
re-entering TLS_ASYNC_FINALIZE gives the same result.

EchCalcAcceptance() additionally rejects offsets that do not lie inside
the message, so a future caller mistake cannot produce a negative hash
length again. The server-side caller, EchWriteAcceptance(), already
passes header-relative values and is unaffected.

Behaviour change: a fragmented ServerHello or HelloRetryRequest no
longer spuriously rejects ECH. A ServerHello coalesced behind a
HelloRetryRequest that accepted ECH now fails with INVALID_PARAMETER,
which is the RFC 9849 section 6.1.5 outcome.

Tests: test_tls13_ech_hrr_coalesced_server_hello and
test_tls13_ech_fragmented_server_hello, both failing before the fix and
both needing HAVE_ECH, HAVE_SNI and the manual memio dependencies.
In padded decrypt mode the function held two kinds of pending state at
once: the most recent decrypted block in ctx->lastBlock for the padding
check in Final, and a partial input block in ctx->buf. Nothing kept the
two mutually exclusive, so a call that completed the partial block and
carried further full blocks could write more than the inl + block_size
that EVP_DecryptUpdate promises, which is what our own doxygen example
for the function relies on. The library's own callers decrypt in single
Update calls and are unaffected.

A stored block is now output as soon as more input arrives, and the
buffer completion path stores one only when the call ends on a block
boundary, which is what OpenSSL does. Every call therefore stays within
inl + block_size, the flush in the full blocks path becomes dead and is
removed, and Final still sees either lastUsed or a non-zero bufUsed for
truncated input.

The same path also stored a block completed from ctx->buf when padding
is disabled, where Final only checks that nothing is buffered and never
looks at lastBlock, so that plaintext was silently dropped. The store is
skipped with WOLFSSL_EVP_CIPH_NO_PADDING set, and Final now returns a
block that was stored before padding was turned off.

Behaviour change: which Update call returns a given block moves one call
earlier for input that is not fed in block multiples, matching OpenSSL,
so the three chunked decrypt checks in wolfcrypt/test/test.c are
updated. Two of the three had no body on the if and had never checked
anything. out must be at least inl + block size and must not overlap in;
the doxygen documented neither and sized its example buffer at exactly
inl. Strict in-place operation is not supported and never was.

Tests: test_evp_cipher_update_chunked_bound, which decrypts a 64 byte
AES-128-CBC ciphertext in every three way split against a buffer of
exactly inl + block size followed by a canary, and
test_evp_cipher_update_no_padding_buffered.
In the WC_PKCS7_AUTHENV_ATRBEND state the streaming decoder guaranteed
only MAX_LENGTH_SZ + ASN_TAG_SZ bytes before copying the authTag out of
pkiMsg. Its re-buffer guard compared the tag size plus the OCTET STRING
header against pkiMsgSz, the size of the whole buffer, instead of the
bytes left after the header, so for a real message it never fired. Both
explicit BUFFER_E bounds checks in front of the copy were compiled only
under NO_PKCS7_STREAM, and no configure option defines that macro.

Measure the re-buffer guard against pkiMsgSz - localIdx and, after a
successful wc_PKCS7_AddDataToStream(), restore localIdx from the header
offset and refresh pkiMsgSz, the idiom wc_PKCS7_DecodeEnvelopedData()
already uses for encryptedContent. Make the pre-copy BUFFER_E check
unconditional so the copy cannot exceed the available input whatever the
streaming logic decides, and drop the weaker NO_PKCS7_STREAM-only check
it subsumes.

Two adjacent defects in the same state go with it. The state prologue
never restored encOID, unlike WC_PKCS7_AUTHENV_5 and _6, so the AES-GCM
and AES-CCM tag size gates below it compared against 0 on every
re-entry, and the bound above makes that re-entry the normal streaming
path rather than a rare corner. The re-buffer call also returned
directly, so an error from it skipped the shared handler that releases
the AAD and resets the stream; it now breaks into that handler, which
already passes WC_PKCS7_WANT_READ_E through untouched.

Behaviour change: input ending inside the tag returns
WC_PKCS7_WANT_READ_E when streaming and BUFFER_E under NO_PKCS7_STREAM.
A chunked delivery whose read boundary falls inside the tag now
completes once the rest arrives; previously it failed with
AES_GCM_AUTH_E.

test_wc_PKCS7_DecodeAuthEnvelopedData_truncated was compiled only under
NO_PKCS7_STREAM, so the default streaming decoder was never exercised,
and it decoded from an oversized stack buffer. It now runs in streaming
builds, decodes from exact sized heap copies, and sweeps truncations
into the tag as well as the existing cut inside encryptedContent.
DoServerHello() checked only that the ServerHello's cipher suite
appeared in the client's own list, never that it is usable at the
negotiated version. VerifyServerSuite() has exactly that check on the
server, and the client had no equivalent. A resuming ClientHello is
pinned to the cached session's version by wolfSSL_set_session() but
still carries the full configured suite list, so a downgrade-capable
client resuming a TLS 1.2 session by session ID offers TLS 1.3-only
suites inside a TLS 1.2 hello. With HAVE_NULL_CIPHER built,
TLS_SHA384_SHA384 is in the default client list without any application
opt-in. Selecting such a suite gives SetCipherSpecs() sizes whose TLS
1.2 key block does not fit key_dig, on a resumption path that derives
keys before the peer is authenticated.

Reject the suite in SetCipherSpecs(), where the specs become
authoritative: specs.kea == any_kea marks exactly the suites
VerifyServerSuite() rejects, so one check covers both DoServerHello()
paths as well as the DTLS key-import API and the sniffer. Every other
caller already runs with ssl->version at TLS 1.3 when a TLS 1.3 suite is
legitimate. DeriveTlsKeys() additionally returns BUFFER_E for a key
block larger than its buffer. wc_PRF_TLS() keeps no MAX_PRF_DIG bound
because wolfSSL_export_keying_material() and the EAP key export
legitimately ask for more.

Stop offering the suites in the first place: SendClientHello() is
reached only below TLS 1.3, since SendTls13ClientHello() takes over
above it, so filter TLS 1.3-only suites out of the cipher_suites it
writes and fail with SUITES_ERROR if nothing is left.
VerifyServerSuite() already carried IsTls13CipherSuite(), a second copy
of the same predicate; the two are merged into IsTls13OnlySuite(),
keeping the HAVE_NULL_CIPHER and SM4 guards, so a future TLS 1.3 suite
has one list to be added to.

SetCipherSpecs() zeroes ssl->specs before returning UNSUPPORTED_SUITE.
GetCipherSpec() has already written the rejected suite's key, hash and
IV sizes there, and ssl->options.tls, ssl->hmac and the DTLS fields are
not updated on that path, so a caller that tolerated the failure would
run TLS 1.2 with a TLS 1.3 key block.

Behaviour change: a TLS 1.2 or DTLS 1.2 peer that picks a TLS 1.3 suite
now fails with UNSUPPORTED_SUITE at ServerHello rather than
BAD_KEA_TYPE_E or a Finished failure.

Tests: test_tls12_server_hello_tls13_suite rewrites the suite of a
resuming ServerHello and expects UNSUPPORTED_SUITE;
test_tls12_client_hello_no_tls13_suites checks that the resuming
ClientHello drops the TLS 1.3 suites the non-resuming one offers, and
still resumes.
EccSpecifiedECDomainDecode() took the curve size straight from the DER
length of the prime INTEGER with no upper bound, and
wc_EccPublicKeyDecode() then installed that curve on the caller's key.
Every fixed buffer in ecc.c is dimensioned for MAX_ECC_BYTES, so a curve
larger than that left the export and encode paths padding a coordinate
outside its buffer, with both the offset and the bytes written taken
from the key. One of those paths reaches it through the library's own
allocation, with no output buffer from the caller at all.

Reject the parameters where the size is taken from input, which is where
the static ecc_sets[] table establishes the same invariant for every
named curve. wc_ecc_set_custom_curve() applies the bound too, so a curve
installed through the API cannot exceed the fixed buffers either, and
the WOLFSSL_ECC_CURVE_STATIC branch bounds the a/b/order lengths, which
are hex-encoded into MAX_ECC_STRING arrays from their own unbounded DER
lengths. wc_ecc_set_custom_curve() rejects a non-positive size as well,
matching the decoder: size 0 let a one byte 0x04 satisfy the importer's
length check and yield a degenerate public key. Both reject paths log.

Behaviour change: explicit-parameter EC keys and certificates whose
prime is longer than MAX_ECC_BYTES (66 bytes without SAKKE, 128 with it)
now fail to decode with ASN_PARSE_E instead of being accepted with a dp
the rest of the library cannot handle safely. Every standard prime curve
up to P-521 still decodes.

Known limitation in WOLFSSL_ECC_CURVE_STATIC builds: the order bound is
MAX_ECC_BYTES, but the Koblitz curves carry an order one byte wider than
their prime, so in a build whose MAX_ECC_BYTES equals that prime size an
explicit parameter key for one of them is now refused. MAX_ECC_STRING
cannot hold its hex form, which is what the unbounded DataToHexString()
overflowed before, so this fails closed where it used to corrupt the
struct.

Test: test_wc_EccPublicKeyDecode_explicit_curve_size, which builds the
SubjectPublicKeyInfo at run time so the control prime tracks
MAX_ECC_BYTES in whatever configuration it is built for.
@Frauschi Frauschi self-assigned this Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

gcc-arm-cortex-m0plus

  • FLASH: .text +84 B (+0.1%, 67,183 B / 262,144 B, total: 26% used)

gcc-arm-cortex-m3

  • FLASH: .text +96 B (+0.1%, 126,695 B / 262,144 B, total: 48% used)

gcc-arm-cortex-m4-baremetal

  • FLASH: .text +64 B (+0.1%, 69,603 B / 262,144 B, total: 27% used)

gcc-arm-cortex-m4-dtls13

  • FLASH: .text +64 B (+0.0%, 188,028 B / 1,048,576 B, total: 18% used)

gcc-arm-cortex-m4-min-ecc

  • FLASH: .text +64 B (+0.1%, 64,453 B / 262,144 B, total: 25% used)

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .rodata +32 B, .text +256 B (+0.0%, 785,604 B / 1,048,576 B, total: 75% used)

gcc-arm-cortex-m4-pkcs7

  • FLASH: .text +64 B (+0.0%, 217,753 B / 262,144 B, total: 83% used)

gcc-arm-cortex-m4-rsa-only

  • FLASH: .text +64 B (+0.0%, 334,176 B / 1,048,576 B, total: 32% used)

gcc-arm-cortex-m4-sp-math

  • FLASH: .text +64 B (+0.1%, 64,453 B / 262,144 B, total: 25% used)

gcc-arm-cortex-m4-tls12

  • FLASH: .text +128 B (+0.1%, 127,475 B / 262,144 B, total: 49% used)

gcc-arm-cortex-m7

  • FLASH: .text +64 B (+0.0%, 205,341 B / 262,144 B, total: 78% used)

linuxkm-standard

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #11484

Scan targets checked: wolfcrypt-src, wolfcrypt-bugs, wolfssl-src, wolfssl-bugs

Findings: 2
2 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Reported findings require changes before merge.

Comment thread wolfcrypt/src/sp_c64.c Outdated
Comment thread tests/unit-mcdc/test_sp_c64_whitebox.c Outdated
_ecc_import_x963_ex2() took the ordinate width from the input length and
never compared it with the curve, so a SubjectPublicKeyInfo naming one
curve but carrying wider ordinates became a key on that curve holding
oversized ordinates. wc_ecc_set_curve() bounds the size only by
ECC_MAXSIZE and, when given a curve id, selects the curve by id without
comparing sizes. The untrusted-import validation did not catch it in SP
builds: sp_ecc_is_point_256/384/521() copy only the low limbs out of the
mp_int, so the truncated point is on the curve and the import succeeds,
where the generic wc_ecc_is_point() returns ECC_OUT_OF_RANGE_E. Every
caller of the importer is affected, including wc_EccPublicKeyDecode(),
the TLS 1.2 and TLS 1.3 key exchange, PKCS#7 KARI, HPKE and the compat
d2i_* functions; none of them compares the wire length with the curve
size first.

wolfSSL_EC_POINT_point2hex() then placed the x-ordinate at an offset
derived from the curve size minus the ordinate size, which goes negative
once an ordinate is wider than the curve. The compressed form and the y
ordinate degenerate the same way.

_ecc_import_x963_ex2() now requires the ordinate size to equal
key->dp->size, returning ECC_BAD_ARG_E otherwise. This is the check
wc_ecc_import_point_der_ex() already has, placed in the shared importer
so it covers every caller and every math back end.

wolfSSL_EC_POINT_point2hex() refuses ordinates larger than the curve
before computing any offset, mirroring wc_ecc_export_point_der(), whose
guard wolfSSL_EC_POINT_point2oct() already inherits, and picks up the
NULL ordinate check that its new bound would otherwise be the first to
dereference past.

sp_ecc_is_point_256/384/521/1024() in all eight SP back ends gain the
mp_count_bits() and modulus comparison that the neighbouring
sp_ecc_check_key_*() already carries, so SP builds no longer accept a
same-length non-canonical encoding that every other back end rejects.
Both functions also gain the mp_isneg() test the generic implementation
has: sp_<n>_from_mp() ignores the sign, so a negative ordinate was read
as its magnitude. sp_ecc_check_key_*() lacked that check before this
change as well. The sp_*.c files are generator output; the change was
made in wolfSSL/scripts sp/ecc.rb and the files regenerated, and the
result is byte for byte what the modified generator produces.

Behaviour change: X9.63 encodings shorter than the curve size are
rejected as well. They are non-canonical under SEC 1 section 2.3.4 and
RFC 5480 and OpenSSL rejects them too. With ECC_CURVE_DEF the size-based
curve lookup still resolves exact-size encodings as before.

Tests: test_wc_ecc_import_x963_oversized and
test_wc_ecc_import_x963_non_canonical cover the importer,
test_wolfSSL_EC_POINT_point2hex_oversized covers the sink, and all three
fail on the unfixed tree. The SP MC/DC drivers gain the new bit-length
and sign branches and an operand exactly the field's width but not below
the modulus, without which the new range compare was only ever reached
with the bit-length guard already true. Those rows now assert
ECC_OUT_OF_RANGE_E rather than discarding the result, and report a
failure separately from a skipped row; removing either guard from a back
end makes them fail.
@wolfSSL-Fenrir-bot
wolfSSL-Fenrir-bot dismissed their stale review September 17, 2026 15:37

Fenrir's latest completed scan found no issues; clearing the prior automated change request.

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #11484

Scan targets checked: wolfcrypt-src, wolfcrypt-bugs, wolfssl-src, wolfssl-bugs

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

@Frauschi Frauschi added the For This Release Release version 5.9.4 label Sep 17, 2026
@Frauschi Frauschi assigned wolfSSL-Bot and unassigned Frauschi Sep 17, 2026
@Frauschi Frauschi removed the For This Release Release version 5.9.4 label Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants