Ferm, poderoso cortafuegos (parte 2)

Este artículo continúa la serie sobre ferm, poderoso cortafuegos para GNU/Linux. En el artículo anterior, mostré algunas de las bases de ferm. En esta ocasión estaré usando algunos ejemplos a medida que voy presentando funcionalidad, para ilustrar maneras efectivas de estructurar la configuración de un cortafuegos mediante ferm.

Uso de la tabla raw

Aunque todos los tutoriales de netfilter explican que la tabla filter es la indicada para hacer el filtrado de paquetes (cosa que es cierta), la tabla raw ofrece ciertas ventajas que permiten aprovecharla para una primera etapa de filtrado, descartando tráfico innecesario partiendo de lo general, para luego refinar hacia lo particular en otras tablas.

Para empezar, la tabla raw es la primera que el kernel evalúa, por lo que si logramos descartar un paquete malicioso en esta etapa, el microprocesador no tiene que emplear innecesariamente ciclos para evaluarlo con el resto de las tablas y cadenas. Además, la tabla raw se evalúa antes de que se active el rastreo de conexiones, por lo que se ahorra al sistema reservar otros recursos para el control de estado.

Personalmente, uso la cadena PREROUTING de la tabla raw para descartar por ejemplo paquetes con direcciones falseadas o inválidas.

Supongamos que tengo un servidor Web escuchando en una dirección IP pública. No tiene sentido que por la interfaz pública entren paquetes con la dirección IP de origen pública.

El kernel tiene constancia de sus interfaces y enruta internamente mediante la interfaz local los paquetes que necesiten transitar entre subredes, sin necesidad de salir al exterior, consumiendo tráfico asignado por nuestro proveedor de servicios. Y a menos que el servidor Web esté en una red donde realmente necesite recibir paquetes destinados a una dirección de MULTICAST o BROADCAST, tampoco tiene sentido que un paquete entrando por la interfaz pública tenga una dirección IP de destino que no sea la pública.

Para filtrar en la tabla raw tráfico entrante por la interfaz pública con direcciones de origen o destino inválidas, podría crear un archivo /etc/ferm/prefiltrado.conf con un contenido como este:

Ahora bien, podría darse el caso de paquetes que sí provienen de una IP válida, solo que están intencionalmente malformados. Esto ocurre bastante con el protocolo TCP, al cual el equipo debe dar un tratamiento especial porque es un protocolo con estado, de manera que consume más recursos que un paquete que utilice el protocolo UDP.

Hay quienes aprovechan esto enviando una gran cantidad de paquetes SYN fragmentados de gran tamaño, buscando provocar una sobrecarga del sistema para que deje de dar servicios o se comporte erráticamente y quizás cree un vector de ataque.

Además, es conocido que el protocolo TCP tiene 6 flags («banderas» o atributos) y hay combinaciones de éstos que son inválidas, pero hay quienes envían paquetes TCP con combinaciones intencionalmente inválidas para que el sistema produzca una respuesta de error que indique si existe una vulnerabilidad que pueda explotarse.

La tabla raw también es útil para frenar tales intentos temprano, lo cual reduce la sobrecarga sobre el rastreo de conexiones. Pero las combinaciones inválidas de flags son varias, y no es algo que cambie con frecuencia, de modo que bien podríamos colocar esta verificación en un archivo separado, junto a la verificación de paquetes fragmentados, y luego simplemente, incluirlo.

Ejemplo de configuración modular

Por ejemplo, podríamos colocar en /etc/ferm/tcpflags.conf algo como esto:

Entonces, modificando nuestro ejemplo anterior que evitaba las direcciones inválidas, solo habría que incluir este archivo:

Funciones y variables integradas

En ferm, las funciones integradas pueden identificarse fácilmente, porque comienzan con el símbolo de arroba. Tres de ellas ya las he mencionado en la primera parte: @include, @def y @hook. Pero hay otras, de las cuales mostraré sólo algunas útiles para no hacer el artículo demasiado extenso (el resto podrá encontrase en la página de manual).

La primera función interesante sobre la que hablaré es @subchain, que se aplica de manera parecida a los bloques, con la diferencia de que ferm crea una cadena personalizada, lo cual permite la reducción de reglas, consiguiendo menor carga del CPU. Para que se entienda mejor la practicidad de esto, conviene hablar un poco sobre las cadenas personalizadas.

