1 puntos por GN⁺ 1 시간 전 | Aún no hay comentarios. | Compartir por WhatsApp
  • La seguridad de memoria mejora mucho, pero incluso en código Rust de producción siguen existiendo problemas en el manejo de límites del sistema, que pueden terminar en vulnerabilidades
  • Los flujos que vuelven a interpretar la misma ruta en varios syscalls, los métodos que cambian permisos después de crear un archivo y la comparación de rutas basada en cadenas facilitan problemas como TOCTOU y exposición de privilegios
  • En Unix, las rutas, variables de entorno y datos de streams circulan como bytes sin procesar, por lo que un manejo centrado en String o el uso de from_utf8_lossy, unwrap y expect puede causar corrupción de datos o DoS
  • Si se descartan errores, un fallo puede parecer éxito, y las diferencias de comportamiento con GNU coreutils pueden convertirse de inmediato en problemas de seguridad en scripts de shell y herramientas con privilegios
  • En esta auditoría no aparecieron bugs de seguridad de memoria como buffer overflow, use-after-free o double-free; el riesgo principal restante estaba concentrado no dentro de Rust, sino en los límites que lo conectan con el mundo exterior

Los límites de Rust que reveló la auditoría

  • Los 44 CVE de uutils publicados por Canonical muestran que incluso en código Rust de producción pueden quedar vulnerabilidades que borrow checker, clippy y cargo audit no detectan
  • El centro del problema no estuvo en la seguridad de memoria, sino en el manejo de límites del sistema
    • Había una brecha de tiempo entre la ruta y el syscall
    • Los datos en bytes de Unix y las cadenas UTF-8 no coincidían
    • Había diferencias de comportamiento con la herramienta original
    • Faltaba manejo de errores y había terminaciones por panic!
  • Esta lista de CVE muestra de forma condensada dónde termina la seguridad en el código de sistemas escrito en Rust

Interpretar una ruta dos veces genera TOCTOU

  • Si se verifica la misma ruta en un syscall y luego se vuelve a operar sobre ella en el siguiente, es fácil terminar en una vulnerabilidad TOCTOU
    • Entre ambas llamadas, un atacante con permiso de escritura en el directorio padre puede cambiar componentes de la ruta por un enlace simbólico
    • En la segunda llamada, el kernel vuelve a interpretar la ruta desde cero y una operación privilegiada puede dirigirse al objetivo elegido por el atacante
  • La API std::fs de Rust usa por defecto una reinterpretación basada en &Path, lo que facilita este tipo de errores
  • En CVE-2026-35355, se explotó un flujo que borra un archivo y luego crea uno nuevo en la misma ruta
    • En src/uu/install/src/install.rs, después de fs::remove_file(to)? venía File::create(to)?
    • Si entre el borrado y la creación to se cambia por un enlace simbólico que apunta a /etc/shadow o a otro objetivo, un proceso privilegiado puede sobrescribir ese archivo
  • La corrección cambió a OpenOptions::create_new(true) para que solo cree archivos nuevos
    • Según la documentación, create_new no permite ni archivos existentes ni dangling symlink en la ubicación de destino
  • Si se necesita operar dos veces sobre la misma ruta, es más seguro anclar la operación al file descriptor
    • Fuera de la creación de archivos nuevos, lo correcto suele ser abrir una vez el directorio padre y trabajar con rutas relativas respecto a ese handle
    • Si una misma ruta se usa dos veces, debe asumirse que hay TOCTOU hasta que se demuestre lo contrario

Los permisos no deben modificarse después de crear, sino definirse al crear

  • Crear un directorio o archivo con permisos por defecto y luego aplicar chmod también genera una breve ventana de exposición
    • Si se escribe fs::create_dir(&path)? y luego fs::set_permissions(&path, Permissions::from_mode(0o700))?, durante ese intervalo path existe con los permisos por defecto
    • Otros usuarios pueden hacer open() en esa ventana, y aunque luego se aplique chmod, los file descriptors ya obtenidos no se recuperan
  • Los permisos deben definirse al momento de crear
    • Hay que usar OpenOptions::mode() y DirBuilderExt::mode() para que nazcan con los permisos deseados
    • El kernel además aplica umask, así que si ese efecto también importa, debe manejarse explícitamente

