From 4cd72927a36bdd6c18ed2308471da6ee504527e8 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 16 May 2026 15:00:58 +0200 Subject: [PATCH 1/6] 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 5d92577987cfd2d83fe0049f86d6e0b0280b80da Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 18 May 2026 10:00:45 +0200 Subject: [PATCH 2/6] 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 fe226166b956ec..632ff0cff52cfe 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; @@ -373,6 +374,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 2bdbdb1710a211b6a424e6f8422816ee2cc39d2e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 18 May 2026 15:15:24 +0200 Subject: [PATCH 3/6] 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 3d6e6e6920db48aaf208b4e0d6bb81adf20bed9d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 19 May 2026 10:01:58 +0200 Subject: [PATCH 4/6] 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 ebdd48c679fc97..884cb1565b587d 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 632ff0cff52cfe..c88d253bd4ab81 100644 --- a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs +++ b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs @@ -375,8 +375,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 @@ -384,6 +383,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( From abee648c7eb0d6841230e3129c08eac48769834d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 20 May 2026 08:14:11 +0200 Subject: [PATCH 5/6] Mark BuildChainRepeatedly_DoesNotExhaustGlobalReferences as OuterLoop --- .../tests/X509Certificates/ChainTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs index c88d253bd4ab81..8f11c223ea8fbf 100644 --- a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs +++ b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs @@ -375,6 +375,7 @@ public static void BuildChainCustomTrustStore( } [PlatformSpecific(TestPlatforms.Android)] + [OuterLoop] [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotInHelix))] public static void BuildChainRepeatedly_DoesNotExhaustGlobalReferences() { From 10a9e85780409eb9be3be7018fd09723cf004611 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 21 May 2026 17:34:45 +0200 Subject: [PATCH 6/6] Remove test --- .../TestUtilities/System/PlatformDetection.cs | 1 - .../tests/X509Certificates/ChainTests.cs | 78 ------------------- 2 files changed, 79 deletions(-) diff --git a/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs b/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs index 884cb1565b587d..ebdd48c679fc97 100644 --- a/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs +++ b/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs @@ -25,7 +25,6 @@ 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 8f11c223ea8fbf..fe226166b956ec 100644 --- a/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs +++ b/src/libraries/System.Security.Cryptography/tests/X509Certificates/ChainTests.cs @@ -4,7 +4,6 @@ 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; @@ -374,83 +373,6 @@ public static void BuildChainCustomTrustStore( } } - [PlatformSpecific(TestPlatforms.Android)] - [OuterLoop] - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotInHelix))] - 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)". - // This tests runs for ~10 minutes on an Android emulator. - 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() {