Cadenas personalizadas

Si bien aprovechar la sintaxis de ferm para reducir reglas es útil, una configuración ineficiente incluso si luce compacta en sintaxis de ferm, se traducirá en un conjunto ineficiente de reglas de netfilter, mientras que una configuración eficiente, producirá reglas que no solo luzcan compactas, sino que además reduzcan realmente la carga del CPU.

Las cadenas personalizadas son muy útiles para esto. Usaré como ejemplo el servicio DHCP para una LAN que usa el bloque de direcciones 192.168.100.0/24, donde queremos proteger con ferm el equipo que proporciona este servicio.

Es bien conocido que en el protocolo DHCP, el equipo cliente que se conecta a una nueva red, envía desde la dirección IP nula (0.0.0.0) un paquete con destino a la dirección de difusión global (255.255.255.255) mediante el protocolo UDP, con el puerto de origen 68 y el puerto de destino 67. Este podría ser el único caso válido para que un equipo de esta subred tenga una dirección IP de origen que no sea de la subred.

Creando la cadena personalizada DHCP, pueden evaluarse en esta solo los paquetes que no tienen una dirección IP de origen perteneciente a la subred de la interfaz LAN, sin complicar innecesariamente las reglas en la cadena PREROUTING, en la que sí podríamos hacer otras validaciones generales, como por ejemplo, evitar la entrada de paquetes con dirección IP o MAC de la propia interfaz.

Ejemplo mediante cadena personalizada manual

Podría crearse un archivo /etc/ferm/dhcp.conf con un contenido como este:

Una cosa que podrá notarse es que en caso de usarse la cadena DHCP como destino de salto, es obligatorio incluir la palabra reservada jump, que sería innecesaria en el caso de destinos integrados en netfilter como ACCEPT, DROP o REJECT.

Ejemplo mediante función de subcadena

Como mencioné antes, una de las funciones integradas en ferm es @subchain, que permite declarar cadenas personalizadas desde una misma regla. De manera que el archivo /etc/ferm/dhcp.conf también podría haberse declarado así:

La ventaja en este caso es solo sintáctica: las reglas de la cadena personalizada DHCP comienzan visualmente en el punto de evaluación para el salto, lo cual facilitará revisiones posteriores. Sin embargo, los dos ejemplos anteriores producen exactamente las mismas reglas de netfilter, y esto puede comprobarse fácilmente ejecutando como superusuario:

Uso de NFLOG para trazas explícitas

Uno de los inconvenientes de netfilter es que al ser simplemente un módulo del kernel, las trazas que genere van por defecto a los mensajes del kernel, donde se mezclan con trazas de otros tipos. Existen formas (no muy intuitivas) de separarlas a un archivo, pero afortunadamente, también existe el paquete ulogd2 que permite enviar las trazas al espacio de ejecución del usuario para entonces decidir donde almacenarlas.

Instalación de ulogd2

Ulogd2 se encuentra en los repositorios, de modo que basta con ejecutar el comando de instalación habitual, por ejemplo, en Debian y derivadas:

La configuración de ulogd2 se encontrará en el archivo /etc/ulogd.conf y puede personalizarse al gusto, permitiendo enviar las trazas a una base de datos SQLite, o MySQL o PostgreSQL, o a un archivo en texto plano, que por defecto se ubica en /var/log/ulog/syslogemu.log (aunque esto puede modificarse).

Reducción de redundancia mediante cadenas personalizadas

El uso de NFLOG es relativamente simple, se utiliza como destino de salto para una regla, permitiendo además establecer un prefijo personalizado que ayude a identificar la traza. Como es un destino de salto que no altera el paquete, si por ejemplo deseamos descartar un paquete después de enviarlo a las trazas, hay que duplicar la regla, de manera que el kernel tendría que evaluarla dos veces, y esto es ineficiente.

Puede crearse entonces una cadena personalizada y usarla como destino de salto, para colocar en esta cadena la regla con NFLOG y un prefijo descriptivo, e inmediatamente detrás, la acción real a tomar, sea DROP o ACCEPT. Pero puede que deseemos incluir esta cadena personalizada de trazas desde más de una tabla, y en este caso todas las trazas lucirían igual, dejando incertidumbre en relación a cuál fue la tabla en que el paquete fue descartado o aceptado.

