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 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