A couple of months ago I spent some time reading code from Signal (libsignal, Android/iOS apps, server, etc.) and came across some interesting issues, which I reported to @Security.
This post describes the case of the UNENCRYPTED_FOR_TESTING hardcoded username in Signal's TLS Proxy implementation, a debugging-only feature that could be 'exploited' (though the impact is very limited) in Signal for Android.
So, what happens when an active actor tries to block your connection to Signal’s servers? Signal offers different alternatives, including community-supported Signal TLS Proxies.
The Signal TLS Proxy
Signal provides plenty of information on how, and when, to use a Signal TLS Proxy.
Signal TLS Proxy is a simple relay proxy implemented using nginx and ‘ssl_preread’. Depending on the received SNI, nginx will relay the connection to the specific server.
https://github.com/signalapp/Signal-TLS-Proxy/blob/main/data/nginx-relay/nginx.conf
...
stream {
map $ssl_preread_server_name $name {
chat.signal.org signal-service;
ud-chat.signal.org signal-service;
storage.signal.org storage-service;
cdn.signal.org signal-cdn;
cdn2.signal.org signal-cdn2;
cdn3.signal.org signal-cdn3;
cdsi.signal.org cdsi;
contentproxy.signal.org content-proxy;
sfu.voip.signal.org sfu;
svr2.signal.org svr2;
svrb.signal.org svrb;
updates.signal.org updates;
updates2.signal.org updates2;
default deny;
}
upstream signal-service {
server chat.signal.org:443;
}
upstream storage-service {
server storage.signal.org:443;
}
...
Therefore there are two TLS layers:
-
Outer TLS layer
This is the connection established by the Signal app to the Signal TLS Proxy domain. This type of TLS Proxy requires any domain with a valid certificate chain, where the root of trust is validated against the platform’s trust anchors -
Inner TLS layer
The inner TLS layer is still the regular ‘Direct’ connection. Therefore, even when connecting through a Signal TLS Proxy, the Signal app preserves the underlying ‘Direct connection’ features, including certificate pinning.
When I was reviewing this implementation in libSignal I noticed something curious, see lines 76-83.
File: libsignal-0.78.2/swift/Sources/LibSignalClient/Net.swift
65: /// Sets the Signal TLS proxy host to be used for all new connections (until overridden).
66: ///
67: /// Sets a domain name and port to be used to proxy all new outgoing connections, using a Signal
68: /// transparent TLS proxy. The proxy can be overridden by calling this method again or unset by
69: /// calling ``Net/clearProxy()``.
70: ///
71: /// Existing connections and services will continue with the setting they were created with.
72: /// (In particular, changing this setting will not affect any existing ``ChatConnection``s.)
73: ///
74: /// - Throws: if the host or port is not structurally valid, such as a port of 0.
75: public func setProxy(host: String, port: UInt16?) throws {
76: // Support <username>@<host> syntax to allow UNENCRYPTED_FOR_TESTING as a marker user.
77: // This is not a stable feature of the API and may go away in the future;
78: // the Rust layer will reject any other users anyway. But it's convenient for us.
79: let (username, host): (String?, String) =
80: if let atSign = host.firstIndex(of: "@") {
81: (String(host[..<atSign]), String(host[atSign...].dropFirst()))
82: } else {
83: (nil, host)
84: }
85:
86: try self.connectionManager.setProxy(
87: scheme: Net.signalTlsProxyScheme,
88: host: host,
89: port: port,
90: username: username,
91: password: nil
92: )
93: }
Apparently, although Signal TLS proxies don’t support authentication, for some reason this function was prepared to receive a hardcoded user: “UNENCRYPTED_FOR_TESTING”. Naturally, that seemed potentially interesting, so I dug deeper.
The ‘UNENCRYPTED_FOR_TESTING’ issue
Signal apps make it easy for users to set up a Signal TLS Proxy via deeplinks, allowing it to be done in just two clicks (when received through a Signal message). Let’s see how a deeplink is validated in Signal for iOS.
iOS
At line 101, a ‘fake’ URL is built using the fragment part of the link, just to allow it be parsed. Then, at line 103, it is verified that the ‘UNENCRYPTED_FOR_TESTING’ username, if present, is only permitted in internal builds (DebugFlags.internalSettings).
File: Signal-iOS-main/SignalServiceKit/Network/SignalProxy/SignalProxy.swift
...
068: @objc
069: public class func isValidProxyLink(_ url: URL) -> Bool {
070: guard url.user == nil, url.password == nil, url.port == nil else {
071: return false
072: }
073:
074: guard url.host?.caseInsensitiveCompare("signal.tube") == .orderedSame else {
075: return false
076: }
077:
078: guard let scheme = url.scheme else {
079: return false
080: }
081: let isValidScheme = (
082: scheme.caseInsensitiveCompare("https") == .orderedSame ||
083: scheme.caseInsensitiveCompare("sgnl") == .orderedSame
084: )
085: guard isValidScheme else {
086: return false
087: }
088:
089: guard isValidProxyFragment(url.fragment) else { return false }
090:
091: return true
092: }
093:
094: public class func isValidProxyFragment(_ fragment: String?) -> Bool {
095: guard
096: let fragment = fragment?.nilIfEmpty,
097: // To quote [RFC 1034][0]: "the total number of octets that represent a domain name
098: // [...] is limited to 255." To be extra careful, we set a maximum of 2048.
099: // [0]: https://tools.ietf.org/html/rfc1034
100: fragment.utf8.count <= 2048,
101: let proxyUrl = URL(string: "fake-protocol://\(fragment)"),
102: proxyUrl.scheme == "fake-protocol",
103: proxyUrl.user == nil || (DebugFlags.internalSettings && proxyUrl.user == "UNENCRYPTED_FOR_TESTING"),
104: proxyUrl.password == nil,
105: proxyUrl.path.isEmpty,
106: proxyUrl.query == nil,
107: proxyUrl.fragment == nil,
108: proxyUrl.port != 0,
109: let proxyHost = proxyUrl.host
110: else {
111: return false
112: }
113:
114: // There must be at least 2 domain labels, and none of them can be empty.
115: let labels = proxyHost.split(separator: ".", omittingEmptySubsequences: false)
116: guard labels.count >= 2 else {
117: return false
118: }
119: guard labels.allSatisfy({ !$0.isEmpty }) else {
120: return false
121: }
122:
123: return true
124: }
Now, looking at the Android implementation we can see the approach is different, less strict than in its iOS counterpart.
Android
It’s mainly a RegEx-based logic (lines 32-35) that does not prevent ‘username@host’ patterns, where ‘username’ can be the hardcoded ‘UNENCRYPTED_FOR_TESTING’.
File: Signal-Android-main/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java
...
28: public final class SignalProxyUtil {
29:
30: private static final String TAG = Log.tag(SignalProxyUtil.class);
31:
32: private static final String PROXY_LINK_HOST = "signal.tube";
33:
34: private static final Pattern PROXY_LINK_PATTERN = Pattern.compile("^(https|sgnl)://" + PROXY_LINK_HOST + "/#([^:]+).*$");
35: private static final Pattern HOST_PATTERN = Pattern.compile("^([^:]+).*$");
...
103: /**
104: * If this is a valid proxy deep link, this will return the embedded host. If not, it will return
105: * null.
106: */
107: public static @Nullable String parseHostFromProxyDeepLink(@Nullable String proxyLink) {
108: if (proxyLink == null) {
109: return null;
110: }
111:
112: Matcher matcher = PROXY_LINK_PATTERN.matcher(proxyLink);
113:
114: if (matcher.matches()) {
115: return matcher.group(2);
116: } else {
117: return null;
118: }
119: }
120:
121: /**
122: * Takes in an address that could be in various formats, and converts it to the format we should
123: * be storing and connecting to.
124: */
125: public static @NonNull String convertUserEnteredAddressToHost(@NonNull String host) {
126: String parsedHost = SignalProxyUtil.parseHostFromProxyDeepLink(host);
127: if (parsedHost != null) {
128: return parsedHost;
129: }
130:
131: Matcher matcher = HOST_PATTERN.matcher(host);
132:
133: if (matcher.matches()) {
134: String result = matcher.group(1);
135: return result != null ? result : "";
136: } else {
137: return host;
138: }
139: }
Once the host pattern is accepted, it is persisted through ‘enableProxy’ without any further validation:
File: Signal-Android-main/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java
48: /**
49: * Handles all things related to enabling a proxy, including saving it and resetting the relevant
50: * network connections.
51: */
52: public static void enableProxy(@NonNull SignalProxy proxy) {
53: SignalStore.proxy().enableProxy(proxy);
54: ConscryptSignal.setUseEngineSocketByDefault(true);
55: AppDependencies.resetNetwork();
56: startListeningToWebsocket();
57: }
...
File: Signal-Android-main/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ProxyValues.java
31: public void enableProxy(@NonNull SignalProxy proxy) {
32: if (Util.isEmpty(proxy.getHost())) {
33: throw new IllegalArgumentException("Empty host!");
34: }
35:
36: getStore().beginWrite()
37: .putBoolean(KEY_PROXY_ENABLED, true)
38: .putString(KEY_HOST, proxy.getHost())
39: .putInteger(KEY_PORT, proxy.getPort())
40: .apply();
41: }
Eventually, we reach libSignal, where the custom ‘UNENCRYPTED_FOR_TESTING’ logic is implemented.
The host is first parsed to check for the presence of that hardcoded username.
File: Signal-Android-main/libsignal-0.78.2/java/client/src/main/java/org/signal/libsignal/net/Network.java
090: /**
091: * Sets the Signal TLS proxy host to be used for all new connections (until overridden).
092: *
093: * <p>Sets a domain name and port to be used to proxy all new outgoing connections, using a Signal
094: * transparent TLS proxy. The proxy can be overridden by calling this method again or unset by
095: * calling {@link #clearProxy}.
096: *
097: * <p>Existing connections and services will continue with the setting they were created with. (In
098: * particular, changing this setting will not affect any existing {@link ChatConnection
099: * ChatConnections}.)
100: *
101: * @throws IOException if the host or port are not (structurally) valid, such as a port that
102: * doesn't fit in u16.
103: */
104: public void setProxy(String host, int port) throws IOException {
105: // Support <username>@<host> syntax to allow UNENCRYPTED_FOR_TESTING as a marker user.
106: // This is not a stable feature of the API and may go away in the future;
107: // the Rust layer will reject any other users anyway. But it's convenient for us.
108: final int atIndex = host.indexOf('@');
109: String username = null;
110: if (atIndex != -1) {
111: username = host.substring(0, atIndex);
112: host = host.substring(atIndex + 1);
113: }
114: this.connectionManager.setProxy(SIGNAL_TLS_PROXY_SCHEME, host, port, username, null);
115: }
116:
Then, based on the hardcoded ‘UNENCRYPTED_FOR_TESTING’ username, the proxy type used for the route (TLS or TCP) is selected.
File: libsignal-main/rust/net/infra/src/route/proxy.rs
172: // Proxies that use TLS are permitted to use any valid certificate, not just our pinned
173: // ones, so we have to defer to the system trust store.
174: const CERTS_FOR_ARBITRARY_PROXY: RootCertificates = RootCertificates::Native;
175:
176: let proxy: ConnectionProxyConfig = match scheme {
177: SIGNAL_TLS_PROXY_SCHEME => {
178: if auth
179: .as_ref()
180: .is_some_and(|auth| auth.username == "UNENCRYPTED_FOR_TESTING")
181: {
182: // This is a testing interface only; we don't have to be super strict about it
183: // because it should be obvious from the username not to use it in general.
184: TcpProxy {
185: proxy_host: host,
186: proxy_port: port.unwrap_or(nonzero!(80u16)),
187: }
188: .into()
189: } else {
190: if auth.is_some() {
191: return Err(ProxyFromPartsError::SchemeDoesNotSupportUsernames(
192: SIGNAL_TLS_PROXY_SCHEME,
193: ));
194: }
195: TlsProxy {
196: proxy_host: host,
197: proxy_port: port.unwrap_or(nonzero!(443u16)),
198: proxy_certs: CERTS_FOR_ARBITRARY_PROXY,
199: }
200: .into()
201: }
202: }
Therefore, something like the following would work in Signal for Android, but not on iOS.
https://signal.tube/#UNENCRYPTED_FOR_TESTING@malicious.proxy.test
Conclusions
This issue allowed users of Android production builds to access the UNENCRYPTED_FOR_TESTING ‘internal testing’ feature implemented in libSignal, which removes the outer TLS layer for Signal TLS proxies.
As a result, unsuspecting users of Signal for Android who clicked on maliciously generated deeplinks could have enabled a proxy that did not comply with Signal’s requirements (e.g., valid certificate), activating logic intended for developer environments only. However, the impact is very limited due to the correct implementation of the inner TLS layer, including certificate pinning as it has been previously discussed.
After reporting it, in just a few days Signal fixed it by wrapping this internal functionality under a feature flag.
Finally, it's important to consider your threat model when accepting an anonymous Signal TLS Proxy: even with the inner TLS layer in place, a malicious Signal TLS proxy can still perform encrypted traffic analysis to infer relationships between users and identify user actions (e.g., send messages or attachments...).