style="display:inline-block;width:728px;height:90px"
data-ad-client="ca-pub-5164839828746352"
data-ad-slot="7563230308">

Ejemplo de ataque de negación de servicio

En seguimiento a la plática del sábado (seguridad en aplicaciones Java), aquí están nuevamente los ejemplos que expuse ese día. Comencemos con el de DoS (Denial of Service: negación de servicio)

Este ataque se puede dar cuando tenemos un ServerSocket aceptando conexiones y manejando un protocolo propietario. En este caso el protocolo es muy simple y consiste simplemente en un mensaje de texto que primero lleva dos bytes de encabezado indicando su longitud (en binario). De modo que para recibir las conexiones tenemos el siguiente código:

ServerSocket server = new ServerSocket(9123);
//Este es un contador para saber cuantas conexiones tenemos abiertas
//Aqui lo incrementamos y las conexiones lo decrementan
AtomicInteger count = new AtomicInteger();
//Ciclo interminable
while (true) {
        //Aceptamos una nueva conexion
        Socket sock = server.accept();
        //Incrementamos el contador, imprimiendo cuantas van
        System.out.println(String.format("Nueva conexion, van %d", count.incrementAndGet()));
        //Arrancamos la nueva conexion en un thread.
        new Thread(new Conexion(sock, count)).start();
}

La clase que maneja la conexion es así:

public class Conexion implements Runnable {

        private Socket sock;
        private AtomicInteger count;

        public Conexion(Socket socket, AtomicInteger cuenta) {
                sock = socket;
                count = cuenta;
        }

        public void run() {
                try {
                        //Leemos el encabezado de datos
                        byte[] buf = new byte[2];
                        if (sock.getInputStream().read(buf) == 2) {
                                //Decodificamos la longitud
                                int largo = ((buf[0] & 0xff) << 8) | (buf[1] & 0xff);
                                //Creamos un buffer de la longitud indicada
                                buf = new byte[largo];
                                //Vamos leyendo bloques hasta tener el mensaje completo
                                int cuantos = sock.getInputStream().read(buf);
                                while (cuantos < buf.length) {
                                        cuantos += sock.getInputStream().read(buf, cuantos, buf.length - cuantos);
                                }
                                //Simplemente contestamos lo mismo que leimos
                                sock.getOutputStream().write(largo >> 8);
                                sock.getOutputStream().write(largo & 0xff);
                                sock.getOutputStream().write(buf);
                                sock.getOutputStream().flush();
                        }
                } catch (IOException ex) {
                        //TODO manejar error
                } finally {
                        try {
                                sock.close();
                        } catch (IOException ex) {
                                //TODO manejar error
                        }
                        //Decrementar el contador global de conexiones
                        count.decrementAndGet();
                }
        }
}

El código anterior es susceptible de un ataque DoS (Denial of Service, negación de servicio) por lo siguiente:

  • No se limitan las conexiones que se reciben
  • No se valida la longitud recibida
  • No se limita el tiempo para leer datos

Por lo tanto, para montar un ataque de DoS contra este servicio se puede hacer un programa sencillo que abra varias conexiones hacia él, ya que cada conexión va a crear un thread en el servidor; si hacemos un programa que abra 500 conexiones y corremos dicho programa 2 veces en 10 equipos locales, cada equipo va a abrir 1000 conexiones (no es tanto como para afectar el equipo local) pero en total estaremos abriendo 10 mil conexiones al servidor.

Cada conexión que el programa de ataque logre establecer con el servidor, puede entonces enviar un encabezado indicando un mensaje largo. En 2 bytes la longitud máxima que se puede indicar es 65535, por lo que se puede enviar dicha longitud y entonces el servidor va a alojar 64KB de memoria (que no es mucho hoy día) para cada conexión. Si logramos abrir 1000 conexiones, vamos a ocasionar que el servidor aloje 64MB de memoria. Con las 10 mil conexiones se alojarían 640MB de memoria, solamente para los mensajes, más el overhead que ocasiona tener tantos sockets abiertos y tantos threads corriendo.

