Détournement de port ou comment s'échapper d'un réseau-prison



L’écriture de cet article a été sponsorisé par Cadoles, la coopérative dans laquelle je travaille depuis plusieurs années.

Si vous aussi vous cherchez des solutions pour permettre à vos développeurs et vos administrateurs systèmes de travailler main dans la main, contactez nous !


TL;DR

Si vous avez besoin d’exposer un service de manière temporaire entre 2 machines derrière un NAT et ayant des règles pare-feu restrictives, essayez https://rebound.cadoles.com.

Aucune installation nécessaire, un simple client SSH suffit !


Avant toute chose…

Les propos tenus dans cet article n’ont pas pour vocation à vous encourager à contourner les règles de sécurité de votre environnement de travail !

La majorité de ces règles sont souvent présentes pour une bonne raison et les esquiver revient à dégrader la sécurité de l’ensemble du système d’information (il ne sert à rien de fermer les fenêtres de votre maison si la porte est grande ouverte).

De manière générale, ouvrir un tunnel pour “échapper” à des règles réseau trop restrictives peut être considéré comme une faute professionnelle SI la décision de la réalisation de cette action n’a pas été prise de manière concertée avec les équipes concernées !

L’ouverture de tunnels temporaires à des fins de test est cependant une solution intéressante dans le cas où:

Ceci étant dit…

La sécurité, cette amie qui vous veut du bien

À tout ceux qui ont subi les règles arbitraires d’un administrateur réseau un peu trop zélote, je vous salue.

Il arrive parfois (souvent) qu’en tant qu’intégrateur de logiciel travaillant sur des solutions “on-premise” nous ayons à intervenir dans des environnements réseaux cloisonnés pour installer ou configurer des services.

Personnellement le “pire” scénario que j’ai pu rencontrer à ce stade a été l’installation et la maintenance d’un service qui nécessitait:

  1. La connexion en RDP sur un serveur “bastion”;
  2. L’utilisation de ce bastion RDP pour accéder à un serveur Windows avec un client SSH mono-fenêtre;
  3. L’utilisation de ce client SSH pour accéder à un second serveur “bastion”, SSH cette fois;
  4. L’utilisation de ce dernier bastion pour accéder à des serveurs Linux, ceux sur lesquels je devais intervenir (avec une connexion unique, sans multi-fenêtres).

L’ensemble de cette chaîne avec des délais d’inactivité réglés au minimum (i.e. 5 minutes environ), qui coupaient la connexion si aucune activité RDP n’était détectée. Pratique lorsque la livraison s’effectue via l’exécution d’un playbook Ansible pouvant excéder ce délai…

Une règle générale dans ce type d’environnement est que l’ensemble des communications avec l’extérieur (entrantes ou sortantes) est rendue impossible par les pare-feux en frontal de la zone réseau. Chaque ouverture doit faire l’objet d’une demande, ce qui en soit d’un point de vue sécurité est tout à fait légitime.

Mais que faire lorsque l’ouverture d’accès est temporaire et uniquement nécessaire à des fins de tests suite à une intervention ? Bien souvent, les demandes a répétition d’ouverture et fermeture d’accès auront raison de la patience de l’équipe système, qui dans le pire des cas délaieront celles ci (consciemment ou non d’ailleurs).

Heureusement une porte de sortie est souvent juste sous nos yeux, si on sait l’identifier…

Ne m’obligez pas à forcer la porte

Une grande partie de ces environnements maintiennent la possibilité d’ouvrir des connexions sortantes sur les ports standards 80 (HTTP) et 443 (HTTPS).

Pourquoi ? Parce que la majorité des systèmes d’exploitation, que ce soit GNU/Linux ou Windows, utilisent des connexions HTTP(S) pour se mettre à jour avec des services de type CDN (Content Delivery Network).

Les principes mêmes de répartition de charge et de haute disponibilité de ces services impliquent qu’il est difficile (mais pas impossible) d’identifier l’ensemble des adresses réseaux avec lesquels ceux ci vont répondre, complexifiant de fait la mise en place de règles réseau restrictives sur les communications sortantes.

