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:

@def $IF_Publica = eth1;
@def $IP_Publica = 203.0.113.21;
domain ip table raw chain PREROUTING {
  policy ACCEPT;
  interface $IF_Publica {
    saddr   $IP_Publica DROP;
    daddr ! $IP_Publica DROP;
  }
}

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:

proto tcp {
  syn fragment DROP;                   # paquete TCP nuevo fragmentado, inválido
  # combinaciones de flags a filtrar
  tcp-flags ALL ALL DROP;              # todos los flags TCP activos, inválido
  tcp-flags ALL NONE DROP;             # ningún flag TCP activo, inválido
  tcp-flags (SYN FIN) (SYN FIN) DROP;  # nueva conexión y finalización de la misma a la vez, inválido
  tcp-flags (SYN RST) (SYN RST) DROP;  # nueva conexión y desconexión inmediata de la misma a la vez, inválido
  tcp-flags (SYN PSH) (SYN PSH) DROP;  # nueva conexión y a la vez empujar buffer sin análisis a la aplicación, inválido
  tcp-flags (SYN URG) (SYN URG) DROP;  # nueva conexión con puntero de evaluación de urgencia a la vez, inválido
  tcp-flags (FIN RST) (FIN RST) DROP;  # finalización y desconexión inmediata a la vez, inválido
  tcp-flags (ACK FIN) FIN DROP;        # finalización sin un flag ACK activo, inválido
  tcp-flags (ACK RST) RST DROP;        # desconexión inmediata sin un flag ACK activo, inválido
  tcp-flags (ACK PSH) PSH DROP;        # empujar buffer a la aplicación sin un flag ACK activo, inválido
  tcp-flags (ACK URG) URG DROP;        # puntero de urgencia sin un flag ACK activo, inválido
}

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

@def $IF_Publica = eth1;
@def $IP_Publica = 203.0.113.21;
domain ip table raw chain PREROUTING {
  policy ACCEPT;
  interface $IF_Publica {
    saddr   $IP_Publica DROP;
    daddr ! $IP_Publica DROP;
    @include 'tcpflags.conf';
  }
}

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:

@def $IF_LAN   = eth0;
@def $MAC_LAN  = 1a:2b:3c:4d:5e:6f;
@def $IP_LAN   = 192.168.100.1;
@def $CIDR_LAN = 192.168.100.0/24;
domain ip table raw {
  chain DHCP {
    saddr ! 0.0.0.0 DROP;         # descartar si la IP de origen no es nula
    daddr ! 255.255.255.255 DROP; # descartar si la IP de destino no es difusión
    proto ! udp DROP;             # descartar si el protocolo no es UDP
    proto udp {
      dport ! 67 DROP;            # descartar si el puerto de destino no es el 67
      sport ! 68 DROP;            # descartar si el puerto de origen no es el 68
    }
  }
  chain PREROUTING {
    policy ACCEPT;
    interface $IF_LAN {
      mod mac mac-source $MAC_LAN DROP; # decartar si la MAC de origen es de la interfaz LAN
      saddr $IP_LAN DROP;               # descartar si la IP de origen es de la interfaz LAN
      saddr ! $CIDR_LAN jump DHCP;      # si la IP de origen no es de la subred, saltar a la cadena DHCP
    }
  }
}

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í:

@def $IF_LAN   = eth0;
@def $MAC_LAN  = 1a:2b:3c:4d:5e:6f;
@def $CIDR_LAN = 192.168.100.0/24;
@def $IP_LAN   = 192.168.100.1;
domain ip table raw chain PREROUTING {
  policy ACCEPT;
  interface $IF_LAN {
    mod mac mac-source $MAC_LAN DROP;
    saddr $IP_LAN DROP;
    saddr ! $CIDR_LAN @subchain DHCP {
      saddr ! 0.0.0.0 DROP;
      daddr ! 255.255.255.255 DROP;
      proto ! udp DROP;
      proto udp {
        dport ! 67 DROP;
        sport ! 68 DROP;
      }
    }
  }
}

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:

# ferm -nl /etc/ferm/dhcp.conf
*filter
:FORWARD ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
COMMIT
*mangle
:FORWARD ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:PREROUTING ACCEPT [0:0]
COMMIT
*raw
:DHCP - [0:0]
:OUTPUT ACCEPT [0:0]
:PREROUTING ACCEPT [0:0]
-A DHCP ! --source 0.0.0.0 --jump DROP
-A DHCP ! --destination 255.255.255.255 --jump DROP
-A DHCP ! --protocol udp --jump DROP
-A DHCP --protocol udp ! --dport 67 --jump DROP
-A DHCP --protocol udp ! --sport 68 --jump DROP
-A PREROUTING --in-interface eth0 --match mac --mac-source 1a:2b:3c:4d:5e:6f --jump DROP
-A PREROUTING --in-interface eth0 --source 192.168.100.1 --jump DROP
-A PREROUTING --in-interface eth0 ! --source 192.168.100.0/24 --jump DHCP
COMMIT

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:

