Un grand nombre de gens utilise un VPN, pour diverses raisons plus ou moins pertinentes.
Si certains acteurs du marché donnent des gages de transparence en divulguant leur code source et/ou en publiant régulièrement des rapports d’audits, de nombreux autres fournisseurs font preuve d’une réelle opacité.
Étudier attentivement un client VPN pourrait donc révéler quelques surprises…
L’application Secure VPN
Souhaitant étudier d’un peu plus près le fonctionnement d’une de ces applications, j’ai choisi – un peu au hasard – de m’intéresser à Secure VPN.
Cette application est relativement populaire puisqu’elle a été téléchargée plus de 100 millions de fois, soit plus que NordVPN et CyberGhost VPN !

La page Google Play de l’application précise qu’elle est développée par la société Secure Signal inc :

Contrairement à ce que ce nom pourrait sous-entendre, cette société n’a à priori aucun lien avec l’éditeur de l’application de messagerie instantanée, dont le site est signal.org.
Le site de l’application
La rubrique « Assistance pour l’application » de Google Play précise une adresse mail (secure-vpn@free-signal.com), une adresse physique et un site internet pour contacter le développeur :

Le site internet en question est securesignal.app.
On peut notamment y lire les témoignages élogieux de Marsha Singer, Tim Shaw et Lindsay Spice :

Par un hasard extraordinaire, ces trois personnes ont des Doppelgängers ayant donnés des témoignages tout aussi élogieux pour une autre application, apptuary.com, à laquelle est dédiée un site web d’aspect rappelant celui de securesignal.app :

Une autre adresse mail de contact est mentionnée sur le site (contact@securesignal.app) :

Le reste du site donne quelques indications sur les capacités techniques du VPN, avec les promesses habituelles de ce genre d’applications (No Log, Safe Protocol, Privacy, etc), sans entrer dans les détails.
Les sites free-signal.com ou secure.free-signal.com ne donnent pas plus d’indication.
Première analyse sur Exodus privacy
Un utilisateur soucieux de savoir dans quelle mesure une application est intrusive mais ne voulant/sachant pas extraire le manifeste de l’apk peut utiliser le site https://reports.exodus-privacy.eu.org.
Il s’avère que Secure VPN utilise 2 pisteurs et nécessite 16 permissions :

Certaines de ces permissions :

comme QUERY_ALL_PACKAGES
, ACCESS_ADSERVICES_TOPICS
, ACCESS_ADSERVICES_ATTRIBUTION
ou ACCESS_ADSERVICES_AD_ID
ne sont pas à proprement parler indispensables au fonctionnement d’un client VPN et sont uniquement présentes pour des besoins publicitaires et/ou de profilage (QUERY_ALL_PACKAGES
permettant ainsi de lister les applications installées sur le téléphone d’une personne, ce qui peut en dire long sur elle).
Première utilisation
L’interface de l’application est très simple.
Lorsqu’elle est lancée pour la première fois, l’application demande à l’utilisateur s’il consent à ce que ses données personnelles soient utilisées à diverses fins, publicitaires notamment :

L’utilisateur a ensuite accès à une icône grisée sur laquelle il faut cliquer pour monter le tunnel VPN :

L’icône de connexion devient bleue lorsque le tunnel VPN est monté :

Une visite de whatismyip.com confirme que l’adresse IP observée par un site internet visité est différente de l’adresse IP réelle du téléphone :

Le manifeste
Jeter un œil au fichier AndroidManifest.xml d’une application étudiée est une étape incontournable, et nous n’y ferons pas exception.
Le manifeste mentionne 24 activities.
Parmi celles-ci, 18 font partie de l’application à proprement parler (toutes sont membres du package com.signallab.secure.activity
).
Nous ne nous attarderons pas sur les activities de l’application :
En effet, le travail d’un client VPN est de chiffrer/déchiffrer le trafic entrant.
Cette tâche, réalisée en tâche de fond, nécessite l’exécution d’un service. Le manifeste déclare 12 services.
Deux d’entre eux, com.signallab.secure.service.SecureService
et com.signallab.lib.SignalService
, font partie de l’application à proprement parler :

