1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • Si se comparan directamente las cifras de CVE de Rust y de C/C++, es fácil pasar por alto la diferencia de criterio sobre cuándo una vulnerabilidad de seguridad de memoria se considera un “problema de la biblioteca”
  • En C/C++, aunque una llamada incorrecta a una API provoque UB o un segfault, por lo general se trata como mal uso del código del usuario, y no se registra cada posibilidad como una CVE
  • La llamada curl_getenv(NULL) en libcurl puede compilar sin advertencias y provocar un segfault en tiempo de ejecución, pero normalmente no se considera una vulnerabilidad de curl
  • En Rust, si no hay unsafe en el código del usuario y un bug de memoria ocurre solo con llamadas a APIs seguras, se considera un soundness bug de la biblioteca
  • Por eso, algunas CVE en Rust se registran con un criterio más estricto que en C/C++, y es difícil juzgar la seguridad de memoria solo comparando la cantidad bruta de CVE

Por qué se distorsiona la comparación por número de CVE

  • CVE es una base de datos que clasifica y reporta vulnerabilidades de seguridad de software
  • Una vulnerabilidad puede surgir de un simple bug de lógica del programa, o de un problema de seguridad de memoria que sea más fácil de convertir en exploit
  • Al comparar la cantidad de CVE entre Rust y C/C++, también aparecen afirmaciones de que Rust “en realidad no es seguro en memoria” o que “no vale la pena adoptarlo”
  • Pero hay una gran diferencia en cómo ambos ecosistemas tratan las vulnerabilidades potenciales relacionadas con la seguridad de memoria

En Rust también puede haber vulnerabilidades

  • Los programas en Rust también pueden provocar UB y bugs de seguridad de memoria
  • En la mayoría de los casos, estos problemas requieren la palabra clave unsafe
  • Es falso afirmar que un programa en Rust nunca puede sufrir UB
  • También puede haber vulnerabilidades generales no relacionadas con la seguridad de memoria en Rust
    • Omitir una verificación de permisos para acceder a un panel de administración puede pasar en cualquier lenguaje

Ejemplo con una biblioteca en C: curl_getenv(NULL)

  • curl es una biblioteca de red basada en C, ampliamente usada y bien mantenida
  • curl_getenv de libcurl es una función de abstracción portable para obtener el valor de variables de entorno en varios sistemas operativos
  • El siguiente programa en C pasa un puntero NULL a curl_getenv
#include <curl/curl.h>
int main(void) {
  curl_getenv(NULL);
}
  • Este programa puede compilarse con gcc test.c -otest -lcurl -Wall -Wextra sin advertencias
  • Al ejecutarlo, puede producir un segfault, y eso puede verse como un bug de seguridad de memoria y una vulnerabilidad potencial
  • Sin embargo, este tipo de caso normalmente no se reporta como una vulnerabilidad de curl

En C/C++ no se crea una CVE solo por la posibilidad de mal uso

  • Un caso problemático como curl_getenv(NULL) normalmente se considera uso incorrecto de la API
  • También se considera que la falla está del lado del código de la aplicación, no en la biblioteca o la API
  • Hay dos razones para esta práctica
    • El sistema de tipos limitado de C dificulta expresar con precisión el contrato de una API, sus invariantes, precondiciones y postcondiciones
    • Tampoco es práctico documentar todos los posibles usos incorrectos
  • De hecho, la documentación de curl_getenv no dice que llamar con NULL esté prohibido ni que pueda terminar en segfault
  • En C/C++, es muy fácil provocar UB por accidente, y si se reportara como CVE toda vulnerabilidad potencial, la mayoría de las bibliotecas quedarían inundadas por una enorme cantidad de CVE
  • Por eso, en C/C++ normalmente no se crea una CVE por la mera “existencia de una API que puede usarse mal”, sino por un caso específico de mal uso

En Rust, el límite de responsabilidad de una API segura es distinto

  • Si en Rust asumimos que una llamada segura como hyper::foo(None) hace que el programa termine en segfault, eso podría convertirse en una CVE de hyper
  • Si hay un bug de memoria sin que exista ningún bloque unsafe en el programa del usuario, entonces la biblioteca tiene que tener un soundness bug
  • En Rust, si una biblioteca puede provocar un bug de memoria al usarse de cualquier manera a través de una API segura, eso se considera un bug de la biblioteca y no del código del usuario
  • Se dice que esa API es unsound o que tiene un soundness hole
  • Aunque todavía no se haya detectado un problema en un programa real, si el uso de una API segura puede provocar un bug de memoria, se puede crear una CVE

