Un panel de administración en un servidor choca casi siempre con el mismo muro: el proceso web necesita hacer algo para lo que no tiene permisos. Bloquear una dirección, abrir un puerto, leer el diario del sistema. La respuesta que se impone es conceder una única línea estrecha en sudoers, y nada más:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

La línea se lee como «solo bloquear». En realidad significa «cualquier cosa, como root». A continuación, por qué ocurre así, cómo comprobarlo en su propio servidor en cinco minutos y por qué se sustituyen esas reglas.

Qué hace realmente *

El detalle decisivo: sudo no compara argumento por argumento, sino la línea de comando completa, y * en el patrón atraviesa sin problema los espacios, es decir, las fronteras entre argumentos. El literal banip situado en medio de la regla no restringe por tanto nada: basta con que la palabra banip aparezca en algún lugar del comando, y antes y después puede pasarse lo que sea.

Lo siguiente es buscar un programa al que se le pueda entregar un comando para ejecutar. En nuestro caso estaba en la propia regla. En fail2ban la acción de bloqueo se define como texto, y ese texto puede redefinirse en marcha:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<comando>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

La primera llamada sustituye el texto de acción de una acción real, la segunda hace que la cárcel se dispare, y fail2ban, que corre como root, ejecuta lo introducido. Lo hicimos el 4 de julio en nuestro propio servidor: un archivo en /root se creó como root, es decir, el proceso web obtuvo permisos completos sobre la máquina a partir de una regla que parecía estrecha.

Por el camino salieron tres detalles que suelen faltar en los análisis ajenos:

  • la opción -c delante de set la corta sudo: en el patrón, set está pegado al nombre del binario y no cabe insertar nada antes;
  • addaction y action sin la palabra banip tampoco pasan, pero todo lo necesario entra en un único comando set … banip …, así que el patrón se cumple;
  • el nombre de la acción tiene que ser real, o no hay nada que sustituir: fail2ban-client get <jail> actions lo muestra.

Quién más figura en esa misma lista

fail2ban no tiene aquí la culpa ni es un caso único. Lo peligroso es la combinación de NOPASSWD con un asterisco. Lo que encontramos al lado en nuestros propios servidores:

  • journalctl *: el diario se abre mediante un paginador, y desde el paginador se lanza un intérprete de comandos. La regla parece referirse a la lectura de registros; en la práctica es un shell de root. El remedio no es un patrón más estrecho, sino el grupo systemd-journal: entonces journalctl funciona sin sudo en absoluto;
  • grep * /var/log/fail2ban.log: el primer argumento de grep es el patrón, pero el asterisco permite suministrar también una segunda ruta, y grep se ejecuta como root. Leer /etc/shadow con esa regla es un solo comando. La sustitución es la misma: el grupo adm para leer los registros;
  • ufw --force * y los ufw allow/deny/delete * desnudos: el proceso web puede apagar el cortafuegos por completo. Resulta especialmente incómodo que en ambos casos el panel no usara esas reglas en absoluto: permisos muertos heredados de versiones tempranas del instalador;
  • lynis-scan.sh *: un envoltorio que pasa "$@" a un proceso root equivale a una regla sin restricción alguna.

Cómo ver qué hay en su servidor

Lo que hay que mirar no es el archivo, sino los permisos efectivos de un usuario concreto: aquel con el que realmente corre el pool de PHP-FPM (no siempre www-data; en una de nuestras máquinas el panel corría como admin):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

Después, la propia lista de archivos, y aquí hay dos trampas que nos costaron tiempo:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

La primera: las reglas viven en dos sitios a la vez. En nuestro caso la línea peligrosa estaba tanto en /etc/sudoers.d/monitor como en el /etc/sudoers principal, en este último en unas ocho copias acumuladas por distintas versiones del instalador. Limpiar un sitio y quedarse tranquilo es la forma habitual de dejar el agujero abierto.

La segunda: sudo ignora los archivos cuyo nombre contiene un punto. Un archivo www-data.bak3 en /etc/sudoers.d/ parece una regla vigente y se lee como una regla vigente, pero no surte efecto. Esto funciona en ambos sentidos: «la regla está, los permisos no» y el falso consuelo de una copia de configuración que «está ahí al lado».

Con qué sustituirlas

Estrechar el patrón no sirve: un asterisco en cualquier punto de la línea devuelve el problema al principio. Solo un enfoque funciona: un envoltorio de root que recibe argumentos posicionales fijos, los valida él mismo y llama al programa sin ninguna posibilidad de añadir nada.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, y nada más
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

El envoltorio pertenece a root, permisos 755, y —esto no es opcional— no debe poder escribirse desde el web, o toda la construcción pierde sentido. En sudoers queda solo él:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

Los argumentos no se enumeran en la regla: los comprueba el propio script, y las posiciones dentro de él son fijas, así que no hay dónde colar un texto de acción. Las reglas de solo lectura —estados, ss, ipset list— van en líneas aparte, también sin asterisco cuando es posible.

Aparte, sobre sustituir sudo por grupos. El enfoque es correcto: systemd-journal para el diario y adm para los registros salen más baratos y más seguros que cualquier regla de sudo. Pero no se puede sacar al usuario web de sus grupos a ciegas. Quitamos www-data de un grupo en un host con panel y obtuvimos 403 en todos los sitios a la vez: allí Apache corre como www-data, los archivos de los sitios pertenecen a otro usuario y el acceso de lectura lo daba precisamente esa pertenencia al grupo. La recuperación exigió un reinicio completo —no una recarga— de Apache y PHP-FPM, porque los procesos antiguos conservan su juego anterior de grupos y producen un «funciona, y luego 403».

Comprobación tras el cambio

Antes de sustituir un archivo de sudoers hay que comprobar su sintaxis, o se puede acabar sin sudo alguno:

sudo visudo -cf /etc/sudoers.d/monitor

Después, confirmar que el vector antiguo está muerto, y hacerlo en nombre de ese mismo usuario:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

La respuesta debe ser a password is required, mientras que llamar al envoltorio con basura en lugar de una dirección debe dar invalid ip. Aquí acecha una trampa propia: sudo -v guarda la confirmación en caché durante quince minutos, y después de eso cualquier comprobación con sudo -n pasa «con éxito». Así fue exactamente como una vez confirmamos una regla que no existía en el servidor. De ahí que sudo -k antes de la comprobación sea obligatorio.

Para qué observar

Las reglas de sudo cambian poco, pero cambian sin ruido: el instalador les añade líneas, el panel del hosting completa las suyas, un paquete eliminado deja las propias atrás. Una revisión puntual cierra lo que existe hoy y no dice nada de lo que traerá la siguiente actualización. La conclusión práctica es sencilla: la lista de quién puede convertirse en root está mejor a la vista, junto al resto del estado del servidor, que en el recuerdo posterior a un incidente. Cómo queda todo esto reunido puede verlo en las páginas de demostración de abajo.