2 puntos por GN⁺ 11 일 전 | 1 comentarios | Compartir por WhatsApp
  • Estructura que rastrea metadatos AllocationRecord junto con los punteros de C/C++ y realiza verificación de límites de memoria al desreferenciarlos
  • Método que mueve juntos el valor original del puntero y sus metadatos correspondientes, o los convierte en llamadas específicas de Fil-C, incluso en asignaciones de punteros, aritmética, paso de argumentos a funciones, retornos y llamadas a malloc/free
  • Los metadatos de punteros dentro de la memoria del heap se almacenan por separado en invisible_bytes; al cargar o guardar punteros se leen y escriben juntos el valor y los metadatos, y también se aplica una verificación de alineación
  • filc_free libera solo visible_bytes e invisible_bytes y mantiene el propio AllocationRecord; la limpieza posterior queda a cargo del recolector de basura, y las variables locales cuya dirección podría escapar se promueven al heap
  • Aunque siguen existiendo complejidades de implementación real como hilos, punteros a función y optimizaciones de memoria y rendimiento, puede servir para la verificación de seguridad de memoria en código C/C++ a gran escala o como ejemplo concreto de sistema de pointer provenance

Modelo simplificado de Fil-C

  • Fil-C usa una estructura que rastrea metadatos AllocationRecord* junto con los punteros para manejar código C/C++ de forma segura en memoria
    • La implementación real reescribe LLVM IR, pero el modelo simplificado adopta la forma de una conversión automática del código fuente C/C++
    • Se agrega una variable local AllocationRecord* correspondiente a cada variable local de tipo puntero en cada función
    • Por ejemplo, a T1* p1 se le agrega AllocationRecord* p1ar = NULL
  • Las asignaciones simples y los cálculos sobre variables locales puntero mueven también el AllocationRecord* junto con el valor original del puntero
    • p1 = p2 se transforma en p1 = p2, p1ar = p2ar
    • p1 = p2 + 10 también va acompañado de p1ar = p2ar
    • Los casts de entero a puntero establecen los metadatos en NULL
    • Los casts de puntero a entero se mantienen tal cual
  • En el paso de argumentos y los retornos de funciones también se pasa un AllocationRecord* adicional junto con el puntero, y ciertas llamadas a la biblioteca estándar se sustituyen por funciones específicas de Fil-C
    • Las llamadas a malloc y free se transforman en filc_malloc y filc_free, respectivamente
    • Por ejemplo, p1 = malloc(x); free(p1); pasa a {p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar);
  • filc_malloc no asigna solo la memoria solicitada, sino que realiza tres asignaciones
    • Asignación del objeto AllocationRecord
    • Asignación de visible_bytes para los datos reales
    • Asignación con calloc de invisible_bytes para almacenar metadatos invisibles
    • AllocationRecord incluye los campos visible_bytes, invisible_bytes y length

Desreferenciación y verificación de límites

  • Al desreferenciar un puntero se usa el AllocationRecord* asociado para realizar una verificación de límites
    • Se comprueba que los metadatos del puntero no sean NULL
    • Se calcula la diferencia entre la posición actual del puntero y la dirección inicial de visible_bytes
    • Se verifica que el desplazamiento sea menor que la longitud total
    • Se verifica que la longitud restante sea suficiente para el tamaño del objeto a desreferenciar
  • El mismo procedimiento de verificación se aplica tanto a lectura como a escritura
    • También se verifica antes de x = *p1
    • Y se aplica la misma forma de verificación antes de *p2 = x
  • Con esta estructura se bloquean los accesos en los que el puntero apunta fuera del rango asignado

Punteros en el heap e invisible_bytes

  • Los punteros almacenados en memoria del heap no pueden gestionarse mediante variables separadas directamente por el compilador como ocurre con las variables locales, por lo que se usa invisible_bytes
    • Si hay un puntero en la posición visible_bytes + i, su AllocationRecord* correspondiente se almacena en invisible_bytes + i
    • Es decir, invisible_bytes funciona como un arreglo cuyo tipo de elemento es AllocationRecord*
  • Al leer o escribir un valor puntero desde memoria, además de la verificación de límites normal se añade una verificación de alineación
    • Se comprueba que el desplazamiento i sea múltiplo de sizeof(AllocationRecord*)
    • Solo si se cumple esa condición puede accederse con seguridad a invisible_bytes como si fuera un arreglo de AllocationRecord**
  • Al cargar un puntero, se cargan juntos el puntero de datos y sus metadatos
    • p2 = *p1 agrega después p2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i)
  • Al almacenar un puntero, se guarda no solo el valor del puntero sino también sus metadatos correspondientes
    • *p1 = p2 ejecuta *(AllocationRecord**)(p1ar->invisible_bytes + i) = p2ar después de almacenar el dato real