Si le(s) pare-feu(x) ne gère(nt) pas la couche 7 du modèle OSI (ce qui est encore souvent le cas) le choix est souvent fait d’autoriser les connexions sortantes sur ces deux ports cibles, sans restriction de destination.

HTTP / SSH, même combat

Comment faire pour profiter de cette porte de sortie pour ouvrir des connexions TCP arbitraires ? Et si possible (cerise sur le gâteau) sans avoir à installer quoi que ce soit sur la machine sur laquelle on intervient ?

Aujourd’hui, la majorité des systèmes d’exploitation intègrent un client SSH par défaut. Et chose particulièrement utile mais souvent oubliée, le protocole SSH intègre de base la possibilité de créer des tunnels TCP !

C4Context
    title Ouverture d'un tunnel SSH pour l'accès à une application web s'exécutant sur le port 8080

    Boundary(NetworkA, "Réseau A") {
        Boundary(MachineA, "Machine A") {
            Container(AppWebA, "Application Web", "http", "Une application web écoutant sur le port 8080")
            Container(SSHClientA, "Client SSH", "ssh", "Le client SSH installé sur la machine A")
        }
        Person(UserA, "Utilisateur A", "Un utilisateur souhaitant exposer le port 8080 de la machine A")
    }

    Boundary(NetworkB, "Réseau B") {
        Person(UserB, "Utilisateur B", "Un utilisateur souhaitant accéder au service de la machine A via la machine B")

        Container(BrowserB, "Navigateur web", "http", "http://server-b:8080/")
        Boundary(MachineB, "Machine B") {
            Container(SSHServerB, "Server SSH", "ssh", "Le serveur SSH installé sur le serveur B")
        }
    }


    Rel(SSHClientA, AppWebA,"Se connecte à")
    Rel(UserA, SSHClientA, "Exécute la commande", "ssh -L 8080:0.0.0.0:8080 user@server-b -p 80")
    BiRel(SSHClientA, SSHServerB, "Créé un tunnel", "server-a:8080 <-> server-b:8080")
    Rel(UserB, BrowserB, "Utilises")
    Rel(BrowserB, SSHServerB, "Se connecte à")

    UpdateRelStyle(UserA, SSHClientA, $offsetY="-160", $offsetX="20")
    UpdateRelStyle(BrowserB, SSHServerB, $offsetY="-50", $offsetX="-20")

Ce modèle est extrêmement simple à adapter pour notre cas d’usage. Voici les étapes à suivre :

Bien que ce modèle soit fonctionnel, il présente toutefois certains inconvénients :

À tout problème (même imaginaire) il y a un développeur

Que faut-il donc faire pour:

  1. Maintenir l’aspect privé du service ?
  2. Pouvoir relier 2 machines distantes, mêmes si les deux machines sont elles-mêmes derrière un NAT ?
  3. Ne pas avoir à installer d’outil supplémentaire sur les machines à connecter ?
  4. Limiter la surface d’attaque du service ? (un serveur SSH complet pour de simples tunnels, ça fait beaucoup);
  5. (Bonus) Obtenir une page de statistiques d’usage du service ?

Une tentative de réponse à ces critères est le service en beta Rebound mis à disposition par Cadoles.

Rebound est un service implémenté en Go proposant un serveur SSH limité à ses fonctionnalités d’ouverture et de maintient de tunnels TCP. Tout comme la solution précédente, il ne nécessite qu’un simple client SSH pour fonctionner. Cependant, il présente également plusieurs avantages supplémentaires:

Un déploiement suit en général la topologie suivante:

