5 puntos por GN⁺ 2025-01-24 | 4 comentarios | Compartir por WhatsApp
  • Mientras EdgeDB movía el I/O de red de Python a Rust, una nueva prueba de HTTP fetch basada en reqwest parecía quedarse colgada solo en CI de ARM64, pero en realidad se estaba cerrando por un crash
  • El análisis del core dump mostró que el punto del problema no era el nuevo código HTTP, sino el interior de getenv() de libc, que fallaba al intentar leer un puntero inválido 0x220 mientras recorría el arreglo de variables de entorno
  • Otro hilo estaba configurando SSL_CERT_FILE y SSL_CERT_DIR en la ruta de openssl-probe, y setenv() reasignó environ; al mismo tiempo, la ruta de manejo de errores de Python llamó a getenv(), creando una condición de carrera
  • El código Rust no tenía unsafe explícito, pero mientras std::env::set_var() modificaba el entorno global, el lock interno de Rust no sincronizaba eso con otros runtimes ni con llamadas directas a libc
  • La solución fue migrar en Linux del backend rust-native-tls/openssl de reqwest a rustls, y tanto Rust 2024 edition como glibc también se están moviendo para reducir este tipo de problemas

Un crash que solo apareció en CI de ARM64

  • EdgeDB estaba creando una nueva función de HTTP fetch mientras migraba una parte considerable de su código de I/O de red de Python a Rust
  • Usaron reqwest como biblioteca cliente HTTP, y la funcionalidad pasaba localmente y en runners de CI x86_64, pero fallaba de forma intermitente en runners de CI ARM64
  • Al principio parecía que el runner de pruebas se quedaba colgado indefinidamente, y en los logs de CI un test seguía ejecutándose sin errores aparentes hasta terminar en timeout horas después
  • La hipótesis inicial era una diferencia en el modelo de memoria entre Intel y ARM64
    • Intel tiene un modelo de memoria relativamente estricto, con un orden total en el que todos los procesadores coinciden respecto a las escrituras de memoria
    • ARM tiene un modelo de memoria de orden más débil, donde las escrituras pueden verse en distinto orden desde diferentes hilos

Rastreando el core dump en el entorno de CI con Docker

  • La máquina de CI nocturna corría en Amazon AWS, así que pudieron conectarse como el usuario root real fuera del contenedor para ver dmesg y los logs del sistema
  • Buscaron dentro y fuera del contenedor el PID que parecía haberse quedado colgado, pero ese proceso no existía, lo que reveló que no era un deadlock sino un crash
  • Como los contenedores Docker usan namespaces de procesos, el core dump se entregó al host de Docker, y en journalctl encontraron el core dump del proceso python3
  • La primera vez que abrieron el core con gdb, el backtrace no servía porque faltaban los archivos .so del interior del contenedor
  • Después de copiar desde el contenedor /lib, /usr y otros directorios, y de configurar solib-absolute-prefix en gdb, confirmaron que el punto del crash era getenv() en libc.so.6

El puntero roto de variable de entorno que leyó getenv()

  • El backtrace completo seguía la secuencia getenv()__dcigettext()strerror_r()strerror()PyErr_SetFromErrnoWithFilenameObjects()
  • No era el nuevo código HTTP el que crasheaba directamente; Python estaba construyendo una excepción basada en errno y en ese proceso, a través de una ruta relacionada con gettext, terminó llamando a getenv()
  • La implementación de getenv() en GLIBC 2.17 recorre el environ de POSIX como una lista char ** y examina los punteros a cadenas de variables de entorno hasta el puntero final NULL
  • Según el desensamblado y el estado de los registros, getenv() se crasheó al intentar leer un byte desde la dirección x19 = 0x220
    • 0x220 claramente no era una dirección de memoria válida
    • Al inspeccionar environ en sí, la lista actual de variables de entorno parecía consistente

