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.

Chaque tunnel est une moitié lien, un transporteur et de la cryptographieLes mêmes trois pièces à chaque fois. Seuls le transporteur et la crypto changent.TUNNELMOITIÉ LIENTRANSPORTEURCRYPTOPPP commuté, 1994PPPmodem et ligne téléphoniqueaucunPPPoEPPPtrames EthernetaucunPPTPPPPGREMPPE — cassé, ne pasL2TP sur IPsecPPPUDPIPsecCe billet, construction unPPP sur un ptysocket UDP, via netcatDTLSCe billet, construction deuxtun ou tapsocket UDP, via socatDTLSWireGuardinterface du noyauUDPNoise, et rien à négocier
Chaque tunnel de cette liste a la même forme. Les différences sont dans quel transporteur vont les octets, et si quoi que ce soit les chiffre. PPP fait la moitié « lien » depuis 1994, c’est pourquoi il apparaît sous trois d’entre eux sans avoir été refait pour aucun. Un tun ou un tap fait la même moitié sans aucun protocole, et c’est la seconde construction de ce billet.

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.

Six étapes, chacune ajoutant une chose que vous pouvez tester seuleConstruisez dans cet ordre et vous pourrez toujours redescendre1En clair, sur TCPpppd avec un pty, ou un tap, et un simple écouteur netcat.Marche sur un banc. Fond sur un vrai chemin — deux temporisateurs de retransmission qui se battent.2En clair, sur UDPMême lien, transporteur à datagrammes. Un datagramme perdu est un paquet perdu, rien de plus.C'est le socle. Tout ce qui est au-dessus est facultatif ; ceci ne l'est pas.3TLS, sur TCPncat, stunnel ou openssl enveloppant le transporteur. Vérifiez le certificat aux deux bouts,sinon vous avez du chiffrement sans identité et aucun contrôle d'accès.4DTLS, sur UDPsocat ou openssl, et la forme à viser : chiffrée, authentifiée, toujours en datagrammes.Un paquet perdu reste un paquet perdu au lieu de deux piles qui se disputent.5Compresséezstd entre l'interface et le transporteur. Gardez la fenêtre d'une trame à l'autre là où letransporteur livre dans l'ordre ; une trame autonome par datagramme, plus un dictionnaire, sinon.6Les deux, dans cet ordreCompresser, puis chiffrer. Jamais l'inverse — le texte chiffré ne se compresse pas.Et sachez le prix : compresser avant de chiffrer fuit la longueur du clair. Acceptable sur votre propre trafic.
Six étapes, chacune ajoutant une chose à celle du dessous. Brut sur TCP est là où tout le monde commence, et le seul barreau qui soit une impasse : il tourne sur l’établi et fond sur une vraie liaison. Brut sur UDP est le socle sur lequel tout le reste repose. Puis le chiffrement, puis la compression, puis les deux ensemble. Chaque étape est un lien que vous pouvez monter, pinguer et laisser tourner, de sorte que, quand le haut de l’échelle fait des siennes, vous pouvez la redescendre barreau par barreau.

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 script is 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.

pppd, un pseudo-terminal, netcat et une socketUn paquet, de ppp0 à ppp0, et toutes les mains par lesquelles il passeHÔTE AHÔTE Bppp0interface du noyaupppdtramage HDLCpaire de ptyesclave pour pppdmaître pour l'enfantncatstdin et stdoutpaire de ptymaître pour l'enfantesclave pour pppdncatstdin et stdoutpppdtramage HDLCppp0interface du noyaula socket — TCP ou UDPLes deux boîtes du milieu sont la seule part que vous choisissez. Tout le reste est identique que le transporteur soit une ligne téléphonique,une console série, un flux TCP, un flot de datagrammes UDP ou une session TLS — d'où le fait que changer de transporteur coûte un mot.Un pseudo-terminal n'a pas de broche de détection de porteuse, donc pppd attend une porteuse qui n'arrive jamais. L'option `local` est ce qui l'arrête.
pppd parle du HDLC asynchrone dans le côté esclave d’un pseudo-terminal qu’il s’est alloué. La commande nommée par 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 portUDPReste debout après la déconnexion d’un pair
Ncat (Nmap)ncat -l 443-u-k / --keep-open
OpenBSD netcatnc -l 443-u-k
Traditional netcatnc -l -p 443-unon
GNU netcatnc -l -p 443-unon

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.