C4Context
    title Ouverture d'un tunnel via Rebound pour l'accès à une application web s'exécutant sur le port 8080

    Boundary(PrivateNetworkA, "Réseau privé A") {
        Boundary(MachineA, "Machine A") {
            Container(AppWebA, "Application Web", "http", "Une application web écoutant sur le port 8080")
            Container(SSHClientA, "Client SSH", "ssh", "Le client SSH installé sur la machine A")
        }
        Person(UserA, "Utilisateur A", "Un utilisateur souhaitant exposer le port 8080 de la machine A")
    }

    Boundary(ReboundNetwork, "Réseau Rebound") {
        Boundary(MachineRebound, "Machine Rebound") {
            Container(ReboundServer, "Serveur Rebound", "ssh", "Le serveur Rebound accessible publiquement sur Internet")
        }
    }

    Boundary(NetworkB, "Réseau privé B") {
        Person(UserB, "Utilisateur B", "Un utilisateur souhaitant accéder au service de la machine A via Rebound")

        Boundary(MachineB, "Machine B") {
            Container(BrowserB, "Navigateur web", "http", "http://127.0.0.1:8080/")
            Container(SSHClientB, "Client SSH", "ssh", "Le client SSH installé sur la machine B")
        }
    }

    Rel(SSHClientA, AppWebA,"Se connecte à")
    Rel(UserA, SSHClientA, "Exécute la commande", "ssh -R 0:127.0.0.1:8080 rebound@rebound.cadoles.com -p 80")
    Rel(UserB, SSHClientB, "Exécute la commande", "ssh -L 8080:0.0.0.0:1 <secret>@rebound.cadoles.com -p 80")
    BiRel(SSHClientA, ReboundServer,"Maintient un tunnel avec")
    BiRel(SSHClientB, ReboundServer,"Maintient un tunnel avec")
    Rel(BrowserB, SSHClientB, "Se connecte à")
    Rel(UserB, BrowserB, "Utilises")

    UpdateRelStyle(UserA, SSHClientA, $offsetY="-160", $offsetX="20")
    UpdateRelStyle(UserB, SSHClientB, $offsetY="-160", $offsetX="20")

    UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="3")

N’hésitez pas à le tester et à l’utiliser pour vos propres besoins ! Et comme toujours, si vous avez des remarques, n’hésitez pas à nous les transmettre !

Bonus: servir à la fois le protocole SSH et HTTP(S) sur le même port en Go

Saviez vous que les premiers octets de la séquence d’ouverture d’une connexion SSH sont en réalité une séquence de caractères commençant par SSH ?

Il est possible d’exploiter ce marqueur pour créer un serveur capable de “parler” HTTP(S) et SSH sur le même port d’écoute et de basculer d’un protocole à l’autre en fonction de l’entête détectée lors de l’initialisation de la connexion !

Pour exemple, une implémentation en Go de ce principe:

package main

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"sync"
	"time"

	// On utilise la librairie github.com/gliderlabs/ssh
	// qui est une surcouche à la librairie golang.org/x/crypto/ssh
	// Pour simplifier le code d'amorçage de notre serveur SSH
	"github.com/gliderlabs/ssh"
)

// Cet exemple est une adaptation des éléments de réponse apportés sur ce post StackOverflow:
// https://stackoverflow.com/questions/47699392/how-can-i-serve-ssh-and-https-traffic-from-the-same-listener-in-go

func main() {
	// On "écoute" les connexions entrantes sur le port 8080
	l, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatalf("[FATAL] %+v", err)
	}

	// On utilise la méthode "demux(Listener)" pour séparer les flux
	// HTTP(S) des flux SSH
	sshListener, httpListener := demux(l)

	// On créait un serveur HTTP basique répondant
	// toujours avec le message "Hello <adresse distante>"
	httpServer := &http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			io.WriteString(w, fmt.Sprintf("Hello %s", r.RemoteAddr))
		}),
	}

	// On créait un serveur SSH basique retournant
	// toujours avec le message "Hello <utilisateur_ssh>"
	sshServer := &ssh.Server{
		Handler: func(s ssh.Session) {
			io.WriteString(s, fmt.Sprintf("Hello %s\n", s.User()))
		},
	}

	// On créait un sync.WaitGroup qui nous permettra
	// d'éviter de clôturer l'exécution de manière prématurée
	var wg sync.WaitGroup

	wg.Add(2)

	// On créait une première goroutine qui aura pour charge
	// de faire traiter les connexions SSH entrantes à
	// notre serveur SSH
	go func() {
		defer wg.Done()

		if err := sshServer.Serve(sshListener); err != nil {
			log.Fatalf("[FATAL] %+v", err)
		}
	}()

	// On créait une première goroutine qui aura pour charge
	// de faire traiter les connexions HTTP entrantes à
	// notre serveur HTTP
	go func() {
		defer wg.Done()

		if err := httpServer.Serve(httpListener); err != nil {
			log.Fatalf("[FATAL] %+v", err)
		}
	}()

	// On attend (potentiellement indéfiniment) que les 2 serveurs
	// aient "rendu la main" à nos 2 goroutines.
	wg.Wait()
}

