Skip to content

[Android] Add missing JNI exception checks in crypto PAL#128777

Open
simonrozsival wants to merge 21 commits into
mainfrom
dev/simonrozsival/android-pal-jni-exception-checks-pr1
Open

[Android] Add missing JNI exception checks in crypto PAL#128777
simonrozsival wants to merge 21 commits into
mainfrom
dev/simonrozsival/android-pal-jni-exception-checks-pr1

Conversation

@simonrozsival

@simonrozsival simonrozsival commented May 29, 2026

Copy link
Copy Markdown
Member

Note

This PR description was drafted by an AI assistant (GitHub Copilot CLI) based on the actual code changes in this branch. All code in this PR was authored interactively with Simon driving design decisions through multiple review rounds.

Summary

This PR adds missing JNI exception checks across several files in the Android cryptography PAL. The work is based on a static audit of all Call*Method/NewObject/GetArrayLength-style JNI calls and targets sites where a pending Java exception could silently leak back into managed code.

Beyond adding the exception checks, the changed functions are tightened to follow three patterns already established elsewhere in the PAL:

  1. ON_EXCEPTION_PRINT_AND_GOTO(label) immediately after each fallible JNI call. The macro both prints and clears the exception, so subsequent JNI calls along the cleanup/error path are safe.
  2. INIT_LOCALS(loc, …) + single cleanup:/RELEASE_LOCALS(loc, env) tail for local-ref hygiene, replacing ad-hoc jobject foo = NULL; declarations and scattered DeleteLocalRef calls.
  3. No partial output state on failure — functions either defer caller-visible writes until success or clean up/reset partially promoted global refs before returning failure, so the caller never observes a half-populated result.

A few smaller correctness fixes surfaced during review ride along with the audit: the shared GetEnumAsInt helper no longer deletes the local ref passed to it (each caller now owns and releases its own ref), and SSLStreamWrite now null-checks the buffer returned by EnsureRemaining before use. Both are described under Behavior changes worth flagging below.

Files changed (11)

All under src/native/libs/System.Security.Cryptography.Native.Android/:

File Functions
pal_cipher.c ReinitializeCipher
pal_dsa.c DsaSizeP, GetDsaParameters, GetQParameter, DsaKeyCreateByExplicitParameters
pal_ecc_import_export.c GetECKeyParameters, GetECCurveParameters, CreateKeyPairFromCurveParameters, ConvertBigIntegerToPositiveInt32, EcKeyCreateByExplicitParameters
pal_ecdh.c EcdhDeriveKey
pal_eckey.c EcKeyGetSize
pal_hmac.c HmacCreate
pal_jni.c GetEnumAsInt
pal_ssl.c SSLGetSupportedProtocols
pal_sslstream.c GetHandshakeStatus, WrapAndProcessResult, Close, DoUnwrap, FreeSSLStream, SSLStreamInitialize, AddCertChainToStore, SSLStreamWrite
pal_x509.c X509DecodeCollection, X509ExportPkcs7
pal_x509store.c ContainsEntryForAlias, ContainsMatchingCertificateForAlias, X509StoreAddCertificate, X509StoreAddCertificateWithPrivateKey, X509StoreContainsCertificate, X509StoreRemoveCertificate, EnumerateCertificates, SystemAliasFilter

