web-dev-qa-db-fra.com

strcpy () / strncpy () plante sur un membre de la structure avec un espace supplémentaire lorsque l'optimisation est activée sur Unix?

Lors de l'écriture d'un projet, j'ai rencontré un problème étrange.

Il s'agit du code minimal que j'ai réussi à écrire pour recréer le problème. Je stocke intentionnellement une chaîne réelle à la place de quelque chose d'autre, avec suffisamment d'espace alloué.

// #include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stddef.h> // For offsetof()

typedef struct _pack{
    // The type of `c` doesn't matter as long as it's inside of a struct.
    int64_t c;
} pack;

int main(){
    pack *p;
    char str[9] = "aaaaaaaa"; // Input
    size_t len = offsetof(pack, c) + (strlen(str) + 1);
    p = malloc(len);
    // Version 1: crash
        strcpy((char*)&(p->c), str);
    // Version 2: crash
        strncpy((char*)&(p->c), str, strlen(str)+1);
    // Version 3: works!
        memcpy((char*)&(p->c), str, strlen(str)+1);
    // puts((char*)&(p->c));
    free(p);
  return 0;
}

Le code ci-dessus me déroute:

  • Avec gcc/clang -O0, strcpy() et memcpy() fonctionne sous Linux/WSL, et la puts() ci-dessous donne tout ce que j'ai entré.
  • Avec clang -O0 sur OSX , le code se bloque avec strcpy().
  • Avec gcc/clang -O2 Ou -O3 sur Ubuntu/Fedora/WSL , le code plante = (!!) à strcpy(), tandis que memcpy() fonctionne bien.
  • Avec gcc.exe Sous Windows, le code fonctionne bien quel que soit le niveau d'optimisation.

