在 Unix 上打开优化时,strcpy()/strncpy() 在结构成员上崩溃并带有额外的 space?

strcpy()/strncpy() crashes on structure member with extra space when optimization is turned on on Unix?

写项目的时候,我运行遇到了一个st运行ge问题。

这是我为重现问题而设法编写的最少代码。我故意存储一个实际的字符串来代替其他东西,分配了足够的 space。

// #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;
}

上面的代码让我很困惑:

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

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

此外,这个自定义字符串复制函数看起来与标准函数完全一样,适用于上述任何编译器配置:

char* my_strcpy(char *d, const char* s){
    char *r = d;
    while (*s){
        *(d++) = *(s++);
    }
    *d = '[=12=]';
    return r;
}

问题:

*如果您想讨论结构成员访问冲突,请前往 here


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


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

也请不要抱怨gets()。我没有在我的项目中使用它,只是上面的示例代码。

为什么要把事情弄复杂?像你所做的那样过度复杂化只会为 未定义的行为 提供更多 space,在那部分:

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

warning: passing argument 1 of 'puts' from incompatible pointer ty pe [-Wincompatible-pointer-types] puts(&p->c);

如果幸运的话,你显然会进入未分配的内存区域或可写的地方...

优化与否可能会改变地址的值,它可能有效(因为地址匹配),也可能无效。你只是不能做你想做的事(基本上对编译器说谎

我会:

  • 只分配struct需要的,不要考虑里面的字符串长度,没用
  • 不要使用 gets,因为它不安全且已过时
  • 使用 strdup 而不是您正在使用的容易出错的 memcpy 代码,因为您正在处理字符串。 strdup 不会忘记分配 null 终止符,并会为您将其设置在目标中。
  • 别忘了释放重复的字符串
  • 阅读警告,put(&p->c) 是未定义的行为

test.c:19:10: warning: passing argument 1 of 'puts' from incompatible pointer ty pe [-Wincompatible-pointer-types] puts(&p->c);

我的提议

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;
}

您的指针 p->c 是崩溃的原因。
首先用大小 "unsigned long long" 加上大小 "*p".
初始化结构 其次用所需的区域大小初始化指针 p->c。 复制操作:strcpy(p->c, str);
最后 free first free(p->c) and free(p).
我想是这个。
[编辑]
我会坚持的。 错误原因是它的结构只为指针保留了space,并没有分配指针来存放要复制的数据。
看一下

int main() 
{
    pack *p;
    char str[1024];
    gets(str);
    size_t len_struc = sizeof(*p) + sizeof(unsigned long long);
    p = malloc(len_struc);
    p->c = malloc(strlen(str));
    strcpy(p->c, str); // This do not crashes!
    puts(&p->c);
    free(p->c);
    free(p);
    return 0;
}

[EDIT2]
这不是一种传统的数据存储方式,但它确实有效:

    pack2 *p;
    char str[9] = "aaaaaaaa"; // Input
    size_t len = sizeof(pack) + (strlen(str) + 1);
    p = malloc(len);
    // Version 1: crash
    strcpy((char*)p + sizeof(pack), str);
    free(p);

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

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

经过一些逆向我发现GCC用memcpy_chk替换了strcpy,看这个

// 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;
}

你的 struct pack 让 GCC 相信元素 c 恰好是 8 个字节长。

并且memcpy_chk如果复制长度大于第四个参数就会失败!

所以有2种解法:

  • 修改结构

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

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

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

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

关键是,即使您使用 malloc() 分配了正确的内存大小,编译器也被允许假设您永远不会在 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);
}

注意:标准报价请参考回答。

这都是因为 -D_FORTIFY_SOURCE=2 故意在它认为不安全的地方崩溃。

一些发行版默认启用 -D_FORTIFY_SOURCE=2 构建 gcc。有些人没有。这解释了不同编译器之间的所有差异。如果您使用 -O3 -D_FORTIFY_SOURCE=2.

构建代码,那些通常不会崩溃的可能会崩溃

Why does it fail only if optimization is on?

_FORTIFY_SOURCE 需要优化编译 (-O) 以通过指针转换/赋值跟踪对象大小。有关 _FORTIFY_SOURCE 的更多信息,请参阅 the slides from this talk

Why does strcpy() fail? How can it?

gcc 仅通过 -D_FORTIFY_SOURCE=2strcpy 调用 __memcpy_chk它传递 8 作为目标对象的大小,因为这就是它认为您的意思 / 它可以从您提供的源代码中得出的结果。 strncpy 呼叫 __strncpy_chk.

同样的交易

__memcpy_chk 故意中止。 _FORTIFY_SOURCE 可能会超越 C 语言中的 UB 并禁止看起来 潜在危险 的东西。这使它有权决定您的代码不安全。 (正如其他人所指出的,一个灵活的数组成员作为结构的最后一个成员,and/or 一个与灵活数组成员的联合,是你应该如何表达你在 C 中所做的事情。)


