- La seguridad de memoria y la seguridad de hilos no son conceptos separables, y sin seguridad de hilos no se puede lograr una verdadera seguridad de memoria
- En el caso de lenguajes que no son thread-safe como Go, la seguridad de memoria puede romperse incluso solo por problemas de hilos
- Algunos lenguajes como Java aseguran la seguridad a nivel de lenguaje mediante un modelo de memoria de concurrencia que hace que incluso las data races tengan un comportamiento definido
- Go es vulnerable a las data races y existen casos reales de violaciones de seguridad de memoria
- La propiedad que realmente importa es la ausencia de Undefined Behavior (comportamiento indefinido)
Sin seguridad de hilos no se puede garantizar la seguridad de memoria
Confusión de conceptos: seguridad de memoria vs seguridad de hilos
- Últimamente la seguridad de memoria ha recibido mucha atención, pero no está claro qué significa exactamente en la práctica
- Tradicionalmente, la seguridad de memoria se refiere a lenguajes que impiden accesos de memoria como use-after-free u out-of-bounds
- En cambio, la seguridad de hilos se refiere a programas sin bugs de concurrencia, y ambos conceptos suelen tratarse como separados
- El autor sostiene que esta distinción no resulta útil en la práctica, y enfatiza que lo que realmente queremos es la ausencia de Undefined Behavior (UB)
Violación de la seguridad de memoria por data races: ejemplo en Go
- Para mostrar el problema de tratar por separado la seguridad de memoria y la seguridad de hilos, se presenta un ejemplo en el lenguaje Go
- Go se clasifica como un lenguaje con seguridad de memoria, pero en un programa como el siguiente puede ocurrir un error de memoria solo por una data race
Cambiar repetidamente globalVar a valores de distintos tipos (Int, Ptr) mientras en otra goroutine se lee y se llama a un método
- Como dos hilos actualizan por separado los dos punteros internos de
globalVar (datos y vtable), si se lee en medio de la actualización puede aparecer un estado mezclado que provoque un acceso incorrecto a memoria
- Como resultado, el programa puede intentar referenciar una dirección inválida (por ejemplo,
0x2a, hexadecimal 42) y terminar con un error
- Este fenómeno también puede darse de forma similar con interfaces, slices, etc. de Go, porque no se actualizan múltiples campos de manera atómica
Cómo manejan la concurrencia otros lenguajes y su relación con la seguridad de memoria
- Otros lenguajes como Java también pueden tener data races, pero aplican un modelo de memoria de concurrencia definido para garantizar que el programa no rompa el propio lenguaje
- Ejemplo: Java diseña su modelo de memoria con mucho cuidado para evitar que, incluso en entornos multihilo, el runtime caiga en errores como un segmentation fault forzado
- La mayoría de los lenguajes controlan los problemas de concurrencia de una de estas dos maneras
- Definen un modelo de memoria para que todos los programas concurrentes garanticen un comportamiento consistente (a cambio de limitar optimizaciones del compilador y aumentar la carga de implementación)
- Java, C#, OCaml, JavaScript, WebAssembly, etc.
- Prohíben la mayoría de las data races mediante un sistema de tipos fuerte, y manejan de forma segura solo unas pocas excepciones (Rust, strict concurrency de Swift)
- Go no sigue ninguna de estas dos opciones
- Solo garantiza seguridad de memoria cuando no hay data races
- Tiene herramientas de detección de data races, pero en programas reales hay límites para verificar todos los escenarios mediante pruebas
- Tanto en investigaciones como en experiencia de campo se han reportado múltiples casos reales de violaciones de seguridad de memoria
El modelo de memoria de Go y los problemas de documentación
- La documentación oficial del modelo de memoria de Go dice que la mayoría de las races tienen resultados limitados, pero no explica claramente que algunas data races pueden tener resultados ilimitados
- También se afirma que es similar a Java/JavaScript, pero esos dos lenguajes hacen mucho más esfuerzo que Go para asegurar la seguridad en concurrencia
- Solo en algunas secciones detalladas de la documentación se menciona de forma limitada que ciertas data races pueden provocar comportamiento completamente indefinido
Conclusión: la ausencia de Undefined Behavior (UB) es el verdadero objetivo
- En la práctica, la propiedad que los usuarios realmente quieren es que el programa no rompa el propio lenguaje (ausencia de UB)
- Las distintas vulnerabilidades de seguridad causadas por violaciones de seguridad de memoria ocurren porque el UB llegó a producirse realmente
- En el momento en que ocurre UB, todo lo que sigue pasa a ser impredecible y un atacante puede aprovecharlo
- La diferencia esencial entre lenguajes “seguros” y “no seguros” está en la posibilidad de que ocurra UB
- Más que separar seguridad de memoria, seguridad de hilos, seguridad de tipos, etc., lo central es si puede ocurrir UB o no
- En la práctica existe un espectro de seguridad, y Go es más seguro que C, pero no garantiza una seguridad completa
- Basándose en datos, es muy difícil “demostrar” la seguridad real de Go, y es importante entender bien las consecuencias poco intuitivas de las decisiones que tomó cada lenguaje
1 comentarios
Opiniones de Hacker News
segfaultrepetidamenteSwift también tiene el mismo problema, y alguna vez escribí un programa que mostraba que Swift puede provocar
segfaultcon mucha facilidad al acceder a estructuras de datos compartidasDecir que Go es memory-safe en el sentido de Rust o Java es un poco exagerado
mapno son thread-safe y que hay que tener cuidado al modificarlasMe gustaría escuchar más detalles sobre la situación del problema que ocurrió en Dropbox
Memory safety, más que un concepto de PLT (teoría de lenguajes de programación), es un término de seguridad de software
Al final, los programadores de Go también entienden bien esta diferencia, y por eso Go toma como premisa el enfoque de “no te comuniques compartiendo memoria; comparte memoria comunicándote”
Claro, en la práctica este concepto no se ha materializado del todo, y todos entienden que hoy en Go también hay mucho uso compartido y necesidad de sincronización
De hecho, tras operar Go durante varios años, creo que casi nunca he visto que este tipo de bug ocurra en la práctica
Uber hizo un buen resumen detallado de bugs ocurridos en código Go, y en este artículo hay una tabla que muestra con qué frecuencia sucede realmente el problema
En Go, la mayoría de los problemas de acceso concurrente a
maposliceocurren sobre el mismo slice, y además tiene que darse el fenómeno de “torn read”, así que en la práctica no es tan comúnAun así, la razón por la que la gente suele evitar bien estos problemas probablemente es que normalmente tiene suficiente cuidado y entiende bien el riesgo de reasignar variables en situaciones de acceso concurrente
Como el lenguaje ya incluye
atomics,channelymutex, en la práctica es raro usarlos mal en escenarios concurrentes, y además existe el race detector, así que estos problemas se detectan rápido si aparecenIncluso si hay una degradación de rendimiento, creo que los problemas de torn read son algo que simplemente se puede corregir, y en código Go en producción no han sido un gran problema
Video relacionado
El race detector tampoco encontró nada, y nadie entendía qué estaba pasando
Al final, un contador de bucle estaba desbordándose y repetía el mismo cálculo una cantidad enorme de veces, haciendo que algunas solicitudes tardaran 3 minutos en vez de 100 ms
Nos dimos cuenta indirectamente del problema en producción usando
perf, y mi experiencia de depuración como desarrollador de plataforma fue de gran ayuda para el equipoDespués de estar expuesto a tantos casos distintos de races en Go, personalmente desearía que Rust se adoptara en todas partes
Por ejemplo, este issue requiere una gran refactorización del compilador, así que ha tomado mucho tiempo
Send/Syncde RustEn la práctica todavía hay poco código concurrente en Zig, así que el problema no ha salido mucho a la luz, pero creo que si en el futuro las funciones
asyncse usan más ampliamente, varios problemas podrían explotar al mismo tiempoReleaseSafe, por ejemplo al desreferenciar un puntero cuya variable local ya salió de vida, no está libre del riesgo de corrupción de memoria en ninguno de los modos de optimizaciónClaro, tiene menos bugs que C, pero eso también pasa con C++, y nadie dice que C++ sea memory-safe
Claro, eso no significa que el riesgo no exista en absoluto, pero sí sugiere que probablemente no es un tema prioritario desde la perspectiva de seguridad en aplicaciones Go
En cambio, en código C/C++, entre 60% y 75% de las vulnerabilidades reales provienen de problemas de memory safety
La memory safety también es un continuo, y creo que a partir de cierto punto su utilidad disminuye
Aunque sea un bug que no se pueda explotar, al final sigue siendo un bug y hay que corregirlo
Se dedica mucho más tiempo al mantenimiento que al desarrollo inicial, así que si algo puede reducir el mantenimiento, me parece valioso incluso si retrasa el lanzamiento inicial
En cambio, en Go la thread safety no es una causa principal de CVE
En teoría tiene fundamento, pero en la práctica no destaca tanto
Al compartir memoria, si se rompe una estructura de datos, eso puede provocar comportamiento inseguro o incorrecto en otro thread
Por ejemplo, si un thread cambia el tamaño de un vector mientras otro accede a él, una operación segura en ejecución secuencial se vuelve riesgosa bajo concurrencia
Go tampoco está libre de eso
En cambio, si un problema de thread safety termina en
segfault, puede que lo único posible sea un simple ataque DoS (denegación de servicio)Una race condition puede derivar en ataques más potentes, pero es mucho más difícil de disparar
Esa es una causa principal de corrupción de datos y races
En muchas situaciones, un modelo basado en procesos es mejor que uno basado en threads para la concurrencia, pero tiene la desventaja de ser demasiado pesado
Si lo normal fuera pasar todos los datos necesarios a cada thread mediante message passing, creo que la mayoría de estos problemas desaparecerían
De todos modos, en la plataforma tenemos la libertad de usar variables globales y memoria compartida, así que basta con decidir no usarlas
El objetivo original de Rust no era ser un lenguaje de sistemas memory-safe, sino un lenguaje de sistemas thread-safe, y la memory safety llegó como consecuencia natural
En Rust se puede usar concurrencia estructurada con
thread::scopey similares, así que trabajar con threads es muy cómodochannel, etc.) que el compartir memoria directamenteVer este documento
Ejemplo real: En el código de arriba,
buf.Bytes()pasa una referencia directa a la memoria interna, y cuando se llama aReset()se reutiliza la backing memory, así que tantoprocessDatacomomainacceden al mismo tiempo a la misma memoria y se produce una data raceEn Rust, este código ni siquiera compilaría porque serían dos referencias mutables, y te obligaría a transferir ownership o hacer una copia
En Go es fácil confundirse:
bytes.Buffer.ReadBytes("\n")o.String()devuelven una copia y son seguros, pero.Bytes()es peligroso como en este casoLos canales de Rust previenen este problema de raíz con los conceptos de ownership y transferencia, pero Go no tiene estas protecciones
Como resultado, da la impresión de que es más lento que un
mutexy que para quienes empiezan con Go es incluso más difícil de usar correctamenteEs decir, terminan siendo más frecuentes las races “seguras” o los deadlocks “seguros”
Desde la teoría de PL, el enfoque de Rust hacia race freedom puede ser atractivo, pero en aplicaciones reales los datos importantes de todos modos están en el RDBMS, y por ejemplo si no usas
FOR UPDATEen unSELECT, las races pueden aparecer perfectamenteAunque una app en Rust no use nada de
unsafe, las races siguen existiendo dependiendo de la base de datosSabemos por la ausencia de exploits reales que Go está estructurado de forma que casi no permite bugs de corrupción de memoria
Si seguimos el argumento de este artículo, entonces la mayoría de los lenguajes de alto nivel (en el texto se exceptúa solo Java) tampoco serían memory-safe
Rust puede ser “más” seguro que Go, pero “memory safety” no es un espectro continuo sino un concepto de aprobar o reprobar
Si se va a afirmar que un lenguaje es memory-unsafe, entonces hay que mostrar obligatoriamente un POC
type confusion), entonces Go tampoco está exentoEl ejemplo del artículo muestra que se puede provocar corrupción de memoria fácilmente al tratar por error un
intcomo si fuera un punteroEn la demo se usa adrede el valor 42 para provocar un
segfault, pero si se hubiera usado una dirección real, ocurriría corrupción verdaderaSIGSEGVPor lo tanto, un lenguaje en el que puedan ocurrir data races no puede considerarse memory-safe
En casos así, cuesta llamarlo memory-safe
Para evitar estos problemas, a veces se usan nombres de personas, como “curvatura gaussiana” (
Gaussian Curvature) o “integrales de Riemann” (Riemann Integrals)También existe el caso de términos cuyo significado original se mantuvo de forma estrecha mientras se expandía uno más amplio, como “Galois Group”
Memory safety no es una excepción a esto
Pido un ejemplo concreto
En el FAQ y otros lugares se menciona memory safety o se insinúa en respuestas sobre unions, pero no queda claro qué significa realmente
En una presentación de 2012 de Rob Pike se dijo “Not purely memory safe”, pero ni siquiera se define qué significa
purelyIncluso en la documentación del race detector de Go la definición de “safe” es ambigua (documento de ejemplo)
Desde fuera, en cambio, es frecuente ver afirmaciones fuertes de que Go es un “memory-safe programming language”
Por ejemplo, están los documentos de seguridad de fly.io o el documento que clasifica a Go como memory safe en memorysafety.org
Pero en esos mismos documentos también se describen los “Out of Bounds Reads and Writes” como problemas de memory safety, y el error de Go señalado en el post entra en esa condición
Como mínimo, creo que Go y su comunidad deberían dejar claro el significado exacto de “memory safety”
Mientras existan casos así, lo recomendable es no llamar a Go un lenguaje memory-safe sin explicación adicional
Cuando Go fue creado, predominaba la idea de que “si tiene garbage collector, entonces es memory-safe”, y comparado con C/C++ claramente es mucho más safe