Y para mantener todavía más ocupado el servidor, cada conexión del programa de ataque puede empezar a enviar solamente un byte cada cierto tiempo, para mantener ocupada la conexión del otro lado, tratando de leer un mensaje de 65535 bytes un byte a la vez. Si se envia un byte cada 10 segundos, se puede mantener la conexión ocupada hasta por 13 días (una duración absurda para un protocolo tan simple).

El programa de ataque simplemente debe tener un ciclo para abrir varias conexiones:

for (int i = 0; i < 1000; i++) {
        new Thread(new Ataque()).start();
}

Y todo el ataque se codifica en la clase Ataque:

private static class Ataque implements Runnable {
        public void run() {
                Socket sock = null;
                try {
                        sock = new Socket(host, port);
                        //Escribimos un encabezado muy largo
                        byte[] buflen = new byte[2];
                        lenbuf[0] = (byte)(65535 >> 8);
                        lenbuf[1] = (byte)(65535 & 0xff);
                        sock.getOutputStream().write(lenbuf);
                        //Ahora vamos escribiendo un byte a cada rato
                        for (int i = 0; i < LARGO-1; i++) {
                                //Esperamos 9 segundos
                                Thread.sleep(9000);
                                //Escribimos una A
                                sock.getOutputStream().write(65);
                                //La enviamos
                                sock.getOutputStream().flush();
                        }
                } catch (InterruptedException ex) {
                        //manejar
                } catch (IOException ex) {
                        //manejar
                } finally {
                        if (sock != null) {
                                try {
                                        sock.close();
                                } catch (IOException ex) {
                                        //manejar
                                }
                        }
                }
        }
}

¿Qué podemos hacer para proteger nuestro humilde servidor de este ataque? Bueno pues ya en la vida real depende mucho de la aplicación, para este caso tan sencillo podemos implementar algunas técnicas básicas:

  • Limitar el numero de conexiones que se reciben
  • Limitar el número de threads que procesan las conexiones
  • Limitar el tiempo de lectura de la conexion
  • Limitar la memoria que se usa en las conexiones

Nuestro ciclo principal queda protegido con unas lineas adicionales de código:

//Vamos a usar un thread pool con 50 hilos
//Asi que solamente se van a procesar hasta 50 conexiones de manera simultanea
ExecutorService threadPool = Executors.newFixedThreadPool(50);
//El limite de conexiones que vamos a recibir
//Arbitrariamente definimos 10 por hilo
int max = 500;
AtomicInteger count = new AtomicInteger(0);
ServerSocket server = new ServerSocket(9123);
while (true) {
        //Primero tenemos este ciclo para esperar a que
        //baje el numero de conexiones abiertas
        while (count.get() > max) {
                System.out.println(String.format("Servidor saturado, esperando desconexiones para bajar de %d", count.get()));
                //Si hay saturacion, esperamos medio segundo
                try {
                        Thread.sleep(500);
                } catch (InterruptedException ex) {
                        //manejar
                }
        }
        //Aqui llegamos solamente cuando no hay saturacion
        Socket sock = server.accept();
        System.out.println(String.format("Nueva conexion (van %d)", count.incrementAndGet()));
        //Mandamos la conexion a correr en el thread pool
        threadPool.execute(new Conexion(sock, count));
}

Como pueden ver, el haber hecho la Conexion un Runnable nos facilita ahora ejecutarlas en un thread pool en vez de cada una en su propio thread. Lo anterior está limitando el número de conexiones que se reciben y el número de hilos que procesan dichas conexiones; pero qué hay del tiempo y longitud de los mensajes? Eso se resuelve en la Conexion, agregando un timeout para lectura, un contador del total del tiempo, y validando la longitud que se lee antes de crear el buffer. Digamos que estamos esperando mensajes de máximo 1024 bytes, así que nada más vamos a aceptar mensajes de ese largo, y por muy mala conexión a internet que tengan los clientes, no les debe tomar más de 10 segundos enviar un mensaje completo (y no debería tomarles más de 2 segundos enviar un bloque de datos). El método run de Conexion queda entonces así:

public void run() {
        try {
                byte[] buf = new byte[2];
                if (sock.getInputStream().read(buf) < 2) {
                        //ERROR no podemos leer longitud completa
                        return;
                }
                int largo = ((buf[0] & 0xff) << 8) | (buf[1] & 0xff);

                //Vamos a aceptar mensajes con longitud maxima de 1024 bytes
                if (largo < 1024) {
                        //Vamos a tomar el tiempo que toma en total la lectura
                        //Algo aceptable serian 10 segundos por conexion (y es mucho)
                        long t0 = System.currentTimeMillis();
                        buf = new byte[largo];
                        //Tienen dos segundos para mandarnos cada bloque de datos
                        sock.setSoTimeout(2000);
                        int cuantos = sock.getInputStream().read(buf);
                        boolean timeout = false;
                        while (cuantos < buf.length && !timeout) {
                                cuantos += sock.getInputStream().read(buf, cuantos, buf.length - cuantos);
                                //Medimos el tiempo que ha pasado desde que empezamos a leer
                                timeout = System.currentTimeMillis() - t0 > 10000;
                        }
                        if (!timeout) {
                                //Contestamos solamente si enviaron todo a tiempo
                                sock.getOutputStream().write(largo >> 8);
                                sock.getOutputStream().write(largo & 0xff);
                                sock.getOutputStream().write(buf);
                                sock.getOutputStream().flush();
                        }
                }
        } catch (SocketTimeoutException ex) {
                //Aqui se llega cuando se tardan mas de 2 segundos en un bloque
                System.out.println("Se tardan en mandar datos, adios");
                try {
                        sock.getOutputStream().write("ERROR".getBytes());
                        sock.getOutputStream().flush();
                } catch (IOException e2) {
                        //manejar
                }
        } catch (IOException ex) {
                //TODO manejar
        } finally {
                try {
                        sock.close();
                } catch (IOException ex) {
                        //manejar
                }
                count.getAndDecrement();
        }
}

Supongamos ahora que el atacante se da cuenta que sus conexiones se le cierran en cuanto envía el encabezado de longitud; se dará cuenta que ya estamos validando la longitud, por lo que va a empezar a mandar una longitud más corta cada vez hasta que no le cerremos la conexión, pero cuando si envía un byte cada 10 segundos, se dará cuenta que la conexión se le cierra a los 2 segundos de mandar el primer byte. Por lo que irá bajando el tiempo de espera entre cada envío, pero aunque llegue a menos de 2 segundos y nosotros lo esperemos, solamente logrará enviar 4 o 5 bytes y se le cerrará la conexión porque pusimos un límite de 10 segundos.

Este código adicional que pusimos no va a repeler ataques de DoS, pero podrá al menos administrar los recursos del servidor para que pueda funcionar incluso bajo una gran carga de usuarios legítimos, sin caerse, aunque sea más lento.

En un caso real es muy importante encontrar un equilibrio en estas medidas de seguridad para que no se le cierren conexiones a usuarios legítimos, mientras que se minimiza el uso de recursos para poder sobrevivir ataques de este tipo. Es muy importante también saber que esto no es lo único que hay que hacer; un firewall y sistemas de detección de intrusos que puedan detectar ciertos patrones de conexión ayudan muchísimo (como por ejemplo 10mil conexiones repentidas desde la misma IP y que rechacen paquetes de dicha IP); estos mecanismos que pusimos en la aplicación servidor son la última línea de defensa contra los ataques de DoS.

