Skip to content

Commit 240cb84

Browse files
runningcodeclaude
andcommitted
fix(transport): Reject leaked eMudhra intermediate certificates
The private keys of eight eMudhra intermediates can be recovered from their certificates (CVE-2026-111943). Their Sectigo cross-signs chain to AAA Certificate Services, which is trusted on every Android version, so platform trust store updates won't reach every device. The envelope transport and the distribution client now reject servers that present one of these keys, right after the TLS handshake and before any request is written. Fixes JAVA-783 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d5802a7 commit 240cb84

7 files changed

Lines changed: 214 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.sentry.android.distribution
22

33
import io.sentry.SentryLevel
44
import io.sentry.SentryOptions
5+
import io.sentry.transport.DistrustedCertificates
56
import java.io.BufferedReader
67
import java.io.IOException
78
import java.io.InputStreamReader
@@ -93,6 +94,9 @@ internal class DistributionHttpClient(private val options: SentryOptions) {
9394
connection.sslSocketFactory = options.sslSocketFactory
9495
}
9596

97+
connection.connect()
98+
DistrustedCertificates.verify(connection)
99+
96100
val responseCode = connection.responseCode
97101
val responseBody = readResponse(connection)
98102

‎sentry/api/sentry.api‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7764,6 +7764,10 @@ public final class io/sentry/transport/CurrentDateProvider : io/sentry/transport
77647764
public static fun getInstance ()Lio/sentry/transport/ICurrentDateProvider;
77657765
}
77667766

7767+
public final class io/sentry/transport/DistrustedCertificates {
7768+
public static fun verify (Ljava/net/HttpURLConnection;)V
7769+
}
7770+
77677771
public abstract interface class io/sentry/transport/ICurrentDateProvider {
77687772
public abstract fun getCurrentTimeMillis ()J
77697773
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.sentry.transport;
2+
3+
import java.net.HttpURLConnection;
4+
import java.security.MessageDigest;
5+
import java.security.NoSuchAlgorithmException;
6+
import java.security.cert.Certificate;
7+
import java.util.Arrays;
8+
import java.util.HashSet;
9+
import java.util.Set;
10+
import javax.net.ssl.HttpsURLConnection;
11+
import javax.net.ssl.SSLPeerUnverifiedException;
12+
import org.jetbrains.annotations.ApiStatus;
13+
import org.jetbrains.annotations.NotNull;
14+
import org.jetbrains.annotations.Nullable;
15+
16+
/**
17+
* Rejects servers whose certificate chain contains a CA certificate that is known to be compromised
18+
* but still trusted by platform trust stores.
19+
*
20+
* <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.
24+
*/
25+
@ApiStatus.Internal
26+
public final class DistrustedCertificates {
27+
28+
/**
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.
33+
*
34+
* @see <a
35+
* href="https://groups.google.com/a/mozilla.org/g/dev-security-policy/c/0R3XLJL6FuE">Compromised
36+
* eMudhra Intermediate Certificates</a>
37+
*/
38+
private static final Set<String> SPKI_SHA256_HASHES =
39+
new HashSet<>(
40+
Arrays.asList(
41+
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
42+
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
43+
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
44+
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
45+
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
46+
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
47+
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
48+
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6"));
49+
50+
private DistrustedCertificates() {}
51+
52+
/**
53+
* Disconnects and throws if the server presented a distrusted certificate.
54+
*
55+
* <p>Call it right after {@link HttpURLConnection#connect()}: the TLS handshake is done by then,
56+
* but no part of the request has been written yet. A {@link javax.net.ssl.HostnameVerifier} can't
57+
* do this check, because the JVM's {@link HttpsURLConnection} only calls it when its own hostname
58+
* check fails.
59+
*/
60+
public static void verify(final @NotNull HttpURLConnection connection)
61+
throws SSLPeerUnverifiedException {
62+
if (!(connection instanceof HttpsURLConnection)) {
63+
return;
64+
}
65+
final @Nullable Certificate[] certificates =
66+
((HttpsURLConnection) connection).getServerCertificates();
67+
if (certificates == null) {
68+
return;
69+
}
70+
for (final @NotNull Certificate certificate : certificates) {
71+
if (SPKI_SHA256_HASHES.contains(spkiSha256(certificate))) {
72+
connection.disconnect();
73+
throw new SSLPeerUnverifiedException(
74+
"The server's certificate chain contains a distrusted certificate.");
75+
}
76+
}
77+
}
78+
79+
private static @NotNull String spkiSha256(final @NotNull Certificate certificate) {
80+
final byte[] hash;
81+
try {
82+
hash = MessageDigest.getInstance("SHA-256").digest(certificate.getPublicKey().getEncoded());
83+
} catch (NoSuchAlgorithmException e) {
84+
// Every Java and Android platform is required to support SHA-256.
85+
throw new AssertionError(e);
86+
}
87+
final StringBuilder hex = new StringBuilder(hash.length * 2);
88+
for (final byte b : hash) {
89+
hex.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16));
90+
}
91+
return hex.toString();
92+
}
93+
}

‎sentry/src/main/java/io/sentry/transport/HttpConnection.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ HttpURLConnection open() throws IOException {
143143
}
144144

145145
connection.connect();
146+
DistrustedCertificates.verify(connection);
146147
return connection;
147148
}
148149

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package io.sentry.transport
2+
3+
import java.net.HttpURLConnection
4+
import java.security.PublicKey
5+
import java.security.cert.Certificate
6+
import java.security.cert.CertificateFactory
7+
import javax.net.ssl.HttpsURLConnection
8+
import javax.net.ssl.SSLPeerUnverifiedException
9+
import kotlin.test.Test
10+
import kotlin.test.assertFailsWith
11+
import org.mockito.kotlin.mock
12+
import org.mockito.kotlin.never
13+
import org.mockito.kotlin.verify
14+
import org.mockito.kotlin.whenever
15+
16+
class DistrustedCertificatesTest {
17+
18+
private val connection = mock<HttpsURLConnection>()
19+
20+
@Test
21+
fun `throws and disconnects when the chain contains a distrusted certificate`() {
22+
val certificates = arrayOf(trustedCertificate(), distrustedCertificate())
23+
whenever(connection.serverCertificates).thenReturn(certificates)
24+
25+
assertFailsWith<SSLPeerUnverifiedException> { DistrustedCertificates.verify(connection) }
26+
27+
verify(connection).disconnect()
28+
}
29+
30+
@Test
31+
fun `accepts a chain without distrusted certificates`() {
32+
val certificates = arrayOf(trustedCertificate())
33+
whenever(connection.serverCertificates).thenReturn(certificates)
34+
35+
DistrustedCertificates.verify(connection)
36+
37+
verify(connection, never()).disconnect()
38+
}
39+
40+
@Test
41+
fun `accepts a connection without server certificates`() {
42+
whenever(connection.serverCertificates).thenReturn(null)
43+
44+
DistrustedCertificates.verify(connection)
45+
46+
verify(connection, never()).disconnect()
47+
}
48+
49+
@Test
50+
fun `ignores plain HTTP connections`() {
51+
val connection = mock<HttpURLConnection>()
52+
53+
DistrustedCertificates.verify(connection)
54+
55+
verify(connection, never()).disconnect()
56+
}
57+
58+
companion object {
59+
/** EM DV TLS CA - G3B as cross-signed by Sectigo's AAA Certificate Services root. */
60+
fun distrustedCertificate(): Certificate =
61+
DistrustedCertificatesTest::class
62+
.java
63+
.classLoader!!
64+
.getResourceAsStream("emudhra-em-dv-tls-ca-g3b.pem")!!
65+
.use { CertificateFactory.getInstance("X.509").generateCertificate(it) }
66+
67+
private fun trustedCertificate(): Certificate {
68+
val publicKey = mock<PublicKey>()
69+
whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3))
70+
val certificate = mock<Certificate>()
71+
whenever(certificate.publicKey).thenReturn(publicKey)
72+
return certificate
73+
}
74+
}
75+
}

‎sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import java.net.Proxy.Type
1717
import java.net.URL
1818
import java.nio.charset.Charset
1919
import javax.net.ssl.HttpsURLConnection
20+
import javax.net.ssl.SSLPeerUnverifiedException
2021
import javax.net.ssl.SSLSocketFactory
2122
import kotlin.test.Test
2223
import kotlin.test.assertEquals
24+
import kotlin.test.assertFailsWith
2325
import kotlin.test.assertFalse
2426
import kotlin.test.assertNull
2527
import kotlin.test.assertTrue
@@ -168,6 +170,18 @@ class HttpConnectionTest {
168170
verify(fixture.connection, never()).sslSocketFactory = any()
169171
}
170172

173+
@Test
174+
fun `does not send the envelope when the server presents a distrusted certificate`() {
175+
whenever(fixture.connection.serverCertificates)
176+
.thenReturn(arrayOf(DistrustedCertificatesTest.distrustedCertificate()))
177+
val transport = fixture.getSUT()
178+
179+
assertFailsWith<SSLPeerUnverifiedException> { transport.send(createEnvelope()) }
180+
181+
verify(fixture.connection, never()).outputStream
182+
verify(fixture.connection).disconnect()
183+
}
184+
171185
@Test
172186
fun `When connection error message contains formatting symbols, does not crash the logger`() {
173187
fixture.options.isDebug = true
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw
3+
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
4+
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
5+
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
6+
ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW
7+
MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA
8+
IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO
9+
L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2
10+
PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd
11+
BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG
12+
A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC
13+
MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h
14+
AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB
15+
QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF
16+
BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB
17+
AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE
18+
wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM
19+
QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ
20+
7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl
21+
bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V
22+
GUyRCEIDhF5TBUuKF4E3kf1h
23+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)