Comparar cadenas de ruta no equivale a igualdad en el sistema de archivos

  • La verificación inicial de --preserve-root en chmod solo hacía una comparación de cadenas
    • recursive && preserve_root && file == Path::new("/")
    • Entradas que en realidad apuntan a la raíz, como /../, /./, /usr/.. o un enlace simbólico hacia /, pero que no son literalmente /, podían saltarse la verificación
  • La corrección cambió a comparar después de resolver la ruta real absoluta con fs::canonicalize
    • PR de corrección
    • canonicalize devuelve la ruta real tras resolver .., . y enlaces simbólicos
  • En el caso de --preserve-root, este método funciona porque / no tiene directorio padre
  • Para comparar en general si dos rutas arbitrarias apuntan al mismo objeto del sistema de archivos, no se deben comparar cadenas, sino (dev, inode)
    • GNU coreutils también usa este enfoque
  • En CVE-2026-35363, rm rechazaba . y .., pero permitía ./ y .///, lo que hacía posible borrar el directorio actual
    • Si las diferencias de entrada se manejan solo a nivel de cadena, es fácil esquivar la verificación

En los límites de Unix, los bytes deben tener prioridad sobre las cadenas

  • String y &str en Rust siempre son UTF-8, pero en Unix las rutas, variables de entorno, argumentos y datos de streams viven en un mundo de bytes sin procesar
  • Elegir mal al cruzar ese límite lleva a dos tipos de bugs
    • Las conversiones con pérdida como from_utf8_lossy reemplazan bytes inválidos por U+FFFD y corrompen datos en silencio
    • Las conversiones estrictas como unwrap o ? pueden rechazar la entrada o terminar el proceso
  • El CVE-2026-35346 de comm fue un caso donde la conversión con pérdida arruinó la salida
    • En src/uu/comm/src/comm.rs, los bytes de entrada ra y rb se convertían con String::from_utf8_lossy y luego se imprimían con print!
    • GNU comm conserva los bytes tal cual incluso en archivos binarios, pero uutils reemplazaba el UTF-8 inválido por U+FFFD, dañando la salida
    • La corrección fue usar BufWriter y write_all para escribir los bytes sin procesar directamente a stdout
  • print! obliga a un viaje de ida y vuelta por UTF-8 al pasar por Display, pero Write::write_all no
  • En código de sistemas Unix conviene usar el tipo adecuado para cada caso
    • Para rutas de archivo: Path, PathBuf
    • Para variables de entorno: OsString
    • Para contenido de streams: Vec<u8> o &[u8]
  • Si se pasa por String por comodidad de formateo, es fácil introducir corrupción de datos

Todo panic puede convertirse en una denegación de servicio

  • En una CLI, unwrap, expect, indexación de slices, aritmética sin comprobaciones y from_utf8 pueden convertirse en puntos de DoS cuando el atacante controla la entrada
    • panic! hace unwind del stack y detiene el proceso
    • Si corre dentro de un cron job, CI pipeline o script de shell, puede detener toda la tarea
    • En entornos de ejecución repetida, incluso puede provocar un crash loop que inutilice el sistema completo
  • El CVE-2026-35348 de sort --files0-from abortaba al encontrar nombres de archivo no UTF-8 en una lista separada por NUL
    • El parser llamaba std::str::from_utf8(bytes).expect(...) sobre los bytes de cada nombre
    • GNU sort trata los nombres de archivo como bytes sin procesar, igual que el kernel, pero uutils forzaba UTF-8 y detenía el proceso completo al primer path no UTF-8
  • En código que procesa entradas no confiables, unwrap, expect, indexación y casts con as deben verse como CVE potenciales
    • Conviene usar ?, get, checked_* y try_from, y propagar el error real al caller
  • También se proponen reglas de clippy para detectarlo en CI
    • unwrap_used
    • expect_used
    • panic
    • indexing_slicing
    • arithmetic_side_effects
  • En código de pruebas, estas advertencias pueden ser excesivas, así que es razonable limitarlas al ámbito cfg(test)

Si se descartan errores, un fallo puede parecer éxito

  • Algunos CVE surgieron por ignorar errores o por flujos donde la información del error se pierde
  • chmod -R y chown -R devolvían solo el código de salida del último archivo de toda la operación
    • Aunque fallaran muchos archivos antes, si el último salía bien, el proceso podía terminar con 0
    • El script interpretaría erróneamente que todo el trabajo terminó sin problemas
  • dd llamaba Result::ok() sobre el resultado de set_len() para imitar el comportamiento de GNU sobre /dev/null
    • La intención era descartar el error solo en una situación limitada, pero el mismo código terminó aplicándose también a archivos normales
    • Incluso con el disco lleno, podía quedar silenciosamente un archivo de destino escrito a medias
  • Si se descarta un Result con .ok(), .unwrap_or_default(), o let _ =, se pierden causas de fallo importantes
  • Aunque no se aborte en el primer error, hay que recordar el código de error más grave y terminar con él
  • Si de verdad se debe descartar un Result, hay que dejar en el código la razón por la que ese fallo puede ignorarse con seguridad