Chaque mot de la ligne pppd, et ce qu'il faitLa ligne de commande de la construction UDP, mot par motnodetachau premier plan, pour voir sa sortie et le mettre sous un superviseurnoauthpas d'authentification du pair — c'est le trou ; le tunnel n'a aucun contrôle d'accèslocalignore les lignes de contrôle modem qu'un pty n'a pas. Sans elle, rien ne montepassiveattend un paquet LCP valide au lieu de sortir — comportement de socket serveurnodefaultroutene s'empare pas de la route par défaut quand IPCP finit. Posez la route voulue vous-mêmenoipdefaultne propose pas l'adresse propre de la machine comme bout local192.0.2.1:192.0.2.2local:distant, fixé — pppd rejette toute autre réponse pendant IPCPlcp-echo-interval / -failurela vérification de vivacité — sa propre section plus basmtu / mru 1400laisse de la place pour ce que le transporteur enroule autour de chaque tramepty '...'la commande dont stdin et stdout deviennent le lien — ici, une socket netcatLe bout qui connecte est la même ligne sans `passive` : il initie au lieu d'attendre.
Chaque option de la ligne et ce qu’elle fait. Celle à surveiller est 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.

LCP, puis l'authentification, puis un protocole de contrôle par famille d'adressesTrois étapes, et chacune échoue pour sa propre raison1LCP — le lien lui-mêmeUnité de réception maximale, la carte de contrôle,un nombre magique contre une ligne rebouclée,et si l'un des bouts veut que l'autre s'authentifie.Un échec ici et le transporteur ne fait pas passerles octets proprement dans les deux sens. Regardezla socket, pas les options PPP.2Authentification — facultativePAP envoie un mot de passe en clair. CHAP faitun défi et une réponse MD5. Sautée ici.Les deux prouvent qui est le pair. Aucune ne chiffreun seul octet de ce qui suit.3aIPCPL'adresse IPv4à chaque bout.3bIPV6CPLes identifiantsd'interface 64 bits.Ces deux-là sont indépendants. IPv6 peut monter pendant qu'IPv4 discute encore,et un échec de l'un n'abat pas l'autre. L'interface commence à porter le traficd'une famille dès que le protocole de contrôle de cette famille a fini.
LCP règle le lien lui-même — la taille possible d’une trame, quels caractères de contrôle doivent être échappés, et un nombre magique qui détecte une ligne rebouclée. L’authentification est optionnelle et sautée ici. Puis un protocole de contrôle par couche réseau : IPCP pour IPv4, IPV6CP pour IPv6. Ils sont indépendants, si bien qu’un lien peut porter IPv6 pendant qu’IPv4 discute encore, et un échec dans l’un n’entraîne pas l’autre.

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 trame, l'octet de fanion, et ce que coûte l'échappementUne trame, et les deux octets qui ne peuvent jamais y figurer0x7Efanion0xFFadresse0x03contrôleprotocole1 ou 2 octetscharge utile — votre paquet IPjusqu'à l'unité de réception maximale convenueFCSCRC 16 bits0x7EfanionL'ÉCHAPPEMENT, LA SEULE RÈGLE QUI EXISTETout octet qu'on pourrait prendre pour une frontière est remplacé par 0x7D suivi de l'octet d'origine en OU exclusif avec 0x20.charge 0x7E transmis en 0x7D 0x5Echarge 0x7D transmis en 0x7D 0x5DCes deux-là sont obligatoires. Tout le reste est décidé par la carte des caractères de contrôle — 32 bits, un par caractère.Carte à zéro — le défaut de pppd, et le bon choix sur une socketDeux octets échappés sur 256. Un surcoût que vous ne mesurerez jamais. Une socket est un chemin 8 bits propre et n'a besoin de rien d'autre.Les 32 marqués — le réglage modem prudentSur des charges compressées ou chiffrées, environ un octet sur huit est sous 0x20 et double donc. Près de 12 % du lien, pour rien.
La trame, c’est fanion, adresse, contrôle, protocole, charge utile, séquence de contrôle de trame, fanion. Tout octet à l’intérieur qui pourrait être pris pour une frontière est remplacé par 0x7D suivi de l’octet d’origine XOR 0x20. L’ACCM décide combien d’autres octets subissent le même traitement : zéro sur un chemin 8 bits propre, trente-deux si l’un des bouts demande le réglage prudent par défaut qui suppose un modem qui mange les caractères de contrôle.

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

