忽略 C/C++ 数组中 NULL 终止的额外字节的安全隐患

信息安全 缓冲区溢出 C
2021-08-23 14:43:54

请考虑:英语是我的第二语言。


现在就安全!播客第 518 集HORNET: A Fix for TOR?),在 27:51 处,Steve Gibson 引用了 C/C++ 中易受攻击的代码示例:

“[...]其中一个[易受攻击代码的问题]正在创建一个特定大小的新数组[...]。修复是'特定大小+ 1'。所以,[...]它[易受攻击的代码]只是一个字节太短了。可能是一个 NULL 终止符,所以当你用 size 个对象填充数组时,你会有一个额外的 NULL 字节来保证 NULL 终止,这将阻止该字符串被超支。但这不是编码员所做的:他们忘记了' + 1 ' [...]“

我理解他的意思:当你创建一个数组时,你需要为 NULL 终止字节允许一个额外的字节。我想通过这篇文章获得一个指针,以便进一步研究拥有一个最后一个字节不是字节终止符的数组的影响;我不明白这种疏忽的全部含义,以及这如何导致漏洞利用。当他说有 NULL 终止

“将防止该字符串被溢出”,

我的问题是“在忽略 NULL 终止字符的情况下它是如何溢出的?”。

我知道这是一个巨大的话题,因此不要向社区强加一个过于全面的答案。但是,如果有人能提供一些进一步阅读的建议,我将非常感激并很高兴自己去进行研究。

4个回答

字符串终止漏洞


仔细考虑这一点,使用strncpy()可能是最常见的方式(我能想到的),它可能会产生空终止错误。由于通常人们认为缓冲区的长度不包括\0. 所以你会看到类似下面的内容:

strncpy(a, "0123456789abcdef", sizeof(a));

假设a字符串初始化不会以空值终止char a[16]a那么为什么这是一个问题呢?在记忆中,你现在有类似的东西:

30 31 32 33 34 35 36 37 38 39 61 62 63 64 65 66 
e0 f3 3f 5a 9f 1c ff 94 49 8a 9e f5 3a 5b 64 8e

如果没有空终止符,标准字符串函数将不知道缓冲区的长度。例如,strlen(a)将继续计数,直到达到一个0x00字节。那是什么时候,谁知道呢?但是每当它找到它时,它会返回一个比你的缓冲区大得多的长度;假设是 78。让我们看一个例子:

int main(int argc, char **argv) {
    char a[16];

    strncpy(a, "0123456789abcdef", sizeof(a));

    ... lots of code passes, functions are called...
    ... we finally come back to array a ...

    do_something_with_a(a);
}

void do_something_with_a(char *a) {
    int a_len = 0;
    char new_array[16];

    // Don't know what the length of the 'a' string is, but it's a string so lets use strlen()!
    a_len = strlen(a);
    
    // Gonna munge the 'a' string, so lets copy it first into new_array
    strncpy(new_array, a, a_len);
}

您现在刚刚将 78 个字节写入一个仅分配了 16 个字节的变量。

缓冲区溢出


当写入缓冲区的数据多于分配给该缓冲区的数据时,就会发生缓冲区溢出。这对于字符串没有什么不同,除了许多string.h函数依赖这个空字节来表示字符串的结束。正如我们在上面看到的。

在示例中,我们将 78 个字节写入一个仅分配 16 个字节的缓冲区。不仅如此,它还是一个局部变量。这意味着缓冲区已在堆栈上分配。现在那些最后写入的 66 个字节,它们只是覆盖了堆栈的 66 个字节。

如果您在该缓冲区的末尾写入足够多的数据,您将覆盖另一个局部变量a_len(如果您以后使用它也不好),任何保存在堆栈上的堆栈帧指针,然后是函数的返回地址。现在你真的走了,把事情搞砸了。因为现在返回地址是完全错误的。当到达终点时do_something_with_a(),就会发生不好的事情。

现在我们可以在上面的例子中再添加一个。

