Journal C23: un memset_explicit() qui carbure

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
22
21
jan.
2025

Hello nal,

Parmi les propositions mal-aimées de la norme C23, j'invoque memset_explicit().

Bon d'accord, c'est pas si nouveau : pour C11 on avait déjà memset_s(), dont ce n'est que l'évolution à un paramètre près.
Dans les deux cas, le support s'en est trouvé relégué à l'annexe K, c'est-à-dire le morceau de la norme qu'on est "invité à, mais pas obligé" d'implémenter. Concrètement et ironiquement, seuls deux célèbres compilateurs propriétaires l'implémentent ici et  ; on y revient…

La raison fondamentale est qu'on touche ici à une zone grise : le rôle d'un développeur est d'exprimer une intention, le compilateur d'interpréter et éventuellement optimiser. Mais que se passe-t-il quand les deux se mêlent, voire se brouillent ?

Prenons ce code par exemple:

#define PW_LEN 16

char password[PW_LEN+1];
scanf("%PW_LENs", password);
 [...]
memset(password, 0, PW_LEN);
return;

On saisit un mot de passe qu'on chiffre éventuellement, puis on l'utilise (non montré), enfin on le nettoie avec memset() pour qu'il n'en reste pas trace dans la mémoire de la machine hôte. Et après on n'y accède plus jamais. Logique.

Que se dit le compilateur, avec un niveau d'optimisation de type "release" (= dés "-O1" pour GCC) ? "Pas la peine de garder la dernière instruction, elle ne sert à rien et on y gagne !"
Et c'est comme ça qu'on passe par exemple de cet assembleur x86 :

pxor %xmm0, %xmm0
movups %xmm0, (%rsp)

ou alors :

rep stos %al, (%edi)

à… rien du tout.
Si si, tu peux essayer avec cet exemple ; pas une trace. Autrement dit, paie ta mesure de sécurité !

Le problème est ici que l'intention du développeur est déjouée par le compilateur faute de contexte. Et comme le contexte en C c'est inexistant, on espère qu'il soit véhiculé par une fonction… explicite, dont l'implémentation concrète sera malheureusement très variable car hyper-dépendante de la plate-forme matérielle ET logicielle.

Bon après, concrètement en son absence ? Alors presque chacun a sa solution maison qui marche bien tant qu'on reste… à la maison justement. Entre autres :

  • Linux/GlibC a explicit_bzero(), comme FreeBSD d'ailleurs -sauf que ce dernier ne le déclare pas dans le même header, sacré lui !
  • macOS est semi-pro, il implémente la version de C11 mais pas (encore ?) celle de C23 ;
  • un certain OS répandu en a une qui marche bien tant qu'on utilise son compilateur maison. A sa décharge, c'est plutôt l'implémentation OSS de l'équipe d'en face qui laisse à désirer ;
  • Android n'a… juste rien ?

Face à ce chaos ambiant, je te présente :

Mon implémentation multi-plateforme de memset_explicit()

(qui s'appuie en partie sur le bon travail préalable d'un monsieur)

Elle n'est forcément pas parfaite, mais je te garantis que je l'ai essayée partout où j'ai raisonnablement pu.
Elle fait appel à l'annexe K dans les rares cas où c'est dispo, à défaut tente la solution maison, se replie sur un code assembleur x86/ARM si ça convient, et en dernier recours tente de forcer la main du compilateur.