El acercamiento que suelo adoptar yo, es apoyarme en la función integrada de ferm para concatenación (@cat), y también en la variable integrada que contiene nombre de la tabla donde se evalúa la regla, $TABLE. Hay otras variables integradas que también podrían usarse, como $CHAIN para sustituirlo con el nombre de la cadena, o $FILEBNAME para sustituirlo con el nombre del archivo de configuración, etc.

Ejemplo de archivo modular para trazas

Entonces, puedo crear por ejemplo el archivo /etc/ferm/trazas.conf con un contenido como este:

En este bloque, he creado dos cadenas personalizadas (LOG_DROP y LOG_ACCEPT) y en cada una se usa NFLOG para crear una entrada en las trazas, utilizando como prefijo el nombre elegido para la acción, concatenando el nombre de la tabla donde la regla se evalúa. Una vez guardado el archivo, puede incluirse en la tabla donde pretenda usarse, por ejemplo:

El punto de inclusión de un archivo que contiene cadenas personalizadas debe encontrarse dentro de una tabla, o fallará la inclusión. Además, conviene que la inclusión aparezca al principio de la tabla antes de cualquier regla que use las cadenas personalizadas, o de lo contrario la carga de las reglas podría fallar.

Puede que aun no resulte muy evidente cuál es el objetivo de pasar tanto trabajo, pero los administradores de redes detestamos las tareas repetitivas, y consideramos preferible pasar un poco de trabajo una sola vez, desarrollando una solución eficiente que nos evite pasar trabajo todo el tiempo.

Ejemplo de cortafuegos para un servidor Web

Lo ilustraré mediante un nuevo ejemplo que toma elementos de los ejemplos anteriores. En este caso, digamos que deseo permitir acceso a un servidor Web que escucha por los puertos 80 (HTTP) y 443 (HTTPS) y también permitir los comandos de diagnóstico ping y traceroute, descartando todo lo demás. También ilustraré el uso del rastreo de conexiones para descartar desde la tabla mangle paquetes inválidos (por ejemplo, paquetes TCP fuera de la ventana esperada o con un numero de secuencia incorrecto, etc.), como una segunda etapa de filtrado.

Supongamos que creo el archivo /etc/ferm/webserver.conf, con un contenido como este:

En este caso, los paquetes a los que se aplica el destino de salto LOG_DROP podrán aparecer en las trazas como DROP_raw, DROP_mangle o DROP_filter, en dependencia del punto donde fueron descartados, lo cual facilita notablmente el diagnóstico.

Similarmente, los paquetes a los que aplicamos el el destino de salto LOG_ACCEPT quedarán debidamente identificados, y esto permitirá filtrar luego las trazas y determinar si quizás hubo direcciones IP de origen con demasiadas peticiones de acceso permitidas, etc.

En el caso de la cadena OUTPUT, la idea es no permitir la salida de paquetes con dirección de origen falseada, ni dirección de destino local (es decir, que pertenezca a alguna de las interfaces del propio cortafuegos), o de difusión. Un comportamiento de este tipo sería sospechoso y podría indicar que el equipo ha sido vulnerado por algún tipo de actividad maliciosa, por lo que también se registrarán tales intentos.

Se habrá observado que en el ejemplo anterior, declaré el destino LOG_REJECT como subcadena, pero solamente dentro de la tabla filter. Es totalmente intencional, porque de haberlo declarado en el modulo trazas.conf, habría dado problemas al incluir dicho archivo desde las tablas raw o mangle, ya que el destino de salto REJECT sólo es aplicable a las cadenas INPUT, FORWARD y OUTPUT de la tabla filter.

En este caso, como la regla existe en la tabla filter, cualquier conexión nueva usando el protocolo UDP con destino al rango de 100 puertos que declaramos (desde el 33434 hasta el 33534), generará una entrada con la cadena REJECT_filter y además se rechazará el paquete, en este caso mediante un paquete relacionado del protocolo ICMP con la respuesta port-unreachable.

A, propósito, he visto quienes eligen ACCEPT como acción para los paquetes que envía traceroute cuando esto es innecesario y crea vulnerabilidades; lo único que necesita traceroute es que el equipo responda rechazando la conexión, para obtener el tiempo que tarda la respuesta y agregarlo a la lista de saltos por los que pasa la ruta.

