Mullvad exit IPs as a fingerprinting vector

Mullvad is one of the few VPN providers that offers multiple exit IPs for its servers. If two people connect to the same server, they will usually end up with different public IPs.

With only 578 servers (compared to ProtonVPN’s 20,000), this kind of vertical scaling makes sense to avoid cramming too many users onto one IP, which would be a problem on sites with excessive IP blocks and ratelimits.

Surprisingly, the exit IP you are given is not random every time you connect to the server, but is instead chosen based on your WireGuard key, which rotates every 1 to 30 days (unless you use a third-party client, in which case it never rotates).

But wait.. if every server provides you with a freely selected static exit IP, wouldn’t just a few of them be enough to uniquely identify you among every other Mullvad user?

to test it

I wrote a script that repeatedly changes my pubkey and fetches the exit IP for a set of 9 servers. Leaving this running for a night generated data points for 3650 pubkeys, enough to map the egress IP range for each server:

host name start ip end ip #ip
AU-SID-WG-101 103.136.147.5 103.136.147.64 60
CL-SCL-WG-001 149.88.104.4 149.88.104.14 11
de-bear-wg-007 193.32.248.245 193.32.248.252 8
DK-CPH-WG-002 45.129.56.196 45.129.56.226 31
fi-hel-wg-201 185.65.133.10 185.65.133.75 66
US-LAX-WG-001 23.234.72.36 23.234.72.126 91
US-NYC-WG-602 146.70.168.132 146.70.168.190 59
US-SJC-WG-302 142.147.89.212 142.147.89.224 13
za-jnb-wg-002 154.47.30.145 154.47.30.155 11

The size of the pool adds up to over 8.2 trillion outgoing IP combinations for these servers, so you would think that each pubkey would be assigned a unique combination of IPs as the chance of a collision is very low. And yet, somehow all the pubkeys I tested were assigned only one of the 284 combinations.

What’s going on over here?

Different IP, same ratio

You can calculate a numerical position for an exit IP by calculating its distance from the pool’s starting IP.

For example, I.P. 103.136.147.53 assigned by au-syd-wg-101 The 1-based index would be 49 (XXX53 – XXX5 + 1).

Now, if you take the IP positions for any of the 284 combinations linked above, and you divide them by the pool size, a general ratio emerges:

server i p Post pool size Ratio
AU-SID-WG-101 103.136.147.53 49 60 0.816
CL-SCL-WG-001 149.88.104.12 9 11 0.818
de-bear-wg-007 193.32.248.251 7 8 0.875
DK-CPH-WG-002 45.129.56.220 25 31 0.806
fi-hel-wg-201 185.65.133.63 54 66 0.818
US-LAX-WG-001 23.234.72.109 74 91 0.813
US-NYC-WG-602 146.70.168.179 48 59 0.813
US-SJC-WG-302 142.147.89.222 11 13 0.846
za-jnb-wg-002 154.47.30.153 9 11 0.818

Each IP falls within the same percentile of its pool, in this case, 81st.

This explains the limited number of combinations, Mullvad will only assign neighbor exit IPs on all its servers. But why?

Feature or bug?

Eagerly, Server cl-scl-wg-001 And za-jnb-wg-002 Share consistent IP index with each other across all 284 observed IP combinations.

What they have in common is a pool size of 11, and that gives us an idea of ​​what’s going on.

In any language, if you initialize an RNG with a constant seed, a rand-between call with the same bounds will always produce the same result:

use rand::{Rng, SeedableRng};
use rand::rngs::StdRng;

fn main() {
    let seed = 1234;
    for _ in 1..100 {
        let mut rng = StdRng::seed_from_u64(seed);
        let number = rng.random_range(0..1000);
        println!("{}", number) // will always print 56
    }
}

So, the shared index between these two servers indicates that Mullvad is likely using some kind of seed-based RNG to choose the exit IP index, where the upper bound parameter is the pool size.

This is fairly straightforward, but what happens when the boundaries change?

use rand::{Rng, SeedableRng};
use rand::rngs::StdRng;

fn main() {
    let seed = 12345;
    for bound in 10..100 {
        let mut rng = StdRng::seed_from_u64(seed);
        let number = rng.random_range(0..bound);
        let ratio = number as f64 / bound as f64;
        println!("{} {:.3} ", number, ratio)
    }
}
5 0.500 
5 0.455 
6 0.500 
6 0.462 
7 0.500 
7 0.467 
8 0.500 
9 0.529 
9 0.500 
10 0.526 
10 0.500 
11 0.524 
11 0.500 
12 0.522 
12 0.500 
13 0.520 
13 0.500 
14 0.519 
14 0.500 
15 0.517
...

As it turns out, the entropy pool of the RNG is unaffected by the bounds you provide, and at least in Rust, a single float is generated on each first call and used as a multiplicative scale for the bounds, like this: min + round((max - min) * float) (This may be a huge oversimplification)

This is consistent with the behavior we’ve seen in Mullvad’s exit IP picking algorithm, so it’s safe to say this is the cause.

Rust also makes sense as a backend language, given that the client is also written in it.

The thing is, almost none of my programmer friends were able to describe it accurately. random_range The second code snippet will produce output, and the actual behavior surprised even me. It is reasonable to think that each increase in the range will be asymptotic with entropy and result in a different number, even though this is not the case.

Is it possible that the Mullvad developers shared this common misconception, when in fact they intended to have an unlimited number of egress IP combinations? I don’t know, but it’s a weird idea.

correlated detection

I have created a tool that can extract the minimum and maximum float values ​​for a given combination of IPs, available at https://tmctmt.github.io/mullvad-seed-estimator/.

tool

This particular set of IPs in the screenshot resolves to a float value between 0.2909 and 0.2943, for a difference of 0.0034, meaning that 0.34% of Mullvad users share these IPs. At a ball park estimate of 100,000 active Mullvad users, this equates to 340 users.

It’s definitely not as unique as I originally thought, but at the same time, >99% accuracy really isn’t that bad?

As an example, imagine you’re a moderator on a forum and you suspect that a new face is actually a sockpuppet of the user you banned the day before. You check the IP logs, and despite using different Mullvad servers, both accounts resolve to overlapping float ranges. 0.4334 - 0.4428 And 0.4358 - 0.4423. This gives you a >99% probability that they are the same person.

Now apply this to data breaches and IP logs obtained through legal channels and you can see how you can become anonymous behind a VPN through similar correlation attacks.

protect yourself

  • Avoid switching servers more than once per pubkey
  • Force rotate your pubkey by logging out of Mullvad app



<a href

Leave a Comment