// Cette fonction est le coeur du mécanisme de démultiplexage.
// Elle permet d'identifier et séparer les flux entrants en
// observant les 3 premiers octets envoyés par le client distant.
// Si ceux ci correspondent à la chaîne "SSH" alors on associe
// la connexion à un flux SSH.
func demux(l net.Listener) (ssh net.Listener, http net.Listener) {

	// On créait deux nouveaux net.Listener qui ont la particularité
	// de recevoir les connexions via un channel et non pas directement
	// via une connexion TCP
	sshListener, httpListener := newChanListener(l), newChanListener(l)

	// On démarre une goroutine qui a pour charge d'écouter
	// les demandes de connexions TCP entrantes et d'attribuer celles ci
	// à un de nos deux listeners en fonction des premiers octets reçus
	go func() {
		for {
			conn, err := l.Accept()
			if err != nil {
				log.Printf("[ERROR] could not accept connection: %+v", err)
				continue
			}

			// On laisse 10 secondes au client pour envoyer les premiers
			// octets
			conn.SetReadDeadline(time.Now().Add(time.Second * 10))

			// On englobe la connexion dans une version permettant de
			// charger en mémoire tampon les données reçus afin de pouvoir
			// lire les premiers octets de celles ci sans les "perdre"
			// ensuite pour les serveurs
			// La taille de la mémoire tampon est limitée à 3 octets, qui
			// correspond à la chaîne "SSH" que l'on recherche.
			bconn := bufferedConn{conn, bufio.NewReaderSize(conn, 3)}

			// On récupère les 3 premiers octets transmis par le client
			p, err := bconn.Peek(3)

			// On repasse le délai d'attente à l'infini pour éviter
			// d'avoir des coupures prématurées du lien TCP pour la suite
			// du traitement.
			conn.SetReadDeadline(time.Time{})

			// On vérifie que la méthode Peek() n'a pas retourné d'erreur
			// Dans le cas contraire on ignore la demande de connexion et
			// on passe à la suivante.
			if err != nil {
				log.Printf("[ERROR] could not peek connection: %+v", err)
				continue
			}

			prefix := string(p)

			// On regarde si les premiers octets correspondent
			// bien à la chaîne "SSH". Si oui, on passe alors la
			// connexion au listener SSH.

			selectedListener := httpListener
			if prefix == "SSH" {
				selectedListener = sshListener
			}

			if selectedListener.accept != nil {
				selectedListener.accept <- bconn
			}
		}
	}()

	return sshListener, httpListener
}

type chanListener struct {
	accept chan net.Conn
	net.Listener
}

func newChanListener(l net.Listener) *chanListener {
	return &chanListener{
		make(chan net.Conn),
		l,
	}
}

func (l *chanListener) Accept() (net.Conn, error) {
	if l.accept == nil {
		return nil, net.ErrClosed
	}

	return <-l.accept, nil
}

func (l *chanListener) Close() error {
	close(l.accept)
	l.accept = nil
	return nil
}

type bufferedConn struct {
	net.Conn
	r *bufio.Reader
}

func (b bufferedConn) Peek(n int) ([]byte, error) {
	return b.r.Peek(n)
}

func (b bufferedConn) Read(p []byte) (int, error) {
	return b.r.Read(p)
}

Si vous voulez voir le code en action, voici un petit enregistrement de son exécution:

asciicast