Skip to content

Commit cdae9a0

Browse files
runningcodeclaude
andcommitted
fix(transport): Check distrusted eMudhra CAs on every TLS handshake
Checking the server certificates once after connect() missed the new connections HttpURLConnection opens on its own to retry a request, and chains Android completes from intermediates cached on earlier connections. Wrap the SSLSocketFactory so every handshake is checked, and match the leaf's issuer by name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d479c1d commit cdae9a0

14 files changed

Lines changed: 790 additions & 162 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
- Fix a D8 crash (`NullPointerException: Cannot invoke "String.length()"`) when dexing the SDK with AGP 8.0 and older, a regression from 8.61.1 ([#6356](https://github.com/getsentry/sentry-java/pull/6356))
3030
- 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))
3131
- Prevent Reactor pool maintenance tasks from retaining unbounded Sentry scope chains ([#6283](https://github.com/getsentry/sentry-java/pull/6283))
32-
- 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))
32+
- Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336))
3333
- 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.
34-
- 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.
34+
- The check runs on every TLS handshake, including the new connections `HttpURLConnection` opens on its own to retry a request.
35+
- This applies to the default envelope transport and to the distribution update check, but not yet to `ApacheHttpClientTransport` from `sentry-apache-http-client-5`. 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.
3536

3637
## 8.61.1
3738

‎gradle/libs.versions.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.4.
291291
mockito-core = { module = "org.mockito:mockito-core", version = "5.24.0" }
292292
msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" }
293293
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
294+
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
294295
# apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath
295296
okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" }
296297
okio = { module = "com.squareup.okio:okio", version = "1.13.0" }