filc_free y el recolector de basura

  • filc_free libera dos bloques de memoria después de verificar la coherencia con AllocationRecord cuando el puntero no es NULL
    • Verifica par != NULL
    • Verifica p == par->visible_bytes
    • Libera visible_bytes e invisible_bytes
    • Luego cambia visible_bytes e invisible_bytes a NULL y length a 0
  • Aunque filc_malloc realiza tres asignaciones, filc_free no libera el propio objeto AllocationRecord
    • Esa diferencia la maneja el recolector de basura
  • En el modelo simplificado basta con un GC stop-the-world, mientras que Fil-C real usa un recolector paralelo, concurrente e incremental
    • El GC rastrea siguiendo los objetos AllocationRecord
    • Los AllocationRecord inalcanzables pasan a ser candidatos a liberación
  • El GC además realiza dos tareas adicionales
    • Llama a filc_free al liberar AllocationRecord inalcanzables
    • Cambia todos los punteros que apuntan a AllocationRecord con length igual a 0 por un único AllocationRecord canónico de longitud 0
  • Gracias a este comportamiento, no llamar a free no necesariamente provoca una fuga de memoria
    • El GC la libera automáticamente
    • Aun así, llamar a free permite liberar memoria antes de que actúe el GC
  • Después de free, ese AllocationRecord acaba volviéndose inalcanzable y puede limpiarse más adelante

Escape de direcciones de variables locales y promoción al heap

  • La existencia de un GC amplía el rango en el que puede tratarse de forma segura la dirección de variables locales
    • Si se toma la dirección de una variable local y el compilador no puede demostrar que esa dirección no escapa fuera de la vida útil de la variable, se la promueve a una asignación en el heap
  • Esas variables locales se asignan con malloc en lugar de usar la pila
    • No hace falta insertar un free correspondiente por separado
    • El GC se encarga de recolectarlas

Versión de Fil-C de memmove

  • memmove de la biblioteca estándar de C maneja memoria arbitraria, por lo que el compilador tiene el problema de no saber si dentro hay punteros
  • Para ello se aplican heurísticas
    • Los punteros dentro de memoria arbitraria deben estar completamente contenidos dentro de ese rango de memoria
    • Los punteros deben estar correctamente alineados
  • Debido a estas reglas, incluso con el mismo movimiento de 8 bytes puede haber diferencias de comportamiento
    • Si se hace un memmove de 8 bytes alineados de una sola vez, también se mueve la sección correspondiente de invisible_bytes
    • Si se hace memmove 8 veces de a 1 byte, invisible_bytes no se mueve

Complejidades adicionales en la implementación real

  • Hilos

    • La concurrencia aumenta la complejidad del GC
    • filc_free no puede liberar memoria de inmediato
      • Porque puede haber una condición de carrera entre el hilo que libera y otro hilo que accede a la misma memoria
    • Las operaciones atómicas sobre punteros también requieren tratamiento adicional
      • La reescritura básica convierte la carga/almacenamiento de punteros en dos cargas/almacenamientos, rompiendo la atomicidad
  • Punteros a función

    • Metadatos adicionales en AllocationRecord indican si visible_bytes no es un dato común sino un puntero a código ejecutable
    • Una llamada a través del puntero a función p1 comprueba p1 == p1ar->visible_bytes y también verifica que p1ar represente un puntero a función
    • Para evitar ataques de confusión de tipos sobre punteros a función, también hace falta verificación de firmas de tipo en el ABI de llamada
    • Una forma es hacer que todas las funciones tengan la misma firma de tipo
      • Tratándolo como si todos los argumentos se empaquetaran en una estructura y se pasaran por memoria
      • En el límite del ABI, cada función recibiría solo un AllocationRecord correspondiente a esa estructura
  • Optimización del uso de memoria

    • Puede considerarse que filc_malloc no asigne invisible_bytes de inmediato, sino que lo haga de forma diferida cuando haga falta
    • También puede considerarse colocar AllocationRecord y visible_bytes juntos en una sola asignación
    • Si el malloc subyacente adjunta metadatos al inicio de cada asignación, también puede considerarse incorporar esos metadatos en AllocationRecord
  • Optimización de rendimiento

    • La seguridad de memoria de Fil-C conlleva un costo de rendimiento
    • Hay margen para aplicar diversas técnicas que recuperen parte del rendimiento perdido

Cuándo usar Fil-C

  • Puede usarse cuando un código C/C++ grande parece funcionar, pero no tiene verificación de seguridad de memoria, y se puede tolerar introducir GC y una gran caída de rendimiento para ganar seguridad de memoria
    • Se menciona como posible medida temporal antes de reescribirlo en Java, Go o Rust
  • También puede ejecutarse Fil-C con el propósito de detectar bugs de memoria, como ASan
    • Es posible ejecutar código C/C++ bajo Fil-C para comprobar bugs de memoria
  • En lenguajes donde el lenguaje en tiempo de compilación y el lenguaje en tiempo de ejecución son el mismo, y la seguridad en tiempo de compilación es fuerte, puede servir para evaluación segura en tiempo de compilación
    • Se menciona Zig como ejemplo
    • Aunque la evaluación en tiempo de ejecución no sea segura, la evaluación en tiempo de compilación puede usar una configuración de Fil-C
  • También tiene valor como caso de sistema concreto para pointer provenance
    • Se plantea la pregunta de si, cuando p1 y p2 tienen el mismo tipo, la optimización if (p1 == p2) { f(p1); }if (p1 == p2) { f(p2); } sería posible
    • En Fil-C, como el AllocationRecord* que se pasa a f sería distinto, se afirma claramente que la respuesta es no
    • En este sentido, Fil-C sirve como ejemplo de un sistema concreto que incorpora pointer provenance

1 comentarios

 
GN⁺ 11 일 전
Comentarios en Hacker News
  • Probar invisicaps en algo como chibicc o slimcc parece un experimento bastante interesante.
    También habría espacio para probar conteo de referencias o variantes del invisible capability system, y da la impresión de que quizá se podría ahorrar memoria a cambio de un pequeño costo de indirección.
  • Yo hice filc-bazel-template y lo empaqueté como Bazel target.
    Espero que le sirva a quien quiera usar ambos juntos para hacer hermetic builds.
  • No termino de entender el significado de esta frase.
    Upon freeing an unreachable AllocationRecord, call filc_free on it.
    Según yo, lo que intentan decir es que antes de liberar un AR inalcanzable, primero hay que liberar la memoria a la que apuntan los campos visible_bytes e invisible_bytes.
  • Siento que Fil-C es uno de los proyectos más infravalorados que he visto hasta ahora.
    Me parece más interesante que el típico “rewrite it in Rust” por seguridad, porque aquí se plantea poder compilar programas C existentes para que sean completamente memory-safe.
    • Yo diría que aquí hay que mirar varias cosas juntas.
      Primero, Fil-C es más lento y más grande. Si eso fuera aceptable, también se podría decir que en los últimos 10 años habría tenido más sentido irse antes a Java o C# que a Rust.
      Segundo, sigue siendo C. Para mantener código existente está bien, pero si vas a escribir mucho código nuevo, a mí Rust me parece mucho más cómodo.
      Tercero, Fil-C ofrece seguridad en tiempo de ejecución, mientras que Rust puede expresar parte de eso en tiempo de compilación. Y más allá de eso, lenguajes como WUFFS intentan demostrar seguridad en compilación sin checks en runtime, así que aunque el código pueda estar mal en su lógica, el enfoque es evitar crashes o ejecución arbitraria de código.
    • Yo no diría que esté infravalorado aquí. Ya ha habido bastante discusión al respecto.
      Han salido hilos como Fil-Qt: A Qt Base build with Fil-C experience, Linux Sandboxes and Fil-C, Ported freetype, fontconfig, harfbuzz, and graphite to Fil-C, A Note on Fil-C, Notes by djb on using Fil-C, Fil-C: A memory-safe C implementation, y Fil's Unbelievable Garbage Collector.
    • Para mí, la limitación central de Fil-C es que ofrece runtime memory safety.
      Todavía puedes escribir código que no sea memory-safe, y ahora lo que pasa es más bien que el resultado será un crash garantizado en vez de una vulnerabilidad.
      Si estás construyendo algo como una web API que recibe entradas no confiables, este tipo de problema puede terminar convirtiéndose en denial-of-service, así que sí, es mejor, pero no necesariamente lo bastante bueno.
      No lo digo para menospreciar el trabajo de Fil-C; solo creo que el enfoque en runtime tiene límites claros.
    • Gracias por el interés.
      Pero, siendo justos, Fil-C es bastante más lento que Rust, y también usa más memoria.
      A cambio, Fil-C soporta safe dynamic linking y, en ciertos aspectos, incluso podría decirse que es más estricto con la seguridad que Rust.
      Al final son trade-offs, así que cada quien puede elegir según su caso.
    • Mi impresión es que rara vez a un programador de C/C++ se le iluminan los ojos cuando le dices que puede ponerle un garbage collector a su programa.
      Por eso siento que, aunque la idea sea interesante técnicamente, en lo emocional no entra con tanta facilidad.
  • Según yo, Fil-C no es memory-safe en situaciones de data race.
    Los valores de capability y pointer pueden quedar rasgados durante una asignación, así que con un mal interleaving entre hilos se podría acceder a un objeto con un puntero incorrecto y producir comportamiento arbitrario.
    Esa limitación en sí me parece aceptable, pero me decepciona un poco el ambiente donde hasta los partidarios se lanzan con dureza contra quienes señalan ese problema.
    • Hasta donde yo sé, esa parte se maneja usando atomic ops.
      Por desgracia, eso también es una de las grandes fuentes del overhead.
  • A mí me parece que esto es, al final, otra variante de las técnicas de fat pointers.
    Este tipo de enfoque ya se ha implementado y descartado muchas veces porque no da garantías de seguridad suficientes, porque cuesta cruzar non-fat ABI boundaries, o porque el overhead es alto.
    • Pero últimamente vuelve a haber un movimiento hacia soporte de hardware directo para fat pointers, así que quizá no sea algo que convenga descartar demasiado pronto.
      Además, creo que filc no se explica solo como un simple fat pointer.
    • También creo que hay que tomar en cuenta que ya existen varias plataformas con hardware memory tagging.