Skip to main content

The innocuous but interesting case of Signal’s UNENCRYPTED_FOR_TESTING username


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



Popular posts from this blog

What Really Happened in Chernobyl During the Beginning of the Russian Invasion?

This blog post contains the web version of my research paper: " Seeing Through the Invisible: Radiation Spikes Detected in Chernobyl During the Russian Invasion Show Possible Evidence of Fabrication ", which was unveiled at BlackHat USA 2023 . It is intended to ease the indexing and dissemination of the information collected during this research.  In a few days, I'll be in Brussels presenting this research.  The original paper (PDF) can be downloaded here . Additional references: https://www.wired.com/story/chernobyl-radiation-spike-mystery/  (Kim Zetter) https://www.zetter-zeroday.com/p/radiation-spikes-at-chernobyl-a-mystery  (Kim Zetter) https://medium.com/war-notes/chornobyl-3-92216d21b223  (Olegh Bondarenko) INDEX Foreword Executive summary Introduction 1. Physical      1986      Resuspension      Transport      Humidity      Traffic 2. Cyber    ...

De-Anonymization attacks against Proton services

  In November 2021 YesWeHack invited me to participate in a private bug bounty program organized by  Bug Bounty Switzerland on behalf of Proton AG.  The scope of the program was quite interesting and heterogeneous, as it covered most of the applications and services offered by Proton, such as ProtonMail and ProtonVPN. As a result, multiple technologies and codebases were in scope, ranging from typescript, in the open-source part of Protonmail, to .NET/Swift used by ProtonVPN apps for Windows and macOS respectively. Proton is well-known for its privacy-driven services offer, so they are based on Switzerland where the legislation seems to match Proton's requirements to provide that kind of services: thus maximizing the privacy of their communications, minimizing the amount of data they log from their users while keeping a law-abiding status.  It wouldn't be realistic to think of Proton users as an homogenous group; you may be using Proton because you're genuinely w...

Finding vulnerabilities in Swiss Post's e-voting system: part 3

Exactly two years ago I brought my blog back to life, after many years of hiatus, with " Finding vulnerabilities in Swiss Post’s future e-voting system - Part 1 ". That was the first of a series of blog posts covering that system. During these two years I've been periodically assessing the security posture of this e-voting solution, as part of their Bug Bounty program , which I personally recommend.   Since the first time I reviewed their codebase a lot of things have changed, for good, as many areas have been dramatically improved. To be honest, from a security perspective the codebase back then was kind of a mess.   When the first Swiss Post e-voting platform was published, back in 2019, it faced some public scrutiny, mostly from the academic community.  As a result, some significant issues were uncovered , so eventually Swiss Post decided to suspend the deployment of the system. That first version had been developed by Scytl , Spanish company specializ...

Beware of Java's String.getBytes

Sometimes there are subtle bugs whose origin can be found in some quirks from the underlying language used to build the software. This blog post describes one of those cases in order to let both fellow security researchers and developers, who didn't know about it, become aware of this potential vulnerable pattern. In fact, I'm pretty sure that similar bugs to the one herein described likely affect a bunch of products/codebases out there. In previous posts , I've already described some bugs in the Swiss Post's future E-voting system. While reading their  Crypto-Primitives specification , which among other things describes the custom Hashing algorithm Swiss Post implemented, I noticed something potentially interesting. Basically, there are 4 different types that are supported: byte arrays, strings, integers and vectors. Before being hashed, strings are converted to a byte array via the ' StringToByteArray ' algorithm. However, by comparing ' StringToByteArray...

Losing control over Schneider's EcoStruxure Control Expert

  During Q2 2022, in view of the geopolitical situation that unfolded after the Russian invasion of Ukraine, I decided that it wouldn't do any harm to kill some bugs in some of the main players within the ICS arena. I focused in those software frameworks that are running on the engineering workstations so, if compromised, attackers would be in a privileged position to manipulate controllers logic, thus enabling sophisticated attacks with a potential physical impact (i.e triton). I responsibly reported a bunch a unauthenticated remotely exploitable bugs to the corresponding vendors. In one case, after being ignored for months, I had to resort to the 'twitter, do your magic' approach and tweeted that I would be disclosing the issues if the situation persisted. It took just few hours for the vendor to get back to me. The positive side is that they found the bugs interesting and all that mess ended up in paid work.   This blog post covers a similar scenario in a different ven...