Skip to main content

Signal’s Censorship Circumvention is susceptible to AiTM attacks


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

  1. Introduction
  2. From the Signal app to the Signal Server
  3. Censorship Circumvention (CC)
  4. AiTM when CC is enabled
  5. Certificate pinning under CC
  6. RootCertificates::Native
  7. AiTM attacks on Signal’s CC
  8. What’s the impact?
  9. Mitigations
  10. Responsible disclosure
  11. 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:

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

  2. 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")]);
SHA1 Fingerprint=D9:8B:FF:F5:21:1D:66:76:40:22:5A:AA:00:DC:82:59:AC:D1:42:09
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            c1:f3:e2:0e:b4:70:80:c1:a4:fa:f5:83:26:7f:24:36:9a:3e:97
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = US, ST = California, L = Mountain View, O = "Signal Messenger, LLC", CN = Signal Messenger
        Validity
            Not Before: Jan 26 00:45:51 2022 GMT
            Not After : Jan 24 00:45:50 2032 GMT
        Subject: C = US, ST = California, L = Mountain View, O = "Signal Messenger, LLC", CN = Signal Messenger

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

RootCertificates::Native relies on 3rd-party rustls_platform_verifier (line 58-62) to verify the certificates received from the server during the TLS handshake.

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

Essentially, the logic behind rustls_platform_verifier’s verifier for iOS will determine the validity of a certificate according to Apple’s SecTrustEvaluateWithError default behavior. As we can read in the documentation, this includes looking for certificates in the following places:

  • 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

On Android, rustls_platform_verifier uses the System Trust Store. This means that leaf certificates issued by either any of the pre-loaded public trust anchors, or potentially non-public CAs pushed via MDM on supervised devices will be successfully verified.

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


Signal uses a simple detection mechanism for outages that relies on resolving uptime.signal.org. Under normal conditions, the domain resolves to 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.

Instead, the most viable scenario is to force the Signal app, with CC activated, to use the Fastly route by blocking both the Direct and Google routes, as previously described. There is no deterministic guarantee as to which of the three Fastly domains will be selected, since the choice is randomized (see lines 126 and 139–141).

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.