A Look Into Signal’s Encrypted Profiles

Can this feature get abused for OSINT discovery?

According to Signal, “Profiles allow you to add a picture and display name that will be shown alongside your existing phone number when communicating with other users. Conversations will feel more personal. Group threads will be less confusing.

All of this is possible without sacrificing the privacy and security that you have come to expect from Signal.”

This works just like WhatsApp, it displays the names of numbers you don’t already have in your contact list and a neat profile picture. Like Signal explains, “Clients [ie. WhatsApp] could simply upload an image and their chosen name to a remote server. The server would then store this plaintext data, expose a basic profile API, and act as the arbiter for any other clients who request profiles for other users.”. What this means is by default, most IM providers don’t care enough about privacy and will just store all that data in plaintext and make it accessible to anybody requesting the API. The consequences can be very harmful, for instance it was possible to abuse the WhatsApp API to collect random users’ status, alongside the profile picture 😬

My main questions was, can Signal get abused this way? And how does an Encrypted Profile work?

Toying with the Signal API

Signal is using a not so documented API to communicate with the server. You can read the source code but the current specs aren’t on any public Wiki as far as I know.

For this particular research I used Signal Desktop which is Electron based, the main advantage is that you can fire up the Developer Tools from the embedded web view and begin to see the console logs and interfere with JS objects.

The Signal Client is trying to retrieve the profile data

The requests to get the profile data are made to the https://textsecure-service.whispersystems.org/v1/profile/<number> endpoint, where number is the PSTN number of the user (ie. +17752386572 ).

Now, we’d like to replay the request, right? The API requires a Basic HTTP authentication in the form of Base64({number}:{password}) according to this API Protocol wiki page from 2014 (couldn’t find anything more recent ¯\_(ツ)_/¯).

This password is a randomly generated 16 byte ASCII string, created when you set up your account. Now this must be stored somewhere, encrypted or not. I could have looked into the many Signal files but I couldn’t be bothered with that. I went the easy and dirty way 🤩

Getting the authenticating password

Using the JavaScript console you could edit the code but since this password is being accessed at the beginning and then stored in a local variable you can’t access it from there (or I didn’t try hard enough), so I decided to patch the code from the files.

Electron pack all the JS files in an .asar archive, on Windows this will be located at C:\Users\<user>\AppData\Local\Programs\signal-desktop\resources . It’s basically a tar-like format that concatenates files into a single big file — uncompressed. There are a few JSON metadata with the number of files, sizes, etc. So instead of unpacking and repacking the archive, I went straight for the good old hexedit patch in order for the file to stay the same size, whatever works is fine!

I decided to patch the js/modules/web_api.js and simply added a window.log.info() call with the Basic authentication variable as argument. Restart the Signal app, and voilà!

Now we can start our favorite Python interpreter and make some requests to the Signal API, neat!

> GET /v1/profile/{number} HTTP/1.1

> Authorization: Basic {basic_auth}

> Host: textsecure-service.whispersystems.org

>

< HTTP/1.1 200 OK

< Content-Type: application/json

< Vary: Accept-Encoding

<

{"identityKey":"[REDACTED BASE64 BLOB]","name":"[REDACTED BASE64 BLOB]","avatar":"profiles/[REDACTED]"}

We get a JSON response containing the identityKey, which confirms the number is a registered Signal user. If the number is unknown we get a classic 404 error. The avatar is a path to the encrypted profile picture ( https://cdn.signal.org/profiles/<image_path> ).

Let’s focus on the name field.

Encrypted profiles

Once the server replies with the name and avatar data, all we get is some encrypted blob (base64 encoded). To understand how the decryption process work, we should look at the crypto.js file from the libtextsecure folder, where the decryptProfileName() function is located.

This function simply calls decryptProfile() where the real decryption work is handled:

It’s using the SubtleCrypto JS interface and uses the GCM mode with AES (AES-GCM-256). Basically the name field is encoded and encrypted as such:

AES-GCM authentication tag is often mixed within the cyphertext, depending on the crypto API used

Now, where’s the key to decrypt that blob, you might ask.

The “profileKey”

Signal uses a dedicated profileKey to encrypt the name and avatar fields. This key is of course generated by the owner of that data. Which means the server do not have knowledge of the cleartext name and avatar.

If you look closely at the code execution paths in the Signal source code, you’ll notice the 256 bits profileKey is sent E2E encrypted within each message (alongside the actual text content or attachments):

return this.sendMessage({

recipients: [number],

body: messageText,

timestamp,

attachments,

quote,

needsSync: true,

expireTimer,

profileKey,

});

Which means you’ll be able to decipher the name and avatar fields only once the recipient sent you a message, and he explicitly consented to sending you profile information (specifically, the profileKey). This is the core principle and the beauty of this design, you cannot get these items otherwise, you need user interaction (and explicit consent).

Explicit consent asked for sharing the profileKey (which will be sent E2E encrypted alongside the next message)

If you ever have to decipher the name blob manually, here is how to do it in Pyhon:

Discoverability

Now that we know how to send requests and how we could decode the fields, can we write a simple script to grab as many phone numbers metadata as possible? Let’s try!

After having tried a little more than 4k numbers at the slow rate of 2.91 numbers per second (not multi threaded) I received the 413 error code (rate limit exceeded). That’s good news! This means Signal do protect against large OSINT discovery.

Quick findings

Approx. 0.4% of French mobile numbers are registered Signal users (16 registered numbers out of 4267 requested numbers);

of French mobile numbers are registered Signal users (16 registered numbers out of 4267 requested numbers); Half of them have filled a profile name and a profile picture;

Conclusion

To answer the initial question, can this feature get abused for OSINT? I’m doubtful it can. This is certainly state of the art privacy by design: showing a profile name and picture while preserving the E2E confidentiality (server-side data being stored encrypted) and disabling OSINT availability.

This scheme isn’t infallible though, as you could theoretically have multiple dummy accounts to check large amounts of numbers simultaneously, until you exhaust all the number space. Even then, all you get are encrypted blobs. Now you would need to send messages to each of the numbers and pray for them to accept sharing their information with an unknown number and reply. Very unlikely. All you could really do are statistics of Signal usage per country, for example.

Overall, 10/10 would use Signal again

Any remarks or suggestion, feel free to ping me @x0rz. If you liked this article you can also pour me some coffee☕!