Reverse engineering of an Android VPN


Nowadays a large number of people use a VPN, for various more or less relevant reasons.

While some VPN providers give guarantees of transparency by disclosing their source code and/or periodically publishing technical audits reports, many others providers are really opaque.
Carefully study one of these VPN client could therefore reveal some surprises…

The Secure VPN application

I wanted to study the behaviour of such a VPN, so i’ve (quite randomly !) chosen to study Secure VPN.

This application is quite popular since it has been downloaded more than 100 millions times, more than NordVPN and CyberGhost VPN !

The Google Play page of the application specifies it is developed by Secure Signal inc:

Contrary to what this name could lead to believe, this society has nothing to do with the editor of the Signal application.

Secure VPN app website

On the Google Play page, we can find an email address (secure-vpn@free-signal.com), a physical address and a website to contact the developer:

This website is securesignal.app.

We can see the laudatory testimony of Marsha Singer, Tim Shaw and Lindsay Spice:

By a quite extraordinary chance, these three people has Doppelgängers who gave equally laudatory testimonials for another application, apptuary.com.

This application has a dedicated website whose appearance is quite similar to securesignal.app:

Another contact email appears on the site (contact@securesignal.app):

The rest of the site gives some indications regarding the technical capabilities of the VPN, with the usual promises (No Log, Safe Protocol, Privacy, etc), without giving any detail.

The free-signal.com and secure.free-signal.com websites don’t give any clue either.

First analysis with Exodus privacy

A user who wants to know whether an application is intrusive but doesn’t know how/doesn’t want to extract the manifset can use the website https://reports.exodus-privacy.eu.org.

It turns out that Secure VPN uses 2 trackers and requires 16 permissions:

Some of these permissions:

such as

QUERY_ALL_PACKAGES, ACCESS_ADSERVICES_TOPICS, ACCESS_ADSERVICES_ATTRIBUTION or ACCESS_ADSERVICES_AD_ID

are not required by a VPN app, and are here only for adverstisement/profiling needs (QUERY_ALL_PACKAGES allows to list the applications installed on a phone, which can say a lot about its owner).

First use

Secure VPN interface is very simple.

When launched for the first time, the application asks the user if s.he consent to their personal data being used for various purposes, including advertising:

The user then has access to a grayed icon which must be clicked on to mount the VPN tunnel:

The connection icon turns blue when the VPN tunnel is mounted:

A visit to whatismyip.com confirms that the IP address observed by a visited website is different from the actual IP address of the phone:

The manifest

Having a look at the AndroidManifest.xml file of a studied application is an essential step, and we will be no exception.

The manifest declares 24 activities. Among these, 18 are part of the application itself (all are members of the package com.signallab.secure.activity).

We will not dwell on the activities of the application: Indeed, the purpose of a VPN client is to encrypt/decrypt incoming traffic.

This task, carried out in the background, requires the execution of a service. The manifest declares 12 services.

Two of them, com.signallab.secure.service.SecureService and com.signallab.lib.SignalService,

are part of the application itself:

These two services inherit from the android.net.VpnService class, a class used to implement a VPN client. Such a service will create a virtual network interface (/dev/tun, typically).

Once the VPN is launched,

  • phone applications send their network traffic over this interface,
  • the VPN service reads the packets that applications want to send, encrypts them and sends them,
  • the VPN service receives encrypted packets from the VPN server, decrypts them and writes them to the virtual interface,
  • applications receive these decrypted packets on this virtual interface.

The class SignalService contains a method loop(), who calls a method connect() from SignalHelper class:

The connect() method is a native one:

This method is called when the user clicks on the login icon mentioned before.

