diff --git a/CHANGELOG.md b/CHANGELOG.md index 465da8d957d..c27f3f2be17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Fixes - Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152)) +- Refuse to send data to servers that present one of the leaked eMudhra intermediate certificates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) + - Anyone can recover the private keys of these intermediates, and they are cross-signed by a root that every Android version trusts, so device trust store updates don't protect every device. + - This applies to the SDK's own envelope uploads and to the distribution update check. Other HTTPS connections your app makes, including those instrumented by `sentry-okhttp` and the other HTTP client integrations, need their own fix, for example an OkHttp update. ## 8.61.1 diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt index ed2bbd18b1d..9bb80a9f2ab 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -2,6 +2,7 @@ package io.sentry.android.distribution import io.sentry.SentryLevel import io.sentry.SentryOptions +import io.sentry.transport.DistrustedCertificates import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -93,6 +94,9 @@ internal class DistributionHttpClient(private val options: SentryOptions) { connection.sslSocketFactory = options.sslSocketFactory } + connection.connect() + DistrustedCertificates.getInstance().verify(connection) + val responseCode = connection.responseCode val responseBody = readResponse(connection) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index abf8fc28526..78d8616af00 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7764,6 +7764,11 @@ public final class io/sentry/transport/CurrentDateProvider : io/sentry/transport public static fun getInstance ()Lio/sentry/transport/ICurrentDateProvider; } +public final class io/sentry/transport/DistrustedCertificates { + public static fun getInstance ()Lio/sentry/transport/DistrustedCertificates; + public fun verify (Ljava/net/HttpURLConnection;)V +} + public abstract interface class io/sentry/transport/ICurrentDateProvider { public abstract fun getCurrentTimeMillis ()J } diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java new file mode 100644 index 00000000000..87a38e862f9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java @@ -0,0 +1,99 @@ +package io.sentry.transport; + +import java.net.HttpURLConnection; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLPeerUnverifiedException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Rejects servers whose certificate chain contains a CA certificate that is known to be compromised + * but still trusted by platform trust stores. + * + *