safe y unsafe hacen visible la responsabilidad

  • En Rust, la respuesta a “¿esta función se está usando correctamente desde el punto de vista de la seguridad de memoria?” es más clara que en C/C++
    • Si la función llamada no está marcada como unsafe, debería poder usarse de forma segura
    • Si la función llamada es unsafe, entonces el punto de llamada requiere un bloque unsafe, y eso deja claros los puntos de riesgo en la revisión de código y en la base de código
  • Esta distinción es uno de los factores que hacen que la seguridad de memoria de Rust escale de manera práctica en el trabajo real
  • Si el código del usuario no usa unsafe y tampoco hay bugs del compilador, es difícil atribuir al código del usuario la causa potencial de un problema de seguridad de memoria
  • Si la biblioteca no expone una interfaz unsafe, el usuario no debería poder usar esa biblioteca de una manera que provoque bugs de memoria
  • Incluso si la biblioteca usa unsafe internamente y ahí introduce un bug, la corrección se hace dentro de la biblioteca y el usuario vuelve a quedar protegido frente a bugs de memoria

Es difícil comparar la seguridad de memoria solo con la cantidad bruta de CVE

  • Si se aplicara la misma lógica a C, entonces curl_getenv también tendría que marcarse como una CVE de curl, pero C no tiene una distinción como la de safe y unsafe en Rust
  • En la práctica, casi todo el código en C se parece implícitamente a unsafe, así que es difícil aplicar sin más el criterio de Rust
  • Aunque un desarrollador de bibliotecas en C/C++ cree una biblioteca segura y robusta, los muchísimos programas en C que la usan pueden generar con facilidad problemas de seguridad de memoria al manejar mal la API
  • Esta diferencia no solo aplica a curl, sino a casi todas las bibliotecas en C/C++ y también a las bibliotecas estándar de ambos lenguajes
  • Comparaciones de números brutos como la cantidad de CVE por línea de código entre Rust y C/C++ pueden llevar a conclusiones engañosas al evaluar la seguridad de memoria