La causa: una condición de carrera entre setenv() y getenv()

  • setenv() no es una función segura para llamar en un entorno multihilo, y el problema de provocar crashes extraños en getenv() de libc ya se ha redescubierto varias veces
  • Al comparar el desensamblado con el código en C, vieron que x20 correspondía a ep, el puntero que recorre el arreglo environ
  • En el momento del crash, x20 era 0x248b5000, mientras que el environ actual era 0x28655750, unos 60 MB más adelante
  • Al comparar la memoria alrededor del arreglo de entorno anterior con el environ actual, la última diferencia aparecía en las entradas SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt y SSL_CERT_DIR=/etc/ssl/certs
  • Otro hilo movió environ durante una llamada a setenv(), y getenv() siguió leyendo el arreglo anterior de entorno, entrando en una situación de use-after-free

La conexión con openssl-probe y el backend TLS

  • En un issue antiguo relacionado con rust-native-tls, encontraron la pista de que openssl-probe configura las variables de entorno SSL_CERT_FILE y SSL_CERT_DIR para localizar los certificados del sistema
  • En Linux, esa ruta se ejecuta cuando se usa el backend openssl de rust-native-tls
  • El código problemático de openssl-probe configuraba esas dos variables con env::set_var() sin unsafe explícito
    • SSL_CERT_FILE
    • SSL_CERT_DIR
  • Esa combinación hizo que código Rust sin unsafe interactuara mal con el uso de libc dentro del mismo proceso, provocando el crash

Por qué se reprodujo en Linux ARM64

  • Este crash solo ocurre cuando setenv() mueve el arreglo de entorno con realloc justo en el momento en que otro hilo llama a getenv()
  • Para reproducirlo, debían coincidir varias condiciones al mismo tiempo
    • La cantidad de variables de entorno tenía que ser la justa para disparar realloc
    • Un fallo de I/O no relacionado tenía que ser capturado por asyncio
    • La ruta de manejo de errores de Python tenía que llamar a getenv() para obtener la variable de entorno LANGUAGE exactamente en ese mismo instante
  • El valor incorrecto 0x220 estaba cerca del tamaño del entorno anterior en palabras de 64 bits
    • 0x220 / 8 = 68
    • Ese valor sobrescribió el NULL final del bloque de entorno anterior, y parecía ser el valor que usa el malloc del sistema para indicar el tamaño de un bloque libre
  • Como hacían falta tantas precondiciones, que fuera bastante reproducible en una sola plataforma fue, de hecho, una situación de suerte

La pista confirmada en el desensamblado de ARM64

  • En el desensamblado de getenv(), no era fácil ver dónde cambiaba x20, lo que generó confusión
  • La clave era el modo de direccionamiento pre-indexado de AArch64
  • ldr x19, [x20, #8]! funciona así
    • x19 = *(x20 + 8)
    • x20 = x20 + 8
  • Debido a ese modo de direccionamiento, x20 iba recorriendo el arreglo de punteros de variables de entorno aunque no apareciera escrito explícitamente a la izquierda

La corrección aplicada y los cambios en proyectos relacionados

  • Finalmente decidieron migrar en Linux del backend rust-native-tls/openssl de reqwest a rustls
  • Originalmente habían elegido el backend native TLS para evitar cargar dos motores TLS mientras movían código de Python a Rust
  • Después de este problema, concluyeron que a corto plazo cargar dos motores TLS era aceptable
  • Como alternativa, también se podía hacer la primera llamada a try_init_ssl_cert_env_vars() mientras se sostenía el GIL de Python
    • Rust tiene un lock interno para evitar carreras al leer y escribir el entorno entre código Rust
    • Pero eso no alcanza a impedir casos donde código de otros lenguajes usa libc directamente
    • Mantener el GIL al menos puede evitar la carrera con hilos de Python
  • El proyecto Rust ya reconocía este problema y planea volver unsafe las funciones setter del entorno en la edition 2024
  • El proyecto glibc también agregó recientemente cambios para mejorar la seguridad de hilos de getenv(), evitando realloc y dejando con fugas los arreglos viejos de entorno

4 comentarios

 
carnoxen 2025-01-24

setenv no es thread-safe y C no quiere corregirlo

La función setenv está causando problemas otra vez.

 
y15un 2025-01-24

Yo pondría el título como: "La falta de seguridad para hilos de la stdlib de C es algo que ni Rust, por muy seguro que sea, puede remediar". :)

 
halfenif 2025-01-24