Un paquet perdu, deux transporteurs, deux issues très différentesUn paquet disparaît. Ce que fait ensuite chaque transporteur.TRANSPORTEUR TCP — le tunnel masque la perte1Le transporteur perd un segment.2Il le retransmet. Les octets arrivent en retard,pas manquants — et c'est tout le problème.3Le TCP tunnelé ne voit pas le transporteur. Il litle retard comme de la congestion, double sontemporisateur et retransmet des données déjà en vol.4Le transporteur doit l'original et un doublon,sur une liaison qui vient de prouver qu'elle perd.La file grandit plus vite que les couches ne la vident.Le débit s'effondre bien avant la liaison.TRANSPORTEUR UDP — la perte atteint la couche à qui elle appartient1Le transporteur jette le paquet et ne dit rien.2La trame PPP à l'intérieur rate sa somme de contrôleet est jetée. L'octet de fanion suivant resynchronise.3Le TCP tunnelé voit une vraie perte, parce que pourune fois on lui a dit la vérité sur le chemin.4Il divise sa fenêtre par deux et retransmet une fois.Le contrôle de congestion fait exactement ce pourquoi il est conçu. Un paquet perdu coûte un paquet.
Le TCP transporteur garantit la livraison, donc un segment perdu est retransmis et les octets arrivent tard plutôt que pas du tout. Le TCP tunnelé à l’intérieur ne voit que le retard, décide que le réseau est congestionné, se replie et retransmet les mêmes données — que le transporteur doit maintenant livrer aussi. Le temporisateur de chaque couche essaie de réparer un problème que l’autre couche possède déjà, et la file grandit plus vite que l’une ou l’autre ne peut la vider. Un transporteur UDP jette le paquet, le TCP intérieur voit une vraie perte, et son contrôle de congestion fait le travail pour lequel il a été conçu.

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.

