Stéphane.
NetworkArchivé

TyphoonVPN — un service VPN vendu en ligne, provisionné dans un fichier système

Sept emplacements, deux protocoles, un site marchand et une application Android. Le tunnel tenait ; la caisse, elle, croyait le navigateur du client sur parole.

Technologies employées

  • Debian
  • SoftEther VPN
  • OpenVPN
  • xl2tpd
  • IPsec
  • iptables
  • PHP 5
  • MySQL
  • Apache
  • Android
  • Java
  • PayPal

Entre 2013 et 2015, j’ai fait tourner un service VPN payant sous le nom de TyphoonVPN : un site marchand, une base de comptes, des serveurs répartis dans sept pays, un paiement PayPal, et — pour la dernière version, à l’automne 2015 — une application Android maison dérivée d’ics-openvpn. Des gens s’y sont inscrits, ont payé, et ont eu un tunnel qui fonctionnait.

Ce qui suit est la relecture de ce code aujourd’hui. La partie réseau tenait debout. La partie qui décidait qui avait le droit d’ouvrir un compte, elle, faisait confiance au navigateur du client — et le fichier qui portait les identifiants du service était écrit à la volée par des pages PHP. C’est le projet où j’ai appris ce que veut dire « frontière de confiance », en le faisant mal.

Ce que le service proposait

L’offre tenait en une page : un abonnement mensuel réglé par PayPal, un essai gratuit de soixante jours, et le choix entre sept emplacements de sortie — France, Singapour, Chine, États-Unis, Royaume-Uni, Allemagne, Espagne.

Deux protocoles étaient réellement servis :

  • L2TP sur IPsec, porté par xl2tpd et pppd, avec une plage cliente en 172.16.1.20-172.16.254.254 et les résolveurs publics de Google poussés aux clients ;
  • OpenVPN, exposé par un serveur SoftEther en mode compatible, joint en UDP sur le port 1194.

Le site était en PHP 5 sur Apache, avec une base MySQL db_vpn, et un second site distinct servi aux mobiles par redirection — la feuille de styles du site principal imposait min-width: 1150px, ce qui rendait la version mobile obligatoire plutôt que souhaitable.

La chaîne de provisionnement

Voilà comment un compte naissait. C’est le schéma qu’il faut lire en entier avant tout le reste : le défaut central du projet y est visible sans lire une ligne de code.

Chaîne de provisionnement d’un compte TyphoonVPN Le navigateur du client passe par le site PHP, qui l’envoie vers PayPal. PayPal renvoie ensuite le navigateur vers la page validate2.php du site : c’est ce retour, et lui seul, qui déclenche la création du compte. Aucune confirmation ne circule directement de PayPal au serveur. La page de validation écrit alors dans la base MySQL et ajoute une ligne au fichier système /etc/ppp/chap-secrets lu par xl2tpd. Un cron quotidien fait le chemin inverse à l’échéance : il vide l’enregistrement en base et retire la ligne du fichier.navigateurle clientsite PHPachat.php · buy2.phpPayPalbouton Buy Nowinscriptionformulaire signévalidate2.phpcrée le compte sansvérifier le paiementle navigateur revientsur l’URL du champ returnMySQL db_vpnmot de passe en clair/etc/ppp/chap-secretslu par xl2tpdINSERT INTO vpnligne ajoutéecron quotidienstenzorevoc.phpà l’échéance : enregistrement vidé, ligne retirée par expression régulière
Rien ne relie PayPal au serveur : la création du compte est déclenchée par le retour du navigateur, une URL que le client peut appeler lui-même.

Le bouton d’achat était un formulaire PayPal signé, avec l’adresse de retour écrite dans un champ caché :

buy2.php
<form action="https://www.paypal.com/cgi-bin/webscr" method="post">
  <input
    type="hidden"
    name="return"
    value="http://typhoonvpn.com/validate2.php"
  />
  <input type="hidden" name="cmd" value="_s-xclick" />
  <input type="hidden" name="encrypted" value="-----BEGIN PKCS7-----…" />
</form>

Le formulaire est bien signé : personne ne pouvait modifier le montant. Mais validate2.php n’attendait rien de PayPal. Cette page lisait la session, créait le compte, envoyait le courriel d’identifiants et redirigeait vers le tutoriel. Il suffisait donc de s’inscrire, puis d’appeler l’URL de retour directement, pour obtenir un accès sans jamais passer par la page de paiement.

Le compte VPN écrit dans un fichier système

La création de compte se terminait par ceci — une page web qui ouvre en écriture le fichier des secrets de pppd :

validate.php
$file = "/etc/ppp/chap-secrets";
$f = fopen($file, 'a+');

fwrite($f, "\r\n");
fwrite($f, "\n" . $email);
fwrite($f, '  l2tpd  ');
fwrite($f, $motDePasse);
fwrite($f, '  *');
fclose($f);