# apt install ulogd2

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:

chain LOG_DROP {
  NFLOG nflog-prefix @cat('DROP_', $TABLE);
  DROP;
}
chain LOG_ACCEPT {
  NFLOG nflog-prefix @cat('ACCEPT_', $TABLE);
  ACCEPT;
}

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:

table filter {
  @include 'trazas.conf';
  [...]
}

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:

@def $IF_Publica   = eth1;
@def $IP_Publica   = 203.0.113.21;
@def $MAC_Publica  = 1a:2b:3c:4d:5e:6f;
@def $Puertos_Web  = (80 443);
@def $Puertos_traceroute = 33434:33534;
domain ip {
  table raw {
    @include 'trazas.conf';
    chain PREROUTING {
      policy ACCEPT;
      interface $IF_Publica {
        mod mac mac-source $MAC_Publica jump LOG_DROP;
        saddr $IP_Publica jump LOG_DROP;
        daddr ! $IP_Publica jump LOG_DROP;
        @include 'tcpflags.conf';
      }
    }
  }
  table mangle {
    @include 'trazas.conf';
    chain PREROUTING {
      policy ACCEPT;
      interface $IF_Publica {
        mod conntrack ctstate INVALID jump LOG_DROP;
        mod conntrack ctstate NEW fragment jump LOG_DROP;
      }
    }
  }
  table filter {
    @include 'trazas.conf';
    chain INPUT {
      policy DROP;
      interface lo ACCEPT;
      mod conntrack ctstate (ESTABLISHED RELATED) ACCEPT;
      mod conntrack ctstate NEW {
        proto icmp icmp-type echo-request jump LOG_ACCEPT;
        proto udp dport $Puertos_traceroute @subchain LOG_REJECT {
          NFLOG nflog-prefix REJECT;
          REJECT;
        }
        proto tcp mod multiport destination-ports $Puertos_Web jump LOG_ACCEPT;
      }
      jump LOG_DROP;
    }
    chain FORWARD policy DROP;
    chain OUTPUT {
      policy ACCEPT;
      outerface $IF_Publica {
        saddr ! IP_Publica jump LOG_DROP;
        mod addrtype dst-type (LOCAL BROADCAST) jump LOG_DROP;
      }
    }
  }
}

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:

*filter
:FORWARD DROP [0:0]
:INPUT DROP [0:0]
:LOG_ACCEPT - [0:0]
:LOG_DROP - [0:0]
:LOG_REJECT - [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT --in-interface lo --jump ACCEPT
-A INPUT --match conntrack --ctstate ESTABLISHED,RELATED --jump ACCEPT
-A INPUT --match conntrack --ctstate NEW --protocol icmp --icmp-type echo-request --jump LOG_ACCEPT
-A INPUT --match conntrack --ctstate NEW --protocol udp --dport 33434:33534 --jump LOG_REJECT
-A INPUT --match conntrack --ctstate NEW --protocol tcp --match multiport --destination-ports 80,443 --jump LOG_ACCEPT
-A INPUT --jump LOG_DROP
-A LOG_ACCEPT --jump NFLOG --nflog-prefix ACCEPT_filter
-A LOG_ACCEPT --jump ACCEPT
-A LOG_DROP --jump NFLOG --nflog-prefix DROP_filter
-A LOG_DROP --jump DROP
-A LOG_REJECT --jump NFLOG --nflog-prefix REJECT
-A LOG_REJECT --jump REJECT
-A OUTPUT --in-interface eth1 ! --source IP_Publica --jump LOG_DROP
-A OUTPUT --in-interface eth1 --match addrtype --dst-type LOCAL --jump LOG_DROP
-A OUTPUT --in-interface eth1 --match addrtype --dst-type BROADCAST --jump LOG_DROP
COMMIT
*mangle
:FORWARD ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:LOG_ACCEPT - [0:0]
:LOG_DROP - [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:PREROUTING ACCEPT [0:0]
-A LOG_ACCEPT --jump NFLOG --nflog-prefix ACCEPT_mangle
-A LOG_ACCEPT --jump ACCEPT
-A LOG_DROP --jump NFLOG --nflog-prefix DROP_mangle
-A LOG_DROP --jump DROP
-A PREROUTING --in-interface eth1 --match conntrack --ctstate INVALID --jump LOG_DROP
-A PREROUTING --in-interface eth1 --match conntrack --ctstate NEW --fragment --jump LOG_DROP
COMMIT
*raw
:LOG_ACCEPT - [0:0]
:LOG_DROP - [0:0]
:OUTPUT ACCEPT [0:0]
:PREROUTING ACCEPT [0:0]
-A LOG_ACCEPT --jump NFLOG --nflog-prefix ACCEPT_raw
-A LOG_ACCEPT --jump ACCEPT
-A LOG_DROP --jump NFLOG --nflog-prefix DROP_raw
-A LOG_DROP --jump DROP
-A PREROUTING --in-interface eth1 --match mac --mac-source 1a:2b:3c:4d:5e:6f --jump LOG_DROP
-A PREROUTING --in-interface eth1 --source 203.0.113.21 --jump LOG_DROP
-A PREROUTING --in-interface eth1 ! --destination 203.0.113.21 --jump LOG_DROP
-A PREROUTING --in-interface eth1 --protocol tcp --syn --fragment --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags ALL ALL --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags ALL NONE --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags SYN,FIN SYN,FIN --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags SYN,RST SYN,RST --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags SYN,PSH SYN,PSH --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags SYN,URG SYN,URG --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags FIN,RST FIN,RST --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags ACK,FIN FIN --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags ACK,RST RST --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags ACK,PSH PSH --jump DROP
-A PREROUTING --in-interface eth1 --protocol tcp --tcp-flags ACK,URG URG --jump DROP
COMMIT

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:

@def $IF_LAN = eth0;
@def $PC01_MAC = 1a:2b:3c:4d:5e:6f;
@def $PC01_IP  = 192.168.0.2;
@def $PC02_MAC = 1a:2b:3c:4d:5e:7f;
@def $PC02_IP  = 192.168.0.3;
@def $PC03_MAC = 1a:2b:3c:4d:5e:8f;
@def $PC03_IP  = 192.168.0.4;
table raw {
  @include 'trazas.conf';
  chain PREROUTING {
    policy ACCEPT;
    interface $IF_LAN {
      saddr $PC01_IP mod mac mac-source ! $PC01_MAC jump LOG_DROP;
      saddr $PC02_IP mod mac mac-source ! $PC02_MAC jump LOG_DROP;
      saddr $PC03_IP mod mac mac-source ! $PC03_MAC jump LOG_DROP;
    }
  }
}

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:

@def &ip_nomac($origen, $mac, $destino) =
  saddr $origen mod mac mac-source ! $mac -m conntrack --ctstate NEW jump $destino;

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:

@include 'direcciones.conf';
@def &ip_nomac($origen, $mac, $destino) =
  saddr $origen mod mac mac-source ! $mac jump $destino;
table raw {
  @include 'trazas.conf';
  chain PREROUTING {
    policy ACCEPT;
    interface $IF_LAN {
      &ip_nomac($PC01_IP, $PC01_MAC, LOG_DROP);
      &ip_nomac($PC02_IP, $PC02_MAC, LOG_DROP);
      &ip_nomac($PC03_IP, $PC03_MAC, LOG_DROP);
    }
  }
}

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:

iptables -N syn_flood
iptables -A INPUT -p tcp --syn -j syn_flood
iptables -A syn_flood -m limit --limit 500/s --limit-burst 2000 -j RETURN
iptables -A syn_flood -j DROP

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:

$ man iptables-extensions

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:

@def &lim_src($nombre, $tasa, $rafaga, $expira, $destino) = {
  mod hashlimit
    hashlimit-mode srcip             # el límite se aplica solo por la IP de origen
    hashlimit-above $tasa            # la tasa de transferencia puede ser en cantidad paquetes,
                                     # paquetes por unidad de tiempo, o flujo de datos por tiempo,
                                     # pero el límite se aplicará sólo cuando se sobrepase la tasa
    hashlimit-burst $rafaga          # cuánto permitir por primera vez, antes de aplicar el límite
    hashlimit-htable-expire $expira  # cantidad de milisegundos tras los cuales el límite expira
    hashlimit-name $nombre           # nombre del hash bucket para almacenar este límite
  jump $destino;                     # destino de salto
}

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:

domain ip table raw {
  @include 'trazas.conf';
  chain PREROUTING {
    policy ACCEPT;
    interface ! lo protocol tcp dport 443 syn
      &lim_src(limite_https, 128kb/minute, 1mb, 180000, LOG_DROP);
  }
}

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 Hugo Florentino Cancelar la respuesta

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


*