Comentarios

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.
Imagen de rugi

Hombre

Hombre Enrique!!!

Felicitaciones por la plática... estuvo genial :D

Saludos!!
---
RuGI

PD. Se hecha de menos tu cuenta en twitter XD

Imagen de benek

Me uno a la moción!

+1 a todo el comentario de RuGI, sobre la plática y la cuenta de twitter también, cómo no!!

Enrique, Twitter no es real-time social media, así que piénselo :-P

Javier Benek.

Imagen de rugi

bueeeno, con que sepamos que

bueeeno, con que sepamos que tiene cuenta y que podemos decir, "hey! yo lo sigo", con eso {para iniciar} nos basta.
Seguramente programará un bot, para twittear periódicamente, digo, ....si puede lograr ataques DoS que no puede hacer!! ;)

Felicidades ezamudio ,

Felicidades ezamudio , excelente charla.
Me uno a la lista de followers que seguró tendrás cuando te unas a twitter >=)
Estaria bien que destilaras sabiduria en twitter de vez en cuando para los mortales

Saludos !

Imagen de rugi

uufffff..... Estoy seguro

uufffff..... {modo_presion_diplomatica_on}Estoy seguro que con el humilde apoyo que ahora manifestamos, Enrique activará, lo más pronto que sus actividades le permitan, su cuenta en twitter.{/modo_presion_diplomatica_on}

Qué tal Enrique, Muy buen

Qué tal Enrique,

Muy buen post!. Aunque te seré honesto, jamás he visto código así puesto en producción y más porque tu mismo mencionas que código así no sería capaz de aguantar carga pesada. Obviamente todo depende de los proyectos en los que alguien ha trabajado, el tamaño de los mismos y el personal que ha colaborado en ellos, pero la única parte dónde he visto código generando threads por cada conexión y con problemas cómo los que mencionas es en libros de texto que intentan abordar esos temas de manera sencilla para evitar confundir al lector. Para cosas mas complejas que involucren miles de peticiones, cálculos complejos o grandes cantidades de información que debe ser procesada, debes ser muy cuidadoso con todos y cada uno de esos recursos, y para ello existen diferentes técnicas. Y hablamos de técnicas nada modernas, el proyecto apache en sus primeras versiones (80s?) utilizó pre-forking para el manejo de múltiples peticiones. Cómo siempre, todo depende de la tarea que se intenta resolver y del contexto en el que lo quieres abordar

No es mi intención iniciar esto como un comentario pendante sino un punto de enfoque para entrar en discusión acerca del tópico. Aunque yo los llamaría ataques de "denegación" de servicio, creo que los puntos que comentas son ejemplos claros de explotación sólo de aquellos recursos que tienen que generarse al manejar múltiples peticiones, y aún para esto, hay diversas técnicas para abordarlos. Otra cosa con la que no concuerdo es que, limitar la lectura o incluso el tiempo de procesamiento de una petición depende mucho del tipo de tarea que estés realizando, E/S no siempre es el problema; problemas que resuelven o computan cálculos complejos o grandes cantidades de información pueden tomar definitivamente más de unos segundos y utilizar mucho más CPU. Hablemos del caso remoto: imaginemos que realmente necesites implementarlo tú; actualmente tienes excelentes y _sencillas_ APIs que fueron diseñadas para crear este tipo de administradores de recursos o pools para cuestiones como: threads, conexiones a diferentes protocolos, etc. La verdadera interrogante aquí es.. por qué habría de hacerse todo desde 0 o sin la necesidad de utilizar dichas APIs que fueron construidas y probadas especialmente para esas situaciones, considerando también que son parte de la biblioteca estándar?. Yo opino que muchas veces es simplemente desinformación. Incluso, en entornos más limitados (JME), las mismas APIs están generalmente disponibles, y te proporcionan las herramientas necesarias para construir dichos administradores de recursos, tienes ahora bounded queues y semaphores para poder crear tus propios pools de _inserte aquí su recurso favorito_. Concurrencia y recursos compartidos sería otro problema, pero hey! buenas noticias! también ya tienes algoritmos no-bloqueantes de muchas estructuras de datos tradicionales, por eso de que en Java se solía resolver todo con excesiva sincronización.