Ces deux services héritent de la classe android.net.VpnService, classe utilisée pour implémenter un client VPN. Un tel service va créer une interface réseau virtuelle (/dev/tun
, typiquement).
Lorsque le VPN est lancé,
- les applications du téléphone envoient leur trafic réseau sur cette interface
- le service VPN lit les paquets que les applications souhaitent envoyer, les chiffre et les envoie
- le service VPN reçoit les paquets chiffrés provenant du serveur VPN, les déchiffre et les écrit sur l’interface virtuelle
- les applications reçoivent ces paquets déchiffrés sur cette interface virtuelle.
La classe SignalService
comporte une méthode loop()
, qui appelle la méthode connect()
de la classe SignalHelper
:

La méthode connect()
est une méthode native :

Cette méthode est appelée lorsque l’utilisateur clique sur l’icône de connexion mentionnée auparavant.
On peut tracer l’appel à cette méthode connect()
avec Frida (c’est le script trace_SignalHelper_connect.js dans le github associé
https://github.com/T0lva/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)
Les arguments de la méthode connect()
sont :
- Le descripteur de fichier sur l’interface virtuelle
/dev/tun
, - L’adresse IP du serveur VPN à contacter,
- les ports UDP et TCP sur lesquels contacter le serveur VPN,
- des entiers (
userId
etuserToken
) identifiant l’utilisateur, - une clé (valant ici
Fh8YUC9uTVv2qJikWbXCHh
), - un booléen ainsi qu’une constante indiquant l’algorithme supporté.
D’après la documentation Android (https://developer.android.com/reference/java/lang/Thread#start()), l’invocation de la méthode start
d’un objet Thread
provoque l’appel de la méthode run
de l’objet en question.
La méthode start
de VpnThread
est justement appelée dans la méthode onStartCommand
de SignalService
:

Cette méthode onStartCommand
est le point d’entrée par laquelle l’extérieur peut lancer le service en question.
Les librairies natives de l’application
L’application embarque 3 libairies natives, libz.so, liblog.so et libchannel.so.
Cette dernière est nettement plus grosse que les deux autres, et c’est elle qui implémente la plupart des méthodes natives employées par les classes java de l’application.
Intuitivement, la façon la plus « facile » de créer un client VPN est « d’emballer » un client OpenVPN dans un apk : L’application contiendra une classe héritant de android.net.VpnService
, classe qui finira par lancer le client natif.
Aucune des fonctions exportées par libchannel.so ne comporte dans son nom les mots-clé « openvpn » ou « ovpn » que contiennent un grand nombre de fonctions exportées par la librairie libovpn utilisée dans ce type d’applications :

Cela laisse penser que Secure VPN n’utilise pas la OpenVPN, même s’il est possible que libchannel.so soit une version customisée de libovpn.
Trafic échangé avec le serveur VPN
Si l’on examine le trafic intercepté après avoir lancé l’application et monté le tunnel VPN, on observe, au fil des sessions :
- essentiellement du trafic avec le port tcp 443 du serveur,
- ou essentiellement du trafic avec le port tcp 9981 du serveur,
- ou bien essentiellement du trafic avec le port udp 53 du serveur.
Bien que le port 443 soit dédié au trafic TLS, le trafic capturé n’est pas du trafic TLS, un paquet TLS applicatif commençant nécessairement par \x17\x03
:

De même, le trafic sur le port 53 n’est pas du trafic DNS, comme l’indique les erreurs remontées par wireshark :

Descente jusqu’au natif
L’appel de la méthode connect()
de la classe SignalHelper
se traduit par un appel à la fonction du même nom dans libchannel.so.
Cette fonction crée un objet de la classe SignalLinkClient
et positionne différents champs de cet objet avec les fonctions setSignalRouter
, enableObscure
, setUsers
, setProto
, setBackupPort
, connect
et setTunnel
,
avant d’appeler la fonction runLoop
qui est la boucle infinie principale du service client VPN.

Donnons quelques détails sur les rôles respectifs de ces fonctions d’initialisation :
- Le constructeur
SignalLinkClient
initialise un objetSignalPackage
, vers lequel pointe un des champs duSignalLinkClient
. Les 8 premiers octets duSignalPackage
pointent à leur tour vers un buffer de 1500 octets, valeur usuelle de la MTU : ce buffer sera probablement utilisé pour manipuler des paquets réseau. SignalLinkClient::setSignalRouter
: Cette fonction copie l’adresse d’un objet statiqueSignalRouter
au début duSignalLinkClient
. L’objetSignalRouter
contient différents pointeurs de fonction. Comme les fonctions en question n’interviennent pas dans le chiffrement/déchiffrement du trafic VPN, nous ne y intéresserons pas particulièrement.- La fonction
SignalLinkClient::enableObscure
modifie leSignalPackage
adressé par l’un des champs duSignalLinkClient
: À la fin de l’exécution de cette fonction, un des champs duSignalPackage
pointe vers unSignalObfuscator
, qui contient essentiellement la clé d’obfuscation passée en paramètre deSignalHelper.connect
. SignalLinkClient::setUser
copie les entiersuserId
etuserToken
dans leSignalLinkClient
.SignalLinkClient::setProto
etSignalLinkClient::setBackupPort
ont pour effets respectifs de positionner des booléens spécifiants les protocoles supportés (UDP et TCP), et de positionner des numéros de port.- La fonction
SignalLinkClient::connect
initialise des tableaux deRemoteLink *
, objets décrivant un serveur VPN (adresse IP + port + protocole). - Enfin, la fonction
SignalLinkClient::setTunnel
copie le descripteur de fichier sur l’interface virtuelle/dev/tun
dans leSignalLinkClient
.
La boucle de traitement runLoop()
Lorsque toutes les étapes d’initialisation ont eu lieu, la fonction runLoop` est appelée.
Dans les grandes lignes, cette fonction est une boucle infinie à l’intérieur de laquelle on surveille, avec l’api epoll
, des ensembles de descripteurs de fichiers :
Lorsqu’une application du téléphone envoie un paquet réseau, ce paquet en clair est écrit sur /dev/tun
. Le client VPN lit ce paquet, le chiffre et l’envoie vers le serveur VPN.
À l’inverse, un paquet provenant du serveur VPN est déchiffré et écrit sur /dev/tun
afin d’être relayé à l’application destinataire.

Ainsi,
- les paquets clairs à émettre sont lus sur
/dev/tun
, chiffrés et envoyé au serveur par la fonctionSignalLinkClient::processTunIn
, - les paquets chiffrés en provenance du serveur VPN sont déchiffrés puis écrits sur
/dev/tun
par la fonctionSignalLinkClient::processLinkData
.
Traitement des paquets sortants
La fonction SignalLinkClient::processTunIn
de traitement des paquets sortants
Cette fonction commence par lire, à l’offset 0x468
du SignalLinkClient
, depuis /dev/tun
, le paquet à envoyer. Ce paquet est traité par la fonction SignalLinkClient::writeToLink
.

La fonction writeToLink
Cette fonction commence par une opération d’initialisation, dont l’un des effets est d’écrire le magic word \x01\x00_SiG
dans le buffer dont l’adresse est conservée au début du SignalPackage
.
Le reste du travail est réalisé par la fonction SignalPackage::setData
, et le paquet chiffré est ensuite envoyé au serveur VPN.

La fonction setData
Après quelques vérifications préliminaires, la fonction SignalPackage::setData
calcule deux entiers de 64 bits construits à partir des entiers userId
et userToken
identifiants l’utilisateur.
Ces deux entiers sont écrits dans un buffer pointé par un des champs du SignalPackage
. Le paquet en clair est copié à la suite de ces deux entiers.
Le rôle de ces 128 bits précédants le paquet pourrait être de permettre au serveur VPN d’identifier l’utilisateur à l’origine du paquet reçu.

La fonction SignalObfuscator::encode
est finalement appelée. C’est cette dernière qui va invoquer les primitives cryptographiques chiffrant le paquet.
La fonction encode
Cette fonction comporte deux blocs conditionnés par la valeur de son dernier argument, algo
. Si il vaut 1
, le paquet est chiffré avec AES GCM. Si il vaut 0
, c’est ChaCha20 qui est employé.

Comme nous le verrons plus tard c’est AES qui est utilisé par la très grande majorité des serveurs VPN.
Nous ne nous attarderons donc pas sur le deuxième bloc, qui invoque ChaCha20.
Voici finalement la séquence de chiffrement d’un paquet clair :
- Les 16 premiers octets de la clé d’obfuscation sont utilisé comme clé AES (positionnée par `gcm_setkey`, qui appelle `aes_setkey`, qui invoque `aes_set_encryption_key`).
- Les 12 octets suivants de la clé d’obfuscation sont utilisés comme nonce GCM, via l’appel `gcm_start`. En pratique, la clé d’obfucation n’est pas assez longue (elle devrait faire 16 + 12 = 28 octets), et les 6 derniers octets du nonce sont toujours nuls.
- Le chiffrement AES-GCM du paquet clair est assuré par `gcm_update`, le résultat chiffré étant écrit dans l’objet `SignalObfuscator`.
- Ensuite, le tag GCM est généré par un appel à `gcm_finish`.
- Enfin, le paquet chiffré est copié dans l’emplacement initialement occupé par le paquet clair, l’envoi du paquet chiffré étant, comme vu auparavant, réalisé à la fin de la fonction `SignalLinkClient::writeToLink`.
La fonction processLinkData
traitant les paquets entrants
Une fois que le traitement d’un paquet sortant est bien compris, l’étude du traitement des paquets entrants est beaucoup plus facile.
Décrivons succintement comment un paquet est déchiffré :
Les paquets reçus sont traités par la fonction SignalLinkClient::writeToTun
.
Dans cette dernière, le déchiffrement du paquet est réalisé par SignalPackage::decodePackage
, via SignalObfuscator::decode
, équivalent de SignalObfuscator::encode
dédié à la réception.
L’algorithme de chiffrement est-il vraiment AES ?
Même si les noms de fonctions parlent d’AES, il n’est pas à exclure que, volontairement ou pas, le développeur utilise autre chose qu’AES. Il est donc préférable de s’assurer que la primitive de chiffrement est bien AES.
La fonction en question, appelée dans gcm_setkey
et gcm_update
, est la fonction aes_cipher
.
On pourrait analyser le code décompilé produit par Ghidra pour s’assurer qu’on a bien affaire à AES, mais cela serait extrémement fastidieux, et pourquoi s’embêter alors qu’on peut hooker aes_cipher
avec Frida ?
On lance ainsi un script (trace_AesGm.js) qui intercepte une séquence gcm_setkey
, gcm_update
et 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 ]->
Le script affiche l’entrée et la sortie de gcm_update
, ainsi que la clé de chiffrement et le nonce. On note au passage que les arguments de gcm_finish
, à savoir le pointeur et la longueur du tag, sont nuls.
Reproduisons le calcul réalisé avec quelques lignes de python (script decrypt.py) :
#!/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}")
Le script nous donne la même sortie que celle observée à la fin de gcm_finish
: le client VPN utilise bien AES en mode 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 de l’implémentation cryptographique utilisée
Dans AES, chaque tour (comme la clé fait 16 octets, ici il y a 10 tours) applique trois transformations successives – même si le dernier tour est légèrement différent :
- La transformation SubBytes, qui associe à chacun des 16 octets du bloc d’entrée un autre octet ;
- La transformation ShiftRows. Dans cette transformation, les octets du bloc d’entrée sont répartis en 4 groupes de 4 pour former une matrice carrée. La première ligne de cette matrice n’est pas modifiée, les éléments de la deuxième ligne sont décalés de 1, les éléments de la troisième ligne sont décalés de 2, et les éléments de la quatrième ligne sont décalés de 3.
- 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.
Pour des raisons d’efficacité, ces transformations sont généralement implémentées sous la forme de tables précalculées et stockées dans le binaire.
Ce n’est pas le cas ici. Si l’on examine les tables utilisées par la fonction aes_cipher
implémentant le chiffrement AES :

et que l’on regarde où est défini la table `DAT_0015ed78` :

On constate que son contenu n’est pas initialisé.
On observe également une référence croisée menant à une fonction aes_init_keygen_tables
, dont le rôle, comme son nom l’indique, est d’initialiser les tables en question.
Quelques recherches en ligne permettent de retrouver la trace d’une implémentation de AES utilisant une fonction aes_init_keygen_tables
:

L’implémentation de AES en question est celle du serveur web embarqué mongoose, édité par la société Cesanta : https://github.com/cesanta/mongoose/blob/master/mongoose.c
Une comparaison du code décompilé par Ghidra et du code de aes_init_keygen_tables
présent sur github confirme que la version présente dans libchannel.so est probablement issue de ce dépôt.
Par ailleurs, les autres fonctions cryptographiques utilisées lors du traitement d’un paquet sont aussi présente dans ce dépôt.
Cet élément permet de comprendre les rôles respectifs des arguments des fonctions cryptographiques gcm_setkey
, gcm_start
, gcm_update
et gcm_finish
utilisées par libchannel.so.
Focus sur les mécanismes cryptographiques utilisés
GCM est un mode authentifiant : Il chiffre un message et calcule un tag d’authentification garantissant son intégrité. La partie chiffrement du mode GCM est assurée par le mode opératoire CTR. Dans le mode CTR, un compteur est utilisé pour chiffrer le message : Pour chiffrer un bloc du message, on chiffre le compteur, on xor le résultat avec le bloc, puis on incrémente le compteur avant de passer au bloc suivant.
On peut résumer cela plus formellement par la formule
Y_{i} = X_{i} ^ E(counter + i)
.
Un message chiffré avec AES en mode CTR est xoré avec une suite d’octets aléatoires, un peu comme si l’on utilisait un chiffrement par flot.
Il est donc capital, lorsque l’on utilise le mode CTR – et donc si l’on emploie le mode GCM – de ne pas réutiliser ce compteur pour chiffrer deux messages. Si l’on ignore cette précaution, on se retrouve dans la situation suivante :
- Le chiffré
C1
est le résultat du xor du message clairM1
avec la suite d’octets aléatoiresR
, - Le chiffré
C2
est le résultat du xor du message clairM2
avec la suite d’octets aléatoiresR
, - Un observateur peut xorer
C1
avecC2
, ce qui lui donneM1 ^ M2
, à partir duquel il peut extraire de l’information surM1
et/ouM2
.
Regardons comment les fonctions de mongoose sont employées par libchannel.so.
Fonction gcm_start
Le code de mongoose décrit le rôle des arguments de cette fonction :

Les deux derniers arguments précisent une « AEAD data » : lorsque l’on utilise un mode authentifiant, ce sont des données qui sont authentifiées en plus des données chiffrées, sans être elles-mêmes chiffrées.
Dans la fonction SignalObfuscator::encode
, on voit que les deux derniers arguments de gcm_start
sont nuls (voir capture d’écran plus haut) : Il n’y a donc pas de AEAD data.
Réutilisons le dernier script Frida employé pour tracer des chiffrements successifs de différents paquets réseau en partance :
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
Nous observons quelque chose que nous savions déjà après étude de SignalObfuscator::encode
:
le nonce est identique (ici \x4d\x47\x5a\x51\x62\x65\x00\x00\x00\x00\x00\x00
) pour deux paquets différents !
Fonction gcm_finish
La fonction gcm_finish
a pour argument :
- un pointeur
ctx
sur le contexte, - un pointeur
tag
indiquant où le tag GCM doit être copié, - un entier
tag_len
indiquant la longueur du tag GCM.

La lecture du code de cette fonction montre que si `tag` et `tag_len` sont nuls, le tag GCM ne fait l’objet d’aucune copie.
C’est précisément le cas ici (voir capture d’écran du code de `SignalObfuscator::encode`) : Le tag GCM du paquet n’est donc copié nulle part !
Script de déchiffrement du trafic
À ce stade, nous disposons de toute les informations nécessaires pour déchiffrer le trafic Secure VPN.
On développe un script dissect_securevpn_traffic.py capable de déchiffrer le trafic Secure VPN capturé.
Pour fonctionner, le script a besoin :
- de l’adresse IP, du port et éventuellement du protocole de transport du serveur VPN,
- de la clé d’obfuscation,
- d’un pcap à déchiffrer.
Dans l’exemple ci-dessous, le trafic passant dans le tunnel VPN est du trafic ICMP à destination du serveur 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'
Le script est relativement élémentaire puisqu’il identifie le début d’un paquet IPv4 en recherchant le couple d’octets `\x45\x00`, ce qui ne marche pas tout le temps mais est suffisant pour un PoC.
Le script dissect_securevpn_traffic.py et les pcap utilisés sont disponibles dans le dépôt github associé https://github.com/T0lva/securevpn.
La clé d’obfuscation et le processus d’enrôlement
Toute la sécurité du protocole de chiffrement de ce VPN semble reposer sur la clé d’obfuscation, dont sont issus la clé de chiffrement et le nonce GCM.
C’est le serveur VPN (l’infrastructure Secure VPN en réalité) qui communique cette clé au client lors de la première utilisation de l’application.
Interceptons le trafic HTTPS de l’application avec HTTP Toolkit lors de sa première utilisation :

Et lors d’une utilisation ultérieure :

On constate que l’application contacte les endpoints /ip
, /v2/devices
, /vip/v2/prices
et /v2/server
de s3.free-signal.com lors de sa première utilisation, et uniquement les endpoints /ip
et /vip/v2/prices
lors des utilisations ultérieures.
Petite différence supplémentaire, les noms de domaine des serveurs sont utilisés lors de la première utilisation, alors que c’est leurs adresses IP qui sont utilisées lors des utilisations suivantes.
Examinons les informations échangées entre l’application et le backend Secure VPN lors de ce processus d’enrôlement :
Rôle des requêtes sur .free-signal.com/ip
Lorsque l’application contacte ce endpoint, le serveur lui répond l’adresse IP du téléphone où elle est installée :

Rôle des requêtes sur .free-signal.com/v2/devices
Cette requête POST
envoie un blob de donnée au backend :

L’envoi de requêtes HTTP par l’application utilise la classe com.signallab.lib.utils.net.HttpClients
.
Au sein de cette classe, c’est la méthode request
qui fait le gros du travail :

Dans cette requête, l’en-tête s-req-token
est le condensat MD5 d’autres champs de la requête :

Le corps de la requête est traité par la méthode encode
de com.signallab.lib.utils.net.HttpClients
(voir captures d’écran précédentes)
On utilise le hook frida suivant pour tracer les appels à cette méthode et récupérer le corps de la requête avant traitement par 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;
};
On intercepte aussi les appels à `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;
};
On constate que le corps de la requête sur /v2/devices
est un json contenant diverses informations sur le 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
Le blob obtenu en sortie de encode
est identique au corps de la requête.
Le json soumis contient deux informations qui pourraient être assez discriminantes : le dev_id
et le dev_imsi
.
Le nom de cette dernière laisse penser qu’il pourrait s’agir de l’identifiant IMSI. Les tests ayant été menés sur un téléphone dépourvu de carte SIM, le contenu de ce champ est vide ici, si bien qu’il n’est pas possible d’être certain de sa valeur.
La réponse du serveur est elle aussi un blob, qui est décodé par la méthode com.signallab.lib.utils.net.HttpClients.decode
:

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
}
C’est donc en réponse à cette requête que le backend fournit le couple d’identifiants auth_id
et auth_token
.
Rôle des requêtes sur .free-signal.com/vip/v2/prices
Après décodage, la réponse à la requête sur ce endpoint comporte des informations sur les options d’abonnement disponibles :
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"
}
Rôle des requêtes sur .free-signal.com/v2/server
Les valeurs des en-têtes s-auth-id
et s-auth-token
de cette méthode sont données par les identifiants auth_id
et auth_token
retournés par le endpoint /v2/device
.
Comme auparavant, s-req-token
est un condensat MD5 portant sur une partie de la requête.
L’en-tête s-req-param
, est calculé en deux étapes :
- On encode la chaîne
dev_imsi=&dev_lang=fr_FR
avec la fonctionencode
:
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
- On encode le résultat en base64 :
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
Une fois décodée, la réponse du backend contient la liste des serveurs VPN disponibles au format json.
Chaque entrée de la liste des serveurs contient :
- son adresse IP,
- des informations géographiques et de répartition de charge (tokens « country », « area » et « load »),
- la clé d’obfuscation,
- l’algorithme supporté (token « obs_algo ». Ce token vaut généralement 1, c’est donc généralement AES GCM qui est employé).
- un token booléen « is_vip », indiquant un serveur reservé aux utilisateurs ayant payé un abonnement,
- un token booléen « is_bt » dont le rôle reste inconnu (le terme bt pourrait désigner bittorrent),
- un token booléen « is_running »,
- un token « feature » indiquant le service de VOD compatible avec le serveur.
La liste des serveurs VPN est divisée en trois sous-liste :
- serveurs VPN gratuits,
- serveurs VPN permettant l’accès à des services de vidéo à la demande (Netflix, Amazon prime, etc)
- serveurs VPN payants.
Récupération de la liste des serveurs VPN sur différents terminaux
Voici la liste complète récupérée lors d’une installation sur téléphone physique :
{
"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": ""}
]
}
}
Deux questions restent en suspens :
Deux utilisateurs différents verront-ils la même liste de serveurs ?
Et si un serveur est utilisable par deux utilisateurs, l’est-il avec la même clé d’obfuscation ?
Plusieurs scénarios sont possibles :
- scénario 1 : la liste des serveurs et des clés est fixe ;
- scénario 2 : le backend fournit à chaque client un ensemble différent de serveurs ; un serveur est accessible à deux clients l’est avec la même clé ;
- scénario 3 : le backend fournit à chaque client un ensemble différent de serveurs ; si un serveur est accessible à deux clients, il l’est avec des clés différentes.
Pour apporter des éléments de réponse à ces questions, on observe la première exécution de l’application sur :
- Émulateur n°1 : un émulateur Android 14 utilisant la même connexion wifi que le téléphone android utilisé jusqu’ici,
- Émulateur n°2 : un émulateur Android 14 utilisant une autre connexion wifi.
Ces deux émulateurs sont physiquement situés sur mon poste de travail, et créés avec Android Studio. Tous deux sont des Pixel 6 Pro virtualisés.
On observe également la première utilisation de l’application sur trois émulateurs Corellium (émulateurs n°3, n°4 et n°5).
Chacun de ces trois émulateurs utilise Android 14 et virtualise un modèle de téléphone différent : Huawei P8, Samsung Galaxy S7 et Samsung Galaxy Note 5.
On observe les faits suivants :
- Si l’on considère les listes des serveurs VPN payants sur deux téléphones différents, on constate un nombre significatif de collisions (serveur apparaissant la liste de serveur VIP des deux téléphones).
L’image ci-dessous montre la comparaison des serveurs payants pour les émulateurs n°3 et n°4. Si la majorité des entrées diffèrent, certaines sont partagées entre ces deux émulateurs.

