La biblioteca estándar de C no era segura para hilos, y ni siquiera Rust seguro pudo evitar el crash
(edgedb.com)- Mientras EdgeDB movía el I/O de red de Python a Rust, una nueva prueba de HTTP fetch basada en
reqwestparecí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()delibc, que fallaba al intentar leer un puntero inválido0x220mientras recorría el arreglo de variables de entorno - Otro hilo estaba configurando
SSL_CERT_FILEySSL_CERT_DIRen la ruta deopenssl-probe, ysetenv()reasignóenviron; al mismo tiempo, la ruta de manejo de errores de Python llamó agetenv(), creando una condición de carrera - El código Rust no tenía
unsafeexplícito, pero mientrasstd::env::set_var()modificaba el entorno global, el lock interno de Rust no sincronizaba eso con otros runtimes ni con llamadas directas alibc - La solución fue migrar en Linux del backend
rust-native-tls/openssldereqwestarustls, 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
reqwestcomo 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
dmesgy 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
journalctlencontraron el core dump del procesopython3 - La primera vez que abrieron el core con
gdb, el backtrace no servía porque faltaban los archivos.sodel interior del contenedor - Después de copiar desde el contenedor
/lib,/usry otros directorios, y de configurarsolib-absolute-prefixengdb, confirmaron que el punto del crash eragetenv()enlibc.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 elenvironde POSIX como una listachar **y examina los punteros a cadenas de variables de entorno hasta el puntero finalNULL - Según el desensamblado y el estado de los registros,
getenv()se crasheó al intentar leer un byte desde la direcciónx19 = 0x2200x220claramente no era una dirección de memoria válida- Al inspeccionar
environen 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 engetenv()delibcya se ha redescubierto varias veces- Al comparar el desensamblado con el código en C, vieron que
x20correspondía aep, el puntero que recorre el arregloenviron - En el momento del crash,
x20era0x248b5000, mientras que elenvironactual era0x28655750, unos 60 MB más adelante - Al comparar la memoria alrededor del arreglo de entorno anterior con el
environactual, la última diferencia aparecía en las entradasSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtySSL_CERT_DIR=/etc/ssl/certs - Otro hilo movió
environdurante una llamada asetenv(), ygetenv()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 queopenssl-probeconfigura las variables de entornoSSL_CERT_FILEySSL_CERT_DIRpara localizar los certificados del sistema - En Linux, esa ruta se ejecuta cuando se usa el backend
opensslderust-native-tls - El código problemático de
openssl-probeconfiguraba esas dos variables conenv::set_var()sinunsafeexplícitoSSL_CERT_FILESSL_CERT_DIR
- Esa combinación hizo que código Rust sin
unsafeinteractuara mal con el uso delibcdentro del mismo proceso, provocando el crash
Por qué se reprodujo en Linux ARM64
- Este crash solo ocurre cuando
setenv()mueve el arreglo de entorno conreallocjusto en el momento en que otro hilo llama agetenv() - 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 entornoLANGUAGEexactamente en ese mismo instante
- La cantidad de variables de entorno tenía que ser la justa para disparar
- El valor incorrecto
0x220estaba cerca del tamaño del entorno anterior en palabras de 64 bits0x220 / 8 = 68- Ese valor sobrescribió el
NULLfinal 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 cambiabax20, 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,
x20iba 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/openssldereqwestarustls - 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
libcdirectamente - Mantener el GIL al menos puede evitar la carrera con hilos de Python
- El proyecto Rust ya reconocía este problema y planea volver
unsafelas funciones setter del entorno en la edition 2024 - El proyecto glibc también agregó recientemente cambios para mejorar la seguridad de hilos de
getenv(), evitandoreallocy dejando con fugas los arreglos viejos de entorno
4 comentarios
setenvno es thread-safe y C no quiere corregirloLa función
setenvestá causando problemas otra vez.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". :)
Lo entendí claramente.
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
getenvysetenvounsetenvno 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.
Lamentablemente, el ecosistema no es así: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___.En los frameworks web pasa algo parecido: Vue tiene la directiva
v-htmly React tienedangerouslySetInnerHTML; en este aspecto, creo que Vue es claramente mejor.set_varyremove_varde la biblioteca estándar de Rust exigirán correctamente un bloqueunsafe {}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
getenven más casos en los que se modifica el entorno, pero en C todavía se puede acceder directamente aenviron, así que no puede volverse completamente seguro en situaciones donde haya modificaciones: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...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.
unsafe.Aunque los mantenedores de la biblioteca estándar de C se opongan a hacer que
setenvsea 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
setenvy 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.
extern char **environ;.Mientras
environsea accesible públicamente, ni siquiera hay garantía de quesetenvygetenvse usen, porque no son obligatorios.Si se pudiera eliminar
environ, hacer quesetenvygetenvfueran seguros para hilos sería bastante sencillo. Si no se puede eliminar, es imposible; aun así, se puede argumentar que hacer quesetenvygetenvsean seguros para hilos sería una mejora, aunque no sea una solución completa.exec()que no reciben el entorno como argumento o que buscan enPATHpara encontrar el ejecutable también necesitarían bloqueo.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 ofrecidogetenv_r(), lo hubieran sincronizado consetenv()y hubieran emitido advertencias en tiempo de compilación y enlace al usargetenv(), una buena parte de los problemas habría desaparecidoMá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
El problema no es la implementación, sino la API en sí.
setenv(),unsetenv(),putenv()y, en particular,environson inherentemente inseguros en programas multihilogetenv_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 asetenv()Claro que
getenv_r()sí arregla el caso en que, después de recibir algo congetenv(), otro hilo llama asetenv()e invalida esa memoria, pero no hay forma de impedir las otras llamadas que rompen la APIlibc 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 porgetenv()siga siendo válido durante el tiempo suficiente para que el código llamador lo useTampoco hay una buena forma de hacer seguro el acceso directo a
environ. Se podría hacer queenvironfuera local al hilo, pero entonces la visión del entorno podría divergir permanentemente entre hilos, y una llamada agetenv_r()podría dar un resultado distinto al de consultarenvirondirectamenteMantener 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.htmlTambién hubo una discusión, y desde el primer comentario se mencionaba que causaba problemas en Rust: https://news.ycombinator.com/item?id=38342642
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
setenvAl 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()/environcomo un argumento fuerte para no usar variables de entorno para la configuraciónClaro 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
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
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 essetenv()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()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
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
gdbfácilmente”¿Será que la gente ya perdió la capacidad de compilar y depurar localmente sin la nube ni contenedores?
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
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í
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
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
Hay tantos problemas de este tipo que al final parcheé
getenv/setenv/putenvconLD_PRELOAD