In this article
Manejo seguro de rutas: por qué las operaciones seguras del sistema de archivos son más difíciles de lo que crees
Las vulnerabilidades de recorrido de rutas siguen apareciendo. Las funciones de E/S de archivos de la biblioteca estándar que ofrecen la mayoría de los lenguajes se diseñaron para facilitar el uso, no para entornos hostiles. Cuando tu código se ejecuta en un entorno aislado, un sistema compartido o junto con entradas que no son de confianza, esa brecha se convierte en una vulneración de los límites de seguridad.
El equipo de investigación de seguridad de Snyk se ha encontrado con este patrón repetidamente en distintos ecosistemas:
Vulnerabilidades de alta gravedad en Incus que combinaron la inyección de saltos de línea con ataques mediante enlaces simbólicos para lograr escrituras arbitrarias de archivos y una escalada de privilegios hasta obtener acceso root en el host
Una cadena de escalada de privilegios en NixOS que aprovechó condiciones de carrera en el almacén de Nix para mantener el acceso de escritura a rutas que deberían ser inmutables
Escalada de privilegios en Ubuntu 24.04 desde el usuario predeterminado hasta root, al encadenar la manipulación del sistema de archivos con errores en componentes privilegiados
No se trata de técnicas de ataque exóticas, sino del resultado de código que comprueba una ruta en un paso y la usa en otro, con un intervalo entre ambos en el que un atacante puede cambiar un destino seguro por uno malicioso.
Las tres clases de vulnerabilidades
Traversal de rutas
La clásica. Una entrada proporcionada por el usuario, como ../../etc/passwd, sale del directorio previsto. La mayoría de los desarrolladores sabe que debe estar atenta a esto, pero sigue apareciendo con frecuencia porque validar rutas es más difícil de lo que parece. Las comprobaciones simples de cadenas no contemplan la codificación URL, los bytes nulos, la normalización Unicode ni los separadores de ruta específicos de cada plataforma.
# Looks safe, isn't safe
def read_user_file(filename):
path = os.path.join("/app/uploads", filename)
if not path.startswith("/app/uploads"):
raise ValueError("Access denied")
return open(path).read()
# filename = "../../etc/passwd"
# path = "/app/uploads/../../etc/passwd"
# os.path.join resolves this, but startswith still passes on some inputsAtaques mediante enlaces simbólicos
Un atacante crea un enlace simbólico que conecta la ubicación a la que tu código espera acceder con otra a la que no debería acceder. Si tu código sigue los enlaces simbólicos (el comportamiento predeterminado en la mayoría de los sistemas), leerá o escribirá en el destino que eligió el atacante.
Esto es especialmente peligroso en entornos compartidos, directorios temporales y límites entre contenedores y entornos aislados, donde un atacante puede tener acceso limitado de escritura al sistema de archivos.
Condiciones de carrera TOCTOU
Del inglés Time-of-Check to Time-of-Use (del momento de la comprobación al momento del uso). Tu código valida una ruta y luego opera sobre ella. Entre esas dos operaciones, un atacante cambia el destino. La comprobación se aprueba, pero la operación se realiza sobre otro archivo.
# Classic TOCTOU vulnerability
if os.path.isfile(path) and not os.path.islink(path):
# Attacker swaps path for a symlink right here
with open(path, 'w') as f:
f.write(data) # Now writing to attacker's targetEl problema fundamental es que las operaciones basadas en rutas son intrínsecamente vulnerables a condiciones de carrera en sistemas multiusuario o multiproceso. Entre dos llamadas al sistema que hagan referencia a la misma ruta, el estado del sistema de archivos puede cambiar.
La solución: operaciones basadas en descriptores de archivos
El enfoque correcto combina openat(), O_NOFOLLOW y la familia de llamadas al sistema f* (fstat, fchmod, fchown). La idea clave es que, una vez que tienes un descriptor de archivo abierto, este hace referencia a un objeto específico del sistema de archivos, sin importar qué ocurra con la ruta.
El patrón funciona así:
Empieza con un directorio conocido y seguro (como
/o la raíz de un entorno aislado)Abre cada componente de la ruta por separado con
openat(), usando el descriptor de archivo del directorio anteriorUsa
O_NOFOLLOWpara rechazar enlaces simbólicos en cada pasoValida cada componente (sin
.., sin enlaces simbólicos y con los permisos correctos)Usa el descriptor de archivo final para todas las operaciones posteriores
Así se evitan las condiciones de carrera TOCTOU porque nunca vuelves a usar la ruta después de abrirla. Todas las operaciones se realizan mediante el descriptor de archivo, que el kernel garantiza que hace referencia al mismo inodo, independientemente de los cambios en la ruta.
Cómo lo manejan los lenguajes
C/C++: control total disponible
C y C++ permiten acceder directamente a openat() y O_NOFOLLOW, por lo que implementar una solución correcta es sencillo (aunque requiere mucho código). La solución de Google para ChromeOS (libbrillo/brillo/files/safe_fd.cc) es una buena implementación de referencia.
int safe_open(int dirfd, const char *component) {
return openat(dirfd, component,
O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
}Go: compatibilidad en la biblioteca estándar (desde Go 1.24)
Go agregó os.Root a la biblioteca estándar, que ofrece una forma segura y sin condiciones de carrera de acceder a los archivos dentro de un árbol de directorios. Es el estándar de oro en cuanto a compatibilidad integrada en el lenguaje.
// Go 1.24+ — safe by default
root, err := os.OpenRoot("/sandbox")
if err != nil {
log.Fatal(err)
}
defer root.Close()
f, err := root.Open("user/data.txt")
// Guaranteed to stay within /sandbox, TOCTOU-proofVale la pena leer la publicación del blog de Go sobre el diseño de os.Root para conocer los motivos y los casos límite que tuvieron en cuenta.
Rust: buenas primitivas, implementación manual
Rust expone openat mediante el crate nix y es compatible con O_NOFOLLOW. El crate cap-std ofrece acceso al sistema de archivos basado en capacidades, que evita el recorrido de rutas desde el diseño.
use cap_std::fs::Dir;
let root = Dir::open_ambient_dir("/sandbox", cap_std::ambient_authority())?;
let file = root.open("user/data.txt")?;
// Constrained to /sandboxPython: compatibilidad parcial
Python 3.11 y versiones posteriores incluyen os.open() con los parámetros O_NOFOLLOW y dir_fd, pero crear el patrón completo para recorrer rutas de forma segura requiere trabajo manual. No hay un equivalente en la biblioteca estándar a os.Root de Go.
import os
def safe_open_under(root_path, relative_path):
root_fd = os.open(root_path, os.O_RDONLY | os.O_DIRECTORY)
try:
components = relative_path.split('/')
current_fd = root_fd
for component in components[:-1]:
if component in ('', '.', '..'):
raise ValueError(f"Invalid path component: {component}")
next_fd = os.open(
component,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=current_fd
)
if current_fd != root_fd:
os.close(current_fd)
current_fd = next_fd
return os.open(
components[-1],
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=current_fd
)
finally:
os.close(root_fd)Node.js: ¿prácticamente imposible?
Aquí es donde las cosas se complican. Node.js no expone openat() ni O_NOFOLLOW en su biblioteca estándar. El módulo fs opera exclusivamente con rutas, no con descriptores de archivos. No hay forma de recorrer directorios sin riesgos de TOCTOU en Node.js puro, sin complementos nativos.
Lo mejor que puedes hacer es usar fs.realpath() y luego comprobar el prefijo, pero esto es intrínsecamente vulnerable a condiciones de carrera. Un complemento nativo que use N-API podría exponer openat(), pero implicaría requisitos de compilación y complejidad específica de cada plataforma.
Esta es una carencia importante para un entorno de ejecución que se usa ampliamente en herramientas de CLI, sistemas de compilación y entornos con contenedores, donde la seguridad de las rutas es fundamental.
¿Cuándo importa esto?
No todas las aplicaciones necesitan operaciones con archivos a prueba de TOCTOU. Si tu código solo abre archivos desde rutas codificadas directamente o se ejecuta únicamente en un entorno de un solo usuario sin entradas que no sean de confianza, las funciones de la biblioteca estándar son suficientes.
Deberías prestar atención al manejo seguro de rutas cuando:
Tu código se ejecuta en un entorno aislado o un contenedor y las operaciones del sistema de archivos cruzan límites de confianza
Tu código procesa rutas de archivos proporcionadas por el usuario (cargas, archivos de configuración, directorios de complementos)
Tu código se ejecuta con privilegios elevados y accede a rutas que pueden verse afectadas por usuarios sin privilegios
Tu código opera en entornos compartidos (sistemas multiusuario, canales de CI/CD, sistemas de compilación)
Tu código maneja archivos temporales en directorios con permisos de escritura para todos, como
/tmp
Acciones recomendadas
Audita tu base de código para encontrar operaciones con archivos basadas en rutas que aceptan entradas del usuario
Reemplaza las comprobaciones basadas en rutas (como
startswith()después derealpath()) por un recorrido basado en descriptores de archivos, siempre que sea posibleEn Go 1.24 y versiones posteriores, usa
os.Rootpara cualquier acceso a archivos en entornos aisladosEn Rust, considera usar el crate
cap-stdpara acceder al sistema de archivos mediante capacidadesEn Node.js, reconoce la limitación y aplica varias capas de defensa (validación de entradas, chroot y perfiles de AppArmor/SELinux)
Usa Snyk Code para detectar patrones de recorrido de rutas en tu base de código durante el desarrollo
¿Defectos ocultos en el manejo de archivos, como el recorrido de rutas, están exponiendo silenciosamente tus aplicaciones a vulneraciones más profundas? Descarga el informe técnico The AI Security Crisis in Your Python Environment para descubrir cómo los equipos modernos están previniendo estos riesgos tanto en código generado por IA como en el escrito por desarrolladores.
DOCUMENTO TÉCNICO
La crisis de seguridad de IA en tu entorno de Python
Con el ritmo de desarrollo acelerándose a un ritmo vertiginoso, ¿sabes realmente a qué puede acceder tu entorno de IA?