影响代码开发的五大C++安全问题

504 阅读6分钟

C++为开发者提供了许多强大的功能,这也是它被用于许多行业和许多核心系统的原因。但与一些对资源提供较少直接控制的高级语言不同,C++有各种安全问题,开发人员在编写代码时必须敏锐地意识到这些问题,以避免在项目中引入漏洞。

作为开发人员,我们在构建应用程序时要考虑到我们的终端用户。他们把他们的数据、时间和设备访问权交给我们。我们有责任确保我们的应用程序--以及我们用户的数据--始终是安全和有保障的。

本文探讨了影响代码开发的五大C++安全问题,并提供了缓解这些问题的建议。

1.缓冲区溢出

C++中最常见的安全问题之一是缓冲区溢出。缓冲区溢出是由于C++中没有内置的边界检查功能,而边界检查功能可以减少覆盖内存的风险。在我们分配的内存之外写东西会导致程序崩溃和破坏数据。它甚至可以导致恶意代码的执行。因此,在2021年CWE 25大最危险软件弱点调查中,缓冲区溢出被列为最危险的软件弱点。

2019年对WhatsApp应用程序VOIP堆栈的缓冲区溢出攻击突出了这个漏洞的严重影响。通过这次攻击,攻击者利用WhatsApp的缓冲区溢出漏洞向目标用户的手机注入间谍软件。

让我们看看缓冲区溢出漏洞在实践中是什么样子的。在下面的代码片断中,我们使用gets函数获得用户输入,并检查它是否影响到important_data 变量。

#include <stdio.h>

int main(int argc, char **argv)
{
  volatile int important_data = 0;
  char user_input[10];
 
  gets(user_input);
 
  if(important_data != 0) {
    printf("Warning !!!, the 'important_data' was changed\n");
  } else {
    printf("the 'important_data' was not changed\n");
  }
}

虽然这段代码看起来很无辜,但如果用户输入的字符串超过了user_input 数组的长度,它就容易受到缓冲区溢出攻击。堆栈正在向更低的地址增长,这意味着important_data 在内存中处于user_input 的下面。

有几种方法可以防止这个问题,其中一些取决于我们的编译器或操作系统和内核功能。我们可以用来保证数据安全的主要工具是堆栈金丝雀、地址空间布局随机化(ASLR)和数据执行预防(DEP)。

  • 堆栈金丝雀在每次程序启动时都会向堆栈添加一个新的、随机选择的秘密值。在一个函数返回之前,这个值被验证。
  • ASLR防止攻击者知道内存布局,使得执行缓冲区溢出攻击具有挑战性。如果攻击者不知道数据在内存中的位置,他们就不知道要攻击哪个缓冲区。
  • DEP将某些内存区域,如堆栈,标记为不可执行的内存。

除了使用操作系统和编译器的功能外,我们必须实施良好的编码实践,包括边界检查。我们应该避免使用容易受到缓冲区溢出攻击的标准库函数,如get,strcpy,strcat,scanf, 和printf ,因为它们不执行边界检查。相反,我们应该用同等的安全函数来代替它们,如fgets

2.整数溢出和下溢

整数溢出是指我们试图存储在一个整数变量中的值超过了一个整数可以容纳的最大值。当这个值小于一个整数所能容纳的最小值时,就会发生整数下溢。当它小于这个值时,这个值就会被包裹起来。

2021年CWE的25个最危险的软件弱点调查将其列为第12个最危险的软件弱点。此外,整数溢出和欠流会导致缓冲区溢出漏洞。

下面的代码是在OpenSSH v3.3中发现的一个实际漏洞,是一个整数溢出漏洞如何导致缓冲区溢出攻击的例子。

nresp = packet_get_int();
if (nresp > 0) {
response = xmalloc(nresp*sizeof(char*));
for (i = 0; i < nresp; i++) 
    response[i] = packet_get_string(NULL);
}

这段代码代表了一个整数溢出的漏洞。虽然代码检查的是零值,但如果输入nresp ,等于1073741824 ,就有可能出现零内存分配。将这个值乘以4(char指针的大小)会导致变量溢出,xmalloc(nresp*sizeof(char*)) ,分配一个大小为0 的缓冲区。

为了减轻这个漏洞,我们应该对零/最小值和最大值进行范围检查,以防止溢出或包裹变量的值。

3.指针初始化

指针初始化很关键。我们可以使用一个没有被初始化为内存位置或函数指针的指针来暴露很多数据。如果未初始化的指针指向一个内存位置,它可能会导致程序读取或写入一个意外的内存位置。如果它指向一个函数,它可能导致无意中执行一个任意的函数。

指针也容易受到空指针解引用攻击,这可能导致程序的可靠性问题。指针脱引攻击描述了在初始化为空时访问指针,导致程序中出现未定义行为(UB)。而且,在大多数情况下,它将崩溃。攻击者可以通过强制执行空指针解指来利用这一点。不正确地初始化指针将产生意外的、不可预测的行为。攻击者可以绕过一些安全检查,或者揭示他们以后可以使用的调试信息。

让我们来看看一个由不正确的指针初始化引起的指针取消引用的例子。