Dans certains cas, les listes sont identiques. Ci-dessous on voit la comparaison des serveurs payants pour le téléphone physique initialement utilisé (un Pixel 7) et l’émulateur Pixel 6 Pro (émulateur n°2).
Certaines entrées de la liste ne diffèrent que par le token « load », qui représente visiblement la charge « instantanée » d’un serveur, et sont en réalité les mêmes.

- Si l’on prend la liste des serveurs VPN compatibles avec des services VOD sur deux téléphones différents, on constate là encore un nombre significatifs de collisions. Dans l’exemple ci-dessous (émulateur n°2 et émulateur n°5), les listes sont identiques, modulo le token « load ».

- Si l’on prend la liste des serveurs VPN gratuits sur deux téléphones différents, ces listes sont généralement disjointes (cas des émulateurs n°3 et n°4 ici) :

Ce n’est cependant pas toujours le cas, et le téléphone physique a la même liste de serveurs gratuits que l’émulateur n°4 :

Les émulateurs n°1 et n°2 ont eux aussi une liste de serveurs gratuits identique :

- La situation où un serveur VPN est connu de deux téléphones, mais avec des clés d’obfuscation différentes, n’a pas été observée.
Ces différentes observations vont dans le sens du scénario n°2 : Le backend fournit à chaque client un sous-ensemble des serveurs VPN existants.
Aucune hypothèse ne peut être faite sur l’heuristique utilisée par le backend pour choisir ce sous-ensemble pour un client donné.
Dans les configurations « capturées », dès que deux clients ont un serveur VPN gratuit en commun, alors ils l’ont avec la même clé d’obfuscation : Comme cette clé est l’unique élément cryptographique intervenant dans le chiffrement du trafic, chacun de ces clients pourra déchiffrer le trafic VPN de l’autre !
Cependant, du fait du faible nombre de terminaux utilisés (un téléphone + cinq émulateurs), il n’est pas totalement impossible que la clé d’obfuscation soit calculée à partir de certains paramètres provenant du téléphone, et qu’en définitive la situation « clients accédant au même serveur gratuit avec des clés différentes » puisse se produire.
Récapitulatif des vulnérabilités cryptographiques du VPN
Des vulnérabilités cryptographiques spectaculaires sont présentes dans Secure VPN :
La plus sérieuse est le mécanisme de gestion de clés : Là où tout protocole VPN « raisonnable » comme OpenVPN, IPsec ou Wireguard aurait utilisé un handshake initial (authentification du serveur via un certificat racine connu de l’application + élaboration d’un secret commun via un échange Diffie-Hellman), Secure VPN n’utilise aucun protocole connu et invente un mécanisme de gestion de clés relativement original : Lorsqu’elle est installée, l’application récupère un sous-ensemble de la liste des serveurs existants.
Cet échange est protégé par un canal HTTPS et ne peut pas être intercepté « facilement ».
La situation ne serait donc pas si grave si la clé d’obfuscation pour un serveur donné variait d’un client à l’autre : On pourrait imaginer que le backend dérive la clé d’obfuscation à partir d’identifiants du téléphone (dev_imsi
et dev_id
, par exemple) et d’une clé secrète gardée au chaud dans le backend.
Il n’est pas complètement exclu qu’un mécanisme de « diversification » soit présent, mais si c’est le cas, il est clairement problématique puisqu’on observe des terminaux différents utilisant les mêmes serveurs avec les mêmes clés d’obfuscation.
À vrai dire, il est assez peu probable qu’un tel mécanisme existe, car cela nécessiterait que chaque serveur VPN conserve un nombre de clés d’obfuscation relativement impportant (il faut garder à l’esprit que l’application fait l’objet de plus de cent millions de téléchargements !).
On est donc sur ce qui peut arriver de pire pour un utilisateur d’un VPN : À peu près n’importe qui, pour peu qu’il réussisse à obtenir la même liste de serveurs VPN que vous, peut déchiffrer votre trafic VPN !
Même si ce problème d’utilisation de clés secrètes communes à plusieurs utilisateurs n’existait pas, Secure VPN souffrirait de toute façon de vulnérabilités très problématiques :
- L’integrité du trafic n’est pas garantie : La fonction
SignalObfuscator::encode
calcule bien le tag GCM du paquet à émettre…mais ne l’utilise pas ! - Réutilisation du nonce GCM : La fonction
SignalObfuscator::encode
chiffre chaque paquet réseau indépendamment des autres, en réutilisant à chaque fois le nonce extrait de la clé d’obfuscation. Comme le mode GCM utilise le mode CTR, il est possible de capturer le trafic et d’obtenir une quantité significative d’information en xorant des paquets deux à deux (puisqu’ils sont toujours xorés avec la même suite chiffrante). - Enfin, et c’est un détail au vu de tout ce qui précède, aucune garantie en terme de forward secrecy n’est possible dans la mesure où l’on utilise une clé secrète : Si j’arrive à mettre la main sur la clé d’obfuscation stockée dans votre téléphone, je suis en mesure de déchiffrer votre trafic Secure VPN.
Conclusion
La morale de cet article et que l’on peut faire quelques découvertes surprenantes, y compris dans des applications massivement utilisées, dès que l’on s’y intéresse de près.
On peut s’interroger sur la raison de développer un VPN comportant des failles aussi sérieuses. Une hypothèse assez naturelle, quoique légèrement complotiste, serait d’y voir une volonté délibérée d’introduire des portes dérobées dans l’application, afin de pouvoir accéder au trafic des utilisateurs. En réalité, cela ne tient pas la route : L’administrateur de Secure VPN a par définition accès au trafic utilisateur en clair puisqu’il a la main sur les serveurs VPN !
La réalité est probablement bien plus prosaïque : il est beaucoup plus probable que l’application ait été développée par des développeurs ayant quelques lacunes en cryptologie.
Une réponse à “Rétroconception d’un VPN Android”
Mais pourtant il s’appelle Secure VPN 😭