Un datagramme perdu coûte une trame, et l'octet de fanion suivant récupère le lienLes frontières ne coïncident pas, et cela n'a pas d'importanceTRAMES PPP TELLES QUE pppd LES A ÉCRITES7Etrame A + FCS7Etrame B + FCS7Etrame C + FCS7Etrame D + FCSCE QUE LE TRANSPORTEUR A VRAIMENT ENVOYÉ — netcat lit un tampon, pas une tramedatagramme 1datagramme 2 — perdudatagramme 3datagramme 4La trame B perd son milieu, donc sa somme de contrôle échoue et elle est jetée. La trame C perd ses premiers octets et suit le même chemin.Deux trames perdues, c'est deux paquets IP perdus. La couche du dessus les retransmet, et elle le fait en sachant que le chemin a perdu quelque chose —ce qui est précisément le signal qu'un transporteur TCP aurait masqué en les livrant en retard.
Netcat lit ce qui est dans le tampon et l’écrit dans un datagramme, si bien que les frontières de trame et les frontières de datagramme n’ont rien à voir. Perdez un datagramme et le récepteur voit une trame à qui il manque des octets : la séquence de contrôle de trame échoue et la trame est jetée, exactement comme sur une ligne série bruitée. Le 0x7E suivant resynchronise le flux. Une trame jetée coûte un paquet, et la couche au-dessus le retransmet — ce qui est le signal de perte dont le TCP intérieur avait besoin et qu’il n’a jamais eu sur un transporteur TCP.

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
Détecter un transporteur mort en trente secondes, et le reconstruireUn transporteur peut mourir sans se fermer. Voici comment PPP s'en aperçoit et s'en remet.Le transporteur meurt en silencebout distant éteint · entrée NAT partie ·l'équipement intermédiaire ne relaie plusRien ne le signalepas de FIN, pas de RST · ip link dittoujours ppp0 UP · UDP n'a pas d'étatLe détecteurlcp-echo-interval 10 lcp-echo-failure 3écho toutes les 10 s, coupé après 3 ratés — 30 sLa reconstructionpersist maxfail 0 holdoff 5relancer, ne jamais renoncer, 5 s entre essaisUn seul superviseurunité systemd, Restart=always,pppd relance la commande pty —netcat et socket tout neufs avecMettez l'écho aux deux bouts. Un écho ne prouve que le chemin par lequel la réponse est revenue.Mettez `ip route` dans /etc/ppp/ip-up.d/ et les routes IPv6 dans /etc/ppp/ipv6-up.d/, pour que le routage soit réappliqué à chaque retour du lien —et non posé une fois à la main puis perdu à la première reconnexion.
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.

Nu, TLS sur TCP, ou DTLS sur UDPMême moitié lien aux deux bouts. Seul le milieu change.pppd ou tap0netcat nu, UDPncat --udp --listen 6000pppd ou tap0En clair sur le fil, et l'écouteur prend qui atteint le port le premier. Pas de confidentialité, pas de contrôle d'accès.pppd ou tap0TLS sur TCP — ncat, stunnel ou opensslncat --ssl --ssl-verify --ssl-trustfile tunnel.crt host 6000pppd ou tap0Protégé, et le certificat dit qui est le bout distant. Mais le transporteur est de nouveau TCP — et c'est la seule forme qui peut compresser en flux.pppd ou tap0DTLS sur UDP — socat ou opensslsocat - OPENSSL-DTLS-CLIENT:host:6000,cafile=tunnel.crt,verify=1pppd ou tap0Chiffré, authentifié, et toujours des datagrammes. Un paquet perdu reste perdu au lieu de deux piles qui se disputent. Visez ici.
Le même pppd aux deux bouts tout du long — seule la commande pty change. Le netcat nu vous donne un lien que rien ne protège. TLS sur TCP protège les octets et réintroduit le problème du transporteur TCP. DTLS sur UDP est la forme à viser : chiffré, authentifié, et toujours un transporteur à datagrammes, si bien qu’un paquet perdu reste un paquet perdu au lieu de devenir un combat de retransmission entre deux piles.

É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.

La pile de surcoûts, et la MTU intérieure qui restePartez de la MTU du chemin et servez-vous de ce qui resteen-tête IP20 (v4) / 40 (v6)en-tête UDP8enregistrement DTLS + étiquette≈ 30tramage PPP / Ethernetquelques-unsMTU intérieure — ce que le tunnel peut porter : visez 1400, plancher 1280Un enregistrement DTLS ne peut pas être fragmenté comme un enregistrement TLS s'étale sur un flux TCP : tout cela doit tenir d'un coup dans la MTU du chemin.Même forme qu'OpenVPN depuis 2001 — un transporteur à datagrammes, DTLS, une interface virtuelle au-dessus. Pas excentrique ; juste séparé.
Descendez depuis 1500 : 20 octets d’en-tête IPv4 ou 40 d’IPv6, 8 d’UDP, environ 30 pour l’enregistrement DTLS et son tag, quelques-uns pour le tramage, et le reste est la MTU intérieure que le tunnel peut porter. Visez 1400 sur un chemin ordinaire ; 1280 est le plancher sûr si quelque chose au milieu est lui-même un tunnel. Cette forme — un transporteur à datagrammes, DTLS, une interface virtuelle par-dessus — est assez proche de ce qu’OpenVPN fait depuis 2001. Pas excentrique ; juste déballé.

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.