En definitiva, la identificación de las acciones tomadas sobre las nuevas conexiones resulta útil no solo para auditorías sobre seguridad informática, sino para analizar el rendimiento de nuestro cortafuegos y valorar qué reglas podemos optimizar.

Si comprobamos mediante ferm -nl las reglas de netfilter equivalentes en formato de iptables-save, obtendremos un resultado como este, que no sería tan legible:

A propósito, personalmente me gusta comprobar las reglas de netfilter resultantes de mis configuraciones en ferm, para analizar si es posible optimizarlas para mejor rendimiento.

Funciones personalizadas por el usuario

Además de las funciones integradas, ferm permite funciones definidas por el usuario, lo cual es muy práctico para reducir la complejidad de módulos que usemos frecuentemente y sean fastidiosos de recordar y teclear.

Por ejemplo, supongamos que tenemos algunos equipos en la subred local a los cuales hemos asignado direcciones IP fijas, porque tienen acceso a servicios privilegiados y no deseamos que los intenten suplantar desde el resto de la subred.

Podríamos declarar algo como esto:

El ejemplo anterior, si bien resulta mas legible que la declaración de reglas equivalentes en formato de iptables-save, aun tiene redundancias, por ejemplo, se repiten saddr, mod mac mac-source, y jump. la repeticion es mínima, pero puede reducirse.

Ejemplo de función personalizada para validación de dirección IP contra MAC

Con ferm podríamos declarar una función personalizada como ésta para condensar un poco lo que hay que teclear:

Como puede observarse, las funciones personalizadas comienzan con el símbolo de ampersand como prefijo. La declaración de la función comienza con el nombre de la función, y luego entre paréntesis se nombran los parámetros que la función espera. Finalmente, el valor de la función es la regla o el bloque de reglas en si, usando los parámetros nombrados en la función como si se tratase de variables declaradas anteriormente.

Si además, colocamos el listado de direcciones IP y MAC en un archivo como /etc/ferm/direcciones.conf para separar visualmente las variables de las reglas, la configuración se reduciría bastante:

Implicaciones de establecer límites mediante limit

Naturalmente, las funciones personalizadas pueden ser bastante más elaboradas, y en breve me referiré a un posible uso para imponer límites, pero antes quisiera demostrar que hay que tener cuidado.

Por ejemplo, he visto no pocos tutoriales sobre netfilter donde recomiendan la coincidencia limit, supuestamente para impedir ataques de denegación de servicios. Pero hay un problema: este límite es global; si no se comprenden bien las implicaciones, podríamos provocar nosotros mismos la denegación de servicios que intentábamos evitar.

Lo ilustraré mediante un ejemplo de un artículo publicado reciente en este mismo sitio:

En este caso, se crea la cadena personalizada syn_flood y se define un límite para los paquetes TCP con el flag SYN activo y todos los demás inactivos, es decir, las nuevas peticiones de conexión.

A la cadena syn_flood se agregan reglas que definen que mientras la tasa de paquetes SYN entrantes se mantenga por debajo de 500 por segundo (con la posibilidad de que en caso de picos se permitan hasta 2000 paquetes), simplemente se devuelve la evaluación sin descartar los paquetes y sólo en caso que se exceda este límite, los paquetes se descartan.

Pero supongamos que tenemos 50 clientes auténticos simultáneos realizando cada uno 10 peticiones por segundo (un consumo razonablemente bajo), y de repente aparecen 5 equipos maliciosos realizando ataques de inundación a razón de 400 paquetes por segundo cada uno (un ejemplo bien discreto de ataque coordinado; hay botnets que pueden tener cientos o miles de equipos vulnerados que se explotan para ataques coordinados).

¿Que pasaría entonces? Pues que la cantidad de paquetes SYN por segundo proveniente de los equipos maliciosos, activaría el límite establecido incluso para pico máximo, y nuestro servicio Web de repente se volvería inaccesible para todo el mundo.

En otras palabras, las propias reglas de nuestro cortafuegos habrían provocado la denegación de servicios. Esto ocurre porque como dije antes, la coincidencia limit se aplica para todos los paquetes, independientemente de la dirección IP de origen.

El uso de hashlimit

Una mejor solución sería la coincidencia hashlimit, que permite establecer límites por criterios específicos de dirección IP de origen, puerto de origen, dirección IP de destino y puerto de destino, entre otros parámetros.