Ce court bloc concentre l’essentiel de ce que je referais autrement.

Pour qu’il fonctionne, le compte du serveur web doit pouvoir écrire dans un fichier lu par un démon système au moment d’authentifier un tunnel. Toute vulnérabilité du site — et il y en avait — donne alors la main sur les identifiants du service entier. La frontière entre « ce que le web peut toucher » et « ce dont dépend l’authentification réseau » n’existait tout simplement pas.

Ensuite, l’écriture n’est jamais relue. Elle ajoute, elle ne réconcilie pas. Le fichier conservé avec la sauvegarde en garde toutes les traces :

Ce qu’on trouve dans chap-secretsNombreD’où cela vient
Lignes de compte valides132Une par passage réussi dans validate.php ou free.php
Comptes distincts128Quatre abonnés réinscrits ont donc deux lignes actives, avec deux mots de passe
Lignes dont l’identifiant est vide28Session sans adresse : le script écrivait quand même, sans rien vérifier
Lignes vides158Chaque écriture insérait \r\n puis \n avant l’enregistrement

Vingt-huit enregistrements sans identifiant, tous porteurs du même mot de passe — celui codé en dur dans le parcours d’essai gratuit. Aucun n’a jamais produit d’erreur visible : le fichier restait syntaxiquement lisible, le service démarrait, et rien dans l’interface d’administration ne montrait ces lignes, puisqu’il n’y avait pas d’interface d’administration.

La révocation, elle, était un second programme : un cron quotidien qui relançait une page PHP, cherchait les échéances du jour et retirait les lignes correspondantes par expression régulière sur le contenu du fichier.

stenzorevoc.php
$content = file_get_contents(FICHIER);
$pattern = '|\b' . preg_quote($mot) . '\b|i';
$content = preg_replace($pattern, "", $content);

Remplacer l’adresse par une chaîne vide laisse la ligne en place, amputée de son premier champ : c’est très exactement la fabrique des vingt-huit enregistrements sans identifiant. Deux programmes se partageaient un fichier système, l’un en ajout aveugle, l’autre en recherche-remplacement, sans verrou ni relecture.

Ce que valaient les deux protocoles

ProtocoleCe qui le servaitAuthentificationCe que cela valait vraiment
L2TP sur IPsecxl2tpd + pppd, pile netkeyMS-CHAPv2 sur chap-secrets, secret IPsec communAcceptable pour l’époque, à condition que le mot de passe soit fort — or il était court, et parfois identique
OpenVPNSoftEther en écoute compatibleCertificat client embarqué plus compteLe meilleur des deux : AES-128-CBC et SHA-1, avec une seconde authentification par identifiants

Le profil OpenVPN mérite une précision, parce qu’elle change son niveau réel de garantie. Le fichier .ovpn distribué embarquait le certificat de l’autorité, mais aussi un certificat client et sa clé privée. Tout le monde téléchargeant le même fichier partageait donc la même identité cryptographique. Ce qui distinguait réellement les abonnés, c’était la directive auth-user-pass — le couple identifiant/mot de passe demandé à la connexion. Le certificat servait à authentifier le serveur, pas le client, ce que son nom laissait pourtant croire : l’écran de l’application s’appelait « Choose your certificate ».

Autre trace instructive : l’hôte de destination du profil est un nom en softether.net. La sortie passait donc par le service de relais dynamique de SoftEther, ce qui explique comment un serveur à adresse changeante restait joignable — et signifie aussi qu’un tiers connaissait l’existence et l’adresse de chaque instance.

L’application Android

L’application de 2015 est un fork d’ics-openvpn, le client OpenVPN libre d’Arne Schwabe. Je n’ai pas réécrit de client VPN : j’ai ajouté trois écrans devant le sien — une authentification, une liste de serveurs, et le téléchargement du profil correspondant.

Trois captures d’écran Android côte à côte : un écran de connexion TyphoonVPN avec les champs E-mail et Password et les boutons LOGIN et REGISTER ; une liste intitulée « Choose your certificate » énumérant Serveur 1 - France jusqu’à Serveur 6 - Germany ; et le volet de notifications affichant « TyphoonVPN - testa, 744 bit/s en réception, 272 bit/s en émission, 00:19 ».
Les trois écrans dans l’ordre : l’authentification maison, la liste des sept sorties, et la notification du client OpenVPN une fois le tunnel monté — 19 secondes de connexion, quelques kilo-octets échangés.

Le parcours complet tenait en cinq étapes.

  1. Saisiee-mail et mot de passe
  2. POST login.phpen clair, port 8080
  3. Réponse « success »une chaîne de texte
  4. Téléchargement du .ovpnURL fixe et publique
  5. Import et connexionle client ics-openvpn reprend la main

La réponse du serveur n’était ni signée ni horodatée : la seule chose que l’application testait était l’égalité de cette chaîne avec « success ».