Quatre boutons pppd : un à poser, un à laisser, deux à couperUn à poser, un à laisser, deux à couperPOSEZ-LEMTU et MRUAux deux bouts, avec de la marge : 1400 simple, 1280 sous un tunnel. Une trame fragmentée perd un paquet entier.LAISSEZ-LAACCMDéjà à zéro par défaut, ce qui est juste sur une socket propre. N'y touchez que si quelque chose mange les caractères de contrôle.COUPEZnovjCompression d'en-têtes de Van JacobsonÉconomise une erreur d'arrondi sur un lien rapide, coûte du CPU par paquet et casse sous la perte. Coupée au-dessus du modem.COUPEZnodeflate nobsdcompLa charge est déjà en TLS/DTLS — compresser du texte chiffré est du travail pur, et à travers une frontière de sécurité, une attaque.Rien de tout cela n'accélère le tunnel. PPP n'est pas le goulot — PPPoE fait 2 Gbit sur le même démon, parce que son chemin de données restedans le noyau. Le coût ici, c'est le pty : chaque octet passe en espace utilisateur et revient. Cela appartient au pseudo-terminal, pas à PPP.
La MTU et la MRU, c’est celui qui compte : posez les deux aux deux bouts avec de la marge, car une trame que le transporteur doit fragmenter perd un paquet entier à chaque perte. L’ACCM est déjà à zéro et correcte sur un socket propre. Coupez la compression d’en-tête Van Jacobson avec 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
Un tap, c'est une interface d'un côté et un descripteur de fichier de l'autrePas de démon, pas de protocole, pas de pseudo-terminal. Un descripteur de fichier.NOYAUtap0une interface ordinaire,avec adresse et route/dev/net/tunTUNSETIFF nommele périphérique obtenuvotre processussocat, ou cinquante lignesde Python tenant le fdsocket UDPune trame par datagrammele réseauet le bout distantune lecture = une trameCe qui manque face à la construction PPP : pas de taille de trame négociée, pas de vérification de vivacité, pas d'adresses échangées, pas d'authentification.Vous configurez les deux bouts à la main et ils n'en discutent jamais. Rien ne surveille le lien, donc rien ne vous dira qu'il est mort.
Ni daemon ni protocole. Le noyau présente tap0 comme une interface ordinaire et remet l’autre côté au processus qui a ouvert /dev/net/tun. Une lecture rend exactement une trame Ethernet ; une écriture en injecte exactement une. Tout ce que pppd négociait — adresses, tailles de trame, vivacité — vous le configurez maintenant à la main aux deux bouts, et les deux bouts n’en discutent jamais.

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 :

tapcat.py — le tout, une centaine de lignes tapcat.py · 6 kB

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.

Un datagramme par trame, ou un préfixe de longueur à inventer soi-mêmeUne lecture sur un tap, c'est une trame. La garder ainsi, c'est le travail du transporteur.SUR UDP — la frontière est gratuitedatagramme = trame Adatagramme = trame Bdatagramme = trame Cdatagramme = trame DUne lecture, un datagramme, une écriture au bout distant. Rien à délimiter, rien à tamponner, rien à resynchroniser après une perte.SUR TCP — les frontières ont disparu et il faut les remettreun seul flux d'octets — deux trames peuvent arriver en une lecture, une trame en troislentrame Alentrame Blentrame Clentrame DDeux octets de longueur big-endian devant chaque trame, et un récepteur qui lit la longueur puis exactement ce nombre d'octets.Cela marche, et cela ne se récupère pas. HDLC se resynchronise sur le 0x7E suivant parce qu'un fanion est sans ambiguïté ; un flux préfixéqui perd sa place lit toutes les longueurs suivantes au milieu d'une trame. Ajoutez un marqueur et une somme de contrôle et vous avez refait HDLC.
Sur UDP, une trame est un datagramme et la frontière vient gratuitement. Sur TCP les frontières ont disparu, donc l’émetteur doit préfixer chaque trame d’une longueur et le récepteur doit réassembler à partir de là. PPP n’a pas ce problème parce qu’il apporte son propre tramage — un octet fanion à chaque bout et une somme de contrôle — ce qui est aussi ce qui lui permet de se resynchroniser après un dégât. Un flux préfixé de longueur ne peut pas : décalez-le d’un octet et chaque trame après est fausse.

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.