Ago importante a tener en cuenta es que conviene aplicar este tipo de límite sólo a los paquetes que soliciten nuevas conexiones. De lo contrario, si tengo un equipo con el que me comunico regularmente, pero alguien me envía una inundación de paquetes falseando la IP de origen para suplantar a ese equipo, mis reglas provocarían que cuando yo envíe una solicitud a ese equipo, no recibiré nunca la respuesta, porque mis propias reglas bloquearán los paquetes de respuesta provenientes de esa dirección IP de origen.

Puede que suene complicado, pero es como si dijera: bloquea la entrada al portal de mi casa a todos los mensajeros que vengan de una pizzería. Pero que entonces me entrase hambre y ordenase una pizza a domicilio: la regla bloquearía también al mensajero que simplemente respondía mi pedido, y me quedaría con hambre. La idea entonces es bloquear acceso a todo el mensajero que venga de una pizzería, sin yo haber hecho pedido alguno. Una diferencia aparentemente sutil, pero muy importante.

Ahora bien, la coincidencia hashlimit es trabajosa, porque tiene varios parámetros y algunos son obligatorios, como el nombre de la tabla (en realidad es un hash bucket) donde se guardan las coincidencias con el límite declarado. No explicaré detalladamente el uso de la coincidencia hashlimit, pero está bien documentada, y como de costumbre, podrá consultarse mediante el comando de manual apropiado:

Ejemplo de función personalizada para límites de paquetes por equipo

En definitiva, la coincidencia hashlimit es un buen candidato para reducción, mediante una función definida por el usuario, como esta:

En este caso, utilicé el parámetro hashlimit-above, es decir se aplicará el destino de salto solamente cuando los paquetes excedan el límite declarado. Esta función nos permitirá entonces por ejemplo, minimizar el impacto de ataques de inundación al servicio HTTPS, que lleva un costo adicional de procesamiento en relación al HTTP por la necesidad de un canal cifrado sobre SSL.

La función que acabo de ejemplificar, permite limitar solamente al cliente que intenta sobreparasar el límite de solicitud de conexiones, sin afectar otros servicios, o incluso otros clientes que usen normalmente este servicio, por ejemplo:

Podrá observarse que en este caso declaro la interfaz de manera negativa (algo que mencioné en el artículo anterior de esta serie). Esto evitaría por ejemplo que realicen un ataque de inundación desde una subred local.

En este ejemplo, se impondrá un límite de 128 Kb por minuto para nuevas conexiones desde cada cliente que intente conectarse al servicio HTTPS. Como la ráfaga utilizada fue de 1 MB, esto significa que si el cliente estaba previamente inactivo, se le permitirá hasta 1 MB en el primer minuto sin aplicarle límites, pero en adelante no podrá exceder el límite, al menos por 180 segundos, es decir, 3 minutos. El destino de salto es la cadena LOG_DROP que habíamos definido antes, asi que los intentos que se descarten, quedarán registrados.

Continuará

Dejaré aquí esta segunda parte sobre ferm, espero haber mostrado ejemplos suficientemente ilustrativos de algunas cosas que pueden lograrse con este cortafuegos. En la próxima parte, mostraré entre otras cosas cómo realizar una lista automática de bloqueo temporal para las direcciones IP de origen desde donde provengan paquetes maliciosos.

Si crees que en este artículo algo no quedó suficientemente claro, o deseas que se cubran otros casos de usos mediante ejemplos en alguna próxima parte de esta serie, deja un comentario.

¿De cuánta utilidad te ha parecido este contenido?

¡Haz clic en una estrella para puntuar!

Promedio de puntuación 5 / 5. Recuento de votos: 5

Hasta ahora, ¡no hay votos!. Sé el primero en puntuar este contenido.

Sobre Hugo Florentino 11 artículos
Administrador de redes y sistemas. Usuario regular de GNU/Linux desde Octubre de 2008. Miembro fundador del Grupo de Usuarios de Tecnologías Libres (GUTL).

2 comentarios

    • Firefox 91.0 Firefox 91.0 Windows 10 x64 Edition Windows 10 x64 Edition
      Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0

      Gracias por reportar que a la invocación a include le faltaba la arroba. Arreglado.

Responder a JMIO Cancelar la respuesta

Tu dirección de correo electrónico no será publicada.


*