We can trace the call to this connect() method with Frida (it’s the trace_SignalHelper_connect.js script in the associated github https://github.com/tmalherbe/securevpn):

Spawned `com.fast.free.unblock.secure.vpn`. Resuming main thread!       
[Pixel 7a::com.fast.free.unblock.secure.vpn ]->
appel de connect - arguments :
tunfd : 263
host : 205.185.127.80
udpPorts : 53,9981
tcpPorts : 443,9981
userId : 3363728822350923000
userToken : 2374040680779317000
key : Fh8YUC9uTVv2qJikWbXCHh
supportBt : false
algo : 1

appel de connect - pile d'appel :
java.lang.Exception
    at com.signallab.lib.SignalHelper.connect(Native Method)
    at com.signallab.lib.SignalService$VpnThread.loop(SignalService.java:144)
    at com.signallab.lib.SignalService$VpnThread.run(SignalService.java:1)

The connect() method arguments are:

  • The file descriptor on the virtual interface /dev/tun,
  • the IP address of the VPN server to reach,
  • the UDP and TCP ports on which to contact the VPN server,
  • some integers (userId et userToken) who identify the user,
  • a key (which value is Fh8YUC9uTVv2qJikWbXCHh here),
  • a boolean value and a constant who indicated the cryptographic algorithm to use.

According to Android documentation, (https://developer.android.com/reference/java/lang/Thread#start()), calling the start method of a Thread object induces the call of the run method of the object in question.

The start method of VpnThread is precisely called in the onStartCommand method of SignalService:

This method onStartCommand is the entry point by which this service can be launched.

The application’s native libraries

The application embeds 3 native libraries, libz.so, liblog.so and libchannel.so.

The latter is significantly larger than the two other, and it is this one who implements most of the native methods used by the application’s java classes.

Intuitively, the « easiest » way to create a VPN client is to « wrap » an OpenVPN client into an apk: The application will contain a class inheriting from android.net.VpnService, which will eventually launch the client native.

None of the functions exported by libchannel.so contain in their name the keywords « openvpn » or « ovpn », contained by a large number of functions exported by the libovpn library used in this type of applications:

This suggests that Secure VPN does not use OpenVPN, although it is possible that libchannel.so is a customized version of libovpn.

Traffic exchanged with the VPN server

If we examine the traffic intercepted after launching the application and setting up the VPN tunnel, we observe, over the sessions:

  • mainly traffic with tcp port 443 of the server,
  • or mainly traffic with tcp port 9981 of the server,
  • or mainly traffic with udp port 53 of the server.

Although port 443 is dedicated to TLS traffic, the captured traffic is not TLS traffic, an application TLS packet necessarily begins with \x17\x03:

In the same way, traffic on port 53 is not DNS traffic, as indicated by the errors reported by wireshark:

Descent to native layer

Calling the connect() method of the SignalHelper class results in a call to the function of the same name in libchannel.so.

This function creates an object of the SignalLinkClient class and positions different fields of this object with the functions setSignalRouter, enableObscure, setUsers, setProto, setBackupPort, connect and setTunnel, before to call the runLoop function which is the main infinite loop of the VPN client service.

Let’s give some details on the respective roles of these initialization functions:

  • The SignalLinkClient initializes an object SignalPackage. One of the fields of the SignalLinkClient points toward this SignalPackage object. The 8 first bytes of the SignalPackage then point to a 1500 bytes buffer. 1500 is a common MTU value: This buffer will probably be used to handle network packets.
  • SignalLinkClient::setSignalRouter: This function copies the address of a static object SignalRouter at the beginning of the SignalLinkClient. The SignalRouter object contains several function pointers. As those functions are not used in packet encryption/decryption, we won’t give much attention to them.
  • The functionSignalLinkClient::enableObscure modifies the SignalPackage adressed by one of the field of the SignalLinkClient: At the end of this function, one of the fields of the SignalPackage points toward a SignalObfuscator, who essentially contains the obfuscation key. This obfuscation key is one of the parameters of the SignalHelper.connect function.
  • SignalLinkClient::setUser copies the intergers userId and userToken into the SignalLinkClient.
  • SignalLinkClient::setProto and SignalLinkClient::setBackupPort respective effects is 1/to set some booleans who indicate the supported protocols (UDP and TCP) 2/to set port numbers.
  • The SignalLinkClient::connect function initializes the RemoteLink * arrays, which are objects who describe a VPN server (IP address + port + protocol).
  • Finally, the function SignalLinkClient::setTunnel copies the file descriptor on the /dev/tun virtual interface into the SignalLinkClient.

The infinite processing loop, SignalLinkClient::runLoop()

Once all these initialization steps are done, the runLoop function is called.

Basically, this function is an infinite loop in which some files descriptors are monitored through the epoll api:

When an application wants to send a network packet, this plaintext packet is written on /dev/tun. The VPN client reads this packet, encrypts it and send it to the VPN server.

Conversely, an packet incoming from the VPN server is decrypted and written on /dev/tun in order to be delivered to the recipient application.

Thus,

  • The unencrypted packet to send are read on /dev/tun, encrypted and sent to the server by the SignalLinkClient::processTunIn function,
  • The encrypted packets coming from the VPN server are decrypted and written on /dev/tun by the SignalLinkClient::processLinkData function.

Outgoing packets processing

The outgoing packets SignalLinkClient::processTunIn processing function

Firstly, this function reads the packet to send from /dev/tun into the 0x468 offset of the SignalLinkClient object.

The packet is then processed by the SignalLinkClient::writeToLink subfunction.

The SignalLinkClient::writeToLink function

Firstly, this function performs initialization steps.

One of these steps is to write to magic word `\x01\x00_SiG` in the buffer whose address is stored at the beginning of the `SignalPackage`.

The rest of the work is done by the `SignalPackage::setData`, and the encrypted packet is then sent to the VPN server.

The `SignalPackage::setData` function

After some preliminary checks, the SignalPackage::setData function computes two 64-bits integers from the userId and userToken integers who identify the user.

These two computed integers are written in a buffer whose address is stored in the SignalPackage object. The plaintext packet is copied just after these two integers.

The purpose of these 128 bits could be to give a way to the VPN server to identify which user has sent a given packet.

The SignalObfuscator::encode is finally called. This is this very function who will call all the cryptographic functions who encrypt the packet.

The SignalObfuscator::encode function

This function contains two logical blocks conditioned by the value of its last argument, algo.

If algo is set to 1, then the packet is encrypted with AES GCM. If it is set to 0, encryption is done using ChaCha20.

As we will see later, most of VPN servers use AES. We therefore won’t dwell on the second block, the one who calls ChaCha20.

The heuristic used to encrypt a plaintext packet is the following:

  • The 16 first bytes of the obfuscation key are used as an AES key (via gcm_setkey, who calls aes_setkey, who in its turn calls aes_set_encryption_key).
  • The next 12 bytes of the obfuscation key are used as the GCM nonce, via gcm_start function. In practice, the obfuscation key is not long enough (it should have at least 16 + 12 = 28 bytes), and the 6 last bytes of the nonce are always set to zero.
  • The packet is AES-GCM encrypted using gcm_update. The encrypted packet is written inside the SignalObfuscator object.
  • Then the GCM tag is generated using gcm_finish function.
  • Finally, the encrypted packet is then copied in the location initially occupied by the plaintext packet. As seen before, the sending of the encrypted packet is done at the end of the SignalLinkClient::writeToLink function.
The incoming packets SignalLinkClient::processLinkData processing function

Once we understand well enough how outgoing packets are processed, studying the incoming packets processing chain is much more easy !

Let’s briefly describe how a packet is decrypted:

The received packets is processed by the SignalLinkClient::writeToTun function.

Inside it, the packet decryption is done by SignalPackage::decodePackage, via the SignalObfuscator::decode function, the counterpart of SignalObfuscator::encode for decryption side.

Is it really AES ?

Even if libchannel contains some AES-something functions, it cannot be excluded that, voluntarily or not, the developer used something else than AES.

It is therefore preferable to ensure that the encryption is actually done by AES.

The encryption function, called in gcm_setkey and gcm_update, is the aes_cipher function.

We could analyze the Ghidra-decompiled code to ensure that this function really perform an AES encryption, but encryption algorithm are complicated animals. Moreover, we can hook aes_cipher with Frida, so why bother ?

We therefore launch a script (trace_AesGm.js) who intercepts gcm_setkey, gcm_update and gcm_finish :

thomas@ankou:~/articles/securevpn$ frida -U -p $(frida-ps -U | grep 'Secure VPN' | awk -F ' ' '{print $1}') -l trace_AesGcm.js 
     ____
    / _  |   Frida 16.2.1 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to Pixel 7a (id=3C221JEHN01971)

[Pixel 7a::PID::21108 ]-> Entering into gcm_setkey
gcm_setkey, input : \x46\x68\x38\x59\x55\x43\x39\x75\x54\x56\x76\x32\x71\x4a\x69\x6b
gcm_setkey, key_len : 16
Entering into gcm_start
gcm_start, mode : 1
gcm_start, iv : \x57\x62\x58\x43\x48\x68\x00\x00\x00\x00\x00\x00
gcm_start, iv_len : 12
Entering into gcm_update
gcm_update, input len : 90
gcm_update, input : \xd5\xc2\xb3\x50\x09\xd8\xfc\x6e\x56\x9d\x01\x17\x37\x34\x01\x01\x00\x00\x5f\x53\x69\x47\x2e\xae\x5d\x8a\xc8\xf8\x43\x9a\x20\xf2\x49\x6b\xc4\x2d\x80\xbd\x45\x00\x00\x34\x0b\xe9\x40\x00\x40\x06\x2a\x4e\xc0\xa8\x01\x86\x8e\xfa\xb3\x64\x8e\x36\x01\xbb\xef\x16\x68\xf5\x5d\x8d\xa5\x89\x80\x11\x01\x2c\x74\x97\x00\x00\x01\x01\x08\x0a\x9e\x0f\xe7\xd0\xb4\x12\xd7\x63
gcm_update, gcm_mode : 1
gcm_update, output after : \x48\xb7\xff\x55\xd1\x7c\x26\x92\x5e\x4d\x2d\x5f\x48\x65\xa9\xa9\xa8\x0f\x4a\x56\x3e\x84\x32\xbc\x61\xea\xae\x06\xb9\x87\x31\x65\x33\xcf\x3a\x1d\x8f\x49\x79\x3f\x1d\xe2\xca\x33\xb5\xe5\xd6\xa8\xd7\xd9\x57\xcc\x2d\x67\x8d\xfb\x34\x35\x3a\x74\x07\x1c\x7d\x44\x08\x94\x97\xd0\x3e\x8e\x85\x8d\x34\x55\x79\x0b\xba\xfa\xb3\x39\x28\xc2\xe2\xa0\x0e\xd9\x78\x4f\x9b\x52
Entering into gcm_finish
gcm_finish, tag ptr : 0x0
gcm_finish, tag len : 0
Stopping script
[Pixel 7a::PID::21108 ]->

The script prints the input and the output of gcm_update, and also the nonce and the encryption key.

By the way, we can see that the arguments of gcm_finish, namely the pointer and the length of the tag, are null.

Let’s reproduce the AES GCM computation with a few lines of python (decrypt.py script):

```
#!/usr/bin/python3
# -*- coding: utf-8 -*-

from Cryptodome.Cipher import AES

key = b'\x46\x68\x38\x59\x55\x43\x39\x75\x54\x56\x76\x32\x71\x4a\x69\x6b'
nonce = b'\x57\x62\x58\x43\x48\x68\x00\x00\x00\x00\x00\x00'
plaintext = b'\xd5\xc2\xb3\x50\x09\xd8\xfc\x6e\x56\x9d\x01\x17\x37\x34\x01\x01\x00\x00\x5f\x53\x69\x47\x2e\xae\x5d\x8a\xc8\xf8\x43\x9a\x20\xf2\x49\x6b\xc4\x2d\x80\xbd\x45\x00\x00\x34\x0b\xe9\x40\x00\x40\x06\x2a\x4e\xc0\xa8\x01\x86\x8e\xfa\xb3\x64\x8e\x36\x01\xbb\xef\x16\x68\xf5\x5d\x8d\xa5\x89\x80\x11\x01\x2c\x74\x97\x00\x00\x01\x01\x08\x0a\x9e\x0f\xe7\xd0\xb4\x12\xd7\x63'

cipher = AES.new(key, AES.MODE_GCM, nonce)
ciphertext = cipher.encrypt(plaintext)

print(f"enc ciphertext : {ciphertext}")
```

This script produces the same output as the one observed at the end of gcm_finish: The VPN client really uses AES GCM !

thomas@ankou:~/articles/securevpn$ ./decrypt.py 
enc ciphertext : b'H\xb7\xffU\xd1|&\x92^M-_He\xa9\xa9\xa8\x0fJV>\x842\xbca\xea\xae\x06\xb9\x871e3\xcf:\x1d\x8fIy?\x1d\xe2\xca3\xb5\xe5\xd6\xa8\xd7\xd9W\xcc-g\x8d\xfb45:t\x07\x1c}D\x08\x94\x97\xd0>\x8e\x85\x8d4Uy\x0b\xba\xfa\xb39(\xc2\xe2\xa0\x0e\xd9xO\x9bR'

Identification of the cryptographic implementation used

In AES, each round (the key is a 16 bytes one, so there is 10 rounds) applies three successive transformations – even if the last round is a little bit different:

  • The SubBytes transformation who associates to each of the 16 bytes of the input block another byte ;
  • The ShiftRows transformation. In this transformation, the bytes of the input block are divided into 4 groups of 4 to form a square matrix. The first line of this matrix is untouched, the elements of the second row are shifted by 1, the elements of the third row are shifted by 2, and the elements of the fourth row are shifted by 3.
  • The MixColumns transformation. This transformation also interprets the input block as a 4×4 square matrix, and calculates the image by a matrix transformation of each of the 4 columns of the input matrix.
  • La transformation MixColumns. Cette transformation interprète elle aussi le bloc d’entrée comme une matrice carrée 4×4, et calcule l’image par une transformation matricielle de chacune des 4 colonnes de la matrice d’entrée.

For efficiency reasons, these transformations are generally implemented in the form of tables precomputed and stored in binary.

This is not what happens here. If we look the tables used by the aes_cipher function:

and if we look where is defined this table DAT_0015ed78 :

We see that its content is not initialized.

We also observe a cross-reference leading to a function aes_init_keygen_tables, whose role, as its name suggests, is to initialize the tables in question.

A few online searches allow us to find traces of an AES implementation using an aes_init_keygen_tables function:

The AES implementation in question is that of the mongoose embedded web server, published by the company Cesanta: https://github.com/cesanta/mongoose/blob/master/mongoose.c

A comparison of the code decompiled by Ghidra and the code of aes_init_keygen_tables present on github confirms that the version present in libchannel.so probably comes from this repository.

Furthermore, the other cryptographic functions used when processing a packet are also present in this repository.

This element allows us to understand the respective roles of the arguments of the cryptographic functions gcm_setkey, gcm_start, gcm_update and gcm_finish used by libchannel.so.

Focus on the cryptographic mechanisms used

GCM is an authenticated mode:

It encrypts a message and computes a tag who ensures the message integrity. The encryption part of the GCM mode is done using the CTR operating mode.

In CTR mode, a counter is used to encrypt the message:

To encrypt a message block,

  • we encrypt the counter,
  • we xor the result with the plaintext block,
  • we increment the counter before to process the next block.

This can be summarized more formally by the relationship

Y_{i} = X_{i} ^ E(counter + i).

A message encrypted with AES_CTR is xored with a sequence of random bytes, as if a stream cipher was used.

It is therefore essential, when CTR is used (and therefore when GCM is used !) not to reuse this counter to encrypt two messages.

If we ignore this precaution, we find ourselves in the following situation:

  • The cipher C1 is the result of xoring the plain message M1 with the sequence of random bytes R,
  • The cipher C2 is the result of xoring the plain message M2 with the sequence of random bytes R,
  • An observer can xor C1 with C2, giving it M1 ^ M2, from which it can extract information about M1 and/or M2.

Let’s look at how mongoose functions are used by libchannel.so.

The gcm_start function

The mongoose code describes the role of the arguments to this function:

The last two arguments specify an « AEAD data »: when an authenticated mode is used, those « AEAD data » are authenticated without being encrypted.

In the SignalObfuscator::encode function, we see that the two last arguments of the gcm_start function are null (see in the screenshot above): There is therefore no AEAD data.

Let’s reuse the last Frida script to trace the encryption of the outgoing network packets:

Entering into gcm_setkey
gcm_setkey, input : \x46\x64\x59\x57\x35\x48\x54\x6e\x65\x61\x61\x70\x68\x53\x4e\x79
gcm_setkey, key_len : 16
Entering into gcm_start
gcm_start, mode : 1
gcm_start, iv : \x4d\x47\x5a\x51\x62\x65\x00\x00\x00\x00\x00\x00
gcm_start, iv_len : 12
Entering into gcm_update
gcm_update, input len : 70
gcm_update, input : \xe6\x45\x94\x41\x01\xb3\x01\x01\x00\x00\x5f\x53\x69\x47\x2e\xae\x5d\x8a\xc8\xf8\x43\x9a\x20\xf2\x49\x6b\xc4\x2d\x80\xbd\x45\x00\x00\x28\x00\x00\x40\x00\x40\x06\x11\xc4\xc0\xa8\x01\x86\x8e\xfb\xd7\xe2\xc1\xcc\x01\xbb\xe9\xb4\x0d\xd1\x00\x00\x00\x00\x50\x04\x00\x00\xcb\xc6\x00\x00
gcm_update, gcm_mode : 1
gcm_update, output after : \x06\xea\x80\xdc\x05\xe6\x92\xea\x6c\x54\xad\x87\xf5\x71\xb6\x80\xfb\x03\xae\x3b\x16\xe6\x84\x58\x50\xc7\xd5\x01\x37\x94\x6f\x47\x7f\xed\x76\x16\x46\xb2\xc7\x89\x34\xbc\x8e\x22\x13\x0b\x62\x8a\xf4\xf1\x88\x5d\x81\xec\x90\x12\x45\xbb\x41\x3f\xd2\xd1\x29\xcc\x62\x2a\x88\xa5\x52\x53
Entering into gcm_finish
gcm_finish, tag ptr : 0x0
gcm_finish, tag len : 0
Entering into gcm_setkey
gcm_setkey, input : \x46\x64\x59\x57\x35\x48\x54\x6e\x65\x61\x61\x70\x68\x53\x4e\x79
gcm_setkey, key_len : 16
Entering into gcm_start
gcm_start, mode : 1
gcm_start, iv : \x4d\x47\x5a\x51\x62\x65\x00\x00\x00\x00\x00\x00
gcm_start, iv_len : 12
Entering into gcm_update
gcm_update, input len : 78
gcm_update, input : \x1f\xa6\x2e\x1f\x09\x1b\xe4\x39\x24\x58\x63\x0c\x50\x8f\x01\x01\x00\x00\x5f\x53\x69\x47\x2e\xae\x5d\x8a\xc8\xf8\x43\x9a\x20\xf2\x49\x6b\xc4\x2d\x80\xbd\x45\x00\x00\x28\x00\x00\x40\x00\x40\x06\x11\xc4\xc0\xa8\x01\x86\x8e\xfb\xd7\xe2\xc1\xcc\x01\xbb\xe9\xb4\x0d\xd1\x00\x00\x00\x00\x50\x04\x00\x00\xcb\xc6\x00\x00
gcm_update, gcm_mode : 1
gcm_update, output after : \xff\x09\x3a\x82\x0d\x4e\x77\xd2\x48\x0c\x91\xd8\xcc\xb9\x99\x2f\xa6\x89\x39\x90\x3c\x3b\x8a\x04\x44\x26\xd9\xd4\xf4\xb3\x0a\xb5\x36\xae\xb2\x3b\x86\x0f\xc2\x8f\x25\x50\x4e\x8a\x52\x8d\xac\x77\x32\xd7\x89\x39\x81\xd1\xf7\x5d\x9f\x88\x80\xf3\xd3\x6a\x90\x7c\x6f\xfb\x43\x63\x52\x53\x47\xea\x33\x73\xf8\x29\x38\x2d
Entering into gcm_finish
gcm_finish, tag ptr : 0x0
gcm_finish, tag len : 0

We observe something that we already knew after studying the SignalObfuscator::encode function:

The nonce is the same (\x4d\x47\x5a\x51\x62\x65\x00\x00\x00\x00\x00\x00 here) for two different packets !

The gcm_finish function

The arguments of the gcm_finish are the following:

  • a ctx pointer on the context,
  • a tag pointer who specifies where the GCM tag shall be copied,
  • a tag_len integer who indicates the length of the GCM tag.

Reading the code of this function shows that if tag and tag_len are null, no copy of the GCM tag is done.

This is exactly what happens here (see the screenshot of SignalObfuscator::encode code): The GCM tag is not copied at all !

Traffic decryption script

At this point, we have all the information needed to decrypt Secure VPN traffic.

We develop a dissect_securevpn_traffic.py script who can decrypt the intercepted Secure VPN traffic.

This script needs the following to work:

  • the IP address, the port number and (optionally) the transport protocol of the VPN server,
  • the obfuscation key,
  • the pcap to decrypt.

In the example below, the traffic going into the VPN tunnel is ICMP traffic to 8.8.8.8.

$ ./dissect_securevpn_traffic.py -f traffic_5.pcap -k FdYW5HTneaaphSNyMGZQbe -p 53 -a 51.81.222.204 --proto udp 
decrypted_payload : b'l\xe5~\x0f\x0b% 4\xca\xfb\x976(bVo\x01\x01\x00\x00_SiG\x9aC\xf8\xc8\x8a]\xae.\xbd\x80-\xc4kI\xf2 E\x00\x00T\x00\x00\x00\x003\x01\xcb\x88\x08\x08\x08\x08\xac\x10\x00\x01\x00\x00\x12u\x00\x0e\x00\x13\x1d\x13\x1cf\x00\x00\x00\x00\xee\x1d\x07\x00\x00\x00\x00\x00\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'
plaintext ip_packet : b'E\x00\x00T\x00\x00\x00\x003\x01\xcb\x88\x08\x08\x08\x08\xac\x10\x00\x01\x00\x00\x12u\x00\x0e\x00\x13\x1d\x13\x1cf\x00\x00\x00\x00\xee\x1d\x07\x00\x00\x00\x00\x00\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'
###[ IP ]### 
  version   = 4
  ihl       = 5
  tos       = 0x0
  len       = 84
  id        = 0
  flags     = 
  frag      = 0
  ttl       = 51
  proto     = icmp
  chksum    = 0xcb88
  src       = 8.8.8.8
  dst       = 172.16.0.1
  \options   \
###[ ICMP ]### 
     type      = echo-reply
     code      = 0
     chksum    = 0x1275
     id        = 0xe
     seq       = 0x13
###[ Raw ]### 
        load      = '\x1d\x13\x1cf\x00\x00\x00\x00\xee\x1d\x07\x00\x00\x00\x00\x00\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'

The script is relatively basic since it identifies the start of an IPv4 packet by looking for the pair of bytes \x45\x00, which won’t always work but is well enough for a PoC.

The dissect_securevpn_traffic.py script and the pcap are available in the associated github repository https://github.com/tmalherbe/securevpn.

The obfuscation Key and the enrollment process

All the security of the encryption protocol of this VPN seems to be based on the obfuscation key, from which the encryption key and the GCM nonce come.

It is the VPN server (the Secure VPN infrastructure in reality) which communicates this obfuscation key to the client when the application is used for the first time.

Let’s intercept the application’s HTTPS traffic with HTTP Toolkit the first time it is used:

And when used later:

We observe that the application reaches the endpoints /ip, /v2/devices, /vip/v2/prices and /v2/server of s3.free-signal.com during its first use, and only the endpoints /ip et /vip/v2/prices during subsequent uses.

Small additional difference, the domain names of the servers are used during the first use, while it is their IP addresses which are used during subsequent uses.

Let’s look at the information exchanged between the app and the Secure VPN backend during this enrollment process:

Requests on .free-signal.com/ip

When the application contacts this endpoint, the server responds with the public IP address of the phone:

Requests on .free-signal.com/v2/devices

This POST sends a data blob to the backend:

Sending HTTP requests by the application uses the com.signallab.lib.utils.net.HttpClients class.

Within this class, it’s the request method that does most of the work:

In this request, the s-req-token header is the MD5 digest of other fields in the request:

The request body is processed by the encode method of com.signallab.lib.utils.net.HttpClients (see previous screenshots)

We use the following frida hook to trace calls to this method and retrieve the body of the request before processing by encode:

httpClients.request.implementation = function(str, map, bArr, str2)
{
    console.log("appel de request - arguments :");
    console.log("request, str : " + str);
    console.log("request, bArr : " + hexlify(bArr) + " (" + stringify(bArr) + ")");
    console.log("request, str2 : " + str2 + " (" + hexlify(str2) + ")");

    var result = request.call(this, str, map, bArr, str2);

    console.log("\nappel de request (Url : " + str + " ), résultat : \n" + result);
    console.log("appel de request - pile d'appel :");
    console.log(stackTraceHere());

    return result;
};

We also intercept calls to encode:

httpClients.encode.implementation = function(bArr)
{
    console.log("appel de encode - arguments :");
    console.log("encode, bArr avant : " + stringify(bArr));

    var result = encode.call(this, bArr);

    console.log("encode, bArr apres : " + hexlify(bArr));
    console.log("appel de encode - résultat : " + result);
    console.log("appel de encode - pile d'appel :");
    console.log(stackTraceHere());

    return result;
};

We see that the body of the request on /v2/devices is a json containing various information about the terminal:

```
appel de encode - arguments :
encode, bArr avant : 
{
	"dev_id":"c7059ee8ee463482",
	"dev_model":"Pixel 7a",
	"dev_manufacturer":"Google",
	"dev_lang":"fr_FR","dev_os":"Android 14",
	"dev_country":"fr",
	"app_package":"com.fast.free.unblock.secure.vpn",
	"app_ver_name":"4.2.5",
	"app_ver_code":202403071,
	"dev_imsi":""
}

encode, bArr apres : 
24 fa 9a 30 67 ef e2 c8 ce d2 24 f0 36 18 73 2b 7e 41 c2 2a 78 57 5c 3b d9 2d
64 0b 8b e1 57 e7 95 cb 2b a1 fa 64 6c c9 49 4f 1f 68 97 a4 6c 59 00 06 07 49
9c 70 8a 2e 1a 7a e7 26 6b 5d 93 87 9e 43 6a 3a 5f 3f 8e 10 5c 8f 32 3e fb 91
83 0f c3 1c ca 2a a4 f9 c7 ae 67 28 cd bc ad e9 36 f5 08 9f 2b 0a 5a fe 90 c5
a6 d6 fb c0 6c e2 91 73 6b 91 13 53 d9 22 a2 bd d3 9c 52 f2 68 88 7b bf 53 38
2b cb ab 31 e6 1d b7 9a e0 a0 00 20 bd e6 f6 70 8c 16 f7 b5 60 20 6a 63 2c 0d
3b da e2 34 01 bf 10 6d 61 71 92 31 27 1b 20 17 af b0 e3 ab 12 20 ae eb 09 3b
0d ed 0e 91 83 7b e6 ce ec c4 99 1d f6 5c 37 72 11 70 77 4a 22 dd e4 f1 d5 81
fc fb 0b 9e 7b 39 95 6b f5 08 2d c0 1e 35 a8 5a 0c d9 fa f3 d9 64 af 74 ab 63
e2 88 9d e1 ed 71 b2 81 16 c0 a1 e1 cb 4a b3 55 ae
```

The blob obtained as the output of encode is identical to the body of the request.

The submitted json contains two pieces of information that could be quite discriminating: the dev_id and the dev_imsi.

The name of the latter suggests that it could be the IMSI identifier.

The tests having been carried out on a phone without a SIM card, the content of this field is empty here, so that it is not possible to be certain of its value.

The server response is also a blob, which is decoded by the com.signallab.lib.utils.net.HttpClients.decode method:

```
appel de decode - arguments :
decode, bArr avant : 
5b 0a 52 1c 6e 9f 7c b0 7a 01 b0 b1 75 64 dd 22 8d c4 56 be d2 79 6f 53 67 79
f8 ea 05 07 a1 1e 30 0f ce 00 5e 56 f8 5b c2 13 c5 a2 9d 2e c0 94 17 1b 63 c5
d3 cc 3c ec 71 9c b5 03 ed 72 32 c0 34 04 e1 

j7 : 1713962462255568
Entering into native side of HttpClients::decode
param_4 : 1713962462255568
End of native side of HttpClients::decode

decode, bArr apres :
{
	"auth_id": 1383550594126135778,
	"auth_token": 3954827927911532616
}
```

It is therefore in response to this request that the backend provides the pair of auth_id and auth_token identifiers.

Requests on .free-signal.com/vip/v2/prices

After decoding, the response to the request on this endpoint includes information on the available subscription options:

appel de request (Url : https://s3.free-signal.com/vip/v2/prices/?dev_manufacturer=Google&dev_model=Pixel%207a&dev_lang=fr ),
résultat : 
{
    "product": [
        {"id": "se_year_60", "type": 3, "marked": true, "trial": false, "trial_days": 0},
        {"id": "se_month_10", "type": 2, "marked": false, "trial": false, "trial_days": 0},
        {"id": "se_week_6", "type": 1, "marked": false, "trial": false, "trial_days": 0}
    ],
    "popup": "3days"
}

Requests on .free-signal.com/v2/server

The values ​​of the s-auth-id and s-auth-token headers of this method are given by the auth_id and auth_token identifiers returned by the /v2/device endpoint.

As before, s-req-token is an MD5 digest of part of the request.

The s-req-param header is calculated in two steps:

  • We encode the string dev_imsi=&dev_lang=fr_FR with the encode function:
encode, bArr avant : dev_imsi=&dev_lang=fr_FR
(...)
encode, bArr apres : 85 54 18 60 4a 82 6c 6d 3a 30 93 c0 14 16 14 cf bf a5 be 64 5d 41 59 83
  • The result is base64-encoded:
thomas@ankou:~/articles/securevpn$ echo -n hVQYYEqCbG06MJPAFBYUz7+lvmRdQVmD | base64 -d | hexdump -C
00000000  85 54 18 60 4a 82 6c 6d  3a 30 93 c0 14 16 14 cf  |.T.`J.lm:0......|
00000010  bf a5 be 64 5d 41 59 83                           |...d]AY.|
00000018

Once decoded, the backend response contains the list of available VPN servers in json format.

Each server list entry contains:

  • its IP address,
  • geographic and load distribution information (“country”, “area” and “load” tokens),
  • the obfuscatation key,
  • the supported algorithm (« obs_algo » token. This token is generally set to 1, so AES GCM is generally used).
  • a boolean token « is_vip », indicating a server reserved for users who have paid a subscription,
  • a boolean token « is_bt » whose role remains unknown (the term bt could refer to bittorrent),
  • a boolean token « is_running »,
  • a “feature” token indicating the VOD service compatible with the server.

The list of VPN servers is divided into three sub-lists:

  • Free VPN servers,
  • VPN servers allowing access to video on demand services (Netflix, Amazon prime, etc.)
  • VIP VPN servers.

Retrieving the list of VPN servers on different terminals

Here is the complete list retrieved during an installation on a physical phone:

{
    "config": {"udp": [53, 9981],"tcp": [443, 9981], "tun_mtu": 1380, "dns_server": ["8.8.8.8", "1.1.1.1"]},
    "server": [
        {"ip": "209.141.47.171", "country": "US", "area": "US West", "load": 45, "obs_key": "yUk4jmdCSXcZErGiutxbcc", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "205.185.121.163", "country": "US", "area": "US West", "load": 18, "obs_key": "VkzH5h6CAJhjp5AsBv9Mv6", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "205.185.127.80", "country": "US", "area": "US West", "load": 18, "obs_key": "Fh8YUC9uTVv2qJikWbXCHh", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "51.81.222.204", "country": "US", "area": "US West", "load": 15, "obs_key": "FdYW5HTneaaphSNyMGZQbe", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "167.114.3.117", "country": "CA", "area": "", "load": 24, "obs_key": "RQxyQKzBew7Qw2ZSBcVvB3", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "51.79.68.207", "country": "CA", "area": "", "load": 22, "obs_key": "46WxPL9JhoUGhymmzEHDiy", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "199.195.249.144", "country": "US", "area": "US East", "load": 32, "obs_key": "3M4gTqzzqNgjbxHGHvN5RY", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "198.98.48.96", "country": "US", "area": "US East", "load": 32, "obs_key": "ADwnMgVnUiSVm5Wu2i3U2D", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "15.204.245.155", "country": "US", "area": "US East", "load": 31, "obs_key": "Ljj3uwFrFbfWCdRFpvuUfN", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "15.204.204.38", "country": "US", "area": "US East", "load": 34, "obs_key": "hgsJbhxqSkmZHbfzDSByH6", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "107.189.6.242", "country": "LU", "area": "", "load": 34, "obs_key": "eQGxShreMX86bMhizYATE5", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "104.244.72.70", "country": "LU", "area": "", "load": 35, "obs_key": "UcSHeZRbBcGRj5Msfbb99f", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "51.89.166.197", "country": "GB", "area": "", "load": 30, "obs_key": "EuwFB6mjax2T8BzMxF2XFp", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""},
        {"ip": "198.244.148.161", "country": "GB", "area": "", "load": 32, "obs_key": "KbxK3Rb7mQZK3JDQdnYLhT", "obs_algo": 1, "is_vip": false, "is_bt": false, "is_running": true, "feature": ""}
    ],
    "list": "10:5-61:0-8:1",
    "_features": [
        {"type": "netflix", "name": "Netflix", "url": "https://tiny.one/dmwc2rk"},
        {"type": "prime_video", "name": "Prime Video", "url": "https://tiny.one/j336z7z6"},
        {"type": "iplayer", "name": "BBC iPlayer", "url": "https://tiny.one/3tmkktab"},
        {"type": "hulu", "name": "Hulu", "url": "https://tiny.one/cx2ybpw5"},
        {"type": "hbomax", "name": "HBO Max", "url": "https://tiny.one/ath39vz4"},
        {"type": "disney+", "name": "Disney+", "url": "https://tiny.one/ux4bbhx4"},
        {"type": "apple_tv", "name": "Apple TV+", "url": "https://tiny.one/2mp7kupf"},
        {"type": "utorrent", "name": "µTorrent", "url": "https://tiny.one/tn6p2cbm"}
    ],
    "video": {
        "config": {"udp": [53, 9981], "tcp": [443, 9981], "tun_mtu": 1380, "dns_server": ["8.8.8.8", "1.1.1.1"]}, 
        "server": [
            {"ip": "198.98.55.32", "country": "US", "area": "US East", "load": 0, "obs_key": "vqaFKmxyN2BNaWE6TvYg4h", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "hbomax"},                 {"ip": "209.141.56.225", "country": "US", "area": "US West", "load": 6, "obs_key": "AVxj6k24FCcAvZyZ8XVW63", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "prime_video"},
            {"ip": "209.141.40.164", "country": "US", "area": "US West", "load": 0, "obs_key": "nMyMesqWErSQtghNX7rZkb", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "prime_video"},
            {"ip": "209.141.51.235", "country": "US", "area": "US West", "load": 4, "obs_key": "umj3YibP3Ke2MgZCaPhptR", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "apple_tv"},
            {"ip": "154.3.37.112", "country": "HK", "area": "", "load": 0, "obs_key": "RzSqPnnU9pdSVXZj2JcJhY", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "netflix"},
            {"ip": "174.136.206.64", "country": "US", "area": "San Jose", "load": 0, "obs_key": "C7EqF6Q9GcJ7rR5oLNpvRB", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "netflix"},
            {"ip": "174.136.206.251", "country": "US", "area": "San Jose", "load": 0, "obs_key": "HrmBCUjfgtMEWRAWtijSpX", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "hulu"},
            {"ip": "209.141.32.52", "country": "US", "area": "US West", "load": 0, "obs_key": "rzRdjEdKtpNLUvCa27aVLW", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "disney+"},
            {"ip": "51.195.201.130", "country": "GB", "area": "", "load": 0, "obs_key": "8xGEc6mbmZKr55zuecRVm8", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": "iplayer"}
        ]
    },
    "vip": {
        "config": {"udp": [53, 443, 9981], "tcp": [443, 9981], "tun_mtu": 1380, "dns_server": ["8.8.8.8", "1.1.1.1"]},
        "server": [
            {"ip": "209.141.42.117", "country": "US", "area": "Las Vegas", "load": 17, "obs_key": "Q27T92trww5wM8oWF75yUC", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""}, 
            {"ip": "205.185.117.71", "country": "US", "area": "Las Vegas", "load": 16, "obs_key": "Dj2ffYzCzzd3yTk5XBgb2k", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""}, 
            {"ip": "3.24.182.39", "country": "AU", "area": "Sydney", "load": 3, "obs_key": "MXMx3nm9JUks7f4Uo7RJwF", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "54.252.149.235", "country": "AU", "area": "Sydney", "load": 2, "obs_key": "dTxKnNoMGPMkvypUAe9ZP6", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "3.97.6.58", "country": "CA", "area": "Montreal", "load": 5, "obs_key": "4HoHcsidhAFrxWBpAYLofi", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "3.125.9.233", "country": "DE", "area": "Frankfurt", "load": 5, "obs_key": "F9Nepq3qEuTd9tUizNiizR", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "141.95.67.250", "country": "DE", "area": "Frankfurt", "load": 5, "obs_key": "8aPQsmnnVQ5p9wAPt2PSEL", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.36.174.51", "country": "FR", "area": "Paris", "load": 4, "obs_key": "PKe4u6NZtMgk6CGKMVQ7oZ", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "51.222.14.147", "country": "CA", "area": "Quebec", "load": 9, "obs_key": "enGLSZL9qEymURNZE5e8TQ", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "18.130.167.124", "country": "GB", "area": "London", "load": 7, "obs_key": "3j2FgHQXi2KQVueqUkWonN", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "51.195.203.119", "country": "GB", "area": "London", "load": 4, "obs_key": "SYtf8ca57fyjBRZEzVD4P7", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "89.31.126.175", "country": "JP", "area": "Tokyo", "load": 3, "obs_key": "w5tPPCeV2TP8NUkU9oNa25", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.230.159.152", "country": "JP", "area": "Tokyo", "load": 5, "obs_key": "eZKxh7cDe26ttin9iyJyAb", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "52.78.142.34", "country": "KR", "area": "Seoul", "load": 5, "obs_key": "6Y6kitYTcKUdt8z7hDKXYJ", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.212.77.39", "country": "SG", "area": "", "load": 6, "obs_key": "8fRwfKNtTFZtZXmwrXyFLw", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.250.54.203", "country": "SG", "area": "", "load": 5, "obs_key": "3MgARisZt6FQ6n3VFkG3Ci", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "54.179.136.114", "country": "SG", "area": "", "load": 4, "obs_key": "EfnnbxxzDqLbSbTpUmqfuJ", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "18.143.94.202", "country": "SG", "area": "", "load": 5, "obs_key": "mP6suC8zcCpBECacsT9JhA", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "54.87.9.107", "country": "US", "area": "Virginia", "load": 6, "obs_key": "8w8mrQDoVXr7FceTcthaiW", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "135.148.120.195", "country": "US", "area": "Virginia", "load": 11, "obs_key": "44yjYGyGBVkooaqCiwptwU", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},          {"ip": "35.85.155.32", "country": "US", "area": "Oregon", "load": 4, "obs_key": "jZPZzSz6QEGdCQw4TNwPPn", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "15.204.58.138", "country": "US", "area": "Oregon", "load": 14, "obs_key": "Y2r2vGupoMP8DaguK4aurD", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.234.33.201", "country": "IN", "area": "Mumbai", "load": 7, "obs_key": "F6YoZybfKnrrAA8nFGAJ5H", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.127.78.191", "country": "IN", "area": "Mumbai", "load": 7, "obs_key": "drqkS4UwM9X5YmJGYKBYTR", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "13.127.133.124", "country": "IN", "area": "Mumbai", "load": 7, "obs_key": "RKmN3NNTnHRZt4HqzFrGAX", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "15.206.82.4", "country": "IN", "area": "Mumbai", "load": 5, "obs_key": "4bQo4duR6txesFfGckRwPQ", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "62.216.93.210", "country": "HK", "area": "", "load": 3, "obs_key": "YHgVVSq5kLSJPjixFzKz6d", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "198.98.51.234", "country": "US", "area": "New York", "load": 12, "obs_key": "n35m6GGYKLjMuizRUWjvjV", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "198.98.62.88", "country": "US", "area": "New York", "load": 16, "obs_key": "3biLb3kQT47kHN6A4TXyip", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "51.15.43.251", "country": "NL", "area": "Amsterdam", "load": 5, "obs_key": "JvJdL5ctfwxz863WTf2yNm", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "45.90.58.238", "country": "CH", "area": "", "load": 5, "obs_key": "SczMbJ2F7uGYYoKpSQ9EzK", "obs_algo": 0, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "45.61.185.191", "country": "US", "area": "Miami", "load": 11, "obs_key": "DrHTBo6trF8y4JmMHbHr4X", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "107.189.30.27", "country": "LU", "area": "", "load": 10, "obs_key": "kqayakjpQYMSa38AJXVdzm", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "38.54.57.155", "country": "BR", "area": "Sao Paulo", "load": 4, "obs_key": "2rfqxMBWxK4YuBVAKemwYL", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "45.142.215.43", "country": "LV", "area": "", "load": 0, "obs_key": "Za3EUxX3WqgiegdtzWphVt", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "45.67.229.246", "country": "MD", "area": "", "load": 2, "obs_key": "VxEKJcRMME5Yrw24vvdi5b", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "77.91.74.80", "country": "IL", "area": "", "load": 1, "obs_key": "FDTDigdsaxxdHAGBbnh8dk", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "193.46.56.59", "country": "TR", "area": "Istanbul", "load": 13, "obs_key": "PbrfQZ9GpjEqkLiM9k6sRX", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "45.144.30.124", "country": "RU", "area": "Moscow", "load": 2, "obs_key": "Ky6TvcsJyUrj7m8ebT3fEC", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "3.141.197.10", "country": "US", "area": "Ohio", "load": 9, "obs_key": "TDDTRWC8ppv4ZFkQYRihKP", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "51.68.138.221", "country": "PL", "area": "Warsaw", "load": 10, "obs_key": "5eitF5nVEG2uVYBYR3VFm2", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "151.80.136.62", "country": "FR", "area": "Strasbourg", "load": 4, "obs_key": "dL6xAa4JNKWUUfrLL9nQyf", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""},
            {"ip": "141.94.21.110", "country": "FR", "area": "Gravelines", "load": 6, "obs_key": "XXYMVSrZMuKTzzyvPZrwKG", "obs_algo": 1, "is_vip": true, "is_bt": false, "is_running": true, "feature": ""}
        ]
    }
}

Two questions remain unanswered:

Will two different users see the same server list?

And if a server can be used by two users, can it be used with the same obfuscation key?

Several scenarios are possible:

  • scenario 1: the list of servers and keys is fixed;
  • scenario 2: the backend provides each client with a different set of servers; if a server is accessible to two clients, it is accessible with the same key;
  • scenario 3: the backend provides each client with a different set of servers; if a server is accessible to two clients, it is accessible with different keys.

To provide some answers to these questions, we observe the first execution of the application on:

  • Emulator #1: an Android 14 emulator using the same wifi connection as the Android phone used until now,
  • Emulator #2: an Android 14 emulator using another wifi connection.

These two emulators are physically located on my workstation, and created with Android Studio. Both are virtualized Pixel 6 Pro.

We also observe the first use of the application on three Corellium emulators (emulators #3, #4 and #5).

Each of these three emulators uses Android 14 and virtualizes a different phone model: Huawei P8, Samsung Galaxy S7 and Samsung Galaxy Note 5.

The following facts are observed:

  • If we consider the lists of paid VPN servers on two different phones, we see a significant number of collisions (server appearing the VIP server list of both phones).

The image below shows the comparison of paid servers for emulators #3 and #4. While the majority of entries differ, some are shared between these two emulators.

In some cases the lists are identical. Below we see the comparison of paid servers for the physical phone initially used (a Pixel 7) and the Pixel 6 Pro emulator (emulator #2).

Some entries in the list differ only in the « load » token, which visibly represents the « instantaneous » load of a server, and are actually the same.

  • If we take the list of VPN servers compatible with VOD services on two different phones, we again see a significant number of collisions. In the example below (emulator #2 and emulator #5), the lists are identical, modulo the « load » token.
  • If we take the list of free VPN servers on two different phones, these lists are generally disjointed (case of emulators #3 and #4 here):

This is not always the case, however, and the physical phone has the same list of free servers as emulator #4:

Emulators #1 and #2 also have an identical list of free servers:

  • The situation where a VPN server is known to two phones, but with different obfuscation keys, has not been observed.

These different observations are in line with scenario no. 2: The backend provides each client with a subset of existing VPN servers.

No assumptions can be made about the heuristics used by the backend to choose this subset for a given client.

In « captured » configurations, as soon as two clients have a free VPN server in common, then they have it with the same obfuscation key: As this key is the only cryptographic element involved in the encryption of traffic, each of these clients will be able to decrypt each other’s VPN traffic!

However, due to the small number of terminals used (one phone + five emulators), it is not completely impossible that the obfuscation key is calculated from certain parameters coming from the phone, and that ultimately the situation  » clients accessing the same free server with different keys » may occur.

Summary of Secure VPN cryptographic vulnerabilities

Spectacular cryptographic vulnerabilities are present in Secure VPN:

The most serious is the key management mechanism: Where any « reasonable » VPN protocol such as OpenVPN, IPsec or Wireguard would have used an initial handshake (server authentication via a root certificate known to the application + generation of a common secret via a Diffie-Hellman exchange), Secure VPN uses no known protocol and invents a relatively original key management mechanism: When installed, the application retrieves a subset of the list of existing servers.

This exchange is protected by an HTTPS channel and cannot be intercepted « easily ».

So the situation wouldn’t be so bad if the obfuscation key for a given server varied from one client to another: One could imagine that the backend derives the obfuscation key from phone identifiers (dev_imsi and dev_id, for example) and a secret key kept warm in the backend.

It is not completely excluded that a « diversification » mechanism is present, but if this is the case, it is clearly problematic since we observe different terminals using the same servers with the same obfuscation keys.

In fact, it is quite unlikely that such a mechanism exists, because it would require each VPN server to maintain a relatively large number of obfuscation keys (it should be kept in mind that the application is subject to more than a hundred million downloads!).

So here we are at the worst that can happen for a VPN user: Almost anyone, as long as they manage to obtain the same list of VPN servers as you, can decrypt your VPN traffic!

Even if this problem of using secret keys shared by several users did not exist, Secure VPN would still suffer from very problematic vulnerabilities:

  • Traffic integrity is not guaranteed: The SignalObfuscator::encode function calculates the GCM tag of the packet to send…but does not use it!
  • GCM nonce reuse: The SignalObfuscator::encode function encrypts each network packet independently of the others, each time reusing the nonce extracted from the obfuscation key. As GCM mode uses CTR mode, it is possible to capture traffic and obtain a significant amount of information by xoring packets two by two (since they are always xored with the same cipher sequence).
  • Finally, and this is a detail in view of all the above, no guarantee in terms of forward secrecy is possible to the extent that we use a secret key: If I manage to get my hands on the key obfuscation stored in your phone, I am able to decrypt your Secure VPN traffic.

Conclusion

The conclusion of this article is that we can make some surprising discoveries, even in widely used applications, as soon as we take a close look.

One might question the reason for developing a VPN with such serious flaws. A fairly natural hypothesis, although slightly conspiratorial, would be to see a deliberate desire to introduce a backdoor into the application, in order to be able to access user traffic.

In reality, this does not hold: The Secure VPN administrator, by definition, has access to user traffic in the clear since he has control over the VPN servers!

The reality is probably much more prosaic: it is much more likely that the application was developed by developers with some gaps in cryptology.


Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *