Le modèle simplifié de Fil-C
(corsix.org)- Une architecture qui suit les pointeurs C/C++ en leur associant des métadonnées AllocationRecord, et effectue une vérification des bornes mémoire lors du déréférencement
- Une méthode qui fait circuler ensemble la valeur d’origine du pointeur et les métadonnées associées, ou qui convertit les appels en appels spécifiques à Fil-C, depuis l’affectation de pointeurs, l’arithmétique, le passage d’arguments aux fonctions, les retours, jusqu’aux appels à
mallocetfree - Les métadonnées des pointeurs dans la mémoire du tas sont stockées séparément dans invisible_bytes ; lors des chargements/stockages de pointeurs, la valeur et les métadonnées sont lues et écrites ensemble, avec en plus une vérification d’alignement
filc_freene libère quevisible_bytesetinvisible_bytesen conservant l’AllocationRecord lui-même ; le nettoyage ultérieur est pris en charge par le ramasse-miettes, et les variables locales dont l’adresse peut s’échapper sont promues sur le tas- Même si des complexités d’implémentation réelles subsistent, comme les threads, les pointeurs de fonction ou les optimisations mémoire et performances, cela peut servir d’exemple de système concret pour la vérification de la sûreté mémoire de grands codes C/C++ ou pour la pointer provenance
Modèle simplifié de Fil-C
- Fil-C utilise une architecture qui suit des métadonnées AllocationRecord* avec les pointeurs afin de traiter du code C/C++ de manière sûre en mémoire
- L’implémentation réelle repose sur une réécriture de l’IR LLVM, mais le modèle simplifié prend la forme d’une transformation automatique du code source C/C++
- Pour chaque variable locale de type pointeur dans une fonction, une variable locale
AllocationRecord*correspondante est ajoutée - Par exemple, pour
T1* p1, on ajouteAllocationRecord* p1ar = NULL
- Les affectations simples et calculs sur les variables locales pointeurs déplacent aussi AllocationRecord* avec la valeur d’origine du pointeur
p1 = p2est transformé enp1 = p2, p1ar = p2arp1 = p2 + 10s’accompagne aussi dep1ar = p2ar- Un cast d’un entier vers un pointeur définit les métadonnées à
NULL - Un cast d’un pointeur vers un entier reste inchangé
- Lors du passage d’arguments aux fonctions et des retours, AllocationRecord* est transmis en plus avec le pointeur, et certains appels de bibliothèque standard sont remplacés par des fonctions spécifiques à Fil-C
- Les appels à
mallocetfreesont transformés respectivement enfilc_mallocetfilc_free - Par exemple,
p1 = malloc(x); free(p1);devient{p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar);
- Les appels à
filc_mallocn’alloue pas seulement la mémoire demandée, mais effectue trois allocations- Allocation d’un objet
AllocationRecord - Allocation de
visible_bytespour les données réelles - Allocation par
callocdeinvisible_bytespour stocker les métadonnées invisibles AllocationRecordcontient les champsvisible_bytes,invisible_bytesetlength
- Allocation d’un objet
Déréférencement et vérification des bornes
- Lors du déréférencement d’un pointeur, une vérification des bornes est effectuée à l’aide de l’AllocationRecord* associé
- Vérifier que les métadonnées du pointeur ne sont pas
NULL - Calculer la différence entre la position courante du pointeur et l’adresse de début de
visible_bytes - Vérifier que l’offset est inférieur à la longueur totale
- Vérifier que la longueur restante est suffisante pour la taille de la cible du déréférencement
- Vérifier que les métadonnées du pointeur ne sont pas
- La même procédure de vérification s’applique à la lecture comme à l’écriture
- Une vérification est effectuée avant
x = *p1 - La même forme de vérification est effectuée avant
*p2 = x
- Une vérification est effectuée avant
- Cette structure empêche les accès où la cible pointée sort de la plage allouée
Pointeurs dans le tas et invisible_bytes
- Les pointeurs stockés dans la mémoire du tas ne peuvent pas être gérés par le compilateur via des variables séparées comme les variables locales ; invisible_bytes est donc utilisé
- S’il y a un pointeur à la position
visible_bytes + i, l’AllocationRecord*correspondant est stocké à la positioninvisible_bytes + i - Autrement dit,
invisible_bytesse comporte comme un tableau dont le type d’élément estAllocationRecord*
- S’il y a un pointeur à la position
- Lorsqu’une valeur de pointeur est lue ou écrite en mémoire, une vérification d’alignement s’ajoute à la vérification de bornes habituelle
- Vérifier que l’offset
iest un multiple desizeof(AllocationRecord*) - Cette condition doit être satisfaite pour pouvoir accéder en toute sûreté à
invisible_bytescomme à un tableau deAllocationRecord**
- Vérifier que l’offset
- Lors d’un chargement de pointeur, les métadonnées sont chargées en même temps que le pointeur de données
p2 = *p1est suivi dep2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i)
- Lors d’un stockage de pointeur, les métadonnées correspondantes sont écrites en plus de la valeur du pointeur
*p1 = p2effectue, après le stockage des données réelles,*(AllocationRecord**)(p1ar->invisible_bytes + i) = p2ar
filc_free et le ramasse-miettes
- Quand le pointeur n’est pas
NULL,filc_freevérifie la cohérence avec AllocationRecord puis ne libère que deux zones mémoire- Vérifier
par != NULL - Vérifier
p == par->visible_bytes - Libérer
visible_bytesetinvisible_bytes - Puis définir
visible_bytesetinvisible_bytesàNULL, etlengthà 0
- Vérifier
- Même si
filc_malloceffectue trois allocations,filc_freene libère pas l’objet AllocationRecord lui-même- Cette différence est gérée par le ramasse-miettes
- Dans le modèle simplifié, un GC stop-the-world suffit ; le vrai Fil-C utilise un collecteur parallèle, concurrent et incrémental
- Le GC suit les objets
AllocationRecord - Les
AllocationRecordinaccessibles sont marqués pour libération
- Le GC suit les objets
- Le GC effectue en plus deux tâches
- Appeler
filc_freelors de la libération d’unAllocationRecordinaccessible - Remplacer tous les pointeurs vers un
AllocationRecorddontlengthvaut 0 par un uniqueAllocationRecordcanonique de longueur 0
- Appeler
- Grâce à ce comportement, l’absence d’appel à
freene mène pas à une fuite mémoire- Le GC effectue la libération automatiquement
- Mais l’appel à
freepermet une libération plus précoce que celle du GC
- Après
free, l’AllocationRecordconcerné finit de toute façon par devenir inaccessible et peut être nettoyé plus tard
Échappement d’adresse des variables locales et promotion sur le tas
- En présence d’un GC, la plage dans laquelle l’adresse d’une variable locale peut être manipulée en toute sûreté s’élargit
- Si l’adresse d’une variable locale a été prise et que le compilateur ne peut pas prouver que cette adresse ne s’échappera pas au-delà de la durée de vie de la variable, elle est promue vers une allocation sur le tas
- Ces variables locales sont alors allouées via
mallocau lieu de la pile- Il n’est pas nécessaire d’insérer un
freecorrespondant - Le GC s’occupe de la récupération
- Il n’est pas nécessaire d’insérer un
Version Fil-C de memmove
- Le
memmovede la bibliothèque standard C traite une mémoire arbitraire, ce qui pose le problème suivant : le compilateur ne peut pas savoir s’il y a des pointeurs à l’intérieur - Pour cela, une heuristique est appliquée
- Les pointeurs dans une mémoire arbitraire doivent y être entièrement contenus dans cette plage mémoire
- Les pointeurs doivent être correctement alignés
- Cette règle entraîne des différences de comportement même pour le déplacement des mêmes 8 octets
- Si 8 octets alignés sont déplacés d’un seul coup par
memmove, la portion correspondante deinvisible_bytesse déplace aussi - Si
memmoveest exécuté 8 fois octet par octet,invisible_bytesne se déplace pas
- Si 8 octets alignés sont déplacés d’un seul coup par
Complexités supplémentaires dans l’implémentation réelle
-
Threads
- La concurrence augmente la complexité du GC
filc_freene peut pas libérer immédiatement la mémoire- Car il peut y avoir une condition de course entre le thread qui libère et un autre thread accédant à la même mémoire
- Les opérations atomiques sur les pointeurs nécessitent aussi un traitement supplémentaire
- La réécriture de base remplace les chargements/stockages de pointeurs par deux chargements/stockages, ce qui brise l’atomicité
-
Pointeurs de fonction
- Des métadonnées supplémentaires dans
AllocationRecordpermettent d’indiquer quevisible_bytesn’est pas une donnée ordinaire mais un pointeur vers du code exécutable - Un appel via un pointeur de fonction
p1vérifiep1 == p1ar->visible_byteset contrôle aussi quep1arreprésente bien un pointeur de fonction - Pour empêcher les attaques de confusion de type sur les pointeurs de fonction, une vérification de signature de type est aussi nécessaire dans l’ABI d’appel
- Une méthode consiste à donner la même signature de type à toutes les fonctions
- En traitant cela comme un passage en mémoire de tous les arguments regroupés dans une structure
- À la frontière ABI, chaque fonction ne reçoit alors qu’un seul
AllocationRecordcorrespondant à cette structure
- Des métadonnées supplémentaires dans
-
Optimisation de l’usage mémoire
- On peut envisager que
filc_mallocn’alloue pas immédiatementinvisible_bytes, mais le fasse à la demande - On peut aussi envisager de placer
AllocationRecordetvisible_bytesensemble dans une seule allocation - Si le
mallocsous-jacent attache des métadonnées en tête de chaque allocation, on peut aussi envisager d’intégrer ces métadonnées àAllocationRecord
- On peut envisager que
-
Optimisation des performances
- La sûreté mémoire de Fil-C a un coût en performances
- Il existe une marge pour appliquer diverses techniques afin de récupérer une partie des performances perdues
Quand utiliser Fil-C
- Fil-C peut être utilisé pour de grands codes C/C++ qui semblent fonctionner mais sans vérification de sûreté mémoire, lorsque l’on peut accepter l’introduction d’un GC et une forte baisse de performances au nom de la sûreté mémoire
- Il est mentionné comme mesure temporaire possible avant une réécriture en Java, Go ou Rust
- Comme ASan, Fil-C peut aussi être exécuté dans un but de détection de bugs mémoire
- Il est possible d’exécuter du code C/C++ sous Fil-C pour vérifier la présence de bugs mémoire
- Dans les langages dont le langage de compilation et le langage d’exécution sont identiques, et dont la sûreté à la compilation est forte, cela peut servir à une évaluation sûre à la compilation
- Zig est cité en exemple
- Même si l’évaluation à l’exécution n’est pas sûre, l’évaluation à la compilation peut utiliser une configuration de type Fil-C
- Cela a aussi un intérêt comme exemple de système concret traitant la pointer provenance
- La question est posée de savoir si, lorsque
p1etp2ont le même type, l’optimisation consistant à transformerif (p1 == p2) { f(p1); }enif (p1 == p2) { f(p2); }est possible - Dans Fil-C, comme les
AllocationRecord*transmis àfdiffèrent, il est explicitement indiqué que la réponse est clairement non - En ce sens, Fil-C sert d’exemple de système concret doté de pointer provenance
- La question est posée de savoir si, lorsque
1 commentaires
Commentaires sur Hacker News
Il y aurait aussi de quoi tester le comptage de références ou des variantes du invisible capability system, et on pourrait peut-être économiser de la mémoire en échange d’un léger coût d’indirection
J’espère que cela sera utile à ceux qui veulent utiliser les deux ensemble pour faire des builds hermétiques
Upon freeing an unreachable AllocationRecord, call filc_free on it.À mon avis, ce que cela veut dire, c’est qu’avant de libérer un AR inaccessible, il faut d’abord libérer la mémoire pointée par les champs
visible_bytesetinvisible_bytesLe fait de pouvoir compiler des programmes C existants de manière entièrement memory-safe me paraît plus intéressant que de dire, pour la sûreté, « rewrite it in Rust »
Premièrement, Fil-C est plus lent et plus volumineux. Si cela ne posait pas problème, on aurait aussi pu dire qu’au cours des dix dernières années il aurait mieux valu aller vers Java ou C# avant même Rust
Deuxièmement, cela reste du C. C’est bien pour maintenir du code existant, mais si l’on écrit beaucoup de nouveau code, je trouve Rust bien plus agréable
Troisièmement, Fil-C fournit une sûreté à l’exécution, tandis que Rust peut exprimer une partie des garanties à la compilation. Et des langages comme WUFFS vont encore plus loin en essayant de prouver la sûreté à la compilation sans vérifications à l’exécution, de sorte que le code peut être logiquement faux, mais sans provoquer de crash ni permettre l’exécution de code arbitraire
Des fils comme 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 et Fil's Unbelievable Garbage Collector ont déjà existé
On peut toujours écrire du code non memory-safe, sauf que maintenant le résultat sera plutôt un crash certain qu’une vulnérabilité
Si l’on construit quelque chose comme une API web qui reçoit des entrées non fiables, ce genre de problème peut malgré tout finir en denial-of-service ; c’est mieux, mais difficile d’y voir quelque chose d’assez satisfaisant
Je ne cherche pas à rabaisser le travail sur Fil-C lui-même, mais je pense que l’approche runtime a des limites claires
Mais pour être juste, Fil-C est nettement plus lent que Rust et consomme aussi plus de mémoire
En revanche, Fil-C prend en charge le safe dynamic linking et, à certains égards, on peut même dire qu’il est plus strictement sûr que Rust
Au final, ce sont des compromis, donc autant choisir selon le contexte
Donc, même si l’idée est techniquement intéressante, elle ne prend pas facilement sur le plan émotionnel
La capability et la valeur du pointeur peuvent être déchirées pendant une affectation ; avec un mauvais interleaving de threads, on peut alors accéder à un objet avec un pointeur erroné et provoquer un comportement arbitraire
Cette limite en elle-même peut se comprendre, mais je trouve dommage l’ambiance où même des soutiens du projet s’en prennent vivement à ceux qui soulèvent le problème
Malheureusement, c’est aussi l’une des grandes causes du surcoût
Ce type d’approche a souvent été implémenté puis écarté à plusieurs reprises, parce que les garanties de sécurité n’étaient pas jugées suffisantes, qu’il était difficile de franchir des frontières ABI non fat, ou que le surcoût était trop élevé
En plus, je ne pense pas qu’on puisse résumer filc à de simples fat pointers