From cce39fdad40fd84a58274eb10ae47a1350d7c22e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 16 May 2026 15:00:58 +0200 Subject: [PATCH 1/4] Release Android X509 chain certificate GREFs The Android X509 chain PAL returns JNI global references for chain certificates. Creating X509Certificate2 from those pointers duplicates the references for managed ownership, leaving the native-returned references caller-owned. Release those temporary references after conversion so repeated chain builds do not exhaust ART global refs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interop.X509Chain.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs index 0e5a9a7e724266..c271bcd13d9c0a 100644 --- a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs +++ b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs @@ -48,9 +48,20 @@ internal static X509Certificate2[] X509ChainGetCertificates(SafeX509ChainContext Debug.Assert(res <= certPtrs.Length); var certs = new X509Certificate2[certPtrs.Length]; - for (int i = 0; i < res; i++) + try { - certs[i] = new X509Certificate2(certPtrs[i]); + for (int i = 0; i < res; i++) + { + // X509Certificate2 duplicates these JNI global refs; the native-returned refs remain caller-owned. + certs[i] = new X509Certificate2(certPtrs[i]); + } + } + finally + { + for (int i = 0; i < res; i++) + { + Interop.JObjectLifetime.DeleteGlobalReference(certPtrs[i]); + } } if (res == certPtrs.Length) From c36b4e7c059327e557bd891addeba72be43ff223 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 18 May 2026 10:00:45 +0200 Subject: [PATCH 2/4] Test: stress-test the X509 chain build success path against the JNI gref limit Adds [OuterLoop] regression test BuildChainRepeatedly_DoesNotExhaustGlobalReferences that builds a 6-certificate chain (root + 4 intermediates + endCert) 8,600 times. Without the gref-release fix in PR #128284, AndroidCryptoNative_X509ChainGetCertificates returns caller-owned JNI global references that X509Certificate2 then duplicates, leaving the native-returned refs orphaned. A 6-cert chain leaks 6 grefs per successful build; 8,600 builds therefore leak 51,600 references, which exceeds Android's default global-reference table limit (51,200) and aborts the process with 'global reference table overflow (max=51200)'. Threshold validation against the unfixed code path: - 8,400 iterations completed successfully - 8,500 iterations crashed with the gref-table overflow With the managed try/finally cleanup in place (Interop.X509Chain.X509ChainGetCertificates), 8,600 iterations complete cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/X509Certificates/ChainTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs index 74a3351622e899..8ae26a8639f490 100644 --- a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs +++ b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography.X509Certificates.Tests.Common; using System.Text; using System.Threading; using Test.Cryptography; @@ -376,6 +377,82 @@ public static void BuildChainCustomTrustStore( } } + [PlatformSpecific(TestPlatforms.Android)] + [OuterLoop("Builds 8,600 PKI chains to exceed Android's JNI global reference table; ~10 minutes on an Android emulator.")] + [Fact] + public static void BuildChainRepeatedly_DoesNotExhaustGlobalReferences() + { + // Android aborts the process when its JNI global reference table overflows. This + // 6-certificate chain leaks 6 JNI global refs per successful build without the Android + // PAL cleanup, so 8,600 builds would leak 51,600 certificate refs. 8,400 iterations + // completed without the fix during threshold testing, while 8,500 iterations crashed + // with "global reference table overflow (max=51200)". + const int Iterations = 8_600; + + CertificateAuthority.BuildPrivatePki( + PkiOptions.AllRevocation, + out RevocationResponder responder, + out CertificateAuthority root, + out CertificateAuthority[] intermediates, + out X509Certificate2 endCert, + intermediateAuthorityCount: 4, + registerAuthorities: false, + keyFactory: CertificateAuthority.KeyFactory.RSASize(2048)); + + using (responder) + using (root) + using (CertificateAuthority intermediate1 = intermediates[0]) + using (CertificateAuthority intermediate2 = intermediates[1]) + using (CertificateAuthority intermediate3 = intermediates[2]) + using (CertificateAuthority intermediate4 = intermediates[3]) + using (endCert) + using (ImportedCollection issuerHolder = new ImportedCollection(new X509Certificate2Collection + { + intermediate4.CloneIssuerCert(), + intermediate3.CloneIssuerCert(), + intermediate2.CloneIssuerCert(), + intermediate1.CloneIssuerCert(), + root.CloneIssuerCert(), + })) + using (ChainHolder chainHolder = new ChainHolder()) + { + X509Certificate2Collection issuers = issuerHolder.Collection; + X509Chain chain = CreateChain(chainHolder, endCert, issuers); + + // Each successful Android chain build materializes the chain from caller-owned JNI + // global references. Without releasing those native-returned references, this + // sequential public-API loop eventually exhausts Android process resources. + for (int i = 0; i < Iterations; i++) + { + if (!chain.Build(endCert)) + { + Assert.Fail($"Chain build failed on iteration {i} with '{chain.AllStatusFlags()}'."); + } + + if (i == 0) + { + Assert.Equal(issuers.Count + 1, chain.ChainElements.Count); + } + + chainHolder.DisposeChainElements(); + } + } + + static X509Chain CreateChain(ChainHolder chainHolder, X509Certificate2 endCert, X509Certificate2Collection issuers) + { + X509Chain chain = chainHolder.Chain; + + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationTime = endCert.NotBefore.AddSeconds(1); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.DisableCertificateDownloads = true; + chain.ChainPolicy.ExtraStore.AddRange(issuers); + chain.ChainPolicy.CustomTrustStore.Add(issuers[issuers.Count - 1]); + + return chain; + } + } + [Fact] public static void BuildChainWithSystemTrustAndCustomTrustCertificates() { From 47bef7cd9719e19e9618c6420bdd35081307c1c6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 18 May 2026 15:15:24 +0200 Subject: [PATCH 3/4] Address review: release partial grefs when native side fails mid-loop The native AndroidCryptoNative_X509ChainGetCertificates writes a JNI global ref into certs[i] per loop iteration and bails out via ON_EXCEPTION_PRINT_AND_GOTO if a JNI exception fires partway through. On that path it returns FAIL (0) with the previously-populated certs[] entries still holding grefs. The previous cleanup only ran when res > 0 and only walked the first res entries, so any grefs left behind on the failure path leaked. Move the res == 0 throw inside the try, and scan the whole certPtrs array (with an IntPtr.Zero guard) in the finally so both the success and partial-population failure paths release every entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interop.X509Chain.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs index c271bcd13d9c0a..e763f586d9de8a 100644 --- a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs +++ b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.X509Chain.cs @@ -42,14 +42,15 @@ internal static X509Certificate2[] X509ChainGetCertificates(SafeX509ChainContext var certPtrs = new IntPtr[count]; int res = Interop.AndroidCrypto.X509ChainGetCertificates(ctx, certPtrs, certPtrs.Length); - if (res == 0) - throw new CryptographicException(); - - Debug.Assert(res <= certPtrs.Length); var certs = new X509Certificate2[certPtrs.Length]; try { + if (res == 0) + throw new CryptographicException(); + + Debug.Assert(res <= certPtrs.Length); + for (int i = 0; i < res; i++) { // X509Certificate2 duplicates these JNI global refs; the native-returned refs remain caller-owned. @@ -58,9 +59,15 @@ internal static X509Certificate2[] X509ChainGetCertificates(SafeX509ChainContext } finally { - for (int i = 0; i < res; i++) + // The native side can populate part of certPtrs and then fail (returning 0) if a JNI + // exception is thrown mid-loop, so release every non-null entry rather than only the + // first `res` entries. + for (int i = 0; i < certPtrs.Length; i++) { - Interop.JObjectLifetime.DeleteGlobalReference(certPtrs[i]); + if (certPtrs[i] != IntPtr.Zero) + { + Interop.JObjectLifetime.DeleteGlobalReference(certPtrs[i]); + } } } From eefd7b56a550ee5b1ff1dfca358f6b5cb7513a10 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 19 May 2026 10:01:58 +0200 Subject: [PATCH 4/4] Avoid executing the long running test in Helix --- .../Common/tests/TestUtilities/System/PlatformDetection.cs | 1 + .../tests/X509Certificates/ChainTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs b/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs index 7f8fe69f086c35..14278b303009b5 100644 --- a/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs +++ b/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs @@ -25,6 +25,7 @@ public static partial class PlatformDetection private static readonly Lazy s_IsInHelix = new Lazy(() => Environment.GetEnvironmentVariables().Keys.Cast().Any(key => key.StartsWith("HELIX"))); public static bool IsInHelix => s_IsInHelix.Value; + public static bool IsNotInHelix => !IsInHelix; public static bool IsNetCore => Environment.Version.Major >= 5 || RuntimeInformation.FrameworkDescription.StartsWith(".NET Core", StringComparison.OrdinalIgnoreCase); public static bool IsMonoRuntime => Type.GetType("Mono.RuntimeStructs") != null; diff --git a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs index 8ae26a8639f490..4b5ac85e6a1d63 100644 --- a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs +++ b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs @@ -378,8 +378,7 @@ public static void BuildChainCustomTrustStore( } [PlatformSpecific(TestPlatforms.Android)] - [OuterLoop("Builds 8,600 PKI chains to exceed Android's JNI global reference table; ~10 minutes on an Android emulator.")] - [Fact] + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotInHelix))] public static void BuildChainRepeatedly_DoesNotExhaustGlobalReferences() { // Android aborts the process when its JNI global reference table overflows. This @@ -387,6 +386,7 @@ public static void BuildChainRepeatedly_DoesNotExhaustGlobalReferences() // PAL cleanup, so 8,600 builds would leak 51,600 certificate refs. 8,400 iterations // completed without the fix during threshold testing, while 8,500 iterations crashed // with "global reference table overflow (max=51200)". + // This tests runs for ~10 minutes on an Android emulator. const int Iterations = 8_600; CertificateAuthority.BuildPrivatePki(