La compatibilidad exacta con la herramienta original también es una función de seguridad

  • Varios CVE no aparecieron porque el código hiciera operaciones peligrosas, sino porque se comportaba distinto a GNU
    • Los scripts de shell reales dependen del comportamiento original de GNU, así que una diferencia de significado puede convertirse en un problema de seguridad
  • Un ejemplo representativo es el CVE-2026-35369 de kill -1
    • GNU interpreta -1 como signal 1 y exige un PID
    • uutils lo interpretaba como enviar la señal por defecto al PID -1
    • En Linux, PID -1 significa todos los procesos visibles, así que un simple error tipográfico podía terminar matando el sistema completo
  • En herramientas reimplementadas, la compatibilidad bug-for-bug funciona como una barrera de seguridad que incluye códigos de salida, mensajes de error, edge cases y significado de opciones
  • En cada punto donde el comportamiento difiere de GNU, aumenta la posibilidad de que un script de shell tome una decisión equivocada
  • Ahora uutils también ejecuta en CI la suite de pruebas upstream de GNU coreutils
    • Parece una defensa del tamaño adecuado para bloquear este tipo de diferencias

Hay que resolver antes de cruzar el límite de confianza

  • El CVE-2026-35368 fue una ejecución local de código como root en chroot
  • El patrón del problema estuvo en hacer chroot(new_root)? y luego resolver nombres de usuario dentro de la nueva raíz controlada por el atacante
    • get_user_by_name(name)? terminaba leyendo bibliotecas compartidas del sistema de archivos de la nueva raíz para resolver el nombre de usuario
    • Si el atacante colocaba archivos dentro del chroot, eso podía llevar a ejecución de código con uid 0
  • GNU chroot resuelve al usuario antes de hacer chroot
    • La corrección también cambió a ese mismo orden
  • Una vez que se cruza un límite de confianza, cada llamada de biblioteca puede terminar ejecutando código del atacante
  • El enlace estático tampoco evita este problema
    • get_user_by_name pasa por NSS y puede hacer dlopen de módulos libnss_* en tiempo de ejecución

Los bugs que Rust sí evitó en la práctica

  • También es claro qué tipos de bugs no se encontraron en esta auditoría
    • No hubo buffer overflow
    • Tampoco use-after-free
    • Tampoco double-free
    • Tampoco data races por estado mutable compartido
    • Tampoco null-pointer dereference
    • Tampoco uninitialized memory read
  • Aunque las herramientas tenían bugs, en los resultados de la auditoría no apareció ninguno explotable como lectura arbitraria de memoria
  • GNU coreutils sí ha seguido teniendo en los últimos años CVE de seguridad de memoria de este tipo
    • pwd deep path buffer overflow
    • numfmt out-of-bounds read
    • unexpand --tabs heap buffer overflow
    • od --strings -N escritura de NUL fuera del heap buffer
    • sort lectura de 1 byte antes del heap buffer
    • split --line-bytes heap overwrite en CVE-2024-0684
    • b2sum --check lectura de memoria no asignada con entrada malformada
    • tail -f stack buffer overrun
  • En la comparación para el mismo período, la reimplementación en Rust mantuvo 0 bugs de esa categoría
    • Eso sí, la auditoría no demuestra la ausencia de bugs de seguridad de memoria; solo significa que no se encontraron
  • Los problemas restantes aparecen sobre todo en los límites que conectan con el mundo exterior, más que dentro de Rust
    • rutas
    • bytes y cadenas
    • syscall
    • diferencia temporal y cambios de estado del sistema de archivos

Rust correcto también es Rust idiomático

  • El Rust idiomático no consiste solo en pasar el borrow checker y tener un código donde clippy no se queje
  • La corrección también debe ser parte de lo idiomático
    • Porque las formas de código que sobreviven en el mundo real se consolidan a partir de la experiencia de la comunidad
  • Un sistema robusto no debe ocultar el desorden del mundo real, sino reflejarlo tal cual
    • file descriptors en vez de rutas
    • String en vez de OsStr
    • ? en vez de unwrap
    • compatibilidad bug-for-bug con el original antes que una semántica que solo se vea más limpia
  • El sistema de tipos puede expresar muchas cosas, pero no condiciones fuera de su control, como el paso del tiempo entre dos syscalls
  • El Rust idiomático debe hacer que los tipos, nombres y flujo de control del código expongan la verdad del entorno de ejecución
    • Aunque sea menos bonito que el código elegante de pizarrón, hace falta una forma más honesta

Material de referencia

Aún no hay comentarios.

Aún no hay comentarios.