void do_something_with_a(char *a, char *new_a) {
    int a_len = 0;
    char new_array[16];

    // Don't know what the length of the 'a' string is, but it's a string so
    // lets use strlen()!
    a_len = strlen(a);
    
    // 
    // By the way, copying anything based on a length that's not what you
    // initialized the array with is horrible horrible coding.  But it's
    // just an example.
    //
    // Gonna munge the 'a' string, so lets copy it first into new_array
    strncpy(new_array, a, a_len);
    
    // 'a_len' was on the stack, that we just blew away by writing 66 extra 
    // bytes to the 'new_array' buffer.  So now the first 4 bytes after 16
    // has now been written into a_len.  This can still be interpreted as
    // a signed int.  So if you use the example memory, a_len is now 0xe0f33f5a
    //
    // ... did some more munging ...
    //
    // Now I want to return the new munged string in the *new_a variable
    strncpy(new_a, new_array, a_len);

    // Everything burns

}

我认为我的评论几乎可以解释一切。但最后,您现在已经将大量数据写入了一个数组,很可能认为您只写入了 16 个字节。根据此漏洞的表现方式,这可能会导致通过远程代码执行进行利用。

这是一个非常人为的糟糕编码示例,但是如果您在使用内存和复制数据时不小心,您会看到事情会如何迅速升级。大多数情况下,漏洞不会这么明显。对于大型程序,您有很多事情要做,以至于该漏洞可能不容易被发现,并且可能由代码多个函数调用触发。

有关缓冲区溢出如何工作的更多信息。

在任何人提到它之前,为了简单起见,我在引用内存时忽略了字节序


延伸阅读

漏洞
通用弱点枚举 (CWE) 条目的 完整描述
安全编码字符串演示文稿(PDF 自动下载)
匹兹堡大学 - 安全编码 C/C++:字符串漏洞 (PDF)

通过添加另一个答案,我有可能会变得多余,但我认为现有的答案可能无法完全解决您的问题。在传统的缓冲区溢出漏洞(特别是基于堆栈的漏洞)中,人们会尝试覆盖堆栈上的帧指针,以便在当前函数尝试返回时跳转到利用代码中。

显然,如果您(攻击者)唯一可以使程序写入缓冲区末尾的内容是零字节,那么这将不起作用。您可能会通过使其尝试跳转到无效地址来导致程序以这种方式崩溃,但这只是 DoS 而不是远程代码执行。

但是,假设您让程序将长度为 16 的字符串写入我们将称为“A”的 16 字节缓冲区,这样空字节就会溢出。然后你让程序用不是 \0 的东西覆盖那个空字节,所以现在字符串 A 不是空终止的。如果您随后让程序向您发送 A 的内容,它将读取 A 的末尾,从而可能使您可以访问各种秘密信息。Heartbleed 就是利用这种信息泄露来盗取私钥,相当严重。

此时字符串 A 实际上比程序员预期的要长。不难想象程序员依赖 A 是一个 16 字节的字符串并将其复制到其他地方,可能会使其他缓冲区溢出超过一个字节。然后可以使用它来执行任意代码。

正如您在回复中指出的那样,如果程序员不使用 NULL 字节终止字符串,则缓冲区溢出可能是一个漏洞。原因是大多数字符串函数都假设了这一点,并将继续执行直到遇到零。如果幸运的话,这个错误已经够严重了,以至于你在开发的早期就得到了一个分段错误错误,这样你就可以调试和纠正问题了。但是,对于许多错误,这种明显的故障只会在特殊条件下发生。通常,攻击者可以利用程序的行为方式,并根据漏洞的具体情况,利用它来读取应该隐藏的内存内容,或者将用户输入的数据复制到用户不希望的内存区域控制等。如果缓冲区位于堆栈上,则可以使用后面的exploit来注入代码,并覆盖存储在堆栈帧中位于堆栈上更高地址(在 x86 上)的返回地址。某些操作系统具有保护措施,例如不可执行的内存段,但这绝对是您作为程序员不想依赖的东西:)

