在Unix上启用优化时,strcpy()/ strncpy()在结构成员上崩溃并有额外的空间?

iBu*_*Bug 32 c struct segmentation-fault compiler-optimization strcpy

在编写项目时,我遇到了一个奇怪的问题.

这是我设法编写的用于重新创建问题的最小代码.我故意将一个实际的字符串存储在其他地方,并分配了足够的空间.

// #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;
}
Run Code Online (Sandbox Code Playgroud)

上面的代码令我困惑:

  • 使用gcc/clang -O0,两者strcpy()memcpy()用于Linux/WSL,puts()下面给出了我输入的内容.
  • 随着clang -O0 在OSX,代码崩溃与strcpy().
  • 随着gcc/clang -O2或者-O3 在Ubuntu/Fedora的/ WSL,代码崩溃 (!!)strcpy(),而memcpy()效果很好.
  • 随着gcc.exe在Windows上,代码工作以及任何优化级别.

我还发现了代码的其他一些特征:

  • (看起来)重现崩溃的最小输入是9个字节(包括零终结符),或1+sizeof(p->c).随着这个长度(或更长)的崩溃得到保证(亲爱的......).
  • 即使我分配了额外的空间(最多1MB)malloc(),它也无济于事.以上行为根本不会改变.
  • strncpy() 行为完全相同,即使提供给第3个参数的长度正确.
  • 指针似乎并不重要.如果将结构成员char *c更改为long long c(或int64_t),则行为保持不变.(更新:已经改变).
  • 崩溃消息看起来不规律.给出了很多额外的信息.

    紧急

我尝试了所有这些编译器,它们没有区别:

  • GCC 5.4.0(Ubuntu/Fedora/OS X/WSL,均为64位)
  • GCC 6.3.0(仅限Ubuntu)
  • GCC 7.2.0(Android,norepro ???)(这是C4droid的GCC )
  • Clang 5.0.0(Ubuntu/OS X)
  • MinGW GCC 6.3.0(Windows 7/10,均为x64)

此外,这个自定义字符串复制功能看起来与标准字符串完全相同,适用于上面提到的任何编译器配置:

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

问题:

  • 为什么strcpy()失败?怎么样?
  • 为什么只有在优化开启时才会失败?
  • 为什么memcpy()不管-O级别都不会失败?

*如果您想讨论有关结构成员访问冲突的问题,请在此处进行讨论.


的一部分objdump -d"一个崩溃可执行文件(上WSL)的的输出:

objdump的


PS最初我想写一个结构,其最后一项是指向动态分配空间的指针(对于一个字符串).当我将结构写入文件时,我无法编写指针.我必须写实际的字符串.所以我提出了这个解决方案:强制将一个字符串存储在一个指针的位置.

也请不要抱怨gets().我不在我的项目中使用它,但仅在上面的示例代码中使用它.

Sta*_*eur 30

你正在做的是未定义的行为.

允许编译器假设您永远不会使用多于sizeof int64_t变量成员int64_t c.因此,如果您尝试编写超过sizeof int64_t(又名sizeof c)c,您的代码中将出现一个越界问题.这是因为sizeof "aaaaaaaa"> sizeof int64_t.

关键是,即使您使用分配正确的内存大小malloc(),编译器也可以假设您永远不会使用多于sizeof int64_t您的strcpy()memcpy()调用.因为你发送c(又名int64_t c)的地址.

TL; DR:您试图将9个字节复制到由8个字节组成的类型(我们假设一个字节是一个八位字节).(来自@Kcvin)

如果你想要类似的东西使用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);
}
Run Code Online (Sandbox Code Playgroud)

注意:有关标准报价,请参阅答案.

  • 这个问题在malloc缓冲区中分配了足够的空间,并将指针强制转换为`char*` - 我不知道这是什么UB以任何明显的方式.由于问题和其他答案都突出了函数`_memcpy_chk`,我认为"崩溃"实际上是由检查器代码引起的,gcc仅在strcpy的情况下引入,而不是在memcpy的情况下引入.就个人而言,我的看法是gcc检查实际上非常有用,但并不完全符合. (7认同)
  • 标准指定指向结构初始成员的指针(适当转换)指向结构.如果指向大于结构的分配的指针被强制转换为该结构类型,然后转换为字符类型,则生成的指针可用于访问分配而不受结构大小的限制(除此之外,这允许分配被视为持有结构阵列).我可以看到行为是合理的唯一方法是``(char*)(&p-> c)`与`(char*)(struct pack _*)(&p-> c)不同. (3认同)
  • @Joshua,直到灵活的阵列成员才符合标准 (3认同)
  • 我会发现这个答案更有用,如果你在_Why_上更多的句子,这就是UB. (2认同)

Ayr*_*ess 16

我在Ubuntu 16.10上重现了这个问题,我发现了一些有趣的东西.

编译时gcc -O3 -o ./test ./test.c,如果输入超过8个字节,程序将崩溃.

经过一番倒车我发现,GCC代替strcpymemcpy_chk,看到这一点.

// 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;
}
Run Code Online (Sandbox Code Playgroud)

您的struct pack使GCC相信该元素c正好是8个字节长.

memcpy_chk如果复制长度大于第四个参数,则会失败!

