TL;DR
This post describes the conditions and technical details that enable Adversary-in-The-Middle (AiTM) attacks against Signal when Censorship Circumvention is enabled. However, despite the ability to decrypt TLS traffic between the target and the Signal backend, the end-to-end encryption (E2EE) scheme implemented by Signal prevents attackers from accessing user content such as conversations, audios, attachments, etc., which remains securely encrypted. In simple terms, enabling Censorship Circumvention does not affect the E2EE layer.
The resources required to exploit these issues are limited to nation-states. Unless you’re either a high-value individual (or part of their inner circle) for a nation-state/state-sponsored actors, or a citizen/journalist/activist in an authoritarian country that is closely aligned with others possessing, let’s say, certain expertise, you’re realistically nowhere near being a potential target.
Censorship circumvention may not be perfect, but it is viable approach for a complex situation, that involves systems outside of Signal’s control and inherent limitations. Therefore, there is a tradeoff between security and usability. As a result, this should not be used as a counterpoint to the fact that Signal remains a solid and secure solution, with a verifiable E2EE scheme, unlike other popular messaging apps.
From the perspective of Signal users, this information can help provide situational awareness of various real-world scenarios, understand potential risks, and implement appropriate mitigations. That’s the ultimate goal of this post.
Index
- Introduction
- From the Signal app to the Signal Server
- Censorship Circumvention (CC)
- AiTM when CC is enabled
- Certificate pinning under CC
- RootCertificates::Native
- AiTM attacks on Signal’s CC
- What’s the impact?
- Mitigations
- Responsible disclosure
- Conclusions
Introduction
We have entered 2026 with the US withdrawing from dozens of international treaties and organizations, including cyber ones, putting its military force upfront as the main rationale for ensuring its interests are met, even against its 'allies’.
As a European citizen and independent security researcher, I’ve elaborated on why this new scenario is important for (cyber-physical) security research from now on.
Among the practical implications for future disclosures, for me, this means also reconsidering some of my previous vulnerability reports, specifically if they involve a presumed historical trust consensus around a key element of the internet: certificate authorities. In a new international context of shrinking alliances and growing distrust, it’s more important than ever to share technical details that enable informed decision-making.
In early August’25 I reached out to Signal to report some issues in the Censorship Circumvention/TLS Proxy areas. A couple of months later, in October’25, I published a writeup for one of them, a minor issue that was fixed: “The innocuous but interesting case of Signal’s UNENCRYPTED_FOR_TESTING username”.
However, the main issue I reported to Signal is still present in the production apps to this day. Essentially, it is possible to implement feasible Adversary-in-The-Middle attacks on Signal for iOS and Android, under specific circumstances.
Bearing in mind the new conditions brought by 2026, I see no reason not to publicly document this issue even when it is still exploitable. There are two reasons for this:
-
The resources required to exploit this issue are limited to nation-states. If this technique has any real value in the offensive arena, it’s unrealistic to assume that it isn’t already known by those capable of conducting this kind of operation.
-
As a result, the only way to mitigate the risks, until a new approach to Censorship Circumvention becomes technically viable, is to publicly document the potential risks, allowing the subset of Signal users who could be targeted to act accordingly and take precautionary measures.
The idea behind this post is to explore how Signal implements its Censorship Circumvention feature, which, alongside the Signal TLS Proxy, provides the foundation for enabling Signal users to bypass ISP-grade blocking of the Signal services.
Let’s start.
From the Signal app to the Signal Server
The Signal apps implement three routes to reach their backend, which I illustrate in the following diagram to introduce them more clearly.
I’ve previously detailed how the Signal TLS Proxies work, so let’s focus on the Direct and Censorship Circumvention routes.
To represent how the different services (each with its own domain) exposed by the Signal backend (Chat, CDN, SVRB, etc.) can be reached, Signal encapsulates the required information in domain-config structures. For example, the DOMAIN_CONFIG_CHAT looks like this:
File: libsignal-0.86.10/rust/net/src/env.rs
41: const DOMAIN_CONFIG_CHAT: DomainConfig = DomainConfig { 42: ip_v4: &[ 43: ip_addr!(v4, "76.223.92.165"), 44: ip_addr!(v4, "13.248.212.111"), 45: ], 46: ip_v6: &[ 47: ip_addr!(v6, "2600:9000:a507:ab6d:4ce3:2f58:25d7:9cbf"), 48: ip_addr!(v6, "2600:9000:a61f:527c:d5eb:a431:5239:3232"), 49: ], 50: connect: ConnectionConfig { 51: hostname: "chat.signal.org", 52: port: DEFAULT_HTTPS_PORT, 53: cert: SIGNAL_ROOT_CERTIFICATES, 54: min_tls_version: Some(SslVersion::TLS1_3), 55: http_version: Some(HttpVersion::Http1_1), 56: confirmation_header_name: Some(TIMESTAMP_HEADER_NAME), 57: proxy: Some(ConnectionProxyConfig { 58: path_prefix: "/service", 59: configs: [PROXY_CONFIG_F_PROD, PROXY_CONFIG_G], 60: }), 61: }, 62: };
If you’re in a country where Signal is not blocked, the Direct route will be the default option. In this mode, the Signal app connects to the various services (chat.signal.org, cdn.signal.org, etc.) directly over TLS using the specified hostname, in this case, chat.signal.org. If name resolution fails, the IPs listed in ip_v4 and ip_v6 can be used instead.
In a Direct route, Signal uses the certificates specified in the configuration (cert) to verify the certificate presented by the server during the TLS handshake. As we can see for the chat service, the certificate pinning implementation uses SIGNAL_ROOT_CERTIFICATES, which is Signal’s own CA.
File: libsignal-0.86.10/rust/net/src/certs.rs
8: pub const SIGNAL_ROOT_CERTIFICATES: RootCertificates = 9: RootCertificates::FromStaticDers(&[include_bytes!("../res/signal.cer")]);
Now, what happens when an actor actively tries to block your connection to Signal’s servers? Signal Censorship Circumvention comes into play.
Censorship Circumvention
In addition to Signal TLS Proxies, which essentially rely on the community, Signal implements a built-in anti-censorship mechanism called Censorship Circumvention (CC from now on). The primary technique behind this feature is Domain Fronting.
Signal’s CC uses domain fronting capabilities from Fastly and Google to build alternative routes for bypassing censorship attempts. Essentially, by using reflectors, Signal will forward app requests to their respective services.
The problem is that the current implementation enables feasible Adversary-in-The-Middle (AiTM) attacks against Signal users under very specific circumstances. Let’s see why.
AiTM when CC is enabled
In contrast to a Signal TLS Proxy, which can be configured even with Direct routes to the Signal backend are available, CC can only be enabled under specific circumstances, which also are slightly different between iOS and Android versions.
Basically, there are two ways CC can be enabled: automatically and manually.
1. Automatically
If the user’s phone number belongs to countries that actively block, or have historically blocked Signal:
- iOS: Egypt, Oman, Qatar, UAE, Cuba, Venezuela, Uzbekistan, and Pakistan.
You can see why this is automatically activated at line 386/387.
File: Signal-iOS-7.90.0.1272/SignalServiceKit/Network/OWSSignalService.swift
369: private func updateHasCensoredPhoneNumber(_ localNumber: String?) { 370: if let localNumber { 371: self.hasCensoredPhoneNumber = OWSCensorshipConfiguration.isCensored(e164: localNumber) 372: } else { 373: self.hasCensoredPhoneNumber = false 374: } 375: 376: updateIsCensorshipCircumventionActive() 377: } ... 379: private func updateIsCensorshipCircumventionActive() { 380: if SignalProxy.isEnabled { 381: self.isCensorshipCircumventionActive = false 382: } else if self.isCensorshipCircumventionManuallyDisabled { 383: self.isCensorshipCircumventionActive = false 384: } else if self.isCensorshipCircumventionManuallyActivated { 385: self.isCensorshipCircumventionActive = true 386: } else if self.hasCensoredPhoneNumber { 387: self.isCensorshipCircumventionActive = true 388: } else { 389: self.isCensorshipCircumventionActive = false 390: } 391: }
And the censored country codes.
File: Signal-iOS-7.90.0.1272/SignalServiceKit/Network/OWSCensorshipConfiguration.swift
156: private static let censoredCountryCodes: [String: String] = [ 157: // Egypt 158: "+20": "EG", 159: // Oman 160: "+968": "OM", 161: // Qatar 162: "+974": "QA", 163: // UAE 164: "+971": "AE", 165: // Cuba 166: "+53": "CU", 167: // Venezuela 168: "+58": "VE", 169: // Uzbekistan, 170: "+998": "UZ", 171: // Pakistan 172: "+92": "PK", 173: ]
- Android: Egypt, Oman, Qatar, UAE, Cuba, Venezuela, Uzbekistan, Pakistan, and Iran.
A similar logic is found on Android, see lines 106-107
File: Signal-Android-7.68.5/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt
092: private fun getCensorshipCircumventionState(): CensorshipCircumventionState { 093: val countryCode: Int = SignalE164Util.getLocalCountryCode() 094: val isCountryCodeCensoredByDefault: Boolean = AppDependencies.signalServiceNetworkAccess.isCountryCodeCensoredByDefault(countryCode) 095: val enabledState: SettingsValues.CensorshipCircumventionEnabled = SignalStore.settings.censorshipCircumventionEnabled 096: val hasInternet: Boolean = NetworkConstraint.isMet(AppDependencies.application) 097: val websocketConnected: Boolean = AppDependencies.authWebSocket.state.firstOrError().blockingGet() == WebSocketConnectionState.CONNECTED 098: 099: return when { 100: SignalStore.internal.allowChangingCensorshipSetting -> { 101: CensorshipCircumventionState.AVAILABLE 102: } 103: isCountryCodeCensoredByDefault && enabledState == SettingsValues.CensorshipCircumventionEnabled.DISABLED -> { 104: CensorshipCircumventionState.AVAILABLE_MANUALLY_DISABLED 105: } 106: isCountryCodeCensoredByDefault -> { 107: CensorshipCircumventionState.AVAILABLE_AUTOMATICALLY_ENABLED 108: } 109: !hasInternet && enabledState != SettingsValues.CensorshipCircumventionEnabled.ENABLED -> { 110: CensorshipCircumventionState.UNAVAILABLE_NO_INTERNET 111: } 112: websocketConnected && enabledState != SettingsValues.CensorshipCircumventionEnabled.ENABLED -> { 113: CensorshipCircumventionState.UNAVAILABLE_CONNECTED 114: } 115: else -> { 116: CensorshipCircumventionState.AVAILABLE 117: } 118: } 119: }
File: Signal-Android-7.68.5/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.kt
252: private val defaultCensoredCountryCodes: Set<Int> = setOf( 253: COUNTRY_CODE_EGYPT, 254: COUNTRY_CODE_UAE, 255: COUNTRY_CODE_OMAN, 256: COUNTRY_CODE_QATAR, 257: COUNTRY_CODE_IRAN, 258: COUNTRY_CODE_CUBA, 259: COUNTRY_CODE_UZBEKISTAN, 260: COUNTRY_CODE_VENEZUELA, 261: COUNTRY_CODE_PAKISTAN 262: )
2. Manually
Even if your phone number belongs to a country that doesn’t block Signal, CC can still be manually enabled.
iOS
This requires the Signal app to actively detect when the Direct route to the Chat service fails. In that case, the UI toggle for enabling CC is enabled, so you can manually activate it. (see line 88).
File: Signal-iOS-7.90.0.1272/Signal/src/ViewControllers/AppSettings/Privacy/AdvancedPrivacySettingsViewController.swift
87: } else { 88: isCensorshipCircumventionSwitchEnabled = true 89: censorshipCircumventionSection.footerTitle = OWSLocalizedString( 90: "SETTINGS_ADVANCED_CENSORSHIP_CIRCUMVENTION_FOOTER", 91: comment: "Table footer for the 'censorship circumvention' section when censorship circumvention can be manually enabled." 92: ) 93: } ... 095: censorshipCircumventionSection.add(.switch( 096: withText: OWSLocalizedString( 097: "SETTINGS_ADVANCED_CENSORSHIP_CIRCUMVENTION", 098: comment: "Label for the 'manual censorship circumvention' switch." 099: ), 100: isOn: { SSKEnvironment.shared.signalServiceRef.isCensorshipCircumventionActive }, 101: isEnabled: { isCensorshipCircumventionSwitchEnabled || DebugFlags.exposeCensorshipCircumvention }, 102: target: self, 103: selector: #selector(didToggleEnableCensorshipCircumventionSwitch) 104: ))
Once the CC switch is activated, the user can select the location. Initially, each country had a preferred domain fronting policy ( Fastly or Google ), although as we will see, any location is eventually vulnerable.
Android
On Android, the user can manually activate CC regardless of the status of the Direct route to the Chat service.
File: Signal-Android-7.68.5/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
443: switchPref( 444: title = DSLSettingsText.from("Allow censorship circumvention toggle"), 445: summary = DSLSettingsText.from("Allow changing the censorship circumvention toggle regardless of network connectivity."), 446: isChecked = state.allowCensorshipSetting, 447: onClick = { 448: viewModel.setAllowCensorshipSetting(!state.allowCensorshipSetting) 449: } 450: )
Certificate pinning under CC
Let’s recall the DOMAIN_CONFIG_CHAT structure. When CC is activated, the ‘proxy’ section of the configuration comes into play. Rather than relying on the Direct route, which is assumed to be blocked, two additional routes are established, represented by PROXY_CONFIG_F_PROD and PROXY_CONFIG_G (lines 57-49):
File: libsignal-0.86.10/rust/net/src/env.rs
41: const DOMAIN_CONFIG_CHAT: DomainConfig = DomainConfig { 42: ip_v4: &[ 43: ip_addr!(v4, "76.223.92.165"), 44: ip_addr!(v4, "13.248.212.111"), 45: ], 46: ip_v6: &[ 47: ip_addr!(v6, "2600:9000:a507:ab6d:4ce3:2f58:25d7:9cbf"), 48: ip_addr!(v6, "2600:9000:a61f:527c:d5eb:a431:5239:3232"), 49: ], 50: connect: ConnectionConfig { 51: hostname: "chat.signal.org", 52: port: DEFAULT_HTTPS_PORT, 53: cert: SIGNAL_ROOT_CERTIFICATES, 54: min_tls_version: Some(SslVersion::TLS1_3), 55: http_version: Some(HttpVersion::Http1_1), 56: confirmation_header_name: Some(TIMESTAMP_HEADER_NAME), 57: proxy: Some(ConnectionProxyConfig { 58: path_prefix: "/service", 59: configs: [PROXY_CONFIG_F_PROD, PROXY_CONFIG_G], 60: }), 61: }, 62: };
By analyzing these configurations, we can see what we introduced earlier: Signal implements reflectors for Fastly and Google to leverage their domain fronting capabilities, forwarding requests to its backend, rather than connecting directly. The reflector is a host that will route the request to the appropriate Signal service based on the ‘path_prefix’ (e.g., ‘service’ for Chat, ‘cdsi’ for Content Discovery). For example, instead of sending requests to “chat.signal.org/v1/websocket”, when CC is enabled, the route will go to “reflectorhost/service/v1/websocket”, therefore effectively bypass blocking attempts.
Signal hardcodes three Fastly domains and five Google domains, which are used in the SNI during the TLS handshake to protect the real destination (reflector host).
File: libsignal-0.86.10/rust/net/src/env.rs
256: pub const PROXY_CONFIG_F_STAGING: ProxyConfig = ProxyConfig { 257: route_type: RouteType::ProxyF, 258: http_host: "reflector-staging-signal.global.ssl.fastly.net", 259: sni_list: &[ 260: "github.githubassets.com", 261: "pinterest.com", 262: "www.redditstatic.com", 263: ], 264: certs: RootCertificates::Native, 265: }; 266: 267: pub const PROXY_CONFIG_G: ProxyConfig = ProxyConfig { 268: route_type: RouteType::ProxyG, 269: http_host: "reflector-nrgwuv7kwq-uc.a.run.app", 270: sni_list: &[ 271: "www.google.com", 272: "android.clients.google.com", 273: "clients3.google.com", 274: "clients4.google.com", 275: "googlemail.com", 276: ], 277: certs: PROXY_G_ROOT_CERTIFICATES, 278: };
However, we must pay attention to the ‘certs’ that will be used in these new routes. For Google we have PROXY_G_ROOT_CERTIFICATES (line 277), which are Google-owned root CAs.
File: libsignal-0.86.10/rust/net/src/certs.rs
11: // GIAG2 cert plus root certs from pki.goog 12: pub const PROXY_G_ROOT_CERTIFICATES: RootCertificates = RootCertificates::FromStaticDers(&[ 13: include_bytes!("../res/GIAG2.cer"), 14: include_bytes!("../res/GSR2.cer"), 15: include_bytes!("../res/GSR4.cer"), 16: include_bytes!("../res/GTSR1.cer"), 17: include_bytes!("../res/GTSR2.cer"), 18: include_bytes!("../res/GTSR3.cer"), 19: include_bytes!("../res/GTSR4.cer"), 20: ]); 21:
For Fastly, it is “RootCertificates::Native” (line 264). Let’s take a closer look.
RootCertificates::Native
File: libsignal-0.86.10/rust/net/infra/src/certs.rs
44: impl RootCertificates { 45: /// Configures `connector` to verify certificates against `self`. 46: /// 47: /// **Warning:** If `self` is [`RootCertificates::Native`], the resulting connector will 48: /// **depend on tokio** to verify certificates (using rustls-platform-verifier, isolated to a 49: /// blocking task thread). Moreover, when using the resulting [`Ssl`](boring_signal::ssl::Ssl) 50: /// object, you must call `set_task_waker`. This will be taken care of for you if you use 51: /// tokio-boring (and always poll within a tokio context). 52: pub fn apply_to_connector( 53: &self, 54: connector: &mut SslConnectorBuilder, 55: host: Host<&str>, 56: ) -> Result<(), Error> { 57: let ders: &[&[u8]] = match self { 58: RootCertificates::Native => { 59: static VERIFIER: OnceLock<Box<dyn LimitedServerCertVerifier>> = OnceLock::new(); 60: 61: let verifier = VERIFIER.get_or_init(|| { 62: let mut verifier = rustls_platform_verifier::Verifier::new(); 63: if cfg!(target_os = "linux") 64: && rustls::crypto::CryptoProvider::get_default().is_none() 65: { 66: // On Linux rustls-platform-verifier uses the webpki crate, which requires a 67: // rustls CryptoProvider. On the other platforms, rustls-platform-verifier ought 68: // to work even with no provider set, so we omit this to avoid taking a 69: // dependency on ring. 70: verifier.set_provider(rustls::crypto::ring::default_provider().into()) 71: } 72: 73: if cfg!(target_os = "android") { 74: // rustls-platform-verifier's Android code permanently 75: // attaches the thread that makes the verification calls 76: // to the JVM. Use an implementation that calls into 77: // verification code on a background thread to prevent 78: // the current thread from being attached to the JVM. 79: // 80: // See https://github.com/rustls/rustls-platform-verifier/issues/184 81: Box::new(BackgroundThreadVerifier::new(verifier)) 82: } else { 83: Box::new(TokioBlockingThreadVerifier::new(verifier)) 84: } 85: }); 86: return set_up_platform_verifier(connector, host, &**verifier); 87: } 88: RootCertificates::FromStaticDers(ders) => ders, 89: RootCertificates::FromDer(der) => &[der], 90: }; 91: let mut store_builder = X509StoreBuilder::new()?; 92: for der in ders { 93: store_builder.add_cert(X509::from_der(der)?)?; 94: } 95: connector.set_verify_cert_store(store_builder.build())?; 96: Ok(()) 97: } 98: }
Let’s analyze the logic behind it for both iOS and Android.
iOS
- In the user’s keychain.
- Among any certificates you previously provided by calling
SecTrustSetAnchorCertificates(_:_:). - In a system-provided set of keychains provided for this purpose.
- Over the network, if certain extensions are present in the certificate used to build the chain
This essentially means that any leaf certificate issued by a trust anchor in Apple’s root store, CAs pushed to a supervised device via MDM, or manually installed (and trusted) by the user will be considered valid.
Android
This creates a realistic opportunity for carrying out Adversary-in-The-Middle attacks against the three Fastly domains Signal uses: pinterest.com, www.redditstatic.com, and github.githubassets.com.
AiTM attacks on Signal’s CC
The viability of realistic AiTM attacks against Signal’s CC depends on whether CC is enabled by default for the user, according to the conditions described earlier. We assume the target is operating in a hostile network environment where malicious actors hold a privileged position.
When CC is not already enabled, an attacker may attempt to lure the victim into activating it. I won’t focus on this, as it involves social-engineering techniques that are outside the scope of this post, but it’s worth noting that certain Signal features can be leveraged to increase an attacker’s chances of success, so users should remain aware. Let’s see an example.
Outage detection
127.0.0.1, but if there is an ongoing issue, it will resolve to 127.0.0.2. In a hostile network environment, an attacker can trivially manipulate this, causing the warning dialog shown above to appear for the user.File: Signal-Android-7.68.5/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java
... 22: private static final String IP_SUCCESS = "127.0.0.1"; 23: private static final String IP_FAILURE = "127.0.0.2"; ... 49: @Override 50: public void onRun() throws RetryLaterException { 51: Log.i(TAG, "onRun()"); 52: 53: long timeSinceLastCheck = System.currentTimeMillis() - TextSecurePreferences.getLastOutageCheckTime(context); 54: if (timeSinceLastCheck < CHECK_TIME) { 55: Log.w(TAG, "Skipping service outage check. Too soon."); 56: return; 57: } 58: 59: try { 60: InetAddress address = InetAddress.getByName(BuildConfig.SIGNAL_SERVICE_STATUS_URL); 61: Log.i(TAG, "Received outage check address: " + address.getHostAddress()); 62: 63: if (IP_SUCCESS.equals(address.getHostAddress())) { 64: Log.i(TAG, "Service is available."); 65: TextSecurePreferences.setServiceOutage(context, false); 66: } else if (IP_FAILURE.equals(address.getHostAddress())) { 67: Log.w(TAG, "Service is down."); 68: TextSecurePreferences.setServiceOutage(context, true); 69: } else { 70: Log.w(TAG, "Service status check returned an unrecognized IP address. Could be a weird network state. Prompting retry."); 71: throw new RetryLaterException(new Exception("Unrecognized service outage IP address.")); 72: } 73: 74: TextSecurePreferences.setLastOutageCheckTime(context, System.currentTimeMillis()); 75: } catch (UnknownHostException e) { 76: throw new RetryLaterException(e); 77: } 78: }
File: Signal-iOS-7.90.0.1272/SignalServiceKit/Network/OutageDetection.swift
88: if result == 0 { 89: let addressString = String(cString: hostname) 90: let kHealthyAddress = "127.0.0.1" 91: let kOutageAddress = "127.0.0.2" 92: if addressString == kHealthyAddress { 93: // Do nothing. 94: } else if addressString == kOutageAddress { 95: isOutageDetected = true
This can be abused to set the stage for a ‘support-impersonation’ attack, a pattern that has already been observed and documented by Signal
Recall that on Android, CC can be manually enabled at any time, whereas on iOS Signal only allows CC to be activated after detecting a failure on the Direct route to the Chat service. Attackers can force this condition by blocking the IP addresses listed in the chat configuration’s ip_v4 vector, and by preventing any successful DNS resolution of chat.signal.org.
CC already activated
Once CC is enabled, attackers would likely force Signal to ignore the Google route, since compromising it would be significantly more difficult and would require obtaining a rogue certificate from one of Google’s own trusted root authorities. Once again, this can be implemented by preventing any successful DNS resolution of the five google domains previously listed.
File: libsignal-0.86.10/rust/net/infra/src/route/http.rs
113: impl RouteProvider for DomainFrontRouteProvider { 114: type Route = HttpsTlsRoute<TlsRoute<TcpRoute<UnresolvedHost>>>; 115: 116: fn routes<'s>( 117: &'s self, 118: context: &impl RouteProviderContext, 119: ) -> impl Iterator<Item = Self::Route> + 's { 120: let Self { 121: fronts, 122: http_version, 123: override_nagle_algorithm, 124: } = self; 125: 126: let sni_index = context.random_usize(); 127: 128: fronts.iter().flat_map( 129: move |DomainFrontConfig { 130: http_host, 131: sni_list, 132: root_certs, 133: path_prefix, 134: front_name, 135: return_routes_with_all_snis, 136: }| { 137: let sni_list = if *return_routes_with_all_snis { 138: &**sni_list 139: } else if !sni_list.is_empty() { 140: let index = sni_index % sni_list.len(); 141: &sni_list[index..][..1] 142: } else { 143: &[] 144: }; 145: sni_list.iter().map(|sni| HttpsTlsRoute { 146: inner: TlsRoute { 147: inner: TcpRoute { 148: address: UnresolvedHost(Arc::clone(sni)), 149: port: DEFAULT_HTTPS_PORT, 150: override_nagle_algorithm: *override_nagle_algorithm, 151: }, 152: fragment: TlsRouteFragment { 153: root_certs: root_certs.clone(), 154: sni: Host::Domain(Arc::clone(sni)), 155: alpn: Some((*http_version).into()), 156: min_protocol_version: None, 157: }, 158: }, 159: fragment: HttpRouteFragment { 160: host_header: Arc::clone(http_host), 161: path_prefix: Arc::clone(path_prefix), 162: http_version: Some(*http_version), 163: front_name: Some(*front_name), 164: }, 165: }) 166: }, 167: ) 168: } 169: }
What’s the impact?
If nation-state actors were able to obtain a rogue certificate for any of the Fastly domains from any of the trust anchors in Apple’s root store or Android’s System Trust Store, they could carry out an Adversary-in-The-Middle attack, either at scale or in a targeted manner. This is a complex scenario for which mitigations exist, such as Certificate Transparency. However, given the current geopolitical situation and the US government’s withdrawal from international standards of cooperation, the likelihood of such a scenario has increased significantly (assuming it has not already been exploited).
In other scenarios, for example corporate users running Signal on supervised devices with custom CAs pushed via MDM, the attack can also become feasible.
Assuming a successful attack, one important point must be kept in mind: this attack allows interception and decryption of TLS traffic between the Signal apps and the Signal backend. However, thanks to Signal’s end-to-end encryption scheme, even in this scenario an AiTM attack cannot directly compromise the E2EE layer, so your conversations, audios, and attachments remain secure.
That said, attackers could still exploit this attack in other scenarios. I’ll outline two examples, according to their scale:
1. Targeted
In 2022, during the Twilio incident, the Signal account of cybersecurity journalist Lorenzo Franceschi-Bicchierai was impersonated for 13 hours. He was one of the only three targeted numbers, among more than 1900 available, that the attackers were interested in. This did not give the attackers access to his conversations or data, but by compromising the SMS company that provided Signal’s phone number verification services, they were able to re-register his phone number and, as a result, communicate with others while posing as the journalist.
This situation can be prevented by enabling the Registration Lock feature, which enforces a 7-day delay for anyone attempting to re-register your phone number without knowing your PIN.
In this context, it is worth noting that attackers exploiting this AiTM can immediately remove the Registration Lock on the Signal server, without knowing the victim’s PIN and proceed to re-register their phone number. The Signal server only requires an authenticated session to perform this operation, which is achievable once the TLS traffic is decrypted.
At line 195 we can see how the registration lock is removed.
File: Signal-Server-main/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
189: @DELETE 190: @Path("/registration_lock") 191: public void removeRegistrationLock(@Auth AuthenticatedDevice auth) { 192: final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier()) 193: .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED)); 194: 195: accounts.update(account, a -> a.setRegistrationLock(null, null)); 196: }
At line 102-103, as the status of the Registration Lock is ABSENT, verifyRegistrationLock will successfully return.
File: Signal-Server-main/service/src/main/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManager.java
077: /** 078: * Verifies the given registration lock credentials against the account’s current registration lock, if any 079: * 080: * @param account 081: * @param clientRegistrationLock 082: * @throws RateLimitExceededException 083: * @throws WebApplicationException 084: */ 085: public void verifyRegistrationLock(final Account account, @Nullable final String clientRegistrationLock, 086: final String userAgent, 087: final Flow flow, 088: final PhoneVerificationRequest.VerificationType phoneVerificationType 089: ) throws RateLimitExceededException, WebApplicationException { 090: 091: final Tags expiredTags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), 092: Tag.of(REGISTRATION_LOCK_VERIFICATION_FLOW_TAG_NAME, flow.name()), 093: Tag.of(PHONE_VERIFICATION_TYPE_TAG_NAME, phoneVerificationType.name()) 094: ); 095: 096: final StoredRegistrationLock existingRegistrationLock = account.getRegistrationLock(); 097: 098: switch (existingRegistrationLock.getStatus()) { 099: case EXPIRED: 100: Metrics.counter(EXPIRED_REGISTRATION_LOCK_COUNTER_NAME, expiredTags).increment(); 101: return; 102: case ABSENT: 103: return; 104: case REQUIRED: 105: break; 106: default: 107: throw new RuntimeException("Unexpected status: " + existingRegistrationLock.getStatus()); 108: } 109:
This enables bypassing the check for an existing account in the Registration controller. (line 138)
File: Signal-Server-main/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java
... 123: final Optional<Account> existingAccount = accounts.getByE164(number); 124: 125: existingAccount.ifPresent(account -> { 126: final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen()); 127: final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now()); 128: REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays()); 129: }); 130: 131: if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(account -> account.hasCapability(DeviceCapability.TRANSFER)).orElse(false)) { 132: // If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user) 133: // before we'll let them create a new account "from scratch" 134: throw new WebApplicationException(Response.status(409, "device transfer available").build()); 135: } 136: 137: if (existingAccount.isPresent()) { 138: registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), 139: registrationRequest.accountAttributes().getRegistrationLock(), 140: userAgent, RegistrationLockVerificationManager.Flow.REGISTRATION, verificationType); 141: } 142: ...
2. At Scale
Signal limits the amount of metadata available in clear-text, but some is inevitably exposed. Attackers can leverage this metadata in an AiTM attack, along with other techniques such as encrypted traffic analysis, to map interactions between users and infer connections, patterns, and relationships.
Mitigations
Those users who realistically think could be the target of this kind of operation can implement certain mitigations, such as:
- Use a legitimate, trusted VPN provider.
- Use community-vetted Signal TLS proxies.
Responsible disclosure
The issues described herein were initially reported to Signal on August 4th, 2025. During the subsequent constructive dialogue with the Signal security team, and taking into account the context at that time, it was mutually agreed that I would indefinitely postpone any publication, while Signal would continue considering and working on viable improvements to this feature.
Given the significant changes unfolding in the international context in 2026, the position I initially agreed to is no longer sustainable, neither from a technical nor an ethical perspective.
The contents of this post were shared with the Signal security team before publication.
Conclusions
Despite the issues described, it’s important to keep one thing crystal clear: Signal’s end-to-end encryption is a robust safeguard, keeping user content secure even when TLS traffic could be intercepted during AiTM attacks on Censorship Circumvention.
Everyone should also recognize that carrying out this type of attack requires resources that are largely limited to nation-states (a really limited number of them). As a result, high-value targets or individuals in certain authoritarian countries are the most likely to be targeted, while the vast majority of users face minimal risk.
The main goal of this post is simply to share information in order to provide a better understanding of real-world scenarios, point out potential risks, and suggest ways to protect yourself. I strongly believe that sharing technical details is more important than ever in today’s complex cyber environment, and that it empowers people to make informed decisions.