tuntap
Ce qui traversepaquets IPtrames Ethernet
Surcoût par paquetaucunen-tête Ethernet de 14 octets
ARP, DHCP, diffusionnonoui, tout, sur le tunnel
Protocoles non-IPnonoui
Peut rejoindre un pontnonoui
Équivaut àun lien point à point, comme ppp0un 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.

Compresser avant de chiffrer, et garder la fenêtre si le transporteur le permetL'ordre est fixe. Le mode est la décision.tap0une lecture, une tramecompresserzstd, niveau 1chiffrerDTLS, ou TLStransporteurUDP, ou TCPJamais dansl'autre sens.FLUSH_BLOCK — garder la fenêtreÉmet tout ce qui précède, garde l'historique. Chaque trameest codée contre toutes celles d'avant. Pas de latence ajoutée :une trame entre, une trame sort.5.0%sur du trafic répétitif · 7,5 % sur des lignes de logExige chaque trame livrée, dans l'ordre.Donc : TCP, ou TLS sur TCP. Pas UDP, pas DTLS.FLUSH_FRAME — la jeter à chaque paquetChaque datagramme est une trame zstd complète et se décodeseul : le datagramme N marche encore si 1 à N-1 sont perdus.Le seul mode utilisable par un transporteur à datagrammes.14.8%sur le même trafic · 24,2 % sur des lignes de logUn dictionnaire entraîné en récupère l'essentiel :5,3 % et 15,1 %, et cela reste tolérant à la perte.
La compression va entre le tap et le transporteur, et avant le chiffrement, parce que le texte chiffré ne se compresse pas. FLUSH_BLOCK est le mode qui compte : il émet tout ce qui précède, si bien qu’une trame en donne une trame en sortie sans latence ajoutée, tout en gardant l’historique de compression pour la trame suivante. FLUSH_FRAME jette cet historique à chaque paquet, ce qui le rend sûr sur un transporteur à perte et trois fois pire sur le fil.

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 tunnelflux, FLUSH_BLOCKpar paquet, FLUSH_FRAMEpar paquet avec dictionnaire entraîné
Appels d’API répétitifs et télémétrie5,0 %14,8 %5,3 %
Lignes de log7,5 %24,2 %15,1 %
Texte brut et fichiers de configuration37,8 %51,3 %45,3 %
Octets aléatoires, en lieu et place de TLS100 %+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 sockettap ou tun sur un socket
Tramageinté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’adressenégociée par IPCP et IPV6CPconfigurée à la main aux deux bouts
Vivacitéécho LCP, intégréaucune ; vous l’ajoutez ou le lien meurt en silence
AuthentificationPAP ou CHAP disponibles, tous deux faiblesaucune du tout
Couche3 seulement3 avec tun, 2 avec tap
Bridgingnonoui, avec tap
Surcoûtfanion, en-tête et FCS par trame, plus l’échappementrien, ou 14 octets avec tap
Compressiondeflate et BSD, activés par défaut, de 1996aucune, ou zstd par trame que vous ajoutez et contrôlez
Root nécessaireoui, tout du longpour créer le périphérique ; pas pour l’utiliser
Lignes de pièces mobilesun daemon, trente ans d’âgeun 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. pppd se 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.