‎sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,11 @@ internal class DistributionHttpClient(private val options: SentryOptions) {
9090
connection.connectTimeout = options.connectionTimeoutMillis
9191
connection.readTimeout = options.readTimeoutMillis
9292

93-
if (connection is HttpsURLConnection && options.sslSocketFactory != null) {
94-
connection.sslSocketFactory = options.sslSocketFactory
93+
if (connection is HttpsURLConnection) {
94+
connection.sslSocketFactory =
95+
DistrustedCertificates.wrap(options.sslSocketFactory ?: connection.sslSocketFactory)
9596
}
9697

97-
connection.connect()
98-
DistrustedCertificates.getInstance().verify(connection)
99-
10098
val responseCode = connection.responseCode
10199
val responseBody = readResponse(connection)
102100

‎sentry/api/sentry.api‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7778,8 +7778,7 @@ public final class io/sentry/transport/CurrentDateProvider : io/sentry/transport
77787778
}
77797779

77807780
public final class io/sentry/transport/DistrustedCertificates {
7781-
public static fun getInstance ()Lio/sentry/transport/DistrustedCertificates;
7782-
public fun verify (Ljava/net/HttpURLConnection;)V
7781+
public static fun wrap (Ljavax/net/ssl/SSLSocketFactory;)Ljavax/net/ssl/SSLSocketFactory;
77837782
}
77847783

77857784
public abstract interface class io/sentry/transport/ICurrentDateProvider {

‎sentry/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ dependencies {
3131
testImplementation(libs.mockito.kotlin)
3232
testImplementation(libs.mockito.core)
3333
testImplementation(libs.msgpack)
34+
testImplementation(libs.okhttp.mockwebserver)
35+
testImplementation(libs.okhttp.tls)
3436
testImplementation(libs.okio)
3537
testImplementation(projects.sentryTestSupport)
3638
}
Lines changed: 41 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,81 @@
11
package io.sentry.transport;
22

3-
import java.net.HttpURLConnection;
4-
import java.security.MessageDigest;
5-
import java.security.NoSuchAlgorithmException;
63
import java.security.cert.Certificate;
4+
import java.security.cert.X509Certificate;
75
import java.util.Arrays;
86
import java.util.HashSet;
97
import java.util.Set;
10-
import javax.net.ssl.HttpsURLConnection;
118
import javax.net.ssl.SSLPeerUnverifiedException;
9+
import javax.net.ssl.SSLSocketFactory;
10+
import javax.security.auth.x500.X500Principal;
1211
import org.jetbrains.annotations.ApiStatus;
1312
import org.jetbrains.annotations.NotNull;
14-
import org.jetbrains.annotations.Nullable;
1513

1614
/**
17-
* Rejects servers whose certificate chain contains a CA certificate that is known to be compromised
18-
* but still trusted by platform trust stores.
15+
* Rejects servers whose certificate was issued by a CA that is known to be compromised but still
16+
* trusted by platform trust stores.
1917
*
2018
* <p>Browsers block such certificates on their own and require Certificate Transparency, but {@link
21-
* HttpsURLConnection} does neither. Android only updates its CA store with OS updates (or Mainline
22-
* on Android 14+) and the JVM only with JDK updates, so a distrust by the root programs doesn't
23-
* reach every device.
19+
* javax.net.ssl.HttpsURLConnection} does neither. Android only updates its CA store with OS updates
20+
* (or Mainline on Android 14+) and the JVM only with JDK updates, so a distrust by the root
21+
* programs doesn't reach every device.
2422
*/
2523
@ApiStatus.Internal
2624
public final class DistrustedCertificates {
2725

2826
/**
29-
* SHA-256 hashes of the SubjectPublicKeyInfo of the eight eMudhra intermediates whose private
30-
* keys can be recovered from their certificates (CVE-2026-111943). Each key is in two
31-
* certificates: one issued by eMudhra and a cross-sign by Sectigo's AAA Certificate Services
32-
* root, which is trusted on every Android version. Matching the key covers both.
27+
* The eight eMudhra intermediates whose private keys can be recovered from their certificates
28+
* (CVE-2026-111943). Each key is in two certificates with the same subject: one issued by eMudhra
29+
* and a cross-sign by Sectigo's AAA Certificate Services root, which is trusted on every Android
30+
* version.
31+
*
32+
* <p>They are matched by name rather than by key because the server doesn't have to send them:
33+
* Android caches intermediates from earlier connections and completes a chain that is missing
34+
* one. The server always sends the leaf, which names its issuer, and since none of these CAs may
35+
* issue further CAs ({@code pathlen:0}), any certificate they issued is a leaf.
3336
*
3437
* @see <a
3538
* href="https://groups.google.com/a/mozilla.org/g/dev-security-policy/c/0R3XLJL6FuE">Compromised
3639
* eMudhra Intermediate Certificates</a>
3740
*/
38-
private static final Set<String> SPKI_SHA256_HASHES =
41+
private static final Set<X500Principal> DISTRUSTED_CAS =
3942
new HashSet<>(
4043
Arrays.asList(
41-
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
42-
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
43-
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
44-
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
45-
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
46-
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
47-
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
48-
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6"));
44+
new X500Principal("CN=EM DV TLS CA - G3B, O=eMudhra BV, C=NL"),
45+
new X500Principal("CN=EM EV TLS CA - G3B, O=eMudhra BV, C=NL"),
46+
new X500Principal("CN=EM OV TLS CA - G3B, O=eMudhra BV, C=NL"),
47+
new X500Principal("CN=EM OV CS CA - G3B, O=eMudhra BV, C=NL"),
48+
new X500Principal("CN=EM DV TLS CA - G3C, O=eMudhra Inc, C=US"),
49+
new X500Principal("CN=EM EV TLS CA - G3C, O=eMudhra Inc, C=US"),
50+
new X500Principal("CN=EM OV TLS CA - G3C, O=eMudhra Inc, C=US"),
51+
new X500Principal("CN=EM OV CS CA - G3C, O=eMudhra Inc, C=US")));
4952

50-
private static final DistrustedCertificates instance = new DistrustedCertificates();
53+
private DistrustedCertificates() {}
5154

52-
public static @NotNull DistrustedCertificates getInstance() {
53-
return instance;
55+
/**
56+
* Wraps {@code delegate} so that every TLS handshake of its sockets is checked, including the
57+
* ones {@link java.net.HttpURLConnection} makes on its own to retry a request on a new
58+
* connection.
59+
*/
60+
public static @NotNull SSLSocketFactory wrap(final @NotNull SSLSocketFactory delegate) {
61+
return new DistrustedCertificatesSocketFactory(delegate);
5462
}
5563

56-
private DistrustedCertificates() {}
57-
5864
/**
59-
* Disconnects and throws if the server presented a distrusted certificate.
60-
*
61-
* <p>Call it right after {@link HttpURLConnection#connect()}: the TLS handshake is done by then,
62-
* but no part of the request has been written yet. A {@link javax.net.ssl.HostnameVerifier} can't
63-
* do this check, because the JVM's {@link HttpsURLConnection} only calls it when its own hostname
64-
* check fails.
65+
* @param peerCertificates the certificates the server sent, leaf first
6566
*/
66-
public void verify(final @NotNull HttpURLConnection connection)
67+
static void check(final @NotNull Certificate[] peerCertificates)
6768
throws SSLPeerUnverifiedException {
68-
if (!(connection instanceof HttpsURLConnection)) {
69-
return;
70-
}
71-
final @Nullable Certificate[] certificates =
72-
((HttpsURLConnection) connection).getServerCertificates();
73-
if (certificates == null) {
74-
return;
75-
}
76-
for (final @NotNull Certificate certificate : certificates) {
77-
if (SPKI_SHA256_HASHES.contains(spkiSha256(certificate))) {
78-
connection.disconnect();
69+
for (final Certificate certificate : peerCertificates) {
70+
if (certificate instanceof X509Certificate && isDistrusted((X509Certificate) certificate)) {
7971
throw new SSLPeerUnverifiedException(
8072
"The server's certificate chain contains a distrusted certificate.");
8173
}
8274
}
8375
}
8476

85-
private static @NotNull String spkiSha256(final @NotNull Certificate certificate) {
86-
final byte[] hash;
87-
try {
88-
hash = MessageDigest.getInstance("SHA-256").digest(certificate.getPublicKey().getEncoded());
89-
} catch (NoSuchAlgorithmException e) {
90-
// Every Java and Android platform is required to support SHA-256.
91-
throw new AssertionError(e);
92-
}
93-
final StringBuilder hex = new StringBuilder(hash.length * 2);
94-
for (final byte b : hash) {
95-
hex.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16));
96-
}
97-
return hex.toString();
77+
private static boolean isDistrusted(final @NotNull X509Certificate certificate) {
78+
return DISTRUSTED_CAS.contains(certificate.getIssuerX500Principal())
79+
|| DISTRUSTED_CAS.contains(certificate.getSubjectX500Principal());
9880
}
9981
}

0 commit comments

Comments
 (0)