Articles / Privacy

  • privacy
  • contact-discovery
  • metadata
  • hashing
  • go
  • uk-gdpr

What Your Phone's Contact Upload Tells a Server Without Any Messages

Most people think a messaging service learns about them when they send something. It learns a great deal earlier than that. The moment you tap "find my friends", your phone sends the server your address book, and that is a social graph before there is a single message to look at.

This post covers what that upload reveals, why "we only upload hashes" is much weaker than it sounds, and a short Go program that shows the weakness directly.

What the server learns from the upload alone

An address book is not just a list of numbers. Even stripped to numbers only, the server gets quite a lot:

  • Who you know, including people who have never used the service.
  • Who else has you in their address book, once other users upload theirs.
  • Clusters: family, workplace, a campaign group, a clinic, a lawyer.
  • Which of your contacts are also contacts of each other, which is a rather good guide to how your life is organised.

If names, emails or notes come along for the ride, that is richer still. And the upload usually repeats whenever your contacts change, so the server also sees when your circle shifts.

Non-users are in the data too

Here is the part that tends to get skipped. Your aunt who has never installed the app is still in your upload, and in everyone else's who knows her. The server can assemble a record for a number that has never agreed to anything: who is connected to her, how many people have her saved, and under what names.

People who never signed up can still end up with a profile. Whether that is lawful for a given service is a question for its controller, and depends on how the data is held and used. The ICO's guidance on UK GDPR is the place to start for that side, and it is not something a user's phone settings can fix.

"We only upload hashes" and the size of the phone number space

The common defence is that the app hashes each number before sending it, so the server never sees the real thing. That would work if the inputs were unpredictable. Phone numbers are not.

A UK mobile number is +44, then 7, then nine more digits. That is at most about a billion candidates. A hash of a billion-item space is not a secret; it is a lookup table nobody has bothered to build yet.

Reversing it in a few lines of Go

Ofcom reserves 07700 900000 to 900999 for use in TV drama, so it is a safe range to demonstrate with. This program "receives" a SHA-256 hash and recovers the number by trying every candidate:

package main

import (
	"crypto/sha256"
	"fmt"
)

func main() {
	// What the server was sent: a hash of a drama-reserved number.
	target := sha256.Sum256([]byte("+447700900123"))

	for n := 0; n < 1000; n++ {
		candidate := fmt.Sprintf("+447700900%03d", n)
		if sha256.Sum256([]byte(candidate)) == target {
			fmt.Println("recovered:", candidate)
			return
		}
	}
	fmt.Println("not in range")
}

That is a thousand guesses. Widen the loop to the whole mobile space and it becomes about a billion hashes. SHA-256 is built to be fast, so a laptop gets through that in a matter of minutes at most, and a GPU is faster again. Better still for the attacker, they only have to do it once and keep the table.

Quick detour: why salting does not rescue it

Hang on, surely a salt fixes this? Not here. A salt defeats a precomputed table when each record gets its own salt. But the server has to match your hashes against its own users' numbers, so everyone must hash the same way. A shared salt or a keyed hash with a key the client holds is just a constant the attacker can read out of the app.

The matching requirement is the whole problem. Anything the server can compare against its user list, it can also brute force.

What actually helps

The designs that hold up stop the server from seeing which numbers you asked about. Signal's published approach runs the lookup inside a hardware-protected enclave (Intel SGX) so that the operator is not supposed to be able to see the queries. That moves trust to the hardware and to remote attestation; it is better than plain hashes, but it is still a trust decision rather than a mathematical guarantee.

Other mitigations are plainer, and you can do them without a cryptographer:

  • Make contact upload opt-in, and make the app work without it.
  • Upload only on demand rather than syncing continuously.
  • Do not keep the uploaded list once the match has been returned.
  • Do not upload names, emails or notes if numbers will do.

What you can do as a user

On the phone, deny the contacts permission and see what still works. Typing a number in by hand is slower, but it tells the server about one person rather than your whole address book. Recent iOS versions also let you share a limited selection of contacts with an app instead of all of them, which is worth using if the app insists.

If you build such a feature yourself, treat the address book as the sensitive dataset it is. The worst case is not a leaked message. It is a tidy, complete map of who someone knows, collected before they have said anything at all.