1 comentarios

 
GN⁺ 4 시간 전
Opiniones en Lobste.rs
  • Puede que sea una pregunta ingenua, pero si muchos problemas de C/C++ vienen de comportamiento indefinido, me pregunto por qué no simplemente lo definen

    • Creo que hay al menos tres razones por las que ciertos comportamientos quedan indefinidos en el estándar.
      La primera es que hay cosas que son residuos históricos a los que ya nadie les presta atención y que sí se podrían “simplemente definir”; como dijo @fanf, ya se está trabajando en ello. Por ejemplo, en C un archivo fuente que contiene un literal de cadena sin terminación realmente es comportamiento indefinido.
      La segunda es que hay cosas que sí se pueden definir, pero con un costo de rendimiento. El ejemplo clásico es el overflow de enteros con signo: si simplemente se definiera para que haga wraparound, ya no sería comportamiento indefinido, pero el compilador ya no podría hacer optimizaciones basadas en asumir que “eso nunca ocurre”. Hay mucha gente de compiladores en el comité, y tienden a obsesionarse con los benchmarks, así que no parece algo que vayan a corregir fácilmente. Aun así, no es que no haya ningún cambio; por ejemplo, P2723 propone inicializar implícitamente en 0 todas las variables locales que de otro modo quedarían sin inicializar en C++.
      La tercera es que hay cosas cuyo comportamiento es difícil de definir de manera razonable. Un buen ejemplo es use-after-free. A menos que se obligue a todo el mundo a usar un sistema pesado de capabilities en runtime como Fil-C, o se agreguen anotaciones de vida útil al estilo Rust en todo el lenguaje, no está claro cómo se podría limitar el rango de comportamientos posibles en un use-after-free. Podrías decir “si se usa después de liberar, toca la memoria que haya ahí en ese momento o hace segfault/abort”, pero eso no ayuda a nadie. Sigue siendo peligroso, seguiría generando CVE igual, y no se puede decir nada significativo sobre lo que el programa puede o no puede hacer después; sería básicamente comportamiento indefinido con otro nombre.
      Por desgracia, la tercera categoría es por mucho la más importante en cuanto a impacto, así que aunque está bien “simplemente definir” algunas cosas, eso no cambia mucho la situación general
    • En esta ronda de revisiones, el comité de C sí está reduciendo el comportamiento indefinido del lenguaje. Vean el documento “slaying earthly demons” en https://open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm
      Hasta donde sé, la biblioteca todavía casi no se ha empezado a tratar, pero las funciones que reciben un parámetro de tamaño se cambiaron para que se comporten de manera razonable con punteros nulos. Eso se relacionó con un cambio del lenguaje para permitir sumar 0 a un puntero nulo. Hay muchas funciones que podrían corregirse de manera similar, pero para cambiar getenv() probablemente convendría coordinarlo con POSIX
    • La explicación que más se repite es que algunos comportamientos deben quedar sin definir para permitir optimizaciones que de otro modo no se permitirían. Pero en general me parece más bien una racionalización.
      Casi todas esas ganancias de rendimiento son muy específicas y, en el mejor de los casos, mínimas. Si existe una función que llama a rm -rf / pero que en realidad nunca debería llamarse, y generas una llamada por puntero a función con comportamiento indefinido, técnicamente el compilador tiene permitido generar código que llame incondicionalmente a esa función que borra el disco. Al final no es más que una mala decisión de diseño de la especificación y una herencia histórica
    • Parte del comportamiento indefinido sí se ha ido definiendo con el tiempo, pero mucho sigue teniendo que quedarse así por las optimizaciones. Un ejemplo conocido es que for (int ii = 0; ii < something; ii++) depende de que el overflow de enteros con signo sea indefinido para poder ignorar la posibilidad de something == INT_MAX, y eso habilita varias transformaciones de bucle.
      En Rust, la funcionalidad equivalente está dividida entre funciones seguras y funciones unsafe. Las funciones seguras pueden ser un poco más lentas, y las unsafe permiten comportamiento indefinido si se usan mal. Véanse i32::wrapping_add() y i32::unchecked_add().
      Si en C se pudiera marcar una función como unsafe y agregar una notación que permitiera usar funciones unsafe en ciertas regiones, entonces se podrían empezar a definir variantes seguras. Pero llega un punto en que el esfuerzo de cambiar C, y más importante aún, de cambiar la mentalidad de la gente que controla C, deja de tener sentido para el objetivo; entonces resulta más fácil buscar un lenguaje que encaje mejor con ese objetivo
    • Hay un ejemplo que muestra por qué esto es difícil.
      En C, si pasas a free un puntero que apunta a un objeto en el heap y luego accedes a ese objeto, eso es comportamiento indefinido. En CHERIoT, ese caso se define para que produzca un trap, pero eso solo es posible porque construimos hardware que lo permite. El estándar tiene que soportar hardware muy diverso, así que el problema es con qué se define.
      A grandes rasgos hay dos enfoques. Uno es retrasar la liberación y decir que el objeto no desaparece hasta que todos los punteros que lo señalan hayan desaparecido. Eso requiere algo parecido a un recolector de basura, y para muchos usos de C tiene un overhead imposible de justificar. El otro es definir un sistema de tipos que sepa dónde están todos los punteros que apuntan al objeto y pueda invalidarlos. Rust eligió este segundo enfoque, por eso para implementar estructuras de datos que no sean árboles en Rust se necesita unsafe o alguna funcionalidad de la biblioteca estándar que use unsafe. Esas cosas se pueden incorporar en la etapa de diseño de un lenguaje, pero agregarlas después es casi imposible.
      Los errores de límites son parecidos. En los sistemas CHERI, los límites del objeto o subobjeto son una parte intrínseca del puntero, así que un acceso fuera de límites provoca un trap. En otras plataformas, un puntero no es más que una palabra con una dirección. Después de hacer aritmética, no hay forma de volver a mapearlo al objeto original, así que el problema es de dónde sacar los límites. Herramientas como AddressSanitizer guardan los límites en estructuras separadas y exigen comprobaciones en la aritmética de punteros, pero el overhead de memoria y rendimiento es tan grande que en producción es mucho mejor usar Java que C con ASan activado, y probablemente también escribirías el código más rápido
  • Pensaba que la desreferenciación de un puntero nulo era comportamiento bien definido

    • https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf en la página 4, página 18 del PDF, dice esto:
      1. Términos, definiciones y símbolos

      3.5.3 Comportamiento indefinido

      Ejemplo: un ejemplo de comportamiento indefinido es el comportamiento al desreferenciar un puntero nulo

    • Desde la perspectiva del conjunto de instrucciones del CPU puede ser cierto, pero eso no es el objetivo de la programación; el objetivo es la máquina abstracta de C, y la máquina abstracta de C dice que eso es comportamiento indefinido
  • Hay una parte de este artículo que me hace ruido.
    SEGFAULT es un ataque de denegación de servicio, igual que un pánico.
    Ambos pertenecen a la misma categoría de errores, y normalmente cuando uno piensa en seguridad de memoria piensa en cosas como stack smashing, corrupción de datos o modificación de código. Esas cosas son mucho, mucho más difíciles en Rust, y hasta cierto punto también se pueden dificultar en C.
    En general, el artículo me pareció más bien decir que el sistema de tipos de C es terrible. En C++ se pueden evitar este tipo de errores, y en C usar el atributo nonnull de GCC puede elevar a error de compilación el pasar NULL a una función.
    Personalmente, creo que un ejemplo mejor y más representativo habría sido el acceso fuera de límites

    • Decir que “SEGFAULT es un ataque de denegación de servicio igual que un pánico” no es correcto.
      Un pánico es una verificación de seguridad incorporada en el programa, ocurre de manera confiable y su comportamiento está claramente definido.
      Un segfault es una operación de memoria inválida que el sistema operativo detectó, y solo ocurre para direcciones fuera de las páginas presentes en el mapa de memoria virtual del programa. Por eso, muchos bugs de segfault pueden manipularse para convertirse en alguna forma de ejecución arbitraria de código.
      En condiciones normales ambos pueden parecer dar el mismo resultado, pero en el fondo son cosas completamente distintas.