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
xl2tpdetpppd, avec une plage cliente en172.16.1.20-172.16.254.254et 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.
Le bouton d’achat était un formulaire PayPal signé, avec l’adresse de retour écrite dans un champ caché :
<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 :
$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-secrets | Nombre | D’où cela vient |
|---|---|---|
| Lignes de compte valides | 132 | Une par passage réussi dans validate.php ou free.php |
| Comptes distincts | 128 | Quatre abonnés réinscrits ont donc deux lignes actives, avec deux mots de passe |
| Lignes dont l’identifiant est vide | 28 | Session sans adresse : le script écrivait quand même, sans rien vérifier |
| Lignes vides | 158 | Chaque é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.
$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
| Protocole | Ce qui le servait | Authentification | Ce que cela valait vraiment |
|---|---|---|---|
| L2TP sur IPsec | xl2tpd + pppd, pile netkey | MS-CHAPv2 sur chap-secrets, secret IPsec commun | Acceptable pour l’époque, à condition que le mot de passe soit fort — or il était court, et parfois identique |
| OpenVPN | SoftEther en écoute compatible | Certificat client embarqué plus compte | Le 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.

Le parcours complet tenait en cinq étapes.
- Saisiee-mail et mot de passe
- POST login.phpen clair, port 8080
- Réponse « success »une chaîne de texte
- Téléchargement du .ovpnURL fixe et publique
- 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 ».
Les sept adresses de profils étaient écrites en dur dans l’activité, une branche
if par pays :
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 code | Conséquence | Ce que je ferais aujourd’hui |
|---|---|---|
| Compte créé au retour du navigateur depuis PayPal | Accès gratuit pour qui appelle l’URL de retour | Attendre la notification de serveur à serveur, la rejouer vers le fournisseur, ne rien créer avant |
Pages PHP écrivant dans /etc/ppp/chap-secrets | Le web tient les identifiants du service réseau | Un 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ère | 158 lignes vides, 28 comptes sans identifiant, 4 doublons actifs | Une 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 courriel | Toute fuite de la base est une fuite d’accès | Empreinte 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 gratuit | Un seul secret ouvre tous les comptes d’essai | Gé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 local | TLS obligatoire, profil généré par compte et à durée de vie limitée |
| Un thème de site repris à un concurrent | Aucune identité propre, et un risque juridique | Assumer 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
- 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.
- 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à.
- 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.
- 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.
- 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.
- 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.