Behavior changes worth flagging

  • GetEnumAsInt (pal_jni.c) no longer calls DeleteLocalRef on its enumObj argument — it is now a pure getter. Each of its five call sites already holds the enum result in a named local that is released through the function's own cleanup: path (RELEASE_LOCALS / ReleaseLRef), so every ref is freed exactly once by its owner. This removes a latent double-DeleteLocalRef in GetHandshakeStatus and the same footgun for the other callers.
  • AndroidCryptoNative_GetDsaParameters / AndroidCryptoNative_GetECKeyParameters now zero out their out length parameters (*pLength, *qLength, *cbQx, etc.) at function entry. Previously these were set on success or left untouched on failure. No managed caller relies on the prior partial-write behavior.
  • ContainsEntryForAlias / ContainsMatchingCertificateForAlias (pal_x509store.c) — return type changed from bool to int32_t (SUCCESS/FAIL) with a separate *contains/*matches out-parameter, so callers can distinguish "lookup failed due to JNI exception" from "lookup succeeded; not contained". Callers are updated accordingly.
  • FreeSSLStream now NULL-checks sslStream->managedContextCleanup before invoking it. This guards the case where SSLStreamCreate succeeded but SSLStreamInitialize was never called.
  • SSLStreamInitialize now transfers ownership of managedContextHandle / streamReader / streamWriter / managedContextCleanup to the native SSLStream as soon as initialization begins. Combined with the FreeSSLStream NULL-check above, native cleanup skips the callback only when initialization was never called, while partial JNI setup failures still release the managed context handle.
  • GetHandshakeStatus now splits SSLEngine.getHandshakeStatus() from the enum-ordinal conversion, checks for JNI exceptions/NULL before calling GetEnumAsInt, stores the ordinal in a local and only promotes it to the return value once retrieval fully succeeds (otherwise ret stays -1), and releases the handshake-status local through the standard INIT_LOCALS + cleanup: + RELEASE_LOCALS pattern.
  • AndroidCryptoNative_SSLStreamWrite now captures the buffer returned by EnsureRemaining in a local and bails out through cleanup if it is NULL, instead of assigning it straight to sslStream->appOutBuffer. On a buffer-expansion failure this avoids both a NULL-object JNI call (ByteBuffer.put) and a leaked global ref; the write returns SSLStreamStatus_Error (surfaced as InternalError by the managed caller) instead of crashing. Mirrors the existing handling in DoUnwrap.

Out of scope

A few pre-existing bugs were surfaced during review but intentionally left out of scope to keep this PR focused on the exception-check work:

  • pal_x509store.c:115,141,186EntryFlags_HasCertificate & EntryFlags_MatchesCertificate should be | (dead code since 2021).
  • pal_ecc_import_export.c:574AddGRef should be ToGRef (intermediate lref leak).
  • pal_ecc_import_export.c:577-581CheckJNIExceptions only runs when keyPair is NULL.
  • pal_x509.c X509DecodeCollection — per-iteration gref leak on failure.

These will be addressed as separate targeted fixes.

Related

Other open PRs in the same area (Android crypto PAL / JNI error handling):

Testing

Suite Result
Android native libraries build (./src/native/libs/build-native.sh arm64 Debug -os android ninja) Passed
Android native libraries build (./build.sh libs.native -os android -arch arm64 -c Release) Passed
System.Security.Cryptography.Tests (Android arm64 Debug device) 11715 run / 10738 passed / 0 failed / 977 skipped
System.Net.Security.Tests (Android arm64 Debug device) 4890 run / 4854 passed / 4 failed / 32 skipped (matches pre-PR baseline — unrelated DelayedCertificate/ALPN tests)

CI will provide cross-architecture validation (arm, x86, x64) and additional API levels not covered locally.

simonrozsival and others added 6 commits May 29, 2026 13:09
This is PR 1 of 3 addressing findings from a static audit of the Android
crypto PAL (libSystem.Security.Cryptography.Native.Android). It targets
the 26 HIGH-severity findings on files that are not touched by any other
in-flight PR. Two follow-up PRs will address the remaining HIGH findings
on pal_x509chain.c and pal_x509store.c (after merging dependencies)
and the MEDIUM/LOW findings.

Files affected (10):
  pal_cipher.c               (1 HIGH)
  pal_dsa.c                  (4 HIGH)
  pal_ecc_import_export.c    (8 HIGH)
  pal_ecdh.c                 (1 HIGH)
  pal_eckey.c                (1 HIGH)
  pal_hmac.c                 (1 HIGH + a gref leak on failure path)
  pal_ssl.c                  (3 HIGH)
  pal_sslstream.c            (5 HIGH + 2 tightly coupled bug fixes)
  pal_x509.c                 (2 HIGH)
  pal_x509store.c            (4 HIGH + 1 partial)

Patterns applied
----------------
- Pattern B (chained Call*Method without intermediate checks): inserted
  ON_EXCEPTION_PRINT_AND_GOTO between successive throwing JNI calls so
  later calls never run with a pending exception or on a NULL receiver
  (undefined behavior today, varying by CheckJNI vs. release).

- Pattern C (pending exception leaks back to .NET): added explicit
  CheckJNIExceptions or TryClearJNIExceptions at the relevant return
  paths so callers see a clean FAIL rather than a poisoned env.

- Internal helpers ContainsEntryForAlias and
  ContainsMatchingCertificateForAlias in pal_x509store.c were refactored
  to return int32_t with an out-param so JNI exceptions can no longer be
  swallowed by collapsing them into a bool result.

Tightly coupled bug fixes in pal_sslstream.c
--------------------------------------------
- FreeSSLStream: NULL-check managedContextCleanup before calling it.
  This crash is reachable today on main: when InitializeSslContext
  throws (e.g. EncryptionPolicy.NoEncryption), Dispose runs on a
  zero-initialized SSLStream and dereferences a NULL function pointer.
  Verified by running System.Net.Security.Tests against pristine main:
  same SIGSEGV at AndroidCryptoNative_SSLStreamRelease+48.

- SSLStreamInitialize: hoist managedContext / streamReader /
  streamWriter / managedContextCleanup assignments above the first
  goto so a failure during init leaves the struct in a consistent
  state for FreeSSLStream.

Out of scope
------------
- Explicit abort_unless / abort_if_invalid_pointer_argument on JNI
  pointers in pal_evp.c, pal_ecdsa.c, and pal_dsa.c:192 are left
  as-is per the directive to preserve existing predictable abort
  behavior.

Validation
----------
- ./build.sh libs.native -os android -arch arm64 -c Debug : OK
- ./build.sh clr+libs+host -rc release -lc release         : OK (baseline)
- System.Security.Cryptography.Tests on emulator-5554 :
    Tests run: 11737 Passed: 10738 Failed: 0 Ignored: 658 Skipped: 977
- System.Net.Security.Tests on R58Y30HZ65V (API 36) :
    Tests run: 5124 Passed: 4854 Failed: 4 Ignored: 234 Skipped: 32
  The four failures are pre-existing on main (verified by re-running
  the same tests against a pristine main native lib): two
  CertificateSelectionCallback_DelayedCertificate_OK cases that report
  SkipTestException as FAIL, and two AlpnListTotalSizeExceedsLimit_Throws
  cases where Android wraps ArgumentException in AuthenticationException.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In response to review feedback on PR #1, refactor functions touched by
the previous commit so that they:

- Use ON_EXCEPTION_PRINT_AND_GOTO(cleanup) with a single cleanup label
  instead of bare `if (CheckJNIExceptions(env))` blocks where a
  function has resources to release on either the success or error path.
- Replace raw DeleteLocalRef calls with the NULL-safe ReleaseLRef
  wrapper. This makes it safe to fall through to cleanup with partially
  initialized locals.
- Unify duplicated success/error cleanup blocks into a single cleanup
  section driven by a `ret` (or `success`) sentinel.
- Place the exception check immediately after the JNI call that may
  have thrown, not after subsequent unrelated work.

While refactoring, two pre-existing local-ref / global-ref leaks were
incidentally fixed:

- pal_ecc_import_export.c GetECKeyParameters: the original `error`
  block zeroed the output pointers but did not ReleaseGRef the global
  refs allocated via ToGRef. The new cleanup releases them on failure.
- pal_ecc_import_export.c EcKeyCreateByExplicitParameters: `ec` and
  `keyPairGenerator` were only released on error, leaking on success.
  They are now in the INIT_LOCALS array so they are released in both
  cases.

Validated on the same Android arm64 emulator + physical device used for
the previous commit; System.Security.Cryptography.Tests reports 10738
passed / 0 failed and System.Net.Security.Tests reports 4854 passed /
4 pre-existing failures (DelayedCertificate SkipTestException + ALPN
AuthenticationException wrapping).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In response to the second round of review feedback, refactor the three
functions that write to multiple jobject* out parameters so that they
no longer leave the caller with a partially-populated output on
failure:

* pal_ecc_import_export.c GetECKeyParameters: keep all BigInteger
  results as local refs inside the INIT_LOCALS array and only promote
  them to global refs (via AddGRef) when the entire function succeeds.
  The cleanup path no longer needs to release global refs because none
  were created on the failure path.

* pal_dsa.c GetDsaParameters: extend INIT_LOCALS with pBn, qBn, gBn,
  yBn, xBn; compute byte lengths into local int32_t variables; promote
  to *p/*q/*g/*y/*x only on success.

* pal_cipher.c ReinitializeCipher: move ivBytes to function scope and
  put the exception check immediately after each NewObject call (not
  after ReleaseLRef). Cleanup releases ivBytes alongside the other
  locals so failure paths no longer leak it.

Validated on the same Android arm64 emulator + physical device:
  System.Security.Cryptography.Tests   10738 passed / 0 failed
  System.Net.Security.Tests             4854 passed / 4 pre-existing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Generalize the previous round's principle (only write outputs on the
success path) to the remaining functions modified by this PR:

* pal_eckey.c EcKeyGetSize: use a local `size` variable for the
  CallIntMethod result and only assign `*keySize = size` once we
  reach the success path. The init-time `*keySize = 0` covers all
  failure paths so the cleanup-time re-zero is removed.

* pal_x509store.c ContainsEntryForAlias: only copy `*contains` and
  `*flags` from the locals when `ret == SUCCESS`. On failure the
  caller already ignores them, but matching the documented behaviour
  avoids surprising future readers.

* pal_ecdh.c EcdhDeriveKey: drop the cleanup-time
  `if (ret != SUCCESS) *usedBufferLength = 0;` since the function
  initializes `*usedBufferLength = 0` at entry and only writes the
  final value adjacent to `ret = SUCCESS`.

Validated on the same Android arm64 emulator + physical device:
  System.Security.Cryptography.Tests   10738 passed / 0 failed
  System.Net.Security.Tests             4854 passed / 4 pre-existing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… state in SSLStreamInitialize

Refactor ReinitializeCipher, EcdhDeriveKey and CryptoNative_HmacCreate
to manage their jobject locals via INIT_LOCALS + RELEASE_LOCALS_ENV
instead of ad-hoc stack locals released one-by-one in cleanup. This
matches the convention used elsewhere in the file (e.g.
SSLStreamSetTargetHost, GetQParameter, GetDsaParameters).

Restore AndroidCryptoNative_SSLStreamInitialize to its original
'assign callback/handle fields at the end on the success path'
pattern so that a failed Initialize does not leave the SSLStream in
a partial state. The previous up-front assignment was unnecessary:
FreeSSLStream now NULL-checks managedContextCleanup before invoking
it, which is the correct safeguard for the partial-init case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ng code

RELEASE_LOCALS(loc, env) is the more common convention across the
crypto PAL (35 uses vs 15 for RELEASE_LOCALS_ENV) and makes the env
dependency visible at the call site. The two macros are functionally
equivalent for releasing local refs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival

Copy link
Copy Markdown
Member Author

/azp run runtime-android

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Defensive hardening of the Android System.Security.Cryptography.Native.* PAL to add missing JNI exception checks across ~10 files. Per-function patterns are aligned with existing PAL conventions: ON_EXCEPTION_PRINT_AND_GOTO after each fallible JNI call, INIT_LOCALS + single cleanup:/RELEASE_LOCALS tail, and writing caller-visible out/global-ref state only on the success path. A couple of related correctness fixes ride along (FreeSSLStream NULL-checks managedContextCleanup; SystemAliasFilter handles a NULL return from GetStringUTFChars).

Changes:

  • Add ON_EXCEPTION_PRINT_AND_GOTO(cleanup/error) after JNI calls in cipher/dsa/ec/ecdh/eckey/hmac/ssl/sslstream/x509/x509store, and convert ad-hoc local-ref cleanup to INIT_LOCALS/RELEASE_LOCALS tails.
  • Re-architect ContainsEntryForAlias / ContainsMatchingCertificateForAlias to return SUCCESS/FAIL with a separate out-parameter so callers can distinguish JNI failure from "not contained"; update all callers.
  • SSLStreamInitialize now assigns managedContextHandle/callbacks only on the success path, and FreeSSLStream skips invoking managedContextCleanup when it was never registered.
Show a summary per file
File Description
pal_cipher.c Convert ReinitializeCipher to INIT_LOCALS/cleanup pattern with exception checks.
pal_dsa.c Add exception checks across DSA size/parameter/key paths; only promote to globals on success.
pal_ecc_import_export.c Same hardening for EC import/export; zero out out parameters at entry.
pal_ecdh.c Convert EcdhDeriveKey to INIT_LOCALS + single cleanup.
pal_eckey.c Add exception checks in EcKeyGetSize with proper FAIL propagation.
pal_hmac.c Convert HmacCreate to INIT_LOCALS + success-flag pattern.
pal_ssl.c Add exception checks in SSLGetSupportedProtocols and a cleanup: label.
pal_sslstream.c Add exception checks in Close/DoUnwrap/Write/Init/AddCertChainToStore; NULL-guard managedContextCleanup in FreeSSLStream; defer callback-field assignment to success path.
pal_x509.c Add exception checks after Collection.size and ArrayList construction.
pal_x509store.c Change Contains helpers to SUCCESS/FAIL with out-parameter; update callers; handle NULL GetStringUTFChars in SystemAliasFilter.

Copilot's findings

  • Files reviewed: 10/10 changed files
  • Comments generated: 1

Comment thread src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c Outdated
ON_EXCEPTION_PRINT_AND_GOTO calls CheckJNIExceptions, which clears the
pending exception. After a goto into the cleanup block, the subsequent
CheckJNIExceptions(env) at the end of the block always returned false
(the exception was already cleared), so DoUnwrap silently continued
past a failed NewDirectByteBuffer with netInBuffer in an unchanged
state and position == 0. The unwrap then proceeded against stale
buffer state instead of returning SSLStreamStatus_Error.

Restructure DoUnwrap to follow the convention used elsewhere in this
file (e.g. SSLStreamRead): a single 'cleanup:' label at the end with
'return ret;', and all early exits going through 'goto cleanup' after
setting 'ret'. This also adds the missing exception check after
ByteBuffer.put().

Reported by Copilot review on PR #128777.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

@simonrozsival

Copy link
Copy Markdown
Member Author

/azp run runtime-android

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

Copilot AI review requested due to automatic review settings June 1, 2026 09:11
@github-actions

This comment has been minimized.

Copilot AI review requested due to automatic review settings June 15, 2026 09:06
@simonrozsival

Copy link
Copy Markdown
Member Author

/azp run runtime-android

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 10/10 changed files
  • Comments generated: 3

Comment thread src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c Outdated
@github-actions

This comment has been minimized.

SSLStreamWrite: capture the EnsureRemaining result in a local and
null-check it before assigning to appOutBuffer, mirroring DoUnwrap.
This avoids a NULL-object JNI call and a leaked global ref when
buffer expansion fails.

GetEnumAsInt: stop deleting the caller's local ref. Each of the five
call sites now owns and releases its enum local through the existing
cleanup path (RELEASE_LOCALS / ReleaseLRef), which fixes a double
DeleteLocalRef in GetHandshakeStatus and removes the footgun for the
other callers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival

Copy link
Copy Markdown
Member Author

Note

This response was generated by an AI assistant (GitHub Copilot CLI) on behalf of @simonrozsival.

Thanks for the review. The one ⚠️ actionable finding — the missing NULL check after EnsureRemaining in SSLStreamWrite — is fixed in 5e7b968, using exactly the capture → null-check → assign pattern from DoUnwrap that you referenced. That also closes the global-ref leak you noted.

One correction on the ✅ GetHandshakeStatus section: it's now slightly outdated. Following later review feedback, GetEnumAsInt no longer deletes its argument ref — it's now a pure getter (pal_jni.c) — and the resultHandshakeStatus/resultStatus null-outs in WrapAndProcessResult/DoUnwrap were removed accordingly. Each of the five GetEnumAsInt call sites now owns and releases its own enum local through the existing cleanup (RELEASE_LOCALS / ReleaseLRef), so there is still exactly one release per ref — no double-free and no leak.

On the two items flagged for human review:

  • ContainsEntryForAlias / ContainsMatchingCertificateForAlias (boolint32_t + out-parameter): all four callers are updated, and the change is called out in the PR description for reviewer sign-off.
  • The pre-existing & vs | bug in pal_x509store.c is intentionally out of scope here and tracked for a separate targeted fix (also listed in the PR description).

@github-actions

This comment has been minimized.


// Converts a java.math.BigInteger to a positive int32_t value.
// Returns -1 if bigInteger < 0 or > INT32_MAX
// Returns -1 if bigInteger < 0 or > INT32_MAX, or if a pending JNI exception is detected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is that going to be actionable on the managed side? Or should an out of bounds value and an exception be different return values?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not actionable on the managed side — an out-of-range cofactor and a (now-caught) JNI exception both flow through the pre-existing cofactorInt == -1 path to a NULL return and a generic CryptographicException, so a distinct return value wouldn't give the managed layer anything to branch on. This PR only adds the exception check so that case fails cleanly instead of crashing on a pending exception; the -1 sentinel, the == -1 check, and the "Only positive cofactors" log are all pre-existing. Splitting the sentinel would be a pre-existing-behavior cleanup I'd prefer to keep out of this crash-prevention change.

Note

Reply drafted with GitHub Copilot CLI.

RELEASE_LOCALS(loc, env);
if (!success)
{
ReleaseGRef(env, macObj);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does ReleaseGRef handle NULL, or does it need a guard?

Theoretically we could get rid of this if we flipped the call to ToGRef and Mac::Init (initalize it as an LRef, then promote it to a GRef and return it, there's no ReleaseGRef path)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ReleaseGRef does handle NULL (it guards internally), but I took your cleaner suggestion in c32faf3 — the Mac is now kept as a local ref, init is called on it, and it is promoted to a global ref only after init succeeds (ToGRef deletes the local). That removes the success flag and the ReleaseGRef cleanup path entirely.

Note

Reply drafted with GitHub Copilot CLI.

int value = (*env)->CallIntMethod(env, enumObj, g_EnumOrdinal);
(*env)->DeleteLocalRef(env, enumObj);
return value;
return (*env)->CallIntMethod(env, enumObj, g_EnumOrdinal);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can't throw?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Effectively no — g_EnumOrdinal is java.lang.Enum.ordinal(), a final accessor that just returns the precomputed ordinal field, and every caller passes a non-null receiver. But rather than rely on that, I removed GetEnumAsInt in c32faf3 and inlined (*env)->CallIntMethod(env, ..., g_EnumOrdinal) at each call site followed by ON_EXCEPTION_PRINT_AND_GOTO, so the exception check is explicit at every site and there is no doubt.

Note

Reply drafted with GitHub Copilot CLI.

protocolStr = (*env)->GetStringUTFChars(env, protocol, NULL);
if (protocolStr == NULL)
{
ON_EXCEPTION_PRINT_AND_GOTO(error);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't really expect that there'd be an exception from GetStringUTFChars if it returned a non-NULL value, but given that everything else has the pattern of call then run the macro, I'd expect this to be before the if.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in c32faf3 — reordered to the call -> ON_EXCEPTION_PRINT_AND_GOTO -> NULL-check convention used everywhere else in the file.

Note

Reply drafted with GitHub Copilot CLI.

- pal_hmac.c: keep Mac as a local ref and promote to a global ref only after
  init succeeds, removing the success flag and the ReleaseGRef cleanup path
- pal_sslstream.c, pal_jni.{c,h}: inline GetEnumAsInt at its call sites so the
  JNI exception check is explicit at each site, and remove the now-unused helper
- pal_ssl.c: follow the call -> exception-check -> NULL-check ordering for
  GetStringUTFChars

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 16, 2026 20:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 0 new

@github-actions

This comment has been minimized.

- pal_x509store.c: SystemAliasFilter now uses CheckJNIExceptions (prints +
  clears) instead of TryClearJNIExceptions, so a NULL return from
  GetStringUTFChars (e.g. OutOfMemoryError) is logged like every other
  exception site in this file
- pal_sslstream.c: add ON_EXCEPTION_PRINT_AND_GOTO(cleanup) after the
  netInBuffer.flip() call in DoUnwrap, matching the existing post-flip()
  exception check in DoWrap

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival

Copy link
Copy Markdown
Member Author

/azp run runtime-android

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@bartonjs bartonjs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I made it to the end this time!

Comment thread src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c Outdated
Comment thread src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c Outdated
…ions

Convert the remaining JNI local-ref cleanup in every function this PR
touches to the INIT_LOCALS/RELEASE_LOCALS pattern, replacing the manual
ReleaseLRef/DeleteLocalRef tails flagged in review. Affects
WrapAndProcessResult, DoUnwrap, SSLStreamInitialize and SSLStreamWrite
(pal_sslstream.c), EcKeyGetSize (pal_eckey.c),
ConvertBigIntegerToPositiveInt32 (pal_ecc_import_export.c), and
X509StoreAddCertificate/ContainsCertificate/RemoveCertificate
(pal_x509store.c).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival

Copy link
Copy Markdown
Member Author

@bartonjs you're right on both counts (the pal_sslstream.c comments at WrapAndProcessResult and DoUnwrap) — it was a bigger gap than those two functions. I went through every function this PR touches and converted the remaining manual ReleaseLRef/DeleteLocalRef tails to the INIT_LOCALS/RELEASE_LOCALS pattern in ccdb0cf:

  • pal_sslstream.c: WrapAndProcessResult, DoUnwrap, SSLStreamInitialize, SSLStreamWrite
  • pal_eckey.c: EcKeyGetSize
  • pal_ecc_import_export.c: ConvertBigIntegerToPositiveInt32
  • pal_x509store.c: X509StoreAddCertificate (also collapsed its three early-return release sites into a single cleanup: label), X509StoreContainsCertificate, X509StoreRemoveCertificate

Two touched functions intentionally keep manual cleanup because the fixed-size INIT_LOCALS array can't express their ref lifetimes:

  • SSLGetSupportedProtocols — its protocol/protocolStr are loop-scoped (reassigned and released each iteration). Its function-scoped refs already use INIT_LOCALS.
  • X509StoreAddCertificateWithPrivateKey — already on INIT_LOCALS, but privateKey is deliberately separate since it has mixed LRef/GRef ownership (releasePrivateKey flag).

Functions the PR doesn't otherwise touch were left as-is to keep the change scoped.

Note

Reply drafted with GitHub Copilot CLI.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Copilot Code Review

Holistic Assessment

Motivation: Justified and well-scoped. Unchecked JNI exceptions in native PAL code can silently corrupt state or crash under CheckJNI — this is a real class of bugs that needs systematic treatment.

Approach: Sound. The PR uses established patterns (INIT_LOCALS/RELEASE_LOCALS, ON_EXCEPTION_PRINT_AND_GOTO, single cleanup path) already present in the codebase, applied consistently across all touched functions.

Summary: ✅ LGTM. This is a thorough, well-structured defensive hardening PR. The exception checks are correctly placed after each fallible JNI call, local ref hygiene is properly maintained, and the behavioral changes (early ownership transfer in SSLStreamInitialize, GetEnumAsInt removal, ContainsEntryForAlias return type change) are all improvements. The PR has already been through multiple review rounds and addressed feedback iteratively. No blocking issues found.


Detailed Findings

Detailed Findings

✅ JNI Exception Check Coverage — Thorough and correct

The ON_EXCEPTION_PRINT_AND_GOTO macro is placed after every Call*Method, NewObject, GetArrayLength, CallIntMethod, and CallVoidMethod JNI call that could throw. The macro both prints and clears the pending exception, ensuring subsequent JNI calls on the cleanup path are safe. I verified that no fallible JNI calls in the touched functions are left unchecked.

✅ Local Ref Hygiene — Consistent INIT_LOCALS pattern

All functions now use the INIT_LOCALS/RELEASE_LOCALS pattern with a single cleanup path. This eliminates scattered DeleteLocalRef calls and ensures refs are freed exactly once regardless of the exit path. The conversion from ad-hoc local management to the structured pattern is mechanical and correct.

✅ GetEnumAsInt Removal — Fixes ref ownership bug

Removing GetEnumAsInt (which deleted its argument's local ref) and inlining the CallIntMethod(env, enumObj, g_EnumOrdinal) call at each site is the right fix. Each caller now owns its enum local ref via INIT_LOCALS, avoiding the double-DeleteLocalRef that existed in GetHandshakeStatus and the same footgun at other call sites.

✅ SSLStreamInitialize Ownership Transfer — Fixes pre-existing crash

Moving the managed callback assignments (managedContextCleanup, streamReader, etc.) to the start of SSLStreamInitialize combined with the NULL-check in FreeSSLStream fixes a pre-existing bug: previously, if SSLStreamInitialize failed midway, calling SSLStreamRelease would invoke a NULL function pointer. Now, partial init failures properly clean up the managed context handle.

✅ No Partial Output State — Proper cleanup on failure

Functions like GetDsaParameters and GetECKeyParameters now zero all output parameters at entry, collect results in locals, promote to global refs only at the end (after all operations succeed), and clean up any partially-promoted refs if a late promotion fails. This ensures callers never observe half-populated results.

💡 Pre-existing EntryFlags Bitwise Bug — Acknowledged out-of-scope

In ContainsMatchingCertificateForAlias and X509StoreAddCertificate, the expression EntryFlags_HasCertificate & EntryFlags_MatchesCertificate evaluates to 2 & 4 = 0, making the match check always succeed when an entry exists. This is a pre-existing bug (dead code since 2021 per the PR description) that the PR explicitly scopes out. Agree with deferring to a follow-up fix — mixing it in would expand the blast radius of this already-large PR.

💡 ExpandBuffer leaves oldBuffer flipped on allocation failure — Non-issue in practice

If ByteBuffer.allocate fails after oldBuffer.flip() has already been called, the buffer is left in flipped state. However, all callers treat a NULL return from ExpandBuffer/EnsureRemaining as fatal and return an error status, so the buffer's state becomes irrelevant — FreeSSLStream will release the global ref regardless of buffer position.

Note

This review was generated by Copilot.

Generated by Code Review for issue #128777 · ● 72.3M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants