Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 <a
* href="https://groups.google.com/a/mozilla.org/g/dev-security-policy/c/0R3XLJL6FuE">Compromised
* eMudhra Intermediate Certificates</a>
*/
private static final Set<String> 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.
*
* <p>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();
}
}
13 changes: 11 additions & 2 deletions sentry/src/main/java/io/sentry/transport/HttpConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());

Expand Down Expand Up @@ -143,6 +151,7 @@ HttpURLConnection open() throws IOException {
}

connection.connect();
distrustedCertificates.verify(connection);
return connection;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HttpsURLConnection>()
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<SSLPeerUnverifiedException> { 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<HttpURLConnection>()

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<PublicKey>()
whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3))
val certificate = mock<Certificate>()
whenever(certificate.publicKey).thenReturn(publicKey)
return certificate
}
}
37 changes: 36 additions & 1 deletion sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +43,7 @@ class HttpConnectionTest {
val currentDateProvider = mock<ICurrentDateProvider>()
val authenticatorWrapper = mock<AuthenticatorWrapper>()
val rateLimiter = mock<RateLimiter>()
val distrustedCertificates = mock<DistrustedCertificates>()
var sslSocketFactory: SSLSocketFactory? = null
val requestDetails = mock<RequestDetails>()
val socketTagger = mock<ISocketTagger>()
Expand All @@ -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,
)
}
}

Expand Down Expand Up @@ -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<SSLPeerUnverifiedException> { 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
Expand Down
23 changes: 23 additions & 0 deletions sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem
Original file line number Diff line number Diff line change
@@ -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-----
Loading