Tu peux essayer les étapes du README, et me dire si tout fonctionne dans ton cas particulier.
Je t'en serai très reconnaissant :-) !

  • # volatile ?

    Posté par  (Mastodon) . Évalué à 7 (+4/-0). Dernière modification le 21 janvier 2025 à 09:00.

    Moi j'aurais utilisé le mot clé volatile, mais peut-être ai-je raté quelque chose ?

    En théorie, la théorie et la pratique c'est pareil. En pratique c'est pas vrai.

    • [^] # Re: volatile ?

      Posté par  (site web personnel) . Évalué à 4 (+1/-0).

      Bonne question.

      "La première sécurité est la liberté"

    • [^] # Re: volatile ?

      Posté par  (site web personnel) . Évalué à 4 (+2/-0). Dernière modification le 21 janvier 2025 à 09:30.

      Effectivement !
      Et en effet si tu regardes, c'est ce qui est fait ici dans le pire des cas :

      // ici on gère toutes les architectures connues
      #else
      
      static void* (*volatile memset_explicit_unk)(void*, int, size_t) = memset;
      
      #endif
      

      Je dis le "pire", car cela fait une indirection qui tape sur le pointeur de fonction de la lib C.

      Très suffisant bien sûr ! Mais juste potentiellement moins efficace que d'avoir soit :
      - une implémentation locale qui tirerait un assembleur adapté ;
      - son propre assembleur adapté :-)
      et le fait que ça verrouille une petite zone mémoire pour le besoin.

      (tu le sais bien sûr, mais je précise pour les non-initiés : il ne faut pas appliquer "volatile" sur la variable de la zone à effacer -ici "password"- mais sur l'appel de fonction comme ci-dessus)

      • [^] # Re: volatile ?

        Posté par  (Mastodon) . Évalué à 6 (+3/-0). Dernière modification le 21 janvier 2025 à 10:25.

        tu le sais bien sûr […] : il ne faut pas appliquer "volatile" sur la variable de la zone à effacer

        Ah non je le sais pas, j'aurais utilisé volatile sur la variable, et ensuite un memset() des familles. C'est quoi le soucis du coup ?

        Bon évidemment j'aurais vérifié tout ça, mais c'est ce que j'aurais fait en première intention. Apparemment ça ne marche pas ?

        EDIT : En effet memset() ça marche pas, mais du coup j'aurais fait différemment

        En théorie, la théorie et la pratique c'est pareil. En pratique c'est pas vrai.

        • [^] # Re: volatile ?

          Posté par  (site web personnel) . Évalué à 3 (+1/-0). Dernière modification le 21 janvier 2025 à 10:33.

          En fait, comme le dit @David Demelier ci-dessous (et si j'ai bien interprété sa réponse qui avait l'air en rapport),
          appliquer "volatile" à la variable ne va pas forcément empêcher le compilo de la sortir, comme cité ici (même si je reconnais que c'est capillotracté, et perso je n'avais même pas réussi à le reproduire).

          C'est pour cette raison que, sur le conseil d'un ami qui se reconnaîtra -cf les logs de commit ;-), j'ai appliqué la même logique que la bibliothèque Botan en le mettant plutôt sur le pointeur de fonction.

    • [^] # Re: volatile ?

      Posté par  (site web personnel) . Évalué à 4 (+2/-0).

      La variable volatile n'est pas normalement autorisée à être passée à memset et ne changerait pas forcément le résultat. Le compilateur retire l'appel à memset car il voit qu'il y a rien derrière qui utilise la variable. C'est un mot clé utilisé plutôt en relation avec des accès registres pour éviter des optimisations sur des variables modifiées « à l'extérieur ».

      Je crois qu'en C standard pur (si on a pas memset_explicit) le mieux est de passer la variable à une fonction externe qui fait un memset derrière. Dans ce cas il est possible que le compilateur ne prenne plus trop d'initiatives sans savoir ce que la fonction fait.

      Exemple même avec volatile
      Exemple avec une fonction vite fait

      git is great because linus did it, mercurial is better because he didn't

      • [^] # Re: volatile ?

        Posté par  (Mastodon) . Évalué à 5 (+2/-0).

        Ça marche pas avec un memset, mais tu peux faire la boucle explicite : https://godbolt.org/z/rhzaGTTGj

        En théorie, la théorie et la pratique c'est pareil. En pratique c'est pas vrai.

        • [^] # Re: volatile ?

          Posté par  . Évalué à 1 (+0/-0).

          Est ce que ça offre la garantie qu'un compilo ne supprime pas le code ?
          Sinon dans un bout de code assembleur ? normalement le compilo n'y touche pas…

          • [^] # Re: volatile ?

            Posté par  (Mastodon) . Évalué à 3 (+0/-0).

            Le lien que j'ai mis montre les instruction mov générées par le compilo, et puis c'est clairement la norme C qui le demande.

            En théorie, la théorie et la pratique c'est pareil. En pratique c'est pas vrai.

      • [^] # Re: volatile ?

        Posté par  (site web personnel) . Évalué à 3 (+1/-0). Dernière modification le 21 janvier 2025 à 10:50.

        Exact David !
        D'ailleurs, et sans rapport direct… au début, la fin du code ressemblait à ça :

            puts("Your password is still in memory... Inspect it now! (press any key...)");
            getchar();
        
            memset(password, 0, 16);
        
            puts("Your password is now cleared! (press any key...)");
            getchar();
            return 0;  
        }
        

        C'est-à-dire qu'il y avait une séquence "puts()/getchar()" supplémentaire entre le memset() et le return final.
        Eh bien dans ce cas-là… le compilateur n'optimise pas non plus ! Car il s'agit de fonctions d'I/O et il considère que ça peut interférer avec les buffers -;). (dit autrement : c'est sensible !)

      • [^] # Re: volatile ?

        Posté par  . Évalué à 3 (+1/-0).

        Euh personnellement je pense qu'il y a un bug du compilateur là..
        Si le compilateur se met a retirer des écritures sur de la mémoire volatile, ça va induire des bugs sur les pilotes!

        • [^] # Re: volatile ?

          Posté par  (site web personnel) . Évalué à 3 (+1/-0).

          // https://en.cppreference.com/w/c/string/byte/memset
          // void *memset( void *dest, int ch, size_t count );
          
          
          int
          main(void)
          {
              volatile char tmp[16];
          
              scanf("%15s", tmp);
              memset(tmp, 0, sizeof (tmp));
          }

          Ben justement. Dans le prototype de memset, dest n’est pas déclaré en volatile et toi même tu ne fais pas d’I/O ou de modification de la variable après le scanf. Du coup, ça ne me parait pas aberrant que le compilateur supprime un appel de fonction qui manifestement ne fait rien.

          Mais ça fait longtemps que j’ai pas fait de C et je suis preneur de tout pointeur vers la standard qui permettrai de trancher le débat dans un sens ou l’autre.

          • [^] # Re: volatile ?

            Posté par  (site web personnel) . Évalué à 3 (+1/-0). Dernière modification le 21 janvier 2025 à 11:45.

            Mes pièces rouges : memset() casterait "volatile char*" en "char*", ce qui retirerait l'effet du "volatile".
            C'est tout simple et cité plusieurs fois sur Stack Overflow entre autres.

            Je viens d'essayer ça :

            memset((volatile char*)password, 0, 16);
            

            et ça m'a affiché :

            memset_explicit.c:90:12: warning: passing argument 1 of ‘memset’ discards ‘volatile’ qualifier from pointer target type [-Wdiscarded-qualifiers]

            Un avis là-dessus ?

            • [^] # Re: volatile ?

              Posté par  (Mastodon) . Évalué à 4 (+1/-0).

              Un avis là-dessus ?

              Utilise pas memset() et fais-le à la main.

              Si vraiment c'est pour quelques octets, je chercherais pas du tout à optimiser quoi que ce soit.

              En théorie, la théorie et la pratique c'est pareil. En pratique c'est pas vrai.

              • [^] # Re: volatile ?

                Posté par  (site web personnel) . Évalué à 2 (+0/-0).

                Non mais d'accord :-D.
                En ce qui me concerne c'est bien plus une affaire de sécurité !

                Après là j'avoue, c'est surtout un "jeu" de comprendre le détail des choses. L'existant marche quoi qu'il arrive !

                • [^] # Re: volatile ?

                  Posté par  (site web personnel) . Évalué à 5 (+3/-0).

                  comme disent mes petits camarades de jeu. Si je n’ai pas accès à memset_explicit ou autre équivalent; je ferais le mien avec une boucle for et un volatile, et je laisserai le compilateur se démerder. Je lui fais plus confiance qu’à moi pour générer un asm qui tient la route.

                  https://godbolt.org/z/5G3fdnK6a

                  Seul inconvénient de cette méthode : volatile garantissant que les I/O seront faites dans le même ordre que ce tu as demandé, tu perds la possibilité de vectoriser automatiquement la boucle. Mais bon, est-ce un gros prix à payer pour la sécurité et la simplicité ?

        • [^] # Re: volatile ?

          Posté par  (site web personnel) . Évalué à 6 (+4/-0).

          Il n'y a pas de bug, passer un pointeur volatile à une fonction qui ne prend pas un pointeur volatile est déjà UB (et il y a un warning par défaut).

          volatile c'est pour des registres matériel, pas pour des variables dont on veut empêcher l'optimisation.

          git is great because linus did it, mercurial is better because he didn't

          • [^] # Re: volatile ?

            Posté par  (site web personnel) . Évalué à 4 (+2/-0).

            La meilleure explication à mon sens.
            Court et précis. Merci :-) !

          • [^] # Re: volatile ?

            Posté par  . Évalué à 4 (+2/-0).

            pas pour des variables dont on veut empêcher l'optimisation

            Si, c'est aussi pour ça ! Une variable globale modifiée dans une isr doit être qualifiée de volatile.

      • [^] # Re: volatile ?

        Posté par  (site web personnel) . Évalué à 4 (+1/-0).

        Le compilateur retire l'appel à memset car il voit qu'il y a rien derrière qui utilise la variable. 'Cest un mot clé utilisé plutôt en relation avec des accès registres pour éviter des optimisations sur des variables modifiées « à l'extérieur ».

        Justement, le registre hardware peut être écrit par le hard, mais une écriture peut aussi changer un comportement (send écrit avec un 1). "volatile" ne gére pas ce cas là ?

        "La première sécurité est la liberté"

  • # Bienvenue dans mon monde

    Posté par  (site web personnel) . Évalué à 4 (+2/-0).

    Je travaille dans le domaine des logiels pour carte à puce. Ces softs doivent être sécurisés contre des attaques "externes", où le flot du code ne suit pas ce qui est prévu par le source C ou assembleur. On rajoute donc pas mal de contre-mesure pour s'assurer qu'on ne sort pas du chemin officiel du code (double-vérifications, flags qu'on monte à des points stratégiques du code, ordonnancement particulier, valeurs particulière, …).

    Une des difficultés qu'on rencontre est celle que tu décris ici dans ton article: le compilateur pense naïvement que le code s'exécute comme il le voit, et va avoir tendance à retirer certaines contre-mesures. On bidouille à coup d'assembleur, de volatile, de pragma mais ça suffit pas toujours. Et surtout, assez non-prédictif. Il va décider tout un coup de retirer une contre-mesure qu'il gardait auparavant. On passe donc la version finale de notre code à une moulinette intense pour vérifier la présence des contre-mesures.

    Avantage par rapport à toi: on ne cible qu'une plate-forme à la fois

    • [^] # Re: Bienvenue dans mon monde

      Posté par  . Évalué à 2 (+0/-1).

      Et c'est toujours un dialogue de sourd avec les compilateurs (si il y a dialogue) depuis le temps que cette problématique existe ? J'imagine que des gens ont du proposer des pragmas "do not optimise" ou autre depuis le temps ?

      • [^] # Re: Bienvenue dans mon monde

        Posté par  . Évalué à 5 (+4/-0).

        Il faudrait tester :

        #pragma GCC push_options
        #pragma GCC optimize ("-O0")
        void* memset_explicit(void*, int, size_t)
        {
           ...
        }
        #pragma GCC pop_options

        Les vrais naviguent en -42

        • [^] # Re: Bienvenue dans mon monde

          Posté par  (site web personnel, Mastodon) . Évalué à 3 (+1/-0).

          #pragma GCC push_options

          Roh ! Je ne connaissais pas ça ! Merci de la découverte !

          • [^] # Re: Bienvenue dans mon monde

            Posté par  . Évalué à 4 (+3/-0).

            De rien,

            à la base je m'en servais dans l'embarqué pour avoir les fonctions d'initialisation des périphériques optimisées en taille et les fonctions "utiles" optimisées en vitesse.

            Les vrais naviguent en -42

        • [^] # Re: Bienvenue dans mon monde

          Posté par  (site web personnel, Mastodon) . Évalué à 2 (+0/-0).

          Ça c’est une jolie découverte (pour moi) MeRcI

          “It is seldom that liberty of any kind is lost all at once.” ― David Hume

        • [^] # Re: Bienvenue dans mon monde

          Posté par  . Évalué à 4 (+3/-0).

          C'est confirmé sur ce code :

          //  gcc -O2 -S test_memset.c
          #include <stdlib.h>
          #include <stdio.h>
          #include <string.h>
          
          void *memset_explicit(void * s, int c, size_t n);
          
          int main(int argc, char * argv[])
          {
              char * buf = NULL;
              unsigned checksum;
              char * p;
              if (argc < 2)
              {
                  printf("Please add a parameter (any word)\n");
                  return 1;
              }
              buf = (char *) malloc((strlen(argv[1])+1)*sizeof(char));
              if (!buf) return 2;
              strcpy(buf, argv[1]);
          
              checksum = 0;
              p=buf;
              while (*p)
              {
                  checksum += *p;
                  p++;
              }
              printf("checksum = %u\n", checksum);
              memset_explicit(buf, 0, strlen(buf));
              free(buf);
              return 0;
          }
          
          #pragma GCC push_options
          #pragma GCC optimize ("-O0")
          void *memset_explicit(void * s, int c, size_t n)
          {
              memset(s, c, n);
          }
          #pragma GCC pop_options

          Si on commente le pragma GCC optimize, l'appel à memset disparaît de l'assembleur, et c'est à partir de O2 sur ma version de GCC (standard Debian 12).

          Les vrais naviguent en -42

      • [^] # Re: Bienvenue dans mon monde

        Posté par  (site web personnel, Mastodon) . Évalué à 3 (+1/-0).

        Oui, ou sinon il faut utiliser un langage qui n'est pas conçu comme le C et qui fait exactement ce que tu lui dit, et non pas un équivalent de ce que tu lui dit, mais seulement dans une situation bien précise.

        Ce ne sont pas des bugs des compilateurs, le langage C est décrit comme ça et ne permet pas de faire ce genre de choses de façon fiable. D'où l'introduction dans le langage de fonctions conçues spécifiquement pour répondre à ce type de problème.

      • [^] # Re: Bienvenue dans mon monde

        Posté par  . Évalué à 3 (+1/-0).

        Oui, enfin dans le cas présent, il faut peut être mieux utiliser du code correcte c'est à dire utiliser le type volatile plutôt que de jouer avec les flags de compilation.

        • [^] # Re: Bienvenue dans mon monde

          Posté par  . Évalué à 5 (+3/-0). Dernière modification le 29 janvier 2025 à 10:27.

          Pas forcément, si tu n’as qu’un thread, le volatile va obliger à recharger la valeur depuis la mémoire à chaque utilisation. Ce qui peut réduire les performances par rapport à un stockage dans un registre pendant toute la fonction. Alors qu’on veut juste set la mémoire à 0.
          Une autre solution peut-être de faire une fonction reset to 0 soi même.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.