En resumidas cuentas, creo que hablamos de diferentes problemas, que en su momento pueden causar denegación de servicio al ser explotados, pero cada uno de esos problemas podría incluso ser un tópico de discusión aparte. Sería interesante que pusieras bajo qué condiciones aplicarías las prácticas que mencionas arriba en el caso que REALMENTE lo tengas que hacer tú y no utilizar frameworks tan elementales como los proporcionados en el paquete java.util.concurrent. Si tu tema se centra en aplicaciones cliente servidor genéricas o simplemente en construir servidores capaces de atender múltiples peticiones teniendo control "absoluto" (claro, no vivimos en una utopía), recomiendo leer el artículo "The C10k problem": http://www.kegel.com/c10k.html que expone diversas técnicas.

My .02 cents, Saludos.

PD. Por cierto, en mi experiencia uno de los errores más comunes que ha causado denegación de servicio, incluso de proyectos corren sobre servidores de aplicaciones "potentes" con tolerancia a fallos y otros aspectos de administración de recursos ha sido el no cerrar recursos que ya no se necesitan más (archivos por ejemplo) y así sobre pasar los límites expuestos por el sistema operativo anfitrión.

Imagen de jali

Genial

Hola!
Felicidades por la platica, estuvo excelente!
Saludos!

Imagen de ezamudio

amnesiac:Ejemplo SENCILLO

La idea era ilustrar el DoS y para ello hice un ejemplo lo más sencillo posible. La solución por eso utiliza un thread pool con el ExecutorService de java.util.concurrent que mencionas, y además si se emplean los canales de java.nio se elimina el uso de varios threads para estar leyendo mensajes, aunque no dejaría de usar el pool para procesar las peticiones (cosa que ya en la realidad va a tomar tiempo) y de ahi se envian las respuestas, también usando los métodos asincrónicos de canales de java.nio. Tal vez luego ponga ese ejemplo.

No recuerdo tampoco haber visto código así en producción, directamente tan vulnerable, pero a veces sí hay cosas similares en cuanto al uso de recursos, por ejemplo con servicios disponibles via RMI que se ejecutan directamente en vez de solamente tomar los parámetros y encolar la tarea en un thread pool, etc.

Este ejemplo es nada más un resumen de la plática del sábado, donde ya expliqué más a detalle que solamente es un ejemplo, algo muy simple, y no es una solución definitiva a ataques de DoS, además de que en gran medida esos ataques deben detenerse fuera de la aplicación, a nivel firewall.

En cuanto a lo que mencionas al final, sí, es muy común no liberar recursos, por eso sí me fijé muy bien en cerrar las conexiones en el finally.

Voy a subir ejemplos de inyección de SQL y XSS más al rato, espero tus observaciones y comentarios al respecto (Inyección de SQL sí lo he visto en producción, desafortunadamente; aunque es algo más inherente al escabroso mundo de PHP, en Java también se da cuando programadores novatos no conocen las sencillas soluciones de JDBC).

saludos

donde compilar este codigo o donde ponerlo a correr?

Imagen de ezamudio

copy/paste

Pues lo puedes copiar y pegar en archivos de texto y compilar el proyecto. Esto lo hice como parte de la demo, pero nunca publiqué el código en un repo, aunque puedes bajarlos de esta liga (que por cierto está en otro de los artículos de la serie que publiqué respecto de seguridad en aplicaciones Java). El javadoc del código lo puedes ver aquí.

style="display:inline-block;width:728px;height:90px"
data-ad-client="ca-pub-5164839828746352"
data-ad-slot="7563230308">