Un relais, c'est deux sockets jointes par un tuyau, et le point final se déplaceUne connexion entre, une autre sort, un tuyau entre les deux. Voilà un proxy.clientouvre une session TLSRELAIS — la destination qu'enregistre votre bordurencat -l 7000termine le clientncat farendouvre un nouveau sautbout distantle vrai autre côtéstdoutbackpipe (FIFO) ramène les réponsesclient → relaisrelais → bout distantLa connexion du client finit au relais. Les paquets, non.Votre pare-feu a enregistré une connexion vers cette machine. Vers où elle relaie se décide dans la machine, et en chaîner trois metle vrai bout distant à trois tuyaux — le journal de chaque saut ne montrant qu'une sage connexion locale vers le suivant, et rien au-delà.
Un relais, c’est deux sockets et un tube. L’écouteur termine la connexion du client. Un second netcat ouvre une connexion fraîche plus loin, et la FIFO porte le sens retour entre eux. La session TLS du client se termine ici, au relais, et un nouveau saut commence — si bien que la destination que votre frontière a journalisée est cette machine, et les paquets continuent vers où qu’elle relaie. Chaînez-en trois et le bout distant est à trois tubes, chaque pare-feu de saut ne voyant qu’une connexion locale propre vers le suivant.

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.

Des relais chaînés sur des hôtes compromis blanchissent le point finalChaque saut est la machine d'un autre, et chaque propriétaire ne voit que du milieuattaquantsa seule machinerelais 1réseau d'une autre firmerelais 2un VPS détournérelais 3un routeur domestiquedestinationlà où cela allaitvoit : 1←→2voit : 2←→3voit : 3←→destAucun saut ne voit au-delà de ses deux voisins. Pas d'origine, pas de destination, rien que du milieu.Le trafic se blanchit à travers une série de systèmes appartenant à d'autres, chacun faisant tourner le même relais de deux sockets et untuyau sous le contrôle de l'attaquant. D'où le sortant d'un hôte compromis qui mène à une autre victime, pas à l'attaquant — vingt ans de C2.
Chaque saut est un hôte compromis — la machine d’une autre entreprise, un VPS détourné, un routeur domestique — faisant tourner le même relais deux-sockets-et-un-tube sous le commandement de l’attaquant. Aucun saut ne peut voir au-delà de ses deux voisins : le propriétaire du relais 2 voit une connexion depuis le relais 1 et une vers le relais 3, et rien d’autre. Le trafic est blanchi à travers une chaîne de systèmes tiers, c’est pourquoi le sortant d’un hôte compromis mène si souvent vers une autre victime plutôt que vers 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.


  1. 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. ↩︎

  2. RFC 2637 — Point-to-Point Tunneling Protocol (PPTP), qui transporte PPP dans GRE. ↩︎

  3. RFC 3931 — Layer Two Tunneling Protocol version 3, le descendant sur la voie des standards du protocole qui transporte PPP dans UDP. ↩︎

  4. 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 dans man pppd sur ppp 2.5.1. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  5. 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). » ↩︎ ↩︎

  6. 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. ↩︎

  7. RFC 5072 — IP Version 6 over PPP. IPV6CP et les identifiants d’interface de 64 bits, indépendants d’IPCP. ↩︎

  8. RFC 1994 — PPP Challenge Handshake Authentication Protocol (CHAP). Prouve l’identité du pair ; ne chiffre rien. ↩︎

  9. 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. ↩︎

  10. Ncat Users’ Guide — connecting through a proxy — --proxy et --proxy-type pour HTTP CONNECT et SOCKS 4/5, si bien que le transporteur TLS se termine au proxy, pas au bout distant du tunnel. ↩︎

  11. Ncat Users’ Guide — les options --ssl, --ssl-verify et --ssl-trustfile, et les modes écoute et UDP. Version testée ici : Ncat 7.92. ↩︎

  12. Universal TUN/TAP device driver — la propre documentation du noyau pour /dev/net/tun, TUNSETIFF et la différence entre tun et tap. ↩︎

  13. PEP 784 — Adding Zstandard to the standard library, la raison pour laquelle compression.zstd n’a besoin d’aucun paquet sous Python 3.14. Mesuré ici contre Python 3.14.7 et zstd 1.5.7. ↩︎

  14. 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. ↩︎

  15. 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. ↩︎