刚接触 C 等语言编程的程序员可能会发现避免这些问题的做法很困难或容易出错,但最终它会成为第二天性,尽管仍然可能犯错误。尽可能多地练习,我已经用 C 编程了大约 7 年,但我仍然需要不时纠正一个错误。

练习的一个好方法是分配一个字符数组,用除 0、1 之外的一些不可打印的 ASCII 字符来 memset 整个数组。使用您选择的标准库函数将一些字符串复制到数组中,显然如果它崩溃程序是不正确的。否则,只需使用基本的 for 循环遍历数组中的每个元素并打印出数值,检查以确保 0 是它应该在的位置,如果使用 printf,它将在字符串中的那个点停止。我发现这是一种尝试找出函数之间差异的好方法,例如 strcat、strncat、strcpy、strlcpy、strlcpy、strlcat、sprint 等。我建议在大多数情况下使用 strlcpy 和 strlcat 而不是 strncpy 和 strncat,它们很多更容易,更不容易出错。

另一个提示,如果在你的头脑中验证你的算法似乎很困难,想象你在一个非常小的输入上做同样的事情。对于字符串,假设您正在对仅包含 1 个字符和 NULL 字节的空间的字符串执行操作。这使得很容易看到字符串的许多属性,否则这些属性需要更多的脑力劳动。例如,您需要分配一个包含 2 个元素的数组,即使您需要存储一个字符。第二个元素 str[1] 显然需要为 0。strlen 会报告字符串的长度为 1。现在您可以轻松地概括知道 strlen(str) 始终是 NULL 字节的索引(假设它当然是 N​​ULL 终止 :),同样 strlen(str) - 1 其中 > 0 始终是字符串中最后一个字符的索引。

最后一件事。需要注意的是,以 NULL 结尾的字符串只是一种约定。已经存在并且有许多可能的替代方案。仅当您使用假定 NULL 字节指示内存中应该停止执行操作的点的函数时才需要它。libc 中的字符串函数就是这种情况。您可以编写自己的字符串函数来存储字符串开头的长度。以长度超过 255 个字符的字符串所需的类型双关语引入了一些额外的复杂性为代价,并且在更新字符串时保持这个数字,这种方法的优点是在 O(1) 时间内找到字符串的长度的 O(n)。您还可以将字符串指针和长度值存储在结构中,尽管这可以 t 整齐地推广到不在堆上的字符串。大多数程序员可能只会告诉你,对于大多数事情,你应该坚持使用标准的字符串表示,他们可能是对的。但如果它是你自己的代码,谁应该告诉你该做什么,它是你的通用计算机(至少是有限近似),探索计算的前景,把它变成你的沙箱,玩得开心!

这在上述答案中浮动,但我认为应该明确说明。C/C++ 的字符数组处理有许多潜在的一对一的危险......示例:

""  // a zero length string requiring one byte of storage
    // in memory:  00

"Hi."  // a length 3 string requiring four bytes of storage
       // in memory:  48 69 2e 00
"Hi."[3]  // is the 00, the characters in a string and a string array are indexed starting at 0, to wit
"Hi."[0]  // is the 'H'.

char foo[3]  // a length three character array requiring three bytes of storage
     bar[4]  // a length four character array requiring four bytes of storage

strncpy(foo, "Hi.", 3)  // copies three characters from a length three string to a length three character array.  
                        // The result is not a string because the null is not copied.

strcpy(foo, "Hi.")  // copies four characters from a length three string to a length three character array
                    // This causes overrun of the array.
                    // It writes 00 on whatever (if anything) is allocated next in storage.

strcpy(bar, "Hi.")  // copies four characters from a length three string to a length four character array.
                    // This works/is safe (enough).

所以

  • 长度为三的字符串包含四个字符。
  • 长度为三的字符串不适合长度为三的字符数组
  • 从长度为三的字符串中复制三个字符不会复制该字符串
  • 如果 mystring 是一个长度为n的字符串,则 mystring[ n ] 是终止的 00。因此,人们可能会简要(或根本不)认为复制到第n个字符将复制 00。

或者,总而言之,这是最大程度地设计为导致错误的错误。