J'ai également trouvé d'autres traits du code:

  • (Il ressemble) l'entrée minimum pour reproduire le crash est de 9 octets (y compris le terminateur zéro), ou 1+sizeof(p->c). Avec cette longueur (ou plus), un crash est garanti (Cher moi ...).
  • Même si j'alloue de l'espace supplémentaire (jusqu'à 1 Mo) dans malloc(), cela n'aide pas. Les comportements ci-dessus ne changent pas du tout.
  • strncpy() se comporte exactement de la même manière, même avec la longueur correcte fournie à son troisième argument.
  • Le pointeur ne semble pas avoir d'importance. Si le membre de structure char *c Est changé en long long c (Ou int64_t), Le comportement reste le même. (Mise à jour: déjà modifiée).
  • Le message d'erreur ne semble pas régulier. Beaucoup d'informations supplémentaires sont fournies.

    crash

J'ai essayé tous ces compilateurs et ils n'ont fait aucune différence:

  • GCC 5.4.0 (Ubuntu/Fedora/OS X/WSL, tous sont 64 bits)
  • GCC 6.3.0 (Ubuntu uniquement)
  • GCC 7.2.0 (Android, norepro ???) (Il s'agit du GCC de C4droid )
  • Clang 5.0.0 (Ubuntu/OS X)
  • MinGW GCC 6.3.0 (Windows 7/10, les deux x64)

De plus, cette fonction de copie de chaîne personnalisée, qui ressemble exactement à la fonction standard, fonctionne bien avec toute configuration de compilateur mentionnée ci-dessus:

char* my_strcpy(char *d, const char* s){
    char *r = d;
    while (*s){
        *(d++) = *(s++);
    }
    *d = '\0';
    return r;
}

Des questions:

  • Pourquoi strcpy() échoue? Comment est-ce possible?
  • Pourquoi échoue-t-il uniquement si l'optimisation est activée?
  • Pourquoi memcpy() n'échoue pas quel que soit le niveau de -O ??

* Si vous souhaitez discuter de la violation d'accès aux membres struct, veuillez consulter ici .


Une partie de la sortie de objdump -d D'un exécutable en panne (sur WSL):

objdump


P.S. Au départ, je veux écrire une structure, dont le dernier élément est un pointeur vers un espace alloué dynamiquement (pour une chaîne). Lorsque j'écris la structure dans un fichier, je ne peux pas écrire le pointeur. Je dois écrire la chaîne réelle. J'ai donc trouvé cette solution: forcer le stockage d'une chaîne à la place d'un pointeur.

Ne vous plaignez pas non plus de gets(). Je ne l'utilise pas dans mon projet, mais uniquement l'exemple de code ci-dessus.

32
iBug

J'ai reproduit ce problème sur mon Ubuntu 16.10 et j'ai trouvé quelque chose d'intéressant.

Une fois compilé avec gcc -O3 -o ./test ./test.c, le programme se bloque si l'entrée dépasse 8 octets.

Après quelques inversions, j'ai trouvé que GCC a remplacé strcpy par memcpy_chk, voyez ceci.

// decompile from IDA
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int *v3; // rbx
  int v4; // edx
  unsigned int v5; // eax
  signed __int64 v6; // rbx
  char *v7; // rax
  void *v8; // r12
  const char *v9; // rax
  __int64 _0; // [rsp+0h] [rbp+0h]
  unsigned __int64 vars408; // [rsp+408h] [rbp+408h]

  vars408 = __readfsqword(0x28u);
  v3 = (int *)&_0;
  gets(&_0, argv, envp);
  do
  {
    v4 = *v3;
    ++v3;
    v5 = ~v4 & (v4 - 16843009) & 0x80808080;
  }
  while ( !v5 );
  if ( !((unsigned __int16)~(_Word)v4 & (unsigned __int16)(v4 - 257) & 0x8080) )
    v5 >>= 16;
  if ( !((unsigned __int16)~(_Word)v4 & (unsigned __int16)(v4 - 257) & 0x8080) )
    v3 = (int *)((char *)v3 + 2);
  v6 = (char *)v3 - __CFADD__((_BYTE)v5, (_BYTE)v5) - 3 - (char *)&_0; // strlen
  v7 = (char *)malloc(v6 + 9);
  v8 = v7;
  v9 = (const char *)_memcpy_chk(v7 + 8, &_0, v6 + 1, 8LL); // Forth argument is 8!!
  puts(v9);
  free(v8);
  return 0;
}

Votre struct pack fait croire à GCC que l'élément c fait exactement 8 octets de long.

Et memcpy_chk échouera si la longueur de copie est supérieure au quatrième argument!

Il y a donc 2 solutions:

  • Modifiez votre structure

  • Utilisation des options de compilation -D_FORTIFY_SOURCE=0(aime gcc test.c -O3 -D_FORTIFY_SOURCE=0 -o ./test) pour désactiver les fonctions de fortification.

    Attention: Cela désactivera complètement la vérification du débordement de tampon dans tout le programme !!

16
Ayra Faceless

Ce que vous faites, c'est un comportement indéfini.

Le compilateur est autorisé à supposer que vous n'utiliserez jamais plus de sizeof int64_t Pour le membre variable int64_t c. Donc, si vous essayez d'écrire plus de sizeof int64_t (Alias ​​sizeof c) Sur c, vous aurez un problème hors limites dans votre code. C'est le cas parce que sizeof "aaaaaaaa"> sizeof int64_t.

Le fait est que, même si vous allouez la taille de mémoire correcte à l'aide de malloc(), le compilateur est autorisé à supposer que vous n'utiliserez jamais plus de sizeof int64_t Dans votre strcpy() ou memcpy() appel. Parce que vous envoyez l'adresse de c (alias int64_t c).

TL; DR: Vous essayez de copier 9 octets vers un type composé de 8 octets (nous supposons qu'un octet est un octet). (De @ Kcvin )

Si vous voulez quelque chose de similaire, utilisez des membres de tableau flexibles de C99:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
  size_t size;
  char str[];
} string;

int main(void) {
  char str[] = "aaaaaaaa";
  size_t len_str = strlen(str);
  string *p = malloc(sizeof *p + len_str + 1);
  if (!p) {
    return 1;
  }
  p->size = len_str;
  strcpy(p->str, str);
  puts(p->str);
  strncpy(p->str, str, len_str + 1);
  puts(p->str);
  memcpy(p->str, str, len_str + 1);
  puts(p->str);
  free(p);
}

Remarque: Pour un devis standard, reportez-vous à la réponse this .

30
Stargateur

Aucune réponse n'a encore expliqué en détail pourquoi ce code peut ou non être un comportement indéfini.

La norme est sous-spécifiée dans ce domaine et une proposition est en cours pour la corriger. Selon cette proposition, ce code ne serait PAS un comportement indéfini et les compilateurs générant du code qui se bloque ne seraient pas conformes à la norme mise à jour. (Je revisite cela dans mon paragraphe de conclusion ci-dessous).

Mais notez que sur la base de la discussion de -D_FORTIFY_SOURCE=2 Dans d'autres réponses, il semble que ce comportement soit intentionnel de la part des développeurs impliqués.


Je vais parler basé sur l'extrait de code suivant:

char *x = malloc(9);
pack *y = (pack *)x;
char *z = (char *)&y->c;
char *w = (char *)y;

Maintenant, les trois xzw font référence au même emplacement de mémoire et auraient la même valeur et la même représentation. Mais le compilateur traite z différemment de x. (Le compilateur traite également w différemment de l'un de ces deux, bien que nous ne sachions pas lequel OP n'a pas exploré ce cas).

Ce sujet est appelé provenance du pointeur. Cela signifie la restriction sur quel objet une valeur de pointeur peut s'étendre. Le compilateur prend z comme ayant une provenance uniquement sur y->c, Tandis que x a une provenance sur toute l'allocation de 9 octets.


La norme C actuelle ne précise pas très bien la provenance. Les règles telles que la soustraction de pointeur ne peut se produire qu'entre deux pointeurs vers le même objet tableau est un exemple de règle de provenance. Une autre règle de provenance est celle qui s'applique au code dont nous discutons, C 6.5.6/8:

Lorsqu'une expression de type entier est ajoutée ou soustraite d'un pointeur, le résultat a le type de l'opérande de pointeur. Si l'opérande du pointeur pointe vers un élément d'un objet tableau et que le tableau est suffisamment grand, le résultat pointe vers un élément décalé par rapport à l'élément d'origine de sorte que la différence des indices des éléments du tableau résultant et d'origine soit égale à l'expression entière. En d'autres termes, si l'expression P pointe vers i- ème élément d'un objet tableau, les expressions (P)+N (De manière équivalente, N+(P)) et (P)-N (Où N a la valeur n) pointent respectivement vers les éléments i+n - th et i−n - th des éléments objet tableau, à condition qu'ils existent. De plus, si l'expression P pointe vers le dernier élément d'un objet tableau, l'expression (P)+1 Pointe un après le dernier élément de l'objet tableau, et si l'expression Q pointe un après le dernier élément d'un objet tableau, l'expression (Q)-1 pointe vers le dernier élément de l'objet tableau. Si l'opérande de pointeur et le résultat pointent tous les deux vers des éléments du même objet tableau, ou dépassent le dernier élément de l'objet tableau, l'évaluation ne doit pas produire de débordement; sinon, le comportement n'est pas défini. Si le résultat pointe au-delà du dernier élément de l'objet tableau, il ne doit pas être utilisé comme l'opérande d'un opérateur unaire * Évalué.

La justification de la vérification des limites de strcpy, memcpy revient toujours à cette règle - ces fonctions sont définies pour se comporter comme si elles étaient une série d'affectations de caractères à partir d'un pointeur de base incrémenté pour obtenir au caractère suivant, et l'incrémentation d'un pointeur est couverte par (P)+1 comme indiqué dans cette règle.

Notez que le terme "objet tableau" peut s'appliquer à un objet qui n'a pas été déclaré comme tableau. Ceci est expliqué dans 6.5.6/7:

Pour les besoins de ces opérateurs, un pointeur sur un objet qui n'est pas un élément d'un tableau se comporte comme un pointeur sur le premier élément d'un tableau de longueur un avec le type de l'objet comme type d'élément.


La grande question ici est: qu'est-ce que "l'objet tableau" ? Dans ce code, est-ce y->c, *y Ou l'objet réel de 9 octets retourné par malloc?

Surtout, la norme ne fait aucune lumière sur cette question. Chaque fois que nous avons des objets avec des sous-objets, la norme ne dit pas si 6.5.6/8 fait référence à l'objet ou au sous-objet.

Un autre facteur de complication est que la norme ne fournit pas de définition pour "tableau" , ni pour "objet tableau". Mais pour faire court, l'objet alloué par malloc est décrit comme "un tableau" à divers endroits de la norme, il semble donc que l'objet de 9 octets ici soit un candidat valide pour "le objet tableau ". (En fait, c'est le seulement tel candidat dans le cas de l'utilisation de x pour itérer sur l'allocation de 9 octets, ce que je pense que tout le monde conviendrait est légal).


Remarque: cette section est très spéculative et j'essaie de fournir un argument pour expliquer pourquoi la solution choisie par les compilateurs ici n'est pas auto-cohérente

Un argument pourrait être fait que &y->c Signifie que la provenance est le sous-objet int64_t. Mais cela entraîne immédiatement des difficultés. Par exemple, y a-t-il la provenance de *y? Si tel est le cas, (char *)y Devrait toujours avoir la provenance *y, Mais cela contredit la règle du 6.3.2.3/7 selon laquelle le fait de placer un pointeur sur un autre type et de revenir devrait renvoyer le pointeur d'origine (comme tant que l'alignement n'est pas violé).

Une autre chose qu'il ne couvre pas est la provenance qui se chevauche. Un pointeur peut-il se comparer inégal à un pointeur de même valeur mais de provenance plus petite (qui est un sous-ensemble de la plus grande provenance)?

De plus, si nous appliquons ce même principe au cas où le sous-objet est un tableau:

char arr[2][2];
char *r = (char *)arr;    
++r; ++r; ++r;     // undefined behavior - exceeds bounds of arr[0]

arr est défini comme signifiant &arr[0] dans ce contexte, donc si la provenance de &X est X, alors r est en fait lié à juste la première ligne du tableau - peut-être un résultat surprenant.

Il serait possible de dire que char *r = (char *)arr; mène ici à UB, mais pas char *r = (char *)&arr;. En fait, j'avais l'habitude de promouvoir ce point de vue dans mes publications il y a de nombreuses années. Mais je ne le fais plus: d'après mon expérience de défense de cette position, elle ne peut tout simplement pas devenir cohérente, il y a trop de scénarios problématiques. Et même s'il pouvait être rendu cohérent, le fait demeure que la norme ne le spécifie pas. Au mieux, ce point de vue devrait avoir le statut d'une proposition.


Pour terminer, je recommanderais de lire N2090: Clarifier la provenance du pointeur (ébauche de rapport de défaut ou proposition pour C2x) .

Leur proposition est que la provenance s'applique toujours à une allocation. Cela rend sans objet toutes les subtilités des objets et des sous-objets. Il n'y a aucune sous-allocation. Dans cette proposition, tous les xzw sont identiques et peuvent être utilisés pour s'étendre sur toute l'allocation de 9 octets. À mon humble avis, la simplicité de cela est attrayante, par rapport à ce qui a été discuté dans ma section précédente.

10
M.M

Tout cela est dû au fait que -D_FORTIFY_SOURCE=2 Se bloque intentionnellement sur ce qu'il juge dangereux.

Certaines distributions génèrent gcc avec -D_FORTIFY_SOURCE=2 Activé par défaut. Certains non. Cela explique toutes les différences entre les différents compilateurs. Ceux qui ne plantent pas normalement le seront probablement si vous construisez votre code avec -O3 -D_FORTIFY_SOURCE=2.

Pourquoi échoue-t-il uniquement si l'optimisation est activée?

_FORTIFY_SOURCE Nécessite une compilation avec optimisation (-O) Pour garder une trace de la taille des objets grâce à des conversions/affectations de pointeurs. Voir les diapositives de cette présentation pour en savoir plus sur _FORTIFY_SOURCE.

Pourquoi strcpy () échoue? Comment est-ce possible?

gcc appelle __memcpy_chk pour strcpy uniquement avec -D_FORTIFY_SOURCE=2. Il passe 8 Comme taille de l'objet cible, car c'est ce qu'il pense que vous voulez dire /ce qu'il peut comprendre à partir de la source code que vous lui avez donné. Même accord pour strncpy appeler __strncpy_chk.

__memcpy_chk Est volontairement abandonné. _FORTIFY_SOURCE Peut aller au-delà des choses qui sont UB en C et interdire les choses qui semblent potentiellement dangereuses. Cela lui donne une licence pour décider que votre code n'est pas sûr. (Comme d'autres l'ont souligné, un membre de tableau flexible en tant que dernier membre de votre structure et/ou une union avec un membre de tableau flexible est la façon dont vous devez exprimer ce que vous faites en C.)


gcc prévient même que la vérification échouera toujours:

In function 'strcpy',
    inlined from 'main' at <source>:18:9:
/usr/include/x86_64-linux-gnu/bits/string3.h:110:10: warning: call to __builtin___memcpy_chk will always overflow destination buffer
   return __builtin___strcpy_chk (__dest, __src, __bos (__dest));
          ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

(à partir de gcc7.2 -O3 -Wall sur l'explorateur du compilateur Godbolt ).


Pourquoi memcpy() n'échoue-t-il pas quel que soit le niveau de -O?

IDK.

gcc l'inline entièrement juste une charge/magasin 8B + une charge/magasin 1B. (On dirait une optimisation manquée; il faut savoir que malloc ne l'a pas modifié sur la pile, donc il pourrait simplement le stocker à nouveau au lieu de recharger. (Ou mieux garder la valeur 8B dans un registre.)

3
Peter Cordes

pourquoi compliquer les choses? Trop complexifier comme vous le faites donne juste plus d'espace pour un comportement indéfini , dans cette partie:

memcpy((char*)&p->c, str, strlen(str)+1);
puts((char*)&p->c);

avertissement: passage de l'argument 1 de 'met' à partir d'un pointeur incompatible ty pe [-Wincompatible-pointer-types] met (& p-> c);

vous vous retrouvez clairement dans une zone de mémoire non allouée ou dans un endroit accessible en écriture si vous avez de la chance ...

L'optimisation ou non peut modifier les valeurs des adresses, et cela peut fonctionner (car les adresses correspondent), ou non. Vous juste ne pouvez pas faire ce que vous voulez faire (en gros mentir au compilateur)

Je voudrais:

  • allouer juste ce qui est nécessaire pour la structure, ne pas prendre en compte la longueur de la chaîne à l'intérieur, c'est inutile
  • n'utilisez pas gets car il est dangereux et obsolète
  • utilisez strdup au lieu du code sujet aux bogues memcpy que vous utilisez puisque vous manipulez des chaînes. strdup n'oubliera pas d'allouer le nul-terminator, et le placera dans la cible pour vous.
  • n'oubliez pas de libérer la chaîne dupliquée
  • lire les avertissements, put(&p->c) est un comportement indéfini

test.c: 19: 10: avertissement: passage de l'argument 1 de "met" à partir de pointeurs incompatibles ty pe [-Wincompatible-pointer-types] met (& p-> c);

Ma proposition

int main(){
    pack *p = malloc(sizeof(pack));
    char str[1024];
    fgets(str,sizeof(str),stdin);
    p->c = strdup(str);
    puts(p->c);
    free(p->c);
    free(p);
  return 0;
}
2