void main(){
int *ptr;
if (nullptr != ptr)
 {
*ptr = 5;
 }
}

正如我们所看到的,指针没有被初始化,这意味着一个随机的值被分配给它,而检查将通过,因为它可能是一个非空值。

我们不应该只依赖检查或错误异常处理来避免指针解读攻击。如果我们可以避免的话,我们应该考虑不使用指针,而使用引用。如果我们确实使用指针,我们应该使用智能指针作为原始指针的替代。

指针的另一个替代方法是在我们的实现中采用资源获取即初始化(RAII)技术,该技术保证一个资源对任何有访问权的函数都是可用的。

4.不正确的类型转换

另一个常见的漏洞是不正确的类型转换。大多数与类型转换有关的问题都是通过有符号到无符号的转换发生的,这通常是在函数调用时通过传递错误的参数类型发生的。另一个常见的漏洞是在从较长的数据类型如double转换到float和从long int转换到int时的类型转换,这导致数据在隐式转换中丢失。

下面是一个简单的例子,说明不正确的类型转换漏洞是什么样子。

#include <iostream>
#include <string>
using namespace std;
  
int main()
{
    string str;
    cout << "Please enter your string: \n";
    getline(cin, str);
    unsigned int len = str.length();
    if (len > -1)
    {
    cout << "string length is " << len << "which is bigger than -1 " <<std::endl;
    }else 
    {
    cout << "string length is " << len << " which is less than -1 " <<std::endl;
    }
    return 0;
}

虽然看起来不可能打到else 语句,因为任何输入字符串的长度都会大于-1 ,但这段代码并不工作,总是打到else 语句。根据C++标准和积分推广的概念,如果两个不同数据类型的值被比较,值的表示方法将被改变。

在我们的例子中,signed short int'的值将被转换为较大的类型,unsigned int 。这将把-1 的值转换为无符号整数值,等于4294967295 ,这将导致程序流转到else 语句。

如果你使用无符号整数来减去用户的两个输入值,假设用户永远不会先输入一个较小的值,也会发生同样的问题,导致减去的结果是负数。

根据谷歌C++风格指南,避免用无符号整数做数学运算可以减轻大部分类型转换问题(除了代表位域)。他们还建议避免有符号性之间的混合,使用迭代器和容器而不是指针和大小。

5.格式化字符串的脆弱性

格式串漏洞有两个组成部分:格式函数格式串。在探讨格式字符串漏洞之前,让我们回顾一下格式函数和格式字符串的作用。

格式函数是将编程语言的变量转换为人类可读格式的函数。格式函数的例子有:printffprintf

格式字符串是格式函数的参数,它包含文本和格式字符串参数。我们来看看一个例子。

printf ("This is a test text of number: %d ", 11);
  • "This is a test text of number: %d ", 11" 是格式字符串。
  • "%d" 是格式字符串参数,定义了转换格式。

当我们不检查传给格式函数的参数时,就会发生格式字符串攻击。例如,假设我们实现了一个像下面这样的应用程序。

#include  <stdio.h> 

int main(int argc, char **argv)
{
printf(argv[1]);
return 0;
}

这段代码很容易受到格式串攻击,因为它没有检查用户的输入。所以,攻击是通过传递一个格式化字符串参数和这样的输入发生的。

"./program "Snyk %p %p %p %p %p" " 

这个输入可以从堆栈中获得数据,因为程序的输出是类似下面这样的。

>> ./program "Snyk %p %p %p"
 Synk 0x7ffcc40aafd8 0x7ffcc40aaff0 0x558581c18180%

这个输出是由于printf ,把%p ,作为一个无效指针的引用,试图解释内存地址而造成的。

为了防止这个漏洞,我们需要在代码中添加一个格式参数,如下图所示。

#include  <stdio.h> 

int main(int argc, char **argv)
{
// safe code
printf("%s\n",argv[1]);
return 0;
}

上面的代码片段是安全的,因为它不会解释字符串。例如,如果我们试图编译和运行这段代码,输出结果将如下。

>> ./program "Snyk %p %p %p"
 Synk %p %p %p

输出的只是传递给程序的字符,没有解释为对无效指针的引用。

另一个更好的解决方案是尽可能避免使用printf ,除非你被迫使用它,而是用std::formatstd::vformat(在C++20中引入)代替它,因为它在运行时或编译时根据类型验证输入,在不匹配时引发format_error

减少你的C++安全风险

在这篇文章中,我们探讨了使用C++工作的五大安全风险。我们研究了攻击者如何利用这些漏洞来访问用户数据或从我们的应用程序中检索信息,以及这些漏洞在我们的代码中是什么样子。我们还强调了防止这些漏洞成为全面的安全漏洞的策略。

与对资源提供较少直接控制的高级语言不同,C++有各种各样的漏洞,开发人员在编写代码时必须注意这些漏洞,以避免将它们引入项目。漏洞的利用可以有多种形式,恶意行为者不断扩大他们的攻击策略。

作为开发者,我们有责任实施安全技术,以确保我们的软件是可靠的,并对可能暴露用户信息的安全攻击免疫。有了正确的策略,我们在这篇文章中探讨的所有漏洞都是可以避免的。