Browsers block such certificates on their own and require Certificate Transparency, but {@link + * HttpsURLConnection} does neither. Android only updates its CA store with OS updates (or Mainline + * on Android 14+) and the JVM only with JDK updates, so a distrust by the root programs doesn't + * reach every device. + */ +@ApiStatus.Internal +public final class DistrustedCertificates { + + /** + * SHA-256 hashes of the SubjectPublicKeyInfo of the eight eMudhra intermediates whose private + * keys can be recovered from their certificates (CVE-2026-111943). Each key is in two + * certificates: one issued by eMudhra and a cross-sign by Sectigo's AAA Certificate Services + * root, which is trusted on every Android version. Matching the key covers both. + * + * @see Compromised + * eMudhra Intermediate Certificates + */ + private static final Set SPKI_SHA256_HASHES = + new HashSet<>( + Arrays.asList( + "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b", + "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f", + "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0", + "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8", + "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30", + "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41", + "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105", + "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6")); + + private static final DistrustedCertificates instance = new DistrustedCertificates(); + + public static @NotNull DistrustedCertificates getInstance() { + return instance; + } + + private DistrustedCertificates() {} + + /** + * Disconnects and throws if the server presented a distrusted certificate. + * + *

Call it right after {@link HttpURLConnection#connect()}: the TLS handshake is done by then, + * but no part of the request has been written yet. A {@link javax.net.ssl.HostnameVerifier} can't + * do this check, because the JVM's {@link HttpsURLConnection} only calls it when its own hostname + * check fails. + */ + public void verify(final @NotNull HttpURLConnection connection) + throws SSLPeerUnverifiedException { + if (!(connection instanceof HttpsURLConnection)) { + return; + } + final @Nullable Certificate[] certificates = + ((HttpsURLConnection) connection).getServerCertificates(); + if (certificates == null) { + return; + } + for (final @NotNull Certificate certificate : certificates) { + if (SPKI_SHA256_HASHES.contains(spkiSha256(certificate))) { + connection.disconnect(); + throw new SSLPeerUnverifiedException( + "The server's certificate chain contains a distrusted certificate."); + } + } + } + + private static @NotNull String spkiSha256(final @NotNull Certificate certificate) { + final byte[] hash; + try { + hash = MessageDigest.getInstance("SHA-256").digest(certificate.getPublicKey().getEncoded()); + } catch (NoSuchAlgorithmException e) { + // Every Java and Android platform is required to support SHA-256. + throw new AssertionError(e); + } + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16)); + } + return hex.toString(); + } +} diff --git a/sentry/src/main/java/io/sentry/transport/HttpConnection.java b/sentry/src/main/java/io/sentry/transport/HttpConnection.java index e7935e76cf3..c7c9f82fe13 100644 --- a/sentry/src/main/java/io/sentry/transport/HttpConnection.java +++ b/sentry/src/main/java/io/sentry/transport/HttpConnection.java @@ -33,6 +33,7 @@ final class HttpConnection { private final @NotNull RequestDetails requestDetails; private final @NotNull SentryOptions options; private final @NotNull RateLimiter rateLimiter; + private final @NotNull DistrustedCertificates distrustedCertificates; /** * Constructs a new HTTP transport instance. Notably, the provided {@code requestUpdater} must set @@ -47,17 +48,24 @@ public HttpConnection( final @NotNull SentryOptions options, final @NotNull RequestDetails requestDetails, final @NotNull RateLimiter rateLimiter) { - this(options, requestDetails, AuthenticatorWrapper.getInstance(), rateLimiter); + this( + options, + requestDetails, + AuthenticatorWrapper.getInstance(), + rateLimiter, + DistrustedCertificates.getInstance()); } HttpConnection( final @NotNull SentryOptions options, final @NotNull RequestDetails requestDetails, final @NotNull AuthenticatorWrapper authenticatorWrapper, - final @NotNull RateLimiter rateLimiter) { + final @NotNull RateLimiter rateLimiter, + final @NotNull DistrustedCertificates distrustedCertificates) { this.requestDetails = requestDetails; this.options = options; this.rateLimiter = rateLimiter; + this.distrustedCertificates = distrustedCertificates; this.proxy = resolveProxy(options.getProxy()); @@ -143,6 +151,7 @@ HttpURLConnection open() throws IOException { } connection.connect(); + distrustedCertificates.verify(connection); return connection; } diff --git a/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt new file mode 100644 index 00000000000..da7f2de6a73 --- /dev/null +++ b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt @@ -0,0 +1,72 @@ +package io.sentry.transport + +import java.net.HttpURLConnection +import java.security.PublicKey +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DistrustedCertificatesTest { + + private val connection = mock() + private val sut = DistrustedCertificates.getInstance() + + @Test + fun `throws and disconnects when the chain contains a distrusted certificate`() { + val certificates = arrayOf(trustedCertificate(), distrustedCertificate()) + whenever(connection.serverCertificates).thenReturn(certificates) + + assertFailsWith { sut.verify(connection) } + + verify(connection).disconnect() + } + + @Test + fun `accepts a chain without distrusted certificates`() { + val certificates = arrayOf(trustedCertificate()) + whenever(connection.serverCertificates).thenReturn(certificates) + + sut.verify(connection) + + verify(connection, never()).disconnect() + } + + @Test + fun `accepts a connection without server certificates`() { + whenever(connection.serverCertificates).thenReturn(null) + + sut.verify(connection) + + verify(connection, never()).disconnect() + } + + @Test + fun `ignores plain HTTP connections`() { + val connection = mock() + + sut.verify(connection) + + verify(connection, never()).disconnect() + } + + /** EM DV TLS CA - G3B as cross-signed by Sectigo's AAA Certificate Services root. */ + private fun distrustedCertificate(): Certificate = + javaClass.classLoader!!.getResourceAsStream("emudhra-em-dv-tls-ca-g3b.pem")!!.use { + CertificateFactory.getInstance("X.509").generateCertificate(it) + } + + private fun trustedCertificate(): Certificate { + val publicKey = mock() + whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3)) + val certificate = mock() + whenever(certificate.publicKey).thenReturn(publicKey) + return certificate + } +} diff --git a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt index 4c17c71fecc..f3229925890 100644 --- a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt +++ b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt @@ -17,15 +17,19 @@ import java.net.Proxy.Type import java.net.URL import java.nio.charset.Charset import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocketFactory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.check +import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -39,6 +43,7 @@ class HttpConnectionTest { val currentDateProvider = mock() val authenticatorWrapper = mock() val rateLimiter = mock() + val distrustedCertificates = mock() var sslSocketFactory: SSLSocketFactory? = null val requestDetails = mock() val socketTagger = mock() @@ -61,7 +66,13 @@ class HttpConnectionTest { options.sslSocketFactory = sslSocketFactory options.setSocketTagger(socketTagger) - return HttpConnection(options, requestDetails, authenticatorWrapper, rateLimiter) + return HttpConnection( + options, + requestDetails, + authenticatorWrapper, + rateLimiter, + distrustedCertificates, + ) } } @@ -168,6 +179,30 @@ class HttpConnectionTest { verify(fixture.connection, never()).sslSocketFactory = any() } + @Test + fun `verifies the server certificates after connecting and before writing the envelope`() { + val transport = fixture.getSUT() + + transport.send(createEnvelope()) + + val order = inOrder(fixture.connection, fixture.distrustedCertificates) + order.verify(fixture.connection).connect() + order.verify(fixture.distrustedCertificates).verify(fixture.connection) + order.verify(fixture.connection).outputStream + } + + @Test + fun `does not send the envelope when the server presents a distrusted certificate`() { + doThrow(SSLPeerUnverifiedException("distrusted")) + .whenever(fixture.distrustedCertificates) + .verify(fixture.connection) + val transport = fixture.getSUT() + + assertFailsWith { transport.send(createEnvelope()) } + + verify(fixture.connection, never()).outputStream + } + @Test fun `When connection error message contains formatting symbols, does not crash the logger`() { fixture.options.isDebug = true diff --git a/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem b/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem new file mode 100644 index 00000000000..470f0f7e77d --- /dev/null +++ b/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw +ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV +BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y +ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW +MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO +L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2 +PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd +BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC +MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h +AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB +QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF +BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB +AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE +wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM +QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ +7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl +bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V +GUyRCEIDhF5TBUuKF4E3kf1h +-----END CERTIFICATE-----