Le profil était téléchargé dans le dossier public des téléchargements, puis importé. Rien, à aucune étape, ne rattachait le fichier obtenu au compte qui venait de s’authentifier.

Les sept adresses de profils étaient écrites en dur dans l’activité, une branche if par pays :

dlcertificat.java
if (id == 0) {
    String url = "http://…/files/certificats/Server01-French.ovpn";
    new DownloadFileFromURL().execute(url);
}

Trois conséquences, toutes visibles dans ces quelques lignes.

L’échange se fait en HTTP simple. L’identifiant et le mot de passe partent en clair, et le profil — clé privée comprise — revient en clair. Sur le réseau Wi-Fi d’un hôtel, c’est-à-dire précisément la situation que le produit prétendait protéger, tout est lisible.

Les URL des profils sont publiques et fixes. Le contrôle d’accès s’arrête à l’écran de connexion de l’application ; le fichier, lui, était servi à qui connaissait son adresse. L’authentification n’était donc pas un contrôle, mais une politesse.

Enfin, la gestion d’erreur du téléchargement se réduit à Log.e("Error: ", e.getMessage()), et la réponse du serveur est utilisée sans test : si le serveur ne répondait pas, result.trim() s’exécutait sur une valeur nulle et l’application se fermait. L’utilisateur en déplacement, sur un réseau capricieux, voyait donc l’application disparaître sans un mot.

Ce que je referais autrement

Ce que faisait le codeConséquenceCe que je ferais aujourd’hui
Compte créé au retour du navigateur depuis PayPalAccès gratuit pour qui appelle l’URL de retourAttendre la notification de serveur à serveur, la rejouer vers le fournisseur, ne rien créer avant
Pages PHP écrivant dans /etc/ppp/chap-secretsLe web tient les identifiants du service réseauUn service dédié, une file de tâches, et un compte système distinct du serveur web
Ajout aveugle, révocation par expression régulière158 lignes vides, 28 comptes sans identifiant, 4 doublons actifsUne source de vérité unique en base, un fichier régénéré intégralement puis rechargé
Mots de passe stockés et envoyés en clair par courrielToute fuite de la base est une fuite d’accèsEmpreinte salée en base, mot de passe montré une seule fois, jamais transmis par courriel
Mot de passe unique codé en dur pour l’essai gratuitUn seul secret ouvre tous les comptes d’essaiGénération aléatoire par compte, sans exception pour les offres d’essai
Application et profils servis en HTTP sur un port banaliséIdentifiants et clé privée lisibles sur le réseau localTLS obligatoire, profil généré par compte et à durée de vie limitée
Un thème de site repris à un concurrentAucune identité propre, et un risque juridiqueAssumer une mise en page simple et originale plutôt qu’emprunter celle d’un autre

La dernière ligne mérite d’être dite franchement : les feuilles de styles et une partie des visuels du site venaient d’un fournisseur VPN existant, classes CSS comprises. À l’époque, cela m’a paru être un raccourci de mise en page. C’en est un, et c’est aussi de la contrefaçon.

Enseignements

  1. Un retour de navigateur n’est pas une preuve de paiement : la seule confirmation qui compte est celle qui arrive de serveur à serveur et qu’on peut rejouer auprès de son émetteur.
  2. Un fichier lu par un démon d’authentification ne doit jamais être écrit par une page web — la frontière de confiance passe exactement là.
  3. Un état partagé qu’on modifie par ajout d’un côté et par recherche-remplacement de l’autre finit toujours par contenir des enregistrements que personne n’a voulus.
  4. Régénérer entièrement un fichier depuis une source de vérité coûte plus cher qu’un ajout en fin de fichier, et c’est la seule façon de savoir ce qu’il contient.
  5. Un certificat embarqué dans un profil distribué à tous n’authentifie pas l’abonné : il authentifie le serveur. Nommer l’écran « choisissez votre certificat » ne change rien à ce fait.
  6. Un produit qui vend de la confidentialité et qui transporte ses propres identifiants en clair se contredit avant même d’être installé.

TyphoonVPN a fonctionné : des tunnels se sont montés, dans sept pays, pour des gens qui n’étaient pas mes amis. C’est ce qui rend sa relecture utile. Le réseau n’était pas le point faible — le routage, le NAT et la redirection tenaient, et je les réécrirais presque à l’identique. Ce qui manquait, c’était tout ce qui sépare un montage technique d’un service : une frontière nette entre le web et le système, une vérification côté serveur de ce que le client affirme, et un état dont on peut dire à tout moment ce qu’il contient.

Un an plus tard, le boîtier sKUBE affichait fièrement « VPN Connecté — TyphoonVPN » sur sa page d’état. Ce texte était écrit en dur, et le service qu’il nommait laissait ouvrir un compte sans payer. Les deux projets se sont trompés de la même façon, à un an d’intervalle : ils affirmaient au lieu de vérifier.