Lo entendí claramente.

 
GN⁺ 2025-01-24
Comentarios de Hacker News
  • El punto más importante aquí es que, en la próxima edición de Rust, la función para establecer variables de entorno pasará a ser unsafe.
    Con suerte, parece que el impacto llegará hasta los crates que provocan estos crashes, y mientras tanto ya se abrió un issue upstream: https://github.com/alexcrichton/openssl-probe/issues/30

    • Pero el problema de fondo, es decir, que getenv y setenv o unsetenv no pueden llamarse de forma segura desde hilos distintos, en realidad no se corrige.
      La única solución confiable parece ser cambiar estas funciones para que necesariamente tomen un mutex.
    • Me alegra que el criterio del autor de la biblioteca —“hoy, la mejor solución a este problema es usar una biblioteca como rustls y dejar de usar este crate”— sea razonable.
      Lamentablemente, el ecosistema no es así: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
    • La gente está entrenada para ignorar bloques y prefijos como ____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___.
      En los frameworks web pasa algo parecido: Vue tiene la directiva v-html y React tiene dangerouslySetInnerHTML; en este aspecto, creo que Vue es claramente mejor.
  • set_var y remove_var de la biblioteca estándar de Rust exigirán correctamente un bloque unsafe {} en la próxima edición, la edición 2024.
    La documentación ahora también menciona el problema de seguridad, pero haber hecho estas funciones safe desde el principio fue un error, y también es un error que cometieron lenguajes de más alto nivel.
    https://doc.rust-lang.org/stable/std/env/fn.set_var.html
    glibc tiene un parche que vuelve más seguro getenv en más casos en los que se modifica el entorno, pero en C todavía se puede acceder directamente a environ, así que no puede volverse completamente seguro en situaciones donde haya modificaciones: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...

    • Me sorprende que glibc ahora conserve versiones anteriores y adopte una política de redimensionamiento exponencial.
      Por cada variable de entorno activa se produce una fuga de memoria de tamaño constante amortizado, aunque al parecer las propias variables ya tenían una fuga de ese tipo, incluso dependiente de la longitud, e incluían valores que ya no se usan.
      Seguro habrá casos patológicos de uso en los que, incluso en programas correctos según la API, la memoria crezca sin límite por esto.
      Es interesante, aunque inquietante: para arreglar programas que rompieron las reglas al usar la API en varios hilos, se termina introduciendo en programas que sí siguen la API un bug de crecimiento ilimitado de memoria. Se siente más pragmatismo que dogma.
    • Si la implementación de la biblioteca estándar puede encargarse de la sincronización, me pregunto por qué debería exigir unsafe.
  • Aunque los mantenedores de la biblioteca estándar de C se opongan a hacer que setenv sea segura en múltiples hilos, como mínimo habría que definir una nueva API segura para hilos, ya sea dentro de POSIX o creando primero un estándar de facto para que POSIX lo adopte después.
    Si el tiempo que se usa para explicar por qué no se puede hacer nada se hubiera dedicado a arreglar este problema, ya habría sido posible reemplazar el viejo setenv y deprecarlo o eliminarlo en muchos proyectos de software.
    Viendo que glibc está haciendo cambios para eliminar este problema en la práctica, tampoco resulta muy convincente la afirmación del mantenedor de Musl de que no se puede arreglar dentro de Musl.

    • El mayor problema no es que no exista una API segura para hilos, sino que existe extern char **environ;.
      Mientras environ sea accesible públicamente, ni siquiera hay garantía de que setenv y getenv se usen, porque no son obligatorios.
      Si se pudiera eliminar environ, hacer que setenv y getenv fueran seguros para hilos sería bastante sencillo. Si no se puede eliminar, es imposible; aun así, se puede argumentar que hacer que setenv y getenv sean seguros para hilos sería una mejora, aunque no sea una solución completa.
    • Parece que todas las funciones exec() que no reciben el entorno como argumento o que buscan en PATH para encontrar el ejecutable también necesitarían bloqueo.
    • No me convence que se sepa más que los expertos que concluyeron que esto no puede corregirse de forma compatible hacia atrás.
  • Encontrarse con bugs relacionados con variables de entorno en Linux parece una especie de rito de iniciación, y en otros Unix, curiosamente, suelen ser menos problemáticos
    Linus y el kernel arreglan los bugs de POSIX de forma pragmática, haciendo que no exploten en la práctica, pero resulta un poco gracioso que glibc siga atrasada incluso décadas después de que la gente intentara mitigar aunque fuera un poco el problema
    Es cierto que hay todo tipo de dolores de cabeza como TZ, pero si tan solo hubieran ofrecido getenv_r(), lo hubieran sincronizado con setenv() y hubieran emitido advertencias en tiempo de compilación y enlace al usar getenv(), una buena parte de los problemas habría desaparecido
    Más aún, incluso podrían haber usado un enfoque de copia al escribir (COW), dejando los punteros del entorno como solo lectura
    En cambio, le pasaron el problema a cada aplicación, lo cual es un gran error porque quienes escriben aplicaciones casi no pueden saber qué hacen sus dependencias. Eso fue lo que me pasó hace mucho tiempo, y en ese entonces el proveedor de una biblioteca de código cerrado me dijo que dejara de usar Linux, ese clon de Unix de juguete

    • No sé de dónde sale la conclusión de que “en Linux hay bugs relacionados con variables de entorno y en otros Unix el problema es menor”
      El problema no es la implementación, sino la API en sí. setenv(), unsetenv(), putenv() y, en particular, environ son inherentemente inseguros en programas multihilo
      getenv_r() tampoco puede salvarlo todo. Mientras un hilo copia el valor anterior de una variable de entorno al búfer proporcionado, otro hilo puede llamar a setenv()
      Claro que getenv_r() sí arregla el caso en que, después de recibir algo con getenv(), otro hilo llama a setenv() e invalida esa memoria, pero no hay forma de impedir las otras llamadas que rompen la API
      libc puede mitigar algunos problemas tomando un mutex dentro de getenv()/setenv()/putenv()/unsetenv(), pero aun así no hay forma de que libc garantice que el valor devuelto por getenv() siga siendo válido durante el tiempo suficiente para que el código llamador lo use
      Tampoco hay una buena forma de hacer seguro el acceso directo a environ. Se podría hacer que environ fuera local al hilo, pero entonces la visión del entorno podría divergir permanentemente entre hilos, y una llamada a getenv_r() podría dar un resultado distinto al de consultar environ directamente
      Mantener aquí la compatibilidad hacia atrás es realmente difícil, e incluso solo agregar un mutex para proteger las funciones podría cambiar la semántica de programas existentes y romperlos
  • Antes hubo un artículo sobre lo horrible que es setenv: https://www.evanjones.ca/setenv-is-not-thread-safe.html
    También hubo una discusión, y desde el primer comentario se mencionaba que causaba problemas en Rust: https://news.ycombinator.com/item?id=38342642

    • Eso ya es un hecho conocido
      Aquí, la mayoría del resto del problema parece estar en el entorno de desarrollo. Estaban probando con Docker en una máquina remota de un centro de datos de Amazon, y ese equipo no pudo reportar el crash del proceso
      Además, dentro del contenedor tampoco había suficiente información de símbolos de depuración para obtener un backtrace. Si hubieran recibido un backtrace limpio en el primer fallo, habría quedado claro de inmediato
      Para empezar, también me pregunto por qué usan setenv
  • Al leer esto me acordé del movimiento 12-factor app, en el que varios excompañeros creían muchísimo. Uno de esos “factores” era que la configuración de la aplicación debía hacerse mediante variables de entorno
    Siempre me pareció algo tonto, porque ese método de configuración consiste en meter valores de tipo string en un espacio de nombres plano, como si fuera una canasta
    También veo los riesgos de getenv()/setenv()/environ como un argumento fuerte para no usar variables de entorno para la configuración
    Claro que no siempre hay una alternativa excelente y bien soportada. Yo prefiero archivos de configuración, y también se pueden usar configuraciones con plantilla en las que solo se rellenan los valores de desarrollo, staging y producción. Normalmente uso YAML aunque tenga desventajas y trampas; puede haber mejores formatos de archivo de configuración, pero creo que YAML es mucho mejor que las variables de entorno

    • Hay mucha antipatía fuerte hacia Windows y Microsoft, pero con el tiempo hay bastantes casos en los que su diseño de API termina justificándose
      En NT, las variables de entorno pueden tener tipos y plantillas, y también existe el Registro, una base de datos de configuración con espacios de nombres. Aunque sea verboso y raro
      Además, MSVC ofrece versiones thread-safe de casi todas las funciones de la biblioteca estándar
      A menudo escucho a nuevos desarrolladores de C/C++ lamentarse de la falta de compatibilidad POSIX de MSVC, pero no parecen pensar demasiado en lo que eso significa realmente. Es más bien que quieren compatibilidad cruzada con programas en C escritos en los años 90
    • Tengo una preocupación similar sobre las variables de entorno. No me gusta que se puedan leer desde cualquier lugar
      Dificultan la capacidad de inferir el comportamiento solo a partir de la firma de una función, y vuelven impuras muchas funciones que podrían haber sido puras
      Si existiera una característica del lenguaje para marcar una app de modo que las variables de entorno no pudieran usarse desde ningún proceso y solo pudieran leerse una vez, en bloque y no variable por variable, creo que la usaría en todas partes
    • getenv() en sí está perfectamente bien; el problema es setenv()
      En teoría, el entorno se configura antes de que arranque esa misteriosa app, así que no debería haber necesidad de usarlo
      Pero el espacio de nombres plano, los valores string y el hecho de que sea un espacio global libre compartido por todos, sin saber qué bibliotecas y módulos van a entrar, no son una buena idea aunque no existiera el problema de seguridad de setenv()
    • “12-factor app” debería tener un apéndice que diga que, mientras el proceso está vivo, el entorno debe tratarse como de solo lectura
      La mayoría de los problemas de los que habla la gente aquí parecen venir de abusar del entorno como si fuera un almacén clave-valor para estado global mutable. No sé por qué alguien querría hacer eso
      La JVM trata el entorno como prácticamente inmutable, y es posible que empresas usuarias de Scala y Java como SoundCloud hayan influido en el movimiento 12-factor app. Yo nunca he visto que el entorno cambie ni que cause problemas de threading
      Aunque el entorno cambie, la copia inmutable creada al iniciar la JVM permanece igual, y el código que interactúa con el entorno mediante la API normal de Java no ve esa modificación
      El problema de los archivos de configuración es que el parseo depende de cada proceso. Por eso Linux/Unix es tan desordenado. Cada herramienta tiene convenciones y mecanismos de configuración distintos, y no hay un estándar
      En el ecosistema Docker, dentro del contenedor se puede hacer lo que sea, pero la interfaz con el exterior consiste en montar volúmenes y seguir el complejo método de configuración de cada app, o simplemente usar variables de entorno
      La mayoría del software moderno que hoy se ejecuta con Docker es lo bastante amigable con Docker como para poder controlar completamente su comportamiento con el entorno, y en muchos casos eso basta
      Si usas Docker Compose o Kubernetes, terminas teniendo en un archivo YAML la lista de variables de entorno que define cómo se inicia el proceso, así que en cierta medida obtienes la estructura que querías. No me gusta YAML, pero funciona lo suficientemente bien, y aunque es muy posible que un problema de sintaxis te arruine el día, las alternativas tampoco están libres de problemas
    • En realidad, este es un problema aparte. Si lees las variables de entorno como configuración y luego no las vuelves a tocar, es completamente seguro
      Yo también uso el estilo 12-factor app, pero después de que entran a la app valido y guardo las variables de entorno y los datos. Después de eso no hay ningún problema
  • Es un excelente artículo que profundiza en un bug difícil de notar
    Tenía de todo: un bug intermitente, particularidades de arquitectura, algo escondido dentro de una dependencia, Rust, el GIL de Python e incluso gettext
    Estos reportes detallados de resolución de problemas son lo más cercano a vivirlos uno mismo. Cuando la situación es “mi dependencia lo usa, ¿cómo iba a saberlo?”, no es tan fácil decir “pues no uses X”

  • Dicen que “las máquinas de CI nocturnas corren en Amazon AWS y tienen la ventaja de poder usar un usuario root real, no un contenedor”, pero al mismo tiempo que “fuera del contenedor no están los archivos necesarios y el contenedor está tan minimalista que no se puede instalar gdb fácilmente”
    ¿Será que la gente ya perdió la capacidad de compilar y depurar localmente sin la nube ni contenedores?

    • Sí. Es impactante cuánto ha distorsionado el entendimiento de la gente el SaaS en la nube
      Para hacer cosas trivialísimas ahora se necesita toda clase de complejidad de nube y capas de despliegue. Es como haber revertido al 100% la revolución de la PC y volver a la era tosca y cara de la computación de mainframe
      La razón es que en la nube hay dinero, y la nube es DRM. Si subes el software ahí, puedes cobrar suscripciones, no se puede evadir y puedes mantener una dependencia perfecta para siempre. En muchos casos, los usuarios ni siquiera pueden sacar sus propios datos
      También permite hacer cómodamente análisis en tiempo real para optimizar el producto
      La arquitectura de cómputo está aguas abajo del modelo de negocio. La primera vez que murieron los mainframes fue porque no había internet y las PC eran más baratas, pero también porque los proveedores perdieron mucho poder de lock-in
      Ahora existe una forma de revivir un modelo mucho más rentable. La molesta libertad del usuario desapareció y, francamente, cuando los usuarios obtienen esa libertad, a menudo no pagan, lo que dificulta que exista un negocio de software de buena calidad
    • Este es un problema como corrupción aleatoria de memoria que solo aparece en ARM
      Es muy probable que no pudieran reproducir el crash localmente. Las máquinas de los desarrolladores eran en su mayoría x86 y ahí probablemente no crasheaba
      Deberían haber manejado mejor el crash, pero parece que ellos también son conscientes de ese problema y no es el punto central tratado aquí
    • Claro que no perdimos la capacidad, pero en nuestras máquinas no crasheaba
    • Si lo más probable es que no tengas un dispositivo que ejecute la arquitectura problemática, ¿cómo vas a depurar localmente? De todos modos, es mucho más rápido depurar en el entorno real donde ocurre la falla
  • El estado global mutable es el mal. Si eres amigo de alguien, no dejes que use estado global mutable
    Odio las variables de entorno. Esta es la “forma Linux”, pero la evito como a la peste. Muy recomendable
    libc es terrible y el mundo ya debería pasar página

    • Las variables de entorno están bien si se tratan como solo lectura dentro del proceso
    • Si “el estado global mutable es el mal”, entonces también habría que desechar la CPU y la RAM
    • Me da curiosidad qué propones como alternativa
      El problema no es Linux, ni el estado global mutable o los recursos, ni libc
      El problema es que en el trabajo no nos dan tiempo para hacer las cosas bien. Por ejemplo, para detectarlo con GDB antes de que estalle el problema, tu jefe tendría que darte tiempo para depurar con paciencia el código y todo lo que ese código toca, y rastrearlo hacia atrás
      Hay demasiado dinero metido en código a medio cocer. Triste, pero cierto
    • libc empujó al mundo a la era de la información
    • Me da curiosidad cuál es tu alternativa preferida
  • Hay tantos problemas de este tipo que al final parcheé getenv / setenv / putenv con LD_PRELOAD

    • ¿Lo hiciste con una implementación fija que filtra el entorno, como la que acaba de entrar en glibc?