所以有两个解决方案:

  • 修改你的结构

  • 使用编译选项-D_FORTIFY_SOURCE=0(喜欢gcc test.c -O3 -D_FORTIFY_SOURCE=0 -o ./test)来关闭强化功能.

    注意:这将完全禁用整个程序中的缓冲区溢出检查!


M.M*_*M.M 10

目前还没有答案详细讨论为什么这段代码可能会或可能不会是未定义的行为.

该标准在该领域尚未明确规定,并且有一项提案正在积极修复.根据该提议,此代码不会是未定义的行为,并且生成崩溃代码的编译器将无法遵守更新的标准.(我在下面的结论段中重新审视这一点).

但请注意,基于-D_FORTIFY_SOURCE=2其他答案的讨论,似乎这种行为是有关开发人员的故意.


我将根据以下代码段进行讨论:

char *x = malloc(9);
pack *y = (pack *)x;
char *z = (char *)&y->c;
char *w = (char *)y;
Run Code Online (Sandbox Code Playgroud)

现在,所有三个x z w引用相同的内存位置,并且具有相同的值和相同的表示.但编译器对待的方式z不同x.(编译器也w对这两者中的一个采用不同的方法,尽管我们不知道哪个OP没有探索这种情况).

该主题称为指针出处.它意味着对指针值可以覆盖的对象的限制.编译器只考虑z了起源y->c,而x在整个9字节分配中具有起源.


目前的C标准没有很好地规定出处.诸如指针减法之类的规则可能仅发生在指向同一数组对象的两个指针之间,这是一个起源规则的示例.另一个起源规则是适用于我们正在讨论的代码的规则,C 6.5.6/8:

当一个具有整数类型的表达式被添加到指针或从指针中减去时,结果具有指针操作数的类型.如果指针操作数指向数组对象的元素,并且数组足够大,则结果指向偏离原始元素的元素,使得结果元素和原始数组元素的下标的差异等于整数表达式.换句话说,如果表达式P指向i数组对象的-th元素,则表达式(P)+N(等效地N+(P))和(P)-N(其中N有值n)分别指向数组对象的i+n第-th和i?n-th元素,前提是它们存在.此外,如果表达式P指向数组对象的最后一个元素,则表达式(P)+1指向数组对象的最后一个元素之后,如果表达式Q指向一个超过数组对象的最后一个元素,则表达式(Q)-1指向最后一个元素数组对象的元素.如果指针操作数和结果都指向同一个数组对象的元素,或者指向数组对象的最后一个元素,则评估不应产生溢出; 否则,行为未定义.如果结果指向数组对象的最后一个元素之后,则不应将其用作*已计算的一元运算符的操作数.

对于边界检查的理由strcpy,memcpy总是回来这个规则-这些功能被定义为行为就好像他们是从被递增,以获得下一个字符基类指针一系列字符分配表,以及指针的增量为所涵盖(P)+1如在此规则中讨论.

请注意,术语"数组对象"可能适用于未声明为数组的对象.这在6.5.6/7中有详细说明:

出于这些运算符的目的,指向不是数组元素的对象的指针与指向长度为1的数组的第一个元素的指针的行为相同,其中对象的类型为其元素类型.


这里最大的问题是:什么是"数组对象"?在此代码,是它y->c,*y或通过malloc返回实际9字节的对象?

至关重要的是,该标准对此事没有任何影响.每当我们有子对象的对象时,标准都没有说6.5.6/8是指对象还是子对象.

另一个复杂因素是标准不提供"数组"的定义,也不提供"数组对象"的定义.但是长话短说,分配的对象malloc在标准的不同位置被描述为"数组",所以看起来这里的9字节对象是"数组对象"的有效候选者.(事实上,对于使用迭代9字节分配的情况,这是唯一的候选者x,我认为每个人都同意这是合法的).


注意:这部分非常具有推测性,我试图提供一个论据,说明为什么这里编译器选择的解决方案不是自洽的

参数可以进行这&y->c意味着出处是int64_t子对象.但这确实会导致困难.例如,确实y有起源*y吗?如果是这样,(char *)y应该*y仍然有起源,但是这与6.3.2.3/7的规则相矛盾,即抛出指向另一个类型的指针并返回应该返回原始指针(只要不违反对齐).

它没有涵盖的另一件事是重叠的起源.指针可以比较不等于具有相同值的指针但是较小的起源(它是较大起源的子集)吗?

此外,如果我们将相同的原则应用于子对象是数组的情况:

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

arr&arr[0]在这个上下文中被定义为含义,所以如果&Xis 的起源X,那么r实际上只是数组的第一行 - 也许是一个令人惊讶的结果.

可以说char *r = (char *)arr;这里有UB,但char *r = (char *)&arr;没有.事实上,我曾经多年前在我的帖子中推广这种观点.但我不再这样做了:在我试图捍卫这个位置的经历中,它只是不能自立,有太多的问题场景.即使它可以自我保持,事实仍然是标准没有指定它.充其量,此视图应具有提案的状态.


为了完成,我建议阅读N2090:澄清指针来源(草案缺陷报告或C2x的提案).

他们的建议是,出处总是适用于分配.这使得对象和子对象的所有复杂性都没有实际意义.没有子分配.在该提议中,所有x z w都是相同的,并且可以用于覆盖整个9字节分配.恕我直言,与我上一节中讨论的内容相比,这种方法很简单.