X509Certificate2Collection.ImportFromPem can silently import only part of a certificate bundle on .NET 10.0.13 when a stray -----BEGIN fragment appears directly before the next certificate header. The same three-certificate input produced three certificates on .NET 10.0.12 and two on 10.0.13 in a pinned local comparison. No exception identified the missing certificate.
This guide shows how to reproduce that .NET 10.0.13 ImportFromPem regression, normalize only the affected boundary, and fail closed when the imported collection does not match the expected PEM certificate set. The workaround restored all three certificates on both tested runtimes, but the production guard is more important than the string replacement: code must not accept an incomplete trust or chain bundle merely because parsing returned normally.
Table of Contents
Reproduce the .NET 10.0.13 ImportFromPem regression
The minimal input contains three valid CERTIFICATE PEM blocks. Before the second block, it adds an incomplete -----BEGIN fragment on the same physical line. This is malformed surrounding text, but earlier runtime behavior still found the valid certificate header that follows it.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
static string BuildBundle()
{
var bundle = "";
foreach (string subject in new[]
{
"CN=cert1",
"CN=cert2",
"CN=cert3"
})
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var certificate = new CertificateRequest(
subject,
key,
HashAlgorithmName.SHA256)
.CreateSelfSigned(
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow.AddDays(1));
bundle += (subject == "CN=cert2" ? "-----BEGIN " : "")
+ certificate.ExportCertificatePem()
+ "\n";
}
return bundle;
}
var certificates = new X509Certificate2Collection();
certificates.ImportFromPem(BuildBundle());
Console.WriteLine($"Runtime: {Environment.Version}");
Console.WriteLine($"Imported: {certificates.Count}");
Console.WriteLine(string.Join(", ",
certificates.Cast<X509Certificate2>()
.Select(certificate => certificate.Subject)));
Build the program once against net10.0, then run the same output with two installed runtimes. Using the identical assembly matters because rebuilding under different SDKs introduces another variable.
dotnet build -c Release
dotnet exec --fx-version 10.0.12 \
bin/Release/net10.0/PemImportProbe.dll
dotnet exec --fx-version 10.0.13 \
bin/Release/net10.0/PemImportProbe.dll
| Runtime | Affected input | Normalized input |
|---|---|---|
| .NET 10.0.12 | 3 certificates | 3 certificates |
| .NET 10.0.13 | 2 certificates | 3 certificates |
The local result matches the upstream report’s .NET 10 comparison. The issue also reports the behavior in .NET 11 RC2 and an unaffected .NET 11 RC1 control, but those two RC builds were not executed in this proof.
Understand the silent partial-import risk
ImportFromPem adds every recognized CERTIFICATE block to the collection. Application code often treats a successful return as evidence that the complete bundle was accepted. In this case, that assumption is false: one block disappears and the remaining certificates still arrive.
The consequence depends on how the collection is used. A missing intermediate can break chain construction, a missing leaf can select the wrong workload identity, and a missing trust anchor can make a custom trust store reject peers. Do not reverse those statements into a universal security claim. The reproduction proves silent omission, not certificate-validation bypass, remote exploitation, or a failure in every PEM bundle.
The affected shape is specific: stray text beginning with -----BEGIN sits immediately before a valid header on the same line. Normal certificate exports do not intentionally generate that prefix. It can still appear after concatenation, templating, redaction, or repair code combines fragments without a line boundary. If the application accepts externally assembled bundles, reject oversized input and validate the expected format before importing it.
Build a fail-closed PEM import guard
The narrow workaround is to insert a line boundary before every -----BEGIN token. A correct header at the start of a line merely receives an extra blank line; the malformed prefix and following valid header become separate lines. Do not stop there. Count exact -----BEGIN CERTIFICATE----- labels before import and reject any collection whose count differs.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
static X509Certificate2Collection ImportCompleteBundle(string pem)
{
ArgumentNullException.ThrowIfNull(pem);
const int maxPemCharacters = 1_000_000;
if (pem.Length == 0 || pem.Length > maxPemCharacters)
{
throw new CryptographicException(
"The PEM bundle is empty or exceeds the configured limit.");
}
int expected = CountOccurrences(
pem,
"-----BEGIN CERTIFICATE-----");
if (expected == 0)
{
throw new CryptographicException(
"The bundle contains no CERTIFICATE blocks.");
}
string normalized = pem.Replace(
"-----BEGIN ",
Environment.NewLine + "-----BEGIN ",
StringComparison.Ordinal);
var imported = new X509Certificate2Collection();
imported.ImportFromPem(normalized);
if (imported.Count != expected)
{
foreach (X509Certificate2 certificate in imported)
{
certificate.Dispose();
}
throw new CryptographicException(
$"Expected {expected} certificates but imported " +
$"{imported.Count}. Refusing a partial bundle.");
}
return imported;
}
static int CountOccurrences(string text, string marker)
{
int count = 0;
int offset = 0;
while ((offset = text.IndexOf(
marker,
offset,
StringComparison.Ordinal)) >= 0)
{
count++;
offset += marker.Length;
}
return count;
}
This guard is intentionally narrow. It does not attempt to repair arbitrary PEM syntax, accept other label types, or infer whether a certificate belongs in the bundle. It also leaves ownership explicit: the caller must dispose the imported X509Certificate2 instances when they are no longer needed.
Validate certificate identity, not only count
A count check detects this reproduction, but it does not prove that the right certificates were imported. Two different bundles can contain the same number of blocks. When the application expects a fixed bundle, compare certificate fingerprints or raw DER hashes against configuration maintained outside the bundle itself.
static string Sha256Fingerprint(X509Certificate2 certificate) =>
Convert.ToHexString(SHA256.HashData(certificate.RawData));
static void RequireExpectedCertificates(
X509Certificate2Collection certificates,
IReadOnlySet<string> expectedFingerprints)
{
var actual = certificates
.Cast<X509Certificate2>()
.Select(Sha256Fingerprint)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!actual.SetEquals(expectedFingerprints))
{
throw new CryptographicException(
"The imported certificate set does not match configuration.");
}
}
Do not use a subject name as the identity check. Subjects are not unique, and two certificates can share the same display name. A fingerprint check is appropriate for a deliberately pinned bundle; a dynamic public-key infrastructure needs its own chain policy, renewal model, and trusted source of expected identities.
Log only operational metadata that is safe for the environment: runtime version, expected and actual counts, bundle source identifier, and a non-secret configuration revision. Certificate fingerprints are commonly observable identifiers, but your logging policy may still classify them as sensitive inventory. Never log private keys or a bundle that can contain private-key material.
Run a version-pinned regression test
Keep the affected input as a test fixture and run both the raw and guarded paths. The raw assertion identifies when the runtime changes; the guarded assertion protects the application behavior during rollout.
string bundle = BuildBundle();
var raw = new X509Certificate2Collection();
raw.ImportFromPem(bundle);
using var guarded = new CertificateCollectionOwner(
ImportCompleteBundle(bundle));
Console.WriteLine($"Runtime={Environment.Version}");
Console.WriteLine($"RawCount={raw.Count}");
Console.WriteLine($"GuardedCount={guarded.Certificates.Count}");
if (guarded.Certificates.Count != 3)
{
return 1;
}
return 0;
sealed class CertificateCollectionOwner(
X509Certificate2Collection certificates) : IDisposable
{
public X509Certificate2Collection Certificates { get; } = certificates;
public void Dispose()
{
foreach (X509Certificate2 certificate in Certificates)
{
certificate.Dispose();
}
}
}
Run this test against the exact deployment runtime, not only the build SDK. In the verified environment, SDK 10.0.402 supplied runtime 10.0.13, while an existing task-local SDK 10.0.401 supplied the 10.0.12 control. Record dotnet --info and dotnet --list-runtimes with CI evidence so a passing result can be tied to the executed runtime.
If your container uses a framework-dependent deployment, the host runtime can change independently of the application binary. Add the probe to the image qualification step and expose Environment.Version in deployment diagnostics. For self-contained deployments, rebuild and test the complete artifact after changing the runtime version.
Roll out the workaround safely
- Inventory the input path. Identify whether PEM text comes from files, configuration, secret storage, deployment templates, or remote input.
- Capture expected identity. For fixed bundles, store the expected count and fingerprints in reviewed configuration rather than deriving both from the same untrusted input.
- Add size and label limits. Reject empty, unexpectedly large, mixed-label, or private-key-bearing input before it reaches certificate configuration.
- Test the deployment runtime. Execute the raw and guarded fixtures on every supported operating-system image and runtime version.
- Canary the guard. Observe count mismatches without swapping a live trust store during the first validation stage, then make mismatches fail closed before broader rollout.
- Keep rollback explicit. A servicing downgrade can remove security fixes. Prefer the bounded application guard while the upstream runtime disposition is unresolved, and review the complete servicing bulletin before any rollback.
- Remove deliberately. Re-run the affected fixture after a future runtime fix. Keep the count and identity checks even if the normalization is no longer needed.
Do not rewrite stored PEM files automatically unless that mutation is separately reviewed and reversible. Normalizing a copy immediately before import limits the blast radius and preserves the original bytes for diagnosis. If an upstream producer emits malformed boundaries, fix that producer as well; the import guard should remain a defensive layer, not become the permanent data-cleaning contract.
Know the proof limits
The local proof used Linux x64, .NET SDK 10.0.402, runtimes 10.0.12 and 10.0.13, three generated ECDSA self-signed certificates, and the exact malformed prefix from the upstream report. It established the count difference and verified that inserting line boundaries restores all three certificates in that case. It did not test Windows or macOS, private-key PEM labels, certificate-chain validation, every malformed-input shape, .NET 11 RC1/RC2, or a future runtime fix.
The durable production decision is therefore bounded: normalize the reported boundary only, validate the complete expected certificate set, and retain a pinned regression test. Do not claim that normalization makes arbitrary PEM safe or that a correct count proves trust.
References
- dotnet/runtime issue 135845: ImportFromPem silently drops a certificate
- Microsoft Learn: X509Certificate2Collection.ImportFromPem
- .NET 10.0.13 release notes
- Microsoft Support: .NET 10.0 update for October 13, 2026
- RFC 7468: Textual Encodings of PKIX, PKCS, and CMS Structures
Found this useful? Support more practical developer content.