Un VPN n’est pas un produit. Ce sont deux tâches vissées ensemble, et votre machine a déjà un programme pour chacune.
La première tâche, c’est fabriquer un lien virtuel — une interface qui ressemble à une carte réseau, prend des paquets IP et les passe ailleurs. La seconde, c’est transporter les octets de ce lien d’un bout à l’autre. Achetez un boîtier VPN et vous achetez les deux tâches avec une licence agrafée dessus ; faites-le vous-même et chacune est un programme déjà installé. Il y a deux façons de faire la première tâche sous Linux, et ce billet construit les deux : pppd, qui apporte tout un protocole de lien — adresses négociées, une vérification de vivacité, du tramage — et un tap, qui n’apporte rien et n’est qu’un trou dans le noyau où vous poussez des trames. Des compromis différents, pas des concurrents, et la seconde moitié les met côte à côte.
Ce protocole, c’est PPP, et la raison pour laquelle cela marche est écrite dans son premier paragraphe : PPP est un protocole de lien pour les liaisons point à point1, et il ne précise pas de quoi le lien est fait. Un modem et une ligne téléphonique étaient la réponse d’origine. Ils n’ont jamais été la seule. PPTP a mis PPP dans GRE2. L2TP l’a mis dans UDP3. PPPoE l’a mis dans des trames Ethernet. Chacun est le même protocole avec un coursier différent, et aucun n’a eu besoin de changer PPP pour le faire.
La question n’est donc pas de savoir si vous pouvez faire tourner PPP sur un socket TCP ou UDP, mais lequel, et ce que cela coûte. La réponse courte, calcul à l’appui : prenez UDP. Faire tourner un protocole de flux dans un flux fiable est la seule erreur ici qui a l’air bien sur votre bureau et s’effondre sur une vraie liaison.
Un VPN, C’est Deux Tâches. Vous Avez Déjà Les Deux.
Retirez le marketing de n’importe quel tunnel et trois choses se produisent : quelque chose présente une interface virtuelle et transforme des paquets en flux d’octets, quelque chose transporte ce flux sur un réseau qui fonctionne déjà, et quelque chose le chiffre, ou rien ne le fait.
PPP fait la moitié « lien » proprement — négociation d’adresses, une vérification de vivacité, compression d’en-tête, plusieurs protocoles de couche réseau sur un lien, une étape d’authentification si vous voulez — beaucoup de travail fini qui traîne dans /usr/sbin sans rien faire. Ce qu’il ne fait pas, c’est se soucier du transporteur : donnez-lui un descripteur de fichier qui déplace des octets dans les deux sens et il tourne dessus. Netcat est un programme dont le seul but est d’être ce descripteur de fichier.
Le chiffrement est la partie que personne ne vous tend, et la partie que la plupart de ce billet passe à gagner, parce que netcat n’a pas de réponse pour elle et faire comme si de rien n’était est la façon dont les gens construisent des choses qu’ils ne devraient pas.
Les Étapes, Et Pourquoi Elles Vont Dans Cet Ordre
Tout ci-dessous se construit par étapes, chacune ajoutant une chose à la précédente. Ce n’est pas un procédé pédagogique. C’est comme ça qu’il faut le construire, car quand l’étape six casse, vous devez savoir si l’étape deux marche encore, et vous ne le savez que si l’étape deux a été un jour une chose que vous avez fait tourner seule.
Le lien lui-même se construit de la même façon — le raisonnement derrière la rampe d’une seconde plus bas : un tunnel monte en brut, prouve qu’il peut porter une trame, et ne devient malin qu’ensuite. Une construction qui allume tout d’un coup tombe en un seul bloc, et vous passez la soirée à deviner quelle couche l’a fait.
Ce Que pppd Veut Vraiment, C’est Un Descripteur De Fichier
pppd a été écrit pour les ports série, donc la lecture naïve est qu’il lui faut un port série. Non. Il lui faut un périphérique terminal, et il s’en fabrique un tout seul :
pty script — Specifies that the command
scriptis to be used to communicate rather than a specific terminal device. Pppd will allocate itself a pseudo-tty master/slave pair and use the slave as its terminal device. The script will be run in a child process with the pseudo-tty master as its standard input and output.4
Relisez ça — toute la construction est dedans. pppd fabrique un pseudo-terminal, garde l’esclave, et remet le maître à une commande comme stdin et stdout. Ce que cette commande fait des octets ne regarde pas pppd. Lancez nc là et ils descendent un socket.
pty hérite du côté maître comme stdin et stdout. Netcat copie stdin vers un socket et le socket vers stdout, de sorte que les deux instances pppd se parlent à travers la paire pty et le réseau sans qu’aucune ne sache qu’un socket existe.Il y a une seconde voie, notty, qui utilise à la place les stdin et stdout de pppd. Mais elle lance un processus de dérivation de caractères par lequel chaque octet passe, si bien qu’elle « increases the latency and CPU overhead »4. Prenez pty, sauf raison de ne pas le faire.
Trois faits pratiques avant la première commande, tous vérifiés sur la machine où ceci a été écrit — Fedora 44, ppp-2.5.1-7.fc44 :
$ pppd --version
pppd version 2.5.1
$ ls -l /usr/bin/pppd /dev/ppp
-rwxr-xr-x. 1 root root 393784 Jan 17 2026 /usr/bin/pppd
crw-------. 1 root root 108, 0 Sep 14 08:34 /dev/ppp
$ pppd noauth nodetach pty 'true'
pppd: using the noauth option requires root privilege
Il faut root. Pas moyen d’y couper. Le binaire n’est pas setuid sous Fedora, /dev/ppp est en mode 0600 appartenant à root, et les options dont vous avez besoin sont de toute façon privilégiées. C’est une chose que vous lancez en root ou sous un fichier d’unité, pas une chose qu’un utilisateur fait à la légère. Cela compte plus tard, quand nous arriverons à ce que cela signifie pour votre politique d’egress.
Le côté noyau est modulaire. ppp_generic fait l’interface, ppp_async le tramage à bourrage d’octets dont un pty a besoin, et ppp_deflate/bsd_comp/ppp_mppe la compression et le chiffrement ; ils se chargent à la demande. Si ppp_async manque dans une image de conteneur allégée, le lien monte et ne porte rien — une demi-heure misérable si vous ne savez pas où regarder.
Les lignes de contrôle du modem n’existent pas sur un pty. Sans broche de détection de porteuse, pppd attend une porteuse qui ne vient jamais. La solution tient en un mot, local, qui lui dit d’ignorer les lignes de contrôle du modem4. Omettez-le et rien ne se passe, sans erreur digne d’être lue.
Netcat N’Est Pas Un Seul Programme
Avant la construction, le piège qui mange le plus de temps : « netcat » est au moins quatre programmes aux drapeaux incompatibles, et lequel vous obtenez dépend de votre distribution. Sur cette machine /usr/bin/nc est un lien symbolique vers /usr/bin/ncat, la réécriture de Nmap. Une commande copiée d’une page wiki vieille de quinze ans échoue donc pour des raisons qui n’ont rien à voir avec PPP.
| Implémentation | Écouter sur un port | UDP | Reste debout après la déconnexion d’un pair |
|---|---|---|---|
| Ncat (Nmap) | ncat -l 443 | -u | -k / --keep-open |
| OpenBSD netcat | nc -l 443 | -u | -k |
| Traditional netcat | nc -l -p 443 | -u | non |
| GNU netcat | nc -l -p 443 | -u | non |
Vérifiez donc lequel vous avez avant d’accuser pppd :
readlink -f "$(command -v nc)"
nc --version 2>&1 | head -1 || nc -h 2>&1 | head -1
J’utilise explicitement ncat ci-dessous. C’est celui avec TLS intégré, ce qui compte plus tard, et être explicite signifie que les commandes ne veulent pas silencieusement dire autre chose sur votre machine.
Étape Un : La Construction TCP, Parce Que C’est Ce Que Tout Le Monde Essaie D’Abord
Un bout écoute, un bout se connecte. Les adresses ici viennent de la plage de documentation, vous pouvez donc les coller directement dans un labo sans rien blesser de routable. Le port est 443 partout, et c’est délibéré : le 443 sortant est ouvert par défaut sur presque tous les réseaux. Enveloppez le transporteur dans TLS quelques étapes plus bas et le trafic dessus est indiscernable de n’importe quelle session HTTPS. Y lier un écouteur demande root, ce que le bout distant — la propre machine de l’attaquant, ou un relais — a. C’est tout l’argument de l’egress dans un numéro de port, et j’y reviendrai.
Au bout qui écoute :
pppd nodetach noauth local passive \
nodefaultroute noipdefault \
192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --listen --keep-open 443'
Au bout qui se connecte :
pppd nodetach noauth local \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat 198.51.100.10 443'
Chaque mot là fait un travail, et l’un d’eux, noauth, fait des dégâts discrets. Le tunnel n’a aucun contrôle d’accès, une section à lui plus loin.
noauth : elle abandonne l’authentification du pair, si bien que l’écouteur accepte quiconque arrive. nodefaultroute et noipdefault empêchent le lien de réécrire discrètement votre routage, local le fait monter sur un pty tout court, et la paire épinglée local:remote empêche pppd de prendre une autre réponse pendant IPCP. Le bout qui se connecte est la même ligne sans passive.Montez les deux bouts et vous obtenez un ppp0 sur chacun, une route point à point vers l’adresse distante, et une interface que vous pouvez pinguer, router et tcpdumper. C’est un VPN — aucun paquet installé, aucun daemon configuré, aucune clé échangée, ce qui est le problème, et j’y reviendrai.
Ce Qu’Il A Négocié, Et Comment Le Regarder
Ajoutez debug et pppd journalise l’échange des protocoles de contrôle, ce qui vaut d’être lu une fois même si vous ne le relisez jamais. Le lien monte par étapes, et chaque étape peut échouer seule.
Les deux outils de débogage utiles sont déjà installés et personne ne s’en sert :
# log every frame in both directions to a file
pppd ... debug record /tmp/ppp-trace
# then read it back in a human-readable form
pppdump -h /tmp/ppp-trace | less
record écrit une capture horodatée de chaque octet, pppdump en fait quelque chose de lisible4, et avec tcpdump -ni ppp0 vous voyez les deux côtés — le tramage en dessous et les paquets au-dessus.
La Trame Sur Le Fil, Et Pourquoi 0x7E Est Partout
PPP sur une ligne série — et un pty en est une, du point de vue de pppd — utilise le tramage HDLC asynchrone, et le comprendre fait la différence entre régler ce truc et deviner. Chaque trame commence et finit par le même octet : « Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e) »5. Si 0x7e marque une frontière, il ne peut pas apparaître à l’intérieur d’une trame, donc il est échappé. Idem l’octet d’échappement 0x7d, et tout ce que l’un des bouts a demandé.
La règle d’échappement est exacte : « Each Flag Sequence, Control Escape octet, and any octet which is flagged in the sending Async-Control-Character-Map (ACCM), is replaced by a two octet sequence consisting of the Control Escape octet followed by the original octet exclusive-or’d with hexadecimal 0x20 »5.
Cette dernière partie est le bouton de réglage. L’ACCM fait 32 bits, un par caractère de contrôle, et un 1 veut dire « échappe ça ». Demandez-les tous et, sur des charges chiffrées où les octets sont pour ainsi dire aléatoires, environ un octet sur huit est sous 0x20 — à peu près 12 % de surcoût pour rien.
Le pppd moderne fait déjà ce qu’il faut ici. Lisez la page de manuel plutôt que le folklore :
If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters.4
asyncmap 0 est donc le défaut, pas une astuce, et un socket est un chemin 8 bits propre qui n’a besoin d’aucun échappement au-delà des deux octets obligatoires. Laissez-le tranquille ; ne posez des bits que quand quelque chose au milieu mange vraiment des caractères de contrôle — un serveur de terminaux, un concentrateur série, un mauvais proxy de console. La séquence de contrôle de trame à la fin est un CRC de 16 bits, et c’est elle qui fait marcher la construction UDP — ce qui vient ensuite.
TCP Sur TCP Est Le Mauvais Transporteur
La construction ci-dessus marche. Sur un établi de labo, sur du loopback ou un LAN tranquille, elle marche à merveille, ce qui est exactement pourquoi les gens la livrent.
Puis elle rencontre une vraie liaison avec de la vraie perte. Elle s’écroule d’une façon qui ressemble à tout sauf à ce que c’est.
Le problème, ce sont deux temporisateurs de retransmission indépendants empilés l’un sur l’autre, l’extérieur cachant la perte à l’intérieur. Olaf Titz a écrit l’explication de référence, ouvrant précisément sur cette construction :
A frequently occurring idea for IP tunneling applications is to run a protocol like PPP, which encapsulates IP packets in a format suited for a stream transport (like a modem line), over a TCP-based connection. […] Unfortunately, it doesn’t work well. Long delays and frequent connection aborts are to be expected.6
Voici la forme. Les deux TCP règlent un temporisateur de retransmission d’après leur temps d’aller-retour. Quand le transporteur perd un segment, il retransmet, donc les données de la connexion intérieure arrivent tard. Et le TCP intérieur, qui ne voit pas le transporteur, lit « tard » comme de la congestion et retransmet aussi. Le transporteur a maintenant l’original et un doublon à livrer sur une liaison qui perd déjà des paquets. Le temporisateur intérieur double, la file extérieure grandit, et le débit s’effondre bien avant la liaison. Rien dans les logs ne dit pourquoi.
Ce n’est pas un point subtil d’efficacité. C’est la différence entre un tunnel qui se dégrade avec grâce et un qui cesse de passer du trafic à peut-être 2 % de perte pendant que ping sur la même liaison a encore l’air bien — la raison de prendre UDP, et de ne garder TCP en réserve que pour une liaison qui ne passera rien d’autre.
Alors prenez UDP — par défaut, pas par préférence.
Étape Deux : La Construction UDP, Le Socle
Le même pppd, un transporteur différent. Le seul changement est dans la commande pty.
Bout qui écoute :
pppd nodetach noauth local passive \
nodefaultroute noipdefault \
192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --udp --listen 198.51.100.10 443'
Bout qui se connecte :
pppd nodetach noauth local \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --udp 198.51.100.10 443'
Deux choses à propos d’un écouteur UDP qui vont vous piéger, et aucune n’est un problème PPP.
L’écouteur ne peut pas répondre tant qu’on ne lui a pas parlé. Un socket UDP n’a aucune connexion à accepter, donc netcat ne peut pas savoir où envoyer les réponses avant qu’un datagramme arrive. Il se verrouille sur la première adresse source et le premier port qu’il entend et parle à ça. Cela veut dire que le bout qui se connecte doit envoyer en premier — ce que pppd fait de lui-même, parce que le bout non passif tire immédiatement des LCP configure-requests. Cela veut dire aussi que si le port source du client change, le transporteur parle silencieusement au mauvais endroit. Derrière un NAT au timeout UDP court, c’est un tunnel qui meurt toutes les quelques minutes sans raison visible.
--keep-open ne fait pas ce que vous voulez sur UDP. Le -k de Ncat garde un écouteur TCP acceptant après le départ d’un pair. Sur UDP il n’y a rien à accepter, donc récupérer veut dire relancer le transporteur — ce à quoi servent persist et holdoff, plus bas.
Les deux sont des arguments pour socat, qui gère les pairs UDP plus soigneusement, et pour un superviseur plutôt que de compter sur le fait qu’il reste debout.
Pourquoi PPP Survit À Un Datagramme Perdu
L’objection évidente à un transporteur UDP est que PPP attend un flux d’octets et qu’UDP n’en est pas un : les datagrammes arrivent entiers ou pas du tout, et peuvent arriver dans le désordre. Cela marche quand même, grâce à deux octets que le tramage a portés tout du long.
PPP sur HDLC asynchrone a été conçu pour une ligne qui corrompt les octets. Chaque trame porte une séquence de contrôle de trame de 16 bits ; une trame qui échoue est jetée, et l’octet fanion suivant resynchronise le récepteur. Le désordre est plus rare que la perte et produit le même résultat : une mauvaise FCS, une trame jetée, une resynchro.
Un datagramme perdu coûte donc une trame PPP — un paquet IP, l’état normal de tout réseau jamais construit. Le propriétaire du paquet retransmet, et le contrôle de congestion voit une vraie perte et réagit correctement. C’est tout l’argument pour UDP : il laisse le trafic dans le tunnel découvrir la vérité sur la liaison.
Ne laissez pas les trames devenir assez grosses pour que le transporteur doive les fragmenter, cependant. Un fragment perdu tue alors tout le datagramme et votre taux de perte effectif se multiplie. Gardez la MTU PPP bien sous la MTU du chemin.
Adresses, Routes, Et Faire IPv6 Correctement
ppp0 est une interface point à point — pas de sous-réseau, pas d’ARP — donc local:remote est tout l’adressage, et vous routez explicitement dessus. Pour un hôte unique atteignant un réseau derrière le bout distant :
# on the client, after the link is up
ip route add 203.0.113.0/24 via 192.0.2.1 dev ppp0
Pour que le bout distant relaie pour le compte du client, les deux étapes habituelles :
sysctl -w net.ipv4.ip_forward=1
nft add rule inet nat postrouting oifname "eth0" ip saddr 192.0.2.2/32 masquerade
proxyarp fera répondre le serveur à l’ARP pour le client sur son propre segment4, ce qui est joli jusqu’à ce qu’un second client veuille la même chose. Puis faites la partie que la plupart sautent. Faites tourner IPv6 dessus — un lien point à point sans NAT, sans domaine de diffusion et sans pénurie d’adresses est l’endroit le plus facile de votre réseau pour faire IPv6 correctement :
pppd nodetach noauth local \
+ipv6 ipv6 ::1,::2 \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
pty 'ncat --udp 198.51.100.10 443'
+ipv6 active IPV6CP ; ipv6 <local>,<remote> fixe les deux identifiants d’interface de 64 bits4, le lien monte en lien-local, et vous ajoutez un /64 global par-dessus7. IPV6CP est indépendant d’IPCP, donc noip ne porte que de l’IPv6 — une chose raisonnable à construire en 2026, en un mot.
Le Garder Debout Quand Le Transporteur Meurt En Silence
C’est la panne qui gâche un après-midi, alors elle a sa propre section.
Un transporteur TCP qui meurt mal — le bout distant éteint, une entrée de table NAT expirée, une middlebox qui a cessé de relayer — ne se ferme pas. Il n’y a ni FIN, ni RST, rien. Netcat reste là à tenir un socket qui ne livrera jamais un autre octet, pppd reste là à tenir un pty qui ne verra jamais une autre trame, et ip link rapporte joyeusement ppp0 comme UP. Un transporteur UDP n’a aucun état de connexion, donc il ne remarque jamais rien.
PPP a la réponse intégrée, et elle est désactivée par défaut :
lcp-echo-interval 10 lcp-echo-failure 3
Cela envoie un écho LCP toutes les dix secondes et abat le lien après trois sans réponse4 — trente secondes pour attraper un transporteur mort, exactement dans le cas que la page de manuel nomme, « no hardware modem control lines »4, c’est-à-dire tout pty jamais fait. Puis décidez de ce qui se passe après :
persist maxfail 0 holdoff 5
lcp-echo-interval 10 lcp-echo-failure 3 est le détecteur : écho toutes les dix secondes, lien à terre après trois manqués. persist maxfail 0 holdoff 5 est la reconstruction : redémarrer, ne jamais abandonner, attendre cinq secondes pour qu’une liaison qui bat ne devienne pas une bombe à fork — et pppd relance la commande pty, si bien qu’un netcat et un socket neufs viennent avec. Posez l’écho aux deux bouts ; un superviseur, une unité systemd avec Restart=always, vaut mieux que deux processus qui se disputent. Mettez ip route dans /etc/ppp/ip-up.d/, pour que le routage revienne avec le lien.Il N’A Ni Chiffrement Ni Authentification
Tout ci-dessus est un tunnel qui marche. Ce n’est pas un tunnel sûr, et l’écart n’est pas un détail.
Il n’y a aucun chiffrement. Pas de chiffrement faible. Aucun. Chaque paquet que vous passez là-dedans est en clair sur le fil, enveloppé dans une trame HDLC que n’importe quel outil de capture décode au premier coup d’œil. tcpdump vous montrera le contenu du tunnel de quelqu’un d’autre aussi volontiers que le vôtre.
Il n’y a aucune authentification du pair. noauth le dit. Un écouteur netcat accepte ce qui arrive au port : la première connexion ou le premier datagramme de n’importe où. Le premier arrivé obtient un lien routé dans votre réseau. PPP a bien de l’authentification (PAP en clair, CHAP en défi-réponse8), et les deux prouvent qui est le pair sans chiffrer un seul octet de ce qui suit : CHAP ici vous donne un tunnel qui sait à qui il parle et publie quand même le contenu à quiconque est sur la liaison.
Il y a une option de chiffrement dans la famille PPP — MPPE9, le module qui traîne dans ppp_mppe.ko. N’y touchez pas : c’est du RC4 tiré de l’échange MS-CHAPv2, cassé en public depuis plus d’une décennie, et la raison pour laquelle PPTP est mort. Construire un nouveau tunnel dessus en 2026 choisit un chiffre notoirement cassé plutôt qu’un qui marche et ne coûte rien.
Donc le résumé honnête jusqu’ici : un lien routé sans confidentialité et sans contrôle d’accès. Bien pour un labo, bien à l’intérieur d’un lien déjà chiffré, bien nulle part ailleurs. La solution est de chiffrer le transporteur — les deux prochaines sections, et la raison de prendre ncat plutôt que le netcat que votre distribution a livré.
Étape Trois : Envelopper Le Transporteur Dans TLS
La réponse propre laisse pppd exactement tel quel et remplace le transporteur par un qui fait TLS. pppd n’apprend jamais que quoi que ce soit a changé.
D’abord le certificat. Un auto-signé suffit tant que le client le vérifie. Une session TLS non vérifiée est une conversation chiffrée avec quelqu’un que vous n’avez pas identifié, ce qui n’arrête personne :
openssl req -x509 -newkey rsa:4096 -days 825 -nodes \
-keyout tunnel.key -out tunnel.crt \
-subj "/CN=tunnel.example.net" \
-addext "subjectAltName=DNS:tunnel.example.net"
Ncat
Ncat a TLS intégré. Il chaîne aussi à travers un proxy, --proxy host:port --proxy-type http|socks4|socks510, si bien que le transporteur peut se terminer à un relais plutôt qu’au bout distant du tunnel. C’est le point autour duquel tourne la section egress. Bout qui écoute :
pppd nodetach noauth local passive \
nodefaultroute noipdefault 192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 mtu 1400 mru 1400 \
pty 'ncat --listen --keep-open --ssl --ssl-cert tunnel.crt --ssl-key tunnel.key 443'
Bout qui se connecte :
pppd nodetach noauth local \
nodefaultroute noipdefault 192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 mtu 1400 mru 1400 \
pty 'ncat --ssl --ssl-verify --ssl-trustfile tunnel.crt tunnel.example.net 443'
--ssl-verify est le mot qui compte. Sans lui, --ssl vous donne du chiffrement contre un écouteur passif et rien contre celui qui répond au port en premier ; avec lui, Ncat vérifie la confiance et le nom de domaine contre le fichier de confiance11. Ncat n’a cependant pas de vérification côté serveur du certificat client, donc le serveur ne peut pas identifier le client. Associez-le à CHAP, ou prenez l’un des deux suivants.
Stunnel
L’enrobage traditionnel, et le seul qui fait l’authentification mutuelle correctement :
[ppp]
accept = 443
connect = 127.0.0.1:6001
cert = /etc/stunnel/tunnel.crt
key = /etc/stunnel/tunnel.key
CAfile = /etc/stunnel/clients.crt
verify = 2
verify = 2 exige un certificat client signé par une CA dans CAfile — le contrôle d’accès que la construction netcat n’a jamais eu. Le client lance stunnel en mode client, et la commande pty de pppd se connecte au côté clair local.
Socat
socat fait tout en un processus par bout, avec la vérification activée par défaut :
# listening end
pppd ... pty 'socat - OPENSSL-LISTEN:443,reuseaddr,cert=tunnel.pem,cafile=clients.crt,verify=1'
# connecting end
pppd ... pty 'socat - OPENSSL:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
socat n’est pas installé sur la machine où ceci a été écrit, donc cela vient de sa documentation — vérifiez vos propres drapeaux. Il vaut la peine d’être là : c’est le seul outil de ce billet qui fait tun, tap, TLS et DTLS en un seul processus.
Openssl, Quand Il N’Y A Rien D’Autre
openssl est sur toute machine qui a TLS, et s_server/s_client portent un tube :
# listening end
pppd ... pty 'openssl s_server -quiet -accept 443 -cert tunnel.crt -key tunnel.key'
# connecting end
pppd ... pty 'openssl s_client -quiet -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
-quiet supprime la bannière qui autrement atterrirait dans votre flux PPP, et -verify_return_error fait qu’un échec de vérification ferme la connexion au lieu d’avertir et de continuer. Ce sont des outils de débogage qui se comportent comme tels, mais sur une machine où vous ne pouvez rien installer ils font monter un lien.
Étape Quatre : DTLS, Parce Que Le Transporteur Devrait Rester UDP
Voici la partie délicate, et la raison pour laquelle cette section est à part. Chaque option TLS ci-dessus tourne sur TCP, si bien qu’envelopper le transporteur dans TLS défait l’argument pour UDP et vous rend l’effondrement. Le chiffrement et le bon transport ne devraient pas être un marchandage. DTLS est TLS sur datagrammes, et c’est ce que vous voulez : il garde la protection de la couche d’enregistrement, abandonne les garanties d’ordre et de retransmission, et laisse les paquets perdus perdus, ce qui est exactement ce que la séquence de contrôle de trame de PPP est faite pour absorber.
Ncat ne peut pas le faire. socat et openssl le peuvent :
# listening end
pppd ... pty 'socat - OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1'
# connecting end
pppd ... pty 'socat - OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
Et avec openssl seul, où -dtls choisit n’importe quelle version de DTLS :
# listening end
pppd ... pty 'openssl s_server -quiet -dtls -accept 443 -cert tunnel.crt -key tunnel.key'
# connecting end
pppd ... pty 'openssl s_client -quiet -dtls -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
Surveillez la taille des trames. Un enregistrement DTLS ne peut pas être fragmenté comme un enregistrement TLS s’étale sur un flux TCP, donc tout doit tenir dans la MTU du chemin d’un coup.
Régler La Construction PPP : MTU, Compression Et Ce Qui Aide Vraiment
Quatre boutons, tous des options pppd — la construction tap de la section suivante n’en a aucun, parce qu’elle n’a aucune des machineries qu’ils règlent.
novj au-dessus de la vitesse d’un modem. Elle économise une erreur d’arrondi, coûte du CPU par paquet et casse sous la perte. Coupez deflate/bsdcomp : la charge est déjà chiffrée, et compresser à travers une frontière de sécurité est une attaque, pas une fonctionnalité.Rien de tout cela ne rend le tunnel plus rapide, et la raison compte parce que PPP en prend le blâme et ne devrait pas. PPP n’est pas le goulot. PPPoE porte 2 Gbit sur le même daemon, parce que son chemin de données ne quitte jamais le noyau — ppp_generic et pppoe font le tramage et le relayage, et pppd ne gère que le plan de contrôle. Ce qui vous coûte ici, c’est le pty : chaque octet traverse vers l’espace utilisateur, à travers netcat, dans un socket et revient, un aller-retour que PPPoE ne fait jamais. Cela appartient au pseudo-terminal, pas à PPP.
Ce qui est un bon moment pour regarder l’autre façon de faire, celle où il n’y a pas de pty du tout.
L’Autre Façon : Un Tap, Et Aucun PPP
Tout jusqu’ici a utilisé PPP pour la moitié « lien ». Il y a une seconde façon de faire une interface virtuelle, et elle n’a besoin d’aucun protocole.
Le pilote TUN/TAP du noyau vous remet une interface et un descripteur de fichier liés l’un à l’autre : écrivez un paquet dans le descripteur et il apparaît sur l’interface comme s’il venait d’un fil ; lisez et vous obtenez un paquet que le noyau voulait envoyer. C’est toute l’interface12, et elle est dans Linux depuis 1999.
ip tuntap add dev tap0 mode tap user damien group damien
ip link set tap0 mtu 1400 up
ip addr add 192.0.2.1/30 dev tap0
Deux détails dans cette première commande valent plus qu’il n’y paraît.
user damien rend le périphérique persistant et non privilégié. Créé ainsi, il survit au processus qui l’utilise, et un utilisateur nommé peut l’ouvrir sans être root. Root crée le périphérique une fois ; la chose qui pellette les trames n’a pas besoin de root du tout. Gardez cette pensée pour la section egress.
mode tun est l’autre moitié du même pilote, et c’est celle que la plupart veulent en fait. Tun porte des paquets IP. Tap porte des trames Ethernet. La différence compte assez pour avoir sa propre section plus bas.
Ce que vous abandonnez face à PPP, c’est tout ce que PPP négocie : pas de LCP donc pas de taille de trame convenue et pas de vérification de vivacité, pas d’IPCP/IPV6CP donc les deux bouts configurés à la main, pas d’authentification, pas de compression d’en-tête. Un tap est un trou dans le noyau, et le protocole qui le traverse est celui que vous y mettez. Ce que vous gagnez, c’est aucun surcoût de tramage, aucun échappement, aucun canal de contrôle, et une interface qui peut être bridgée.
Tap Sur UDP, Où Un Datagramme Est Une Trame
C’est la correspondance la plus propre de tout le billet, et elle découle du design. Un tap est un périphérique à datagrammes — un read() rend exactement une trame — et un socket UDP est un socket à datagrammes, un sendto() par datagramme. Une trame va donc dans un datagramme, arrive comme une trame, et il n’y a rien à délimiter, à tamponner ou à resynchroniser. Perdez un datagramme et vous avez perdu une trame, ce à quoi un paquet jeté ressemble de toute façon.
Avec socat, chaque bout est une commande :
# listening end
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up UDP-LISTEN:443
# connecting end
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up UDP:198.51.100.10:443
socat n’est pas installé sur la machine où ceci a été écrit, ces deux-là viennent donc de sa documentation plutôt que d’un lancement ici — vérifiez les noms d’adresse de votre propre build avant de leur faire confiance. Il vaut la peine d’être là : c’est le seul outil de ce billet qui fait tun, tap, TLS et DTLS en un seul processus.
Là où vous ne pouvez rien installer, tout le boulot tient en une cinquantaine de lignes sans dépendance au-delà de la bibliothèque standard. Le cœur, IFF_NO_PI désactive l’en-tête de quatre octets que le pilote préposerait sinon :
TUNSETIFF, IFF_TAP, IFF_NO_PI = 0x400454CA, 0x0002, 0x1000 # IFF_TUN is 0x0001
def open_tap(name):
fd = os.open("/dev/net/tun", os.O_RDWR)
fcntl.ioctl(fd, TUNSETIFF, struct.pack("16sH", name.encode(), IFF_TAP | IFF_NO_PI))
return fd
# ... learn the peer from the first datagram (UDP has no accept), then shovel:
while True:
ready, _, _ = select.select([tap, sock], [], [])
if tap in ready and peer:
sock.sendto(os.read(tap, MTU), peer) # one read = one frame = one datagram
if sock in ready:
frame, src = sock.recvfrom(MTU)
peer = src # last speaker wins — see the note below
if frame:
os.write(tap, frame)
Le fichier complet — l’analyse des arguments, l’IPv6 via getaddrinfo, le datagramme de longueur nulle qui dit à un écouteur UDP où répondre, et la compression qu’ajoute la section suivante — est dans le bundle :
peer = src à chaque datagramme est la partie à lire deux fois. Le dernier à avoir envoyé une trame devient le pair. Pratique derrière un NAT dont le port source ne cesse de bouger, et une porte ouverte sur un réseau non fiable, où quiconque peut envoyer un datagramme au port prend le tunnel. Bien à l’intérieur d’une session DTLS, où cela mène ; pas bien tout seul. La moitié socket du script a été exercée ici sur du loopback IPv6 : l’ouvreur arrive, l’écouteur apprend le pair, une trame traverse et la réponse revient ; la moitié tap a besoin de root, la seule partie que je n’ai pas pu lancer.
Sur TCP Il Faut Inventer Le Tramage Soi-Même
Échangez maintenant le transporteur contre TCP et voyez apparaître tout un problème que PPP avait discrètement résolu en 1994.
TCP est un flux d’octets sans frontières d’enregistrement et sans promesse sur la façon dont les octets sont groupés à l’arrivée : deux trames écrites à la suite peuvent arriver en une lecture, une trame en trois. Le récepteur tient un tas d’octets sans idée d’où finit une trame, et un tap n’accepte que des trames entières.
Sur TCP vous écrivez donc votre propre tramage. Deux octets de longueur big-endian devant chaque trame est la réponse habituelle :
# sending
sock.sendall(struct.pack("!H", len(frame)) + frame)
# receiving
def recv_exactly(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("carrier closed")
buf += chunk
return buf
length = struct.unpack("!H", recv_exactly(sock, 2))[0]
frame = recv_exactly(sock, length)
Cela marche, et c’est strictement pire que la version UDP. C’est de nouveau le problème TCP-sur-TCP ; cela ajoute deux octets et une boucle de réassemblage par trame ; et il n’y a aucun retour depuis une erreur, parce qu’un flux préfixé de longueur qui perd sa place lit chaque longueur suivante au milieu d’une trame. HDLC se resynchronise au 0x7E suivant ; ceci ne peut pas, sinon en réinventant HDLC en pire. Troisième argument pour UDP, et le plus fort : sur un transporteur à datagrammes il n’y a aucun problème de tramage, parce que le transporteur a déjà la seule fonctionnalité dont vous aviez besoin.
Tun Ou Tap : Couche 3, Sauf Si Vous Avez Vraiment Besoin De La Couche 2
Le même pilote vous donne deux périphériques, et les gens choisissent le mauvais sans cesse parce qu’un tutoriel disait tap.
| tun | tap | |
|---|---|---|
| Ce qui traverse | paquets IP | trames Ethernet |
| Surcoût par paquet | aucun | en-tête Ethernet de 14 octets |
| ARP, DHCP, diffusion | non | oui, tout, sur le tunnel |
| Protocoles non-IP | non | oui |
| Peut rejoindre un pont | non | oui |
| Équivaut à | un lien point à point, comme ppp0 | un câble réseau |
Tun est un lien routé — comme l’interface PPP de la première moitié : deux adresses, une route, des paquets qui entrent et sortent. Tap est un câble Ethernet virtuel, donc chaque diffusion, chaque requête ARP et chaque bruit multicast sur le segment traverse maintenant votre tunnel et brûle de la bande passante.
Changez une ligne dans le script pour basculer :
IFF_TUN = 0x0001 # instead of IFF_TAP
Prenez tap quand vous avez vraiment besoin de la couche 2. Il y a de vraies raisons : un protocole qui n’est pas IP, un heartbeat de cluster qui s’attend à voir des diffusions, un serveur DHCP qui doit atteindre des clients à travers le tunnel, ou bridger deux segments en un.
Ce dernier est le cas courant et celui avec lequel il faut être prudent :
ip link add br0 type bridge
ip link set tap0 master br0
ip link set eth1 master br0
ip link set br0 up
Le segment distant fait maintenant partie du vôtre — ses diffusions, son spanning tree, son barattage de tables MAC et, si quelqu’un a été négligent, son serveur DHCP. Bridger deux sites qui font tous deux 192.168.1.0/24 est un mauvais après-midi ; un qui reboucle sur le même segment par un autre chemin est une mauvaise semaine. Par défaut tun, allez vers tap quand vous pouvez nommer la chose de couche 2 dont vous avez besoin, et ne bridgez qu’après avoir vérifié ce qui diffuse des deux côtés.
Les Mêmes Enrobages, Un Processus Par Bout
La construction tap a le même trou que celle en PPP : le transporteur est en clair et l’écouteur accepte le premier arrivé. La solution est la même, et avec socat elle se réduit à une commande par bout, parce qu’il fait le périphérique et termine la session DTLS dans un seul processus :
# listening end
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up \
OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1
# connecting end
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up \
OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1
C’est la construction correcte la plus courte de ce billet. Une interface virtuelle, un transporteur à datagrammes, une authentification mutuelle par certificat et du chiffrement. Deux commandes. Ni daemon, ni négociation de protocole.
verify=1 n’est pas optionnel — le même point que --ssl-verify, et avec le comportement peer = src ci-dessus, une partie non identifiée est à un datagramme de posséder votre tunnel. Si vous êtes coincé avec le script Python, n’y boulonnez pas TLS : pointez-le sur un port loopback et mettez l’enrobage devant, ou prenez socat. Un enrobage TLS bricolé autour d’un tunnel bricolé, ce sont deux chances de rater la partie intéressante.
Étape Cinq : Compresser Le Flux Avec zstd
Il y a encore une chose qui vaut d’être mise dans le backend, et contrairement aux options de compression de pppd elle peut vraiment payer : compresser les trames avec zstd avant qu’elles n’entrent dans le transporteur.
Le mot important là est trames, au pluriel. Compressez le flux, pas chaque paquet isolément. C’est le plus grand effet mesuré de ce billet.
Le trafic réseau est répétitif d’une façon qui n’apparaît qu’à travers les paquets — les mêmes en-têtes, noms d’hôte et clés JSON, encore et encore. Un compresseur qui repart de zéro à chaque trame de 1 400 octets n’en voit rien ; un qui garde sa fenêtre à travers les trames en voit tout.
Sur un Fedora actuel cela n’a besoin de rien d’installé — Python 3.14 a apporté zstd dans la bibliothèque standard13 ; cette machine a 3.14.7 contre zstd 1.5.7 :
from compression.zstd import ZstdCompressor, ZstdDecompressor # Python 3.14+
_c, _d = ZstdCompressor(level=1), ZstdDecompressor()
# FLUSH_BLOCK: emit everything so far, keep the window for the next frame.
out = _c.compress(frame, mode=ZstdCompressor.FLUSH_BLOCK)
frame = _d.decompress(out)
Un compresseur par sens, vivant pour la durée du lien. Un FLUSH_BLOCK par trame, si bien qu’une trame sort dès qu’elle arrive sans rien tamponner, et le bout distant rend une trame par bloc.
Ce Que Vaut La Fenêtre, Mesuré
Chiffres de cette machine. Les mêmes trames de 1 400 octets, le même niveau 1, la seule différence étant si le compresseur garde son historique :
| Trafic dans le tunnel | flux, FLUSH_BLOCK | par paquet, FLUSH_FRAME | par paquet avec dictionnaire entraîné |
|---|---|---|---|
| Appels d’API répétitifs et télémétrie | 5,0 % | 14,8 % | 5,3 % |
| Lignes de log | 7,5 % | 24,2 % | 15,1 % |
| Texte brut et fichiers de configuration | 37,8 % | 51,3 % | 45,3 % |
| Octets aléatoires, en lieu et place de TLS | 100 %+ | 100,7 % | — |
Le débit au niveau 1 a fait 205 Mo/s sur du texte et plus de 1 Go/s sur le trafic répétitif — plus c’est compressible, plus c’est rapide, parce qu’il y a moins à encoder.
Le trafic de log tombe à 7,5 % de sa taille d’origine, contre 24,2 % par paquet — trois fois, mêmes données, même niveau, à partir d’un drapeau. Et le niveau 1 est le niveau : le niveau 3 a rapporté environ un pour cent, le niveau 9 un autre tout en faisant tomber le débit de 211 Mo/s à 61.
Le Hic, Et C’est Le Même Hic Que Tout Le Reste Ici
Une fenêtre partagée signifie que chaque trame dépend des précédentes : perdez-en une et l’historique du décompresseur ne correspond plus, et rien après ne décode. Donc la compression de flux a besoin d’un transporteur qui livre tout dans l’ordre — TCP, ou TLS sur TCP, pas UDP ni DTLS. C’est le seul argument honnête pour le transporteur TCP de tout ce billet. Si ce qui traverse votre tunnel est vraiment compressible — syslog, réplication de base de données en clair, télémétrie, une API bavarde — une session TLS à flux compressé déplace un tiers des octets qu’un transporteur à datagrammes déplacerait, ce qui sur un chemin correct peut battre la pénalité de retransmission. Mesurez-le sur votre propre trafic.
Sur UDP, Où Un Dictionnaire Fait Le Travail De La Fenêtre
Là où le transporteur est UDP ou DTLS, et ce devrait être par défaut, vous ne pouvez pas garder de fenêtre : chaque datagramme tient seul, ce qui veut dire FLUSH_FRAME et la colonne plus faible ci-dessus. Un dictionnaire entraîné donne au compresseur le contexte inter-paquets qu’une fenêtre aurait, sans aucune dépendance entre datagrammes :
# capture a few thousand real frames off the link first, one per file
zstd --train frames/* -o tunnel.dict --maxdict=110000
```[^zstddict]
```python
from compression.zstd import ZstdCompressor, ZstdDecompressor, ZstdDict
d = ZstdDict(open("tunnel.dict", "rb").read())
_c = ZstdCompressor(level=1, zstd_dict=d) # load it ONCE, not per frame
out = _c.compress(frame, mode=ZstdCompressor.FLUSH_FRAME)
Sur le trafic répétitif cela a fait passer la compression par paquet de 14,8 % à 5,3 %, récupérant 96 % de l’écart avec la compression de flux complète tout en restant tolérant à la perte ; sur les lignes de log 55 % de l’écart, sur le texte général 44 %.
Trois règles vont avec. Les deux bouts doivent charger le même dictionnaire ou rien ne décode — vérifié ici, une trame compressée avec dictionnaire lève ZstdError sans lui. Entraînez sur une capture du vrai trafic, car un dictionnaire est un a priori et un mauvais vous coûte : le dictionnaire de texte a rendu un texte différent légèrement pire. Et chargez-le une fois dans un compresseur à longue durée de vie ; le passer à chaque appel a mesuré 5 Mo/s, ce qui n’est pas une faute de frappe.
L’Octet D’En-Tête Et La Rampe D’Une Seconde
Deux petites choses qui empêchent que ce soit fragile.
Chaque datagramme porte un octet d’en-tête, et il nomme le mode au lieu de dire seulement « compressé » : 0x00 la trame telle quelle, 0x01 une trame zstd autonome, 0x02 un bloc d’un flux continu. Un récepteur peut alors décoder ce que le bout distant a choisi sans être configuré pour correspondre, ce qui vaut l’octet à soi seul.
En mode trame l’émetteur n’envoie la forme compressée que quand elle est vraiment plus petite, car la compression n’est pas toujours gagnante : les octets aléatoires sont sortis à 100,7 %, et un ACK TCP de 64 octets se compresse à 73 — l’en-tête zstd de dix octets sur un paquet où il n’y a rien à presser. Le nombre de paquets sur une vraie liaison est dominé par les petits paquets, donc sans cette vérification vous gonfleriez la majorité de votre trafic pour rétrécir la minorité. En mode flux il envoie toujours la forme compressée, car en sauter une désaccorderait les deux fenêtres.
Et le lien démarre en brut : pendant la première seconde chaque trame sort non compressée, quoi que disent les réglages, et chaque sens monte en rampe seul :
RAMP = 1.0 # seconds of raw frames before compression starts
def pack(self, frame):
if self.c is None or time.monotonic() - self.started < RAMP:
return RAW + frame
out = self.c.compress(frame, mode=ZstdCompressor.FLUSH_BLOCK)
return ZSTD + out if len(out) < len(frame) else RAW + frame
C’est l’idée d’étapes du début du billet appliquée à un seul lien. Le tunnel monte sur le chemin le plus simple qu’il a, prouve qu’il peut porter une trame, et ne commence à faire quoi que ce soit de malin qu’ensuite. Quand il casse, vous savez dans quelle seconde il a cassé.
Étape Six : Compressé Et Chiffré, Et Ce Que L’Ordre Coûte
L’ordre compte, et un seul marche : compresser, puis chiffrer. Le texte chiffré ne se compresse pas, comme le montre la ligne à 100,7 % — c’est aussi pourquoi TLS 1.3 a retiré sa propre compression, laissant votre couche comme le seul endroit où le faire. Cet ordre a un problème connu, le même que j’ai soulevé contre deflate : compresser avant de chiffrer fait fuiter le texte clair par la longueur du texte chiffré, et là où un attaquant peut injecter des données choisies à côté d’un secret et observer les tailles, cette fuite a été plus d’une fois une attaque qui marche — CRIME et BREACH contre TLS14, et VORACLE contre exactement cette forme15.
Ce n’est pas une raison de ne jamais compresser. C’est une raison de savoir dans quel cas vous êtes :
- Un lien qui porte votre propre trafic entre deux machines qui vous appartiennent — réplication, sauvegardes, logs, télémétrie — n’a pas de texte clair choisi par un attaquant voyageant avec des secrets. Compressez-le, et compressez le flux.
- Un lien qui porte de la navigation utilisateur quelconque, où le contenu web de quelqu’un d’autre et vos identifiants vont ensemble, est le cas au sujet duquel VORACLE a été écrit. Laissez-le désactivé.
La raison pour laquelle je suis à l’aise de mettre ceci dans la construction tap et pas dans celle en PPP n’est pas un principe : ici vous choisissez délibérément un algorithme moderne, pour du trafic que vous avez regardé, tandis que le deflate de pppd compresse tout par défaut avec un de 1996, que le cas s’y prête ou non.
Ce Que La Construction Tap Gagne Et Ce Qu’Elle Abandonne
Mettez les deux moitiés côte à côte, car elles ne se concurrencent pas. Ce sont des compromis différents.
| pppd sur un socket | tap ou tun sur un socket | |
|---|---|---|
| Tramage | intégré (HDLC, se resynchronise après un dégât) | aucun sur UDP car aucun n’est nécessaire ; inventez-le sur TCP |
| Configuration d’adresse | négociée par IPCP et IPV6CP | configurée à la main aux deux bouts |
| Vivacité | écho LCP, intégré | aucune ; vous l’ajoutez ou le lien meurt en silence |
| Authentification | PAP ou CHAP disponibles, tous deux faibles | aucune du tout |
| Couche | 3 seulement | 3 avec tun, 2 avec tap |
| Bridging | non | oui, avec tap |
| Surcoût | fanion, en-tête et FCS par trame, plus l’échappement | rien, ou 14 octets avec tap |
| Compression | deflate et BSD, activés par défaut, de 1996 | aucune, ou zstd par trame que vous ajoutez et contrôlez |
| Root nécessaire | oui, tout du long | pour créer le périphérique ; pas pour l’utiliser |
| Lignes de pièces mobiles | un daemon, trente ans d’âge | un descripteur de fichier |
PPP vous donne un lien négocié qui se surveille lui-même et le facture d’un protocole. Un tap vous donne un trou brut pour rien, et vous fournissez les pièces manquantes ou vous vous en passez. Pour un tunnel laissé tourner, la vérification de vivacité manquante est celle qui mord : pppd remarque un transporteur mort en trente secondes et le reconstruit, la construction tap ne remarque rien parce que rien dedans ne surveillait. Ajoutez un keepalive, faites-le tourner sous quelque chose qui le relance, ou prenez la construction qui en a déjà un.
Quand C’Est Le Bon Outil, Et Quand Ce Ne L’Est Pas
Ce n’est jamais vraiment le bon outil, et je ne vais pas prétendre le contraire. Tout ci-dessus marche, et rien de tout cela n’est ce que vous devriez faire tourner en production. La version honnête d’un tutoriel comprend la partie où vous posez l’outil, et c’est cette partie.
Ce à quoi il est vraiment bon, c’est à vous montrer comment une chose marche. Un VPN démonté en pièces et, dans la section suivante, comment se comporte vraiment l’egress dès que quelqu’un est dans votre réseau avec root. Ce sont les raisons d’avoir lu ceci. Les cas étroits ci-dessous sont réels, mais ils ne sont pas pourquoi le billet existe.
Prenez la construction PPP quand :
- Le transporteur n’est pas IP du tout — une console série, un gadget USB, une liaison radio, un tube nommé, un canal SSH.
pppdse moque de ce sur quoi voyagent les octets, et un tap ne peut pas vous aider ici. - Vous sauvez quelque chose : une machine avec une console série, sans réseau, et un travail à finir ce soir. PPP sur cette console est un lien routé, installé aux deux bouts sans rien à copier.
- Vous voulez que le lien se débrouille seul. L’écho LCP, la négociation d’adresses et le redémarrage sont gratuits ; les écrire soi-même est la façon dont la construction tap devient un petit produit peu fiable.
Prenez la construction tap quand :
- Vous avez besoin de la couche 2 — un protocole non-IP, un heartbeat de cluster qui veut des diffusions, du DHCP à travers le tunnel, ou deux segments qui doivent n’en faire qu’un.
- Vous voulez le moins de pièces mobiles : sur UDP avec DTLS ce sont deux commandes, ni daemon, ni négociation, rien à échapper.
- Root est rare. Créez le périphérique une fois avec
user, et le processus qui déplace les trames n’a plus jamais besoin de privilèges.
Les deux sont le bon outil quand vous apprenez. Chaque couche est visible et remplaçable séparément, et il n’y a pas de meilleure façon de comprendre ce que fait un produit VPN que d’en construire un à partir des pièces et de regarder chacune monter.
Aucun n’est le bon outil quand vous voulez un VPN. Pour cela, prenez WireGuard. Il est dans le noyau, une fraction du code, il fait la crypto correctement sans rien à négocier et rien à rater, et c’est un protocole à datagrammes par conception. ssh -w vous donne un tun sur une session SSH existante en une commande, et OpenVPN est la version mûre et auditée de la forme DTLS-sur-tap ci-dessus. Tous trois sont meilleurs à cela que tout ce qui est construit ici.
Construisez ceci parce que vous voulez savoir ce qu’il y a dans la boîte que vous achetez. Pas parce que c’était malin.
Ce Que Cela Montre Vraiment : L’Egress Du Côté De L’Attaquant
C’est la raison de lire un billet de construction qu’on vient de vous dire de ne pas utiliser. Retournez-le et regardez depuis l’intérieur de votre propre réseau, en tant que celui qui vient d’y atterrir avec root. Chaque vraie intrusion finit là, par une clé volée, une évasion de conteneur, un service non corrigé, un initié. La question qui décide alors de la gravité de la journée n’est pas « que peuvent-ils exécuter », car ils peuvent tout exécuter. C’est « qu’est-ce qui peut sortir, et l’aviez-vous décidé avant leur arrivée ? »
Si le sortant est ouvert par défaut, la réponse est : tout, et vous ne pouvez plus grand-chose. Rien ici n’était exotique. pppd, ncat, socat et ip sont des paquets de distribution signés déjà sur la machine ; le shim tap fait cinquante lignes de bibliothèque standard. Un port sortant autorisé — et c’est le 443, celui que chaque réseau ouvre par défaut — et il y a un lien routé de votre réseau vers celui de quelqu’un d’autre, chiffré, authentifié, survivant aux redémarrages, portant IPv4 et IPv6, et à votre frontière indiscernable de n’importe quelle session HTTPS que vos utilisateurs font dix mille fois par jour. Faites-le tap et bridgez-le et ce qui a quitté le bâtiment n’est pas une route. C’est le segment.
Il n’y a rien qu’un scanner puisse attraper : aucune signature de malware parce qu’il n’y a pas de malware, aucun protocole étrange parce que c’est une poignée de main TLS normale vers le 443, aucun binaire inhabituel parce que votre propre gestionnaire de paquets a installé chacun. Le proxy journalise une connexion et un compteur d’octets, et les deux ont l’air de travail.
Et la destination n’est même pas fixe. Un socket n’a pas à se terminer là où les paquets aboutissent, parce que le transporteur peut être dirigé par un proxy — un relais qui n’est rien de plus que deux connexions et un tube, ce que la section suivante construit en une ligne de shell. ncat prend --proxy avec --proxy-type http, socks4 ou socks5, si bien que la session TLS que voit votre frontière se termine là où l’attaquant lui a dit de se connecter à travers — un hôte de rebond interne, un point de terminaison SaaS autorisé qui relaie le CONNECT, un relais cloud — et le tunnel chevauche de là vers un endroit que vous ne voyez jamais. HTTP CONNECT et SOCKS font tous deux ça par conception, car c’est à ça que sert un proxy. Une entrée de liste blanche pour une destination à laquelle vous faites confiance n’est donc jamais que de la confiance dans la destination et dans tout ce vers quoi elle relaiera, que vous ne contrôlez pas et ne pouvez pas énumérer. Le point de terminaison dans votre journal de pare-feu est le proxy. Ce n’a jamais été le bout distant.
Alors la vérité inconfortable : dès que quelqu’un est dedans avec root et que le sortant est permissif, le tunnel n’est pas ce que vous pouvez empêcher. Les pièces sont installées, la sortie est ouverte, et elle ne mène même pas là où elle semble mener. Votre unique chance de rendre cela difficile était avant l’arrivée de l’attaquant, à la frontière, en décidant de ce qui peut sortir.
C’est le default-deny en egress, et c’est toute la leçon. Sortant bloqué par défaut ; une liste blanche courte et nommée où une personne a justifié chaque destination et chaque port ; tout le reste refusé, journalisé et alerté. Non parce que cela arrête net un attaquant déterminé — une destination autorisée est un tunnel autorisé — mais parce que l’alternative est de n’avoir aucune décision à faire respecter. Une politique d’egress écrite comme une liste de ports autorisés est une politique sur des numéros de port. Ce n’a jamais été une politique sur ce qui sort, et dès que quelqu’un a root, les numéros de port sont tout ce qu’elle protège.
C’est le même constat que le billet sur ping, qui construit le tunnel à partir de l’écho ICMP, et le billet sur les protocol helpers, où votre pare-feu ouvre les trous lui-même. Trois voies d’entrée, une conclusion : le contrôle que vous croyiez avoir portait sur des protocoles, et aucun de ces protocoles n’est ce qu’il dit être. La frontière, décidée à l’avance et en default-deny, est le seul contrôle qui ait jamais été réel.
Un Proxy, C’Est Deux Connexions Et Un Tube
Il vaut la peine de voir à quel point un relais est peu de chose, car cela explique pourquoi vous ne pouvez pas raisonner sur le bout distant à partir du proche. Un proxy n’est pas un logiciel spécial. C’est une connexion jointe à une autre par un tube. La forme la plus ancienne utilise un tube nommé, une FIFO, pour porter le sens retour : un ncat écoute, un autre se connecte plus loin, et la FIFO câble le chemin de réponse entre eux.
mkfifo backpipe
ncat -l 7000 0<backpipe | ncat farend.example.net 7100 1>backpipe
Lisez-le comme de la plomberie : la sortie de l’écouteur file dans le second ncat et jusqu’au bout distant, et les réponses reviennent par la FIFO jusqu’au client. Deux sockets, un tube, les deux sens, et la connexion du client se termine ici, au relais, tandis que les paquets continuent vers farend et reviennent. J’ai fait exactement cela sur du loopback avec un troisième ncat renvoyant l’écho au bout distant, et une ligne envoyée est revenue après avoir fait tout le trajet.
Ncat fait la même chose en un seul processus, en exec-utant la connexion suivante pour chaque client qui arrive :
ncat -l 7000 --keep-open --sh-exec 'ncat farend.example.net 7100'
Même forme, moins de pièces : le socket de l’écouteur est joint à celui du ncat exec-uté par le tube que le shell place entre eux. Chaînez-en trois et le tunnel traverse trois réseaux, se terminant et repartant à chacun, chaque pare-feu de saut journalisant une connexion locale propre vers le suivant et rien au-delà.
C’est tout le tour de passe-passe, et c’est pourquoi le journal de frontière n’est pas la preuve d’une destination. Chaque relais est le bout distant pour autant que la machine d’avant puisse le dire, et le vrai autre bout est à autant de tubes que personne ne regardait.
Mettez maintenant ces relais sur des machines qui ne sont pas celles de l’attaquant.
Chaque propriétaire le long de cette chaîne ne voit qu’une connexion du saut d’avant vers le saut d’après : ni origine, ni destination, juste du milieu. Ce n’est pas une idée nouvelle que je tends à quiconque. C’est ainsi que les chaînes de pivot et les réseaux C2 fonctionnent depuis vingt ans, et pourquoi le trafic sortant d’un hôte compromis mène si souvent vers une autre victime plutôt que vers l’attaquant. Vos journaux montrent que vous avez parlé à une machine dans un centre de données quelque part. À qui, et vers quoi elle relaie, n’y a jamais figuré.
Le poids défensif tient en une ligne : vous ne pouvez ni attribuer ni faire confiance à une destination que vous n’avez pas restreinte à l’avance. Le temps que le trafic sorte, l’adresse vers laquelle il sort ne vous dit presque rien, car c’est un relais sur la machine de quelqu’un d’autre et le vrai point de terminaison est blanchi derrière.
Le Lien N’A Jamais Été Le Produit
Ce qui me frappe, après avoir démonté ceci, c’est combien peu en est neuf et combien en est vendu.
Le RFC 1661 date de 1994. pppd est dans chaque distribution Linux depuis trente ans, les modules noyau sont huit fichiers dans un répertoire, et tout ce qui fait d’un VPN un VPN — une interface virtuelle, un lien négocié, un transporteur chiffré, une route — ce sont quatre programmes et un certificat. Rien de tout cela n’est difficile ni secret. Ils ont documenté chaque octet et l’ont donné, et une industrie a poussé entre vous et lui, vendant les mêmes quatre pièces dans une boîte avec une licence par siège et un contrat de support qui expire. Les pièces ne se sont pas améliorées. Elles ont été emballées.
Ce n’est pas un argument pour faire tourner ceci en production. Je viens de vous dire de ne pas le faire. C’est un argument pour savoir ce qu’il y a dans la boîte que vous achetez, car le jour où le fournisseur change la licence, se fait racheter, ou met fin à votre modèle, la différence entre un mauvais trimestre et une mauvaise année est de savoir si quelqu’un chez vous sait de quoi la chose était faite.
Prenez une soirée et construisez le tunnel à partir des pièces. Regardez LCP négocier, cassez le lien et regardez-le revenir, retirez le certificat et voyez ce qui cesse de marcher. Puis relisez la fiche technique de votre fournisseur VPN, et voyez combien vous en reconnaissez.
La même soirée achète l’autre moitié. Si un lien routé hors de votre réseau, ce sont quatre programmes installés et un certificat, celui qui vient d’obtenir root sur une de vos machines n’est pas retenu par la difficulté de construire le tunnel. Ce n’est pas difficile. Il n’est retenu que par ce que vous avez décidé, avant son arrivée, d’autoriser à sortir. Construisez-le une fois et vous cessez de penser l’egress comme quelque chose qu’un produit fait respecter, pour commencer à le penser comme une décision que vous avez prise ou non.
Pas grand-chose là-dedans n’est de la magie. La plupart, c’est 1994 avec un coup de peinture, et 1994 n’avait rien de faux. Ça marchait, c’était documenté, et ça tourne encore.
RFC 1661 — The Point-to-Point Protocol (PPP), 1994. Définit le lien, LCP et la famille des protocoles de contrôle réseau qui reposent dessus. ↩︎
RFC 2637 — Point-to-Point Tunneling Protocol (PPTP), qui transporte PPP dans GRE. ↩︎
RFC 3931 — Layer Two Tunneling Protocol version 3, le descendant sur la voie des standards du protocole qui transporte PPP dans UDP. ↩︎
pppd(8) — la page de manuel du daemon PPP. Source pour
pty,notty,local,passive,noipdefault,proxyarp,record,receive-all, les options d’écho LCP et le défaut asyncmap : »If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters. » Les citations ici ont été lues dansman pppdsur ppp 2.5.1. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎RFC 1662 — PPP in HDLC-like Framing. La séquence fanion, la règle de bourrage d’octets et l’Async-Control-Character-Map : »Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e). » ↩︎ ↩︎
Olaf Titz, Why TCP Over TCP Is A Bad Idea — l’explication de référence de l’empilement des retransmissions, ouvrant sur cette construction précise. L’URL d’origine ne sert plus l’article ; ceci est une capture de l’Internet Archive. ↩︎
RFC 5072 — IP Version 6 over PPP. IPV6CP et les identifiants d’interface de 64 bits, indépendants d’IPCP. ↩︎
RFC 1994 — PPP Challenge Handshake Authentication Protocol (CHAP). Prouve l’identité du pair ; ne chiffre rien. ↩︎
RFC 3079 — Deriving Keys for use with Microsoft Point-to-Point Encryption (MPPE). La construction RC4 tirée de l’échange MS-CHAP, et la raison pour laquelle PPTP n’est pas une option vivante. ↩︎
Ncat Users’ Guide — connecting through a proxy —
--proxyet--proxy-typepour HTTP CONNECT et SOCKS 4/5, si bien que le transporteur TLS se termine au proxy, pas au bout distant du tunnel. ↩︎Ncat Users’ Guide — les options
--ssl,--ssl-verifyet--ssl-trustfile, et les modes écoute et UDP. Version testée ici : Ncat 7.92. ↩︎Universal TUN/TAP device driver — la propre documentation du noyau pour
/dev/net/tun,TUNSETIFFet la différence entre tun et tap. ↩︎PEP 784 — Adding Zstandard to the standard library, la raison pour laquelle
compression.zstdn’a besoin d’aucun paquet sous Python 3.14. Mesuré ici contre Python 3.14.7 et zstd 1.5.7. ↩︎RFC 7457 — Summarizing Known Attacks on TLS and DTLS, qui couvre CRIME et la forme générale d’une fuite de longueur due à la compression avant chiffrement. ↩︎
OpenVPN — the VORACLE attack — la même fuite contre un VPN qui compresse avant de chiffrer, et la raison pour laquelle OpenVPN déconseille désormais la compression. ↩︎