7 puntos por GN⁺ 2025-07-25 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2025-07-25
Opiniones de Hacker News
  • Esto pasó en mi equipo de Dropbox: escribir en una estructura de datos en un servidor Go sin sincronización era prácticamente un rito de iniciación para que los ingenieros recién llegados provocaran segfault repetidamente
    Swift también tiene el mismo problema, y alguna vez escribí un programa que mostraba que Swift puede provocar segfault con mucha facilidad al acceder a estructuras de datos compartidas
    Decir que Go es memory-safe en el sentido de Rust o Java es un poco exagerado
  • Swift está avanzando para resolver esto, pero en el mundo real ya existe mucho código inseguro, así que el cambio está siendo muy lento y doloroso
  • Tengo curiosidad, porque normalmente la propia especificación de Go deja bastante claro que estructuras básicas como map no son thread-safe y que hay que tener cuidado al modificarlas
    Me gustaría escuchar más detalles sobre la situación del problema que ocurrió en Dropbox
  • Quiero enfatizar que lo que aquí se llama “memory safety en el sentido de Rust o Java” no es una definición estricta del término
    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
  • Para ponerlo en perspectiva, me gustaría preguntarme cuántos casos modificados que no son memory-safe existen en Go, o qué tan probable es que un programa en Go realmente no sea memory-safe
  • Java tampoco es memory-safe en el mismo sentido que Rust
  • Este tema suele aparecer repetidamente de forma parecida al problema de los soundness holes en Rust; no es un problema inútil en absoluto, pero la probabilidad de toparlo por accidente es bastante baja
    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 map o slice ocurren 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ún
    Aun 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, channel y mutex, 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 aparecen
    Incluso 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
  • Tuve la experiencia de tardar varios meses en atrapar un bug de data race en Go
    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 equipo
    Después de estar expuesto a tantos casos distintos de races en Go, personalmente desearía que Rust se adoptara en todas partes
  • Los maintainers de Rust también reconocen los soundness holes como bugs
    Por ejemplo, este issue requiere una gran refactorización del compilador, así que ha tomado mucho tiempo
  • Uber dice que los programas en Go “exponen 8 veces más concurrencia” que los microservicios en Java, y me pregunto qué significa usar “concurrencia” como si fuera un sustantivo contable
  • Zig también afirma ser memory-safe, pero no tiene un concepto como los tipos Send/Sync de Rust
    En 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 async se usan más ampliamente, varios problemas podrían explotar al mismo tiempo
  • Incluso un programa Zig de un solo hilo compilado con ReleaseSafe, 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ón
  • La afirmación de que Zig es memory-safe está casi al nivel de una broma
    Claro, tiene menos bugs que C, pero eso también pasa con C++, y nadie dice que C++ sea memory-safe
  • En código real, salvo que haya sido diseñado de manera maliciosa, nunca he visto código Go vulnerable por data races
    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
  • Sí he visto código Go vulnerable en la práctica a causa de data races
  • Siento que el dolor del mantenimiento es mucho mayor que un CVE
    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
  • La razón por la que la memory safety importa es que la mayoría de los CVE de programas en C vienen de bugs de memory safety
    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
  • Lo importante es qué se puede hacer realmente desde un thread
    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
  • Los problemas típicos de memory safety en C tienen alta probabilidad de terminar en RCE (ejecución remota de código)
    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
  • Aunque un CVE sea más grave, la corrupción de datos o los crashes causados por bugs de threading siguen siendo bugs que alguien tiene que clasificar, analizar y corregir
  • La triste realidad es que la mayoría de los lenguajes que usan threads ofrecen por defecto variables globales y acceso ilimitado a memoria compartida
    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
  • Rust es un lenguaje moderno representativo que puede integrar la thread safety en el sistema de tipos
    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::scope y similares, así que trabajar con threads es muy cómodo
  • El message passing puede causar más problemas lógicos (race conditions, deadlocks, etc.) que compartir memoria, así que no es una solución universal
  • En Go suele enfatizarse más la comunicación entre goroutines (channel, etc.) que el compartir memoria directamente
    Ver este documento
  • Aunque en Go pases objetos entre goroutines por canales, no existen conceptos como tipos sendable, ownership o referencias read-only, así que no es fácil usarlo de forma segura
    Ejemplo real:
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    En el código de arriba, buf.Bytes() pasa una referencia directa a la memoria interna, y cuando se llama a Reset() se reutiliza la backing memory, así que tanto processData como main acceden al mismo tiempo a la misma memoria y se produce una data race
    En 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 caso
    Los 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 mutex y que para quienes empiezan con Go es incluso más difícil de usar correctamente
  • En programas reales de golang, el patrón de “comunicarse compartiendo” genera enormes cantidades de problemas lógicos, y al final compartir memoria sigue siendo lo común
    Es decir, terminan siendo más frecuentes las races “seguras” o los deadlocks “seguros”
  • En la discusión sobre bugs de concurrencia suele ignorarse que, en la mayoría de las apps, gran parte de los bugs realmente importantes surgen por aplicar mal locks, transacciones o aislamiento de transacciones dentro de la base de datos
    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 UPDATE en un SELECT, las races pueden aparecer perfectamente
    Aunque una app en Rust no use nada de unsafe, las races siguen existiendo dependiendo de la base de datos
  • El término “memory safety” surgió originalmente para explicar un concepto complejo, pero con el tiempo su significado se ha ampliado o reducido
    Sabemos 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
  • Si la parte importante del término memory safety es la “confusión de tipos” (type confusion), entonces Go tampoco está exento
    El ejemplo del artículo muestra que se puede provocar corrupción de memoria fácilmente al tratar por error un int como si fuera un puntero
    En 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 verdadera
  • Una data race viola la memory safety porque puede llevar el programa a un estado que la especificación del lenguaje no contempla, por ejemplo una terminación forzada por SIGSEGV
    Por lo tanto, un lenguaje en el que puedan ocurrir data races no puede considerarse memory-safe
  • Como en el ejemplo del artículo, también es posible una torn read de un fat pointer por confusión de tipos, o una escritura out-of-bounds causada por una torn read de un slice
    En casos así, cuesta llamarlo memory-safe
  • Que los términos evolucionen y cambien de significado es algo común incluso en matemáticas y física
    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
  • Me gustaría saber cuál es la base para decir que Java no es memory-safe según la definición del autor
    Pido un ejemplo concreto
  • Incluso en Go mismo, la definición oficial de memory safety es ambigua
    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 purely
    Incluso 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
  • La definición de memory safety también cambia un poco con la época
    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