gcc 甚至警告说检查总是会失败:

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));
          ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

(来自 gcc7.2 -O3 -Wall on the Godbolt compiler explorer)。


Why doesn't memcpy() fail regardless of -O level?

IDK.

gcc 将其完全内联为一个 8B load/store + 一个 1B load/store。 (似乎错过了优化;它应该知道 malloc 没有在堆栈上修改它,所以它可以再次从立即数中存储它而不是重新加载。(或者最好将 8B 值保存在寄存器中。)

尚未详细讨论为什么此代码可能是或可能不是未定义行为的答案。

该领域的标准未指定,并且有一个积极的提案来修复它。根据该提案,此代码不会是未定义的行为,并且生成崩溃代码的编译器将不符合更新后的标准。 (我在下面的结论段落中重新讨论了这一点)。

但请注意,根据其他答案中 -D_FORTIFY_SOURCE=2 的讨论,这种行为似乎是相关开发人员有意为之。


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

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

现在,所有三个 x z w 都引用相同的内存位置,并且具有相同的值和相同的表示形式。但是编译器对待 zx 的方式不同。 (编译器也将 w 与这两者之一区别对待,尽管我们不知道是哪一个,因为 OP 没有探索这种情况)。

本主题称为指针出处。这意味着对指针值可以跨越哪个对象的限制。编译器将 z 视为仅超过 y->c 的出处,而 x 具有整个 9 字节分配的出处。


当前的 C 标准没有很好地指定出处。 指针减法只能发生在指向同一数组对象的两个指针之间等规则是出处规则的一个示例。另一个出处规则适用于我们正在讨论的代码,C 6.5.6/8:

When an expression that has integer type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integer expression. In other words, if the expression P points to the i-th element of an array object, the expressions (P)+N (equivalently, N+(P)) and (P)-N (where N has the value n) point to, respectively, the i+n-th and i−n-th elements of the array object, provided they exist. Moreover, if the expression P points to the last element of an array object, the expression (P)+1 points one past the last element of the array object, and if the expression Q points one past the last element of an array object, the expression (Q)-1 points to the last element of the array object. If both the pointer operand and the result point to elements of the same array object, or one past the last element of the array object, the evaluation shall not produce an overflow; otherwise, the behavior is undefined. If the result points one past the last element of the array object, it shall not be used as the operand of a unary * operator that is evaluated.

strcpymemcpy 的边界检查的理由总是回到这条规则 - 这些函数被定义为表现得好像它们是来自基指针的一系列字符分配递增以到达下一个字符,指针的递增包含在 (P)+1 中,如本规则所述。

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

For the purposes of these operators, a pointer to an object that is not an element of an array behaves the same as a pointer to the first element of an array of length one with the type of the object as its element type.


这里的大问题是:什么是 "the array object"?在这段代码中,它是 y->c*y 还是由 malloc 编辑的实际 9 字节对象 return?

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

另一个更复杂的因素是 the standard does not provide a definition for "array",也不是 "array object"。但长话短说,由 malloc 分配的对象在标准的各个地方都被描述为 "an array",因此这里的 9 字节对象似乎确实是 [= 的有效候选对象121=]。 (事实上​​ ,对于使用 x 迭代 9 字节分配的情况,这是 只有 这样的候选者,我认为每个人都会同意这是合法的)。


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

一个论点可以 &y->c 意味着出处是 int64_t 子对象。但这确实会立即导致困难。例如,y 是否有 *y 的出处?如果是这样,(char *)y 应该仍然有出处 *y,但这与 6.3.2.3/7 的规则相矛盾,即将指针转换为另一种类型并返回应该 return 原始指针(只要不违反对齐)。

它没有涵盖的另一件事是重叠出处。指针是否可以与具有相同值但出处较小(较大出处的子集)的指针比较不相等?

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

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

arr 在此上下文中被定义为 &arr[0],因此如果 &X 的出处是 X,那么 r 实际上是绑定到只是数组的第一行 -- 也许是一个令人惊讶的结果。

这里可以说 char *r = (char *)arr; 导致 UB,但 char *r = (char *)&arr; 不会。事实上,我多年前曾在我的帖子中宣传过这种观点。但我不再这样做了:根据我试图捍卫这个立场的经验,它就是不能自洽,有太多的问题场景。即使它可以自洽,事实仍然是标准没有指定它。充其量,此视图应具有提案状态。


最后,我建议阅读 N2090: Clarifying Pointer Provenance (Draft Defect Report or Proposal for C2x)

他们的提议是出处始终适用于分配。这使得对象和子对象的所有复杂性都没有意义。没有子分配。在此提案中,所有 x z w 都是相同的,可用于整个 9 字节分配范围。恕我直言,与我在上一节中讨论的内容相比,它的简单性很有吸引力。