关于C++ 编码安全实践的10项建议

Amir Kirsh
Amir Kirsh / 3月 02 2022
关于C++ 编码安全实践的10项建议

安全性——简介

安全性是一个极其重要和深刻的话题。当安全性受到损害时,会发生糟糕的事情。我们在软件开发生命周期的各个阶段都必须记住这一点。不同于一些其他非功能性要求,(通常)不能在之后才在系统中考虑到安全性。ISO 9126 说明了软件系统的质量属性,列出了六个主要类别:

安全性是功能性的一个质量属性。安全性的质量属性包括机密性、完整性、可用性(统称为首字母缩写词“CIA”)、不可否认性、真实性和可归责性。

安全性会对其子属性带来直接影响。这不足为奇。但令人惊讶的是,安全性会对其他质量属性带来影响。例如,安全性会间接影响性能和可靠性。如果系统的安全性受到损害,则在受到拒绝服务 (DoS) 或分布式拒绝服务 (DDoS) 等蓄意攻击之后,或者在仅仅是因为非预期的高流量未得到很好的管理而导致服务中断的情况下,系统不会作出反应。同样地,如果安全性受到损害,系统会变得不可靠,继而产生非预期的结果。安全性受到损害的情况如下所示:

目前在安全性方面趋向于采用“零信任”架构。在该架构中,我们采用“从不信任,永远验证”原则来保护我们的系统。在该架构到位之后,我们坚持通过设计范式实现安全性。仅当架构和设计都安全时,我们才会实现编码的安全性。编码的安全性是这篇博文的主题,且更具体而言,是在进行 C++ 安全开发时的十大最佳实践。

让我们开始吧!

1.了解到当在C++中进行编码时,不具有编译器或在运行时间提供的安全网。

C++ 编译器会生成编程器要求其生成的代码,无需进行任何安全检查。例如在 C# Java 中进行编码时,以错误方式访问数组会导致运行时异常。而在 C++ 中进行编码时,会在编写时造成以错误方式访问内存或内存损坏。以错误或草率方式进行编码会导致溢出(堆栈、堆和缓冲区溢出),继而很容易被用于发起攻击。

2.API的使用应得当.不得依赖于无证行为.不得使用被确立为易受攻击的API。

如果实际行为与假定的场景不同或者随着时间的推移发生了变化,则依赖于无证行为将是一个通往安全漏洞的入口。CWE(社区常见缺陷枚举)在第 440 项“预期行为违规情况”下作了说明。

与此同时,我们强烈建议避免使用众所周知易受攻击的 API,包括但不限于 strcpysprintf系统。我们并不是说这些函数总是不安全的,但如果不小心使用,它们会被攻击者滥用。通常可通过静态代码分析检测和警告这些 API 的不当使用情况。

3.验证输入。这是另一项典型功能。

2021 年,任何人都没有理由写大量关于输入验证的文章,但请放心,如果你信任正确输入的用户,攻击者会找到破坏应用程序的安全性的方法。这一漏洞并非 C++ 所特有的,但二者当前也具有相关性。输入验证并不仅仅意味着验证用户输入。如果输入来自系统之外的任何来源,便可能出现缺陷。即使输入来自你未作控制但认为可靠的其他系统,另一系统可能已经被入侵了,且您不会希望依赖于任何外部系统所采取的安全措施。

值得注意的是,在 OWASP 的十大漏洞清单中,未经验证的输入在 2004 年被列为第一大威胁。但从此之后,出现了两种情况:(a) 应验证和清理输入的理念已经渗透到代码评审核查表和最佳实践中;以及 (b) OWASP 决定重新命名该类别,其中关注由有效数据演变而来的安全威胁,新建名为“注入”的类别,并将一些与“注入”无关的不良输入漏洞移到其它类别。

在接下来的几年中,“注入”类别在表中的排名仍然靠前(第 1 或第 2 位),在 2021 年下滑到第 3 名。

请在 OWASP 输入验证核查表中阅读更多关于输入验证的技术。

4.类型安全性:类型是您的朋友。请勿有意绕过类型检查!

我们已经不再使用传递空白符号 * 来绕过类型检查。同时,也不再采取绕过强类型检查的转换的做法。错误使用空白符号 * 和转换会导致检索错误数据,然后可以利用这些数据。同样地,请勿仅通过 reinterpret_cast C 类转换进行向下转换。正因为你需要 constness 且编译器允许您这样操作,请勿通过 const_cast C 类转换删除 constness。C 类转换、指针转换和 reinterpret_cast 通常会产生风险,并可能是可利用的错误的来源。

同理,虽然这更多的是作为一种最佳实践方法,最好是使用具有衡量单位的强类型。你可以点击此处查看更多相关信息。

5.小心代码中的算术溢出或下溢。

你需要巧妙回答。为什么以下代码:

#include <iostream> 
int main() 
{     
    for (size_t i = 5; i >= 0; --i) 
        std::cout << i << ' '; 
}

表现出这种情况:

这当然是由下溢造成的。当运算结果小于数据类型的最小允许值时会发生下溢。这会造成逻辑错误或执行任意代码。以下显示了 CWE – 190(有一些编辑内容)关于整数溢出的真实示例:

+= 运算会导致溢出,这是带符号整数的未定义行为。该行为可能会导致无限循环或任何其他不良行为。

在通常情况下,请避免在代码中出现未定义行为。第一步应为通过编译器使用 -fsanitize=undefined 标志(均可在 ClangGCC 中获得)提醒代码中的未定义行为。

6.小心处理错误和异常。

程序并不会始终走欢乐路径。你要预想一下出现错误和异常的可能性。在出现错误和异常时,要以可靠的方式进行处理。这与安全性有什么关系呢?要小心处理错误和异常,需要:

    1. 不得泄漏堆栈跟踪、数据库转储、内部错误代码、用户 ID 或任何个人隐私信息等敏感信息。攻击者可以利用这些信息影响系统,或仅仅是使用被泄漏的敏感个人信息。
    2. 不得通过触发异常允许对系统进行身份验证。在出故障时自动打开的场景下会导致这种情况。在这种情况下,即使身份验证以异常方式失败,系统仍可开放使用。攻击者可以通过触发异常对系统进行身份验证。
    3. 不仅要捕获和忽略错误或异常,而且要着手处理。如果忽略了错误返回值或者捕获了异常但未着手处理(即“生吞异常”),系统可能会继续在不一致的状态下运行,这可能会使系统易受攻击。

7.请勿过于看重微小的效率增益,继而使安全性受到损害。

公司选择 C++(和 C)来对需要实现高效运行的系统进行编码。有时,为了实现效率,编程器可以充分运行,继而使安全性受到损害。例如,为了实现效率,可能不会对输入作出验证:“我们没理由假设该变量将具有无效值,那我们先不进行有效性检查吧”——好的,即使可能生成不合理值继而产生意外行为的可能性不大,但也要进行检查!

另一个示例可能是在未进行初始化的情况下生成变量或数据成员,其中了解到稍后将在代码中进行初始化——基本上,尽量避免在不必要的情况下进行初始化。这使得代码容易出错;并且如果在初始化之前实际使用该变量,代码会变得不完善且不安全。有一些方法可以避免在不必要的情况下进行初始化。但是,不对变量或数据成员进行初始化而是希望在使用它们之前进行初始化不包括在内。我们之前提到,这些潜在的漏洞可能会被利用,从而造成安全漏洞。

8.在系统中正确采用安全机制。

实现安全性的其中一种最简单的方法是通过隐藏。只要有足够的时间和资源,便可以侵入任何隐藏的系统。在代码中嵌入密码的这种方法不好,应避免采用。攻击者可以在二进制文件中找到密码并加以利用。即使你将密码隐藏起来(如果未在适当注意的情况下进行隐藏),黑客仍然可以从代码中获得密码。

除非你是一名专业的安全研究人员,否则保持在同一个域中,切勿实现本地加密函数。在使用公共库中的加密 API 时,确保不存在已知漏洞或公开的影响情况。在代码的整个生命周期中持续遵循该模式。如果你使用的加密算法或库出现了漏洞,确保更新该库,并使用固定版本。

还要注意的是,如果安全机制是以随机数为基础的(例如,用户在两阶段验证过程中获得验证代码),则认为 C++ 中基本的简单随机函数属于不良类型

获取随机数的兼容 C++ 解决方案应使用新的 C++11 随机数生成器,如以下的代码片段中所示:

#include <cstdlib>
#include <string>
std::string getRandomId() {
    std::uniform_int_distribution<int> distribution(0, 10000);
    std::random_device rd;
    std::mt19937 engine(rd());
    return std::to_string(distribution(engine));
}

即使在使用了良好的随机数生成器之后,你仍然可以在不良情况下内使用。你可以阅读《如何在不良情况下使用随机数生成器》 了解更多信息,避免任何类型的不良随机性。

最后,如果你的随机数不够随机,你可以使用简单算法。然而,请勿使用未经初始化的变量作为随机生成技术,因为这属于未定义行为。

9.使用C++安全编码标准补充C++编码标准。

你可能使用 C++ 编码标准来获得更好的代码并遵循 C++ 最佳实践。经常被忽略的对应部分为 SEI Cert C++ C++ 安全编码标准(可以 PDF 格式 html 格式进行浏览)。SEI Cert C++ 标准分为规则类别,包括:声明和初始化 (DCL)、表达式 (EXP)、整数 (INT)、容器 (CTR)、字符和字符串 (STR)、内存管理 (MEM)、输入输出 (FIO)、异常和错误处理 (ERR)、面向对象编程 (OOP)、并发 (CON) 和杂项 (MSC)。并非该标准中的所有项目都共享相同的安全威胁级别,但该标准本身会映射每个项目的安全威胁级别,以显示出项目的严重性及其可能性。

10.使用适当的工具来检测安全问题。

再次提醒一下,这是在许多编程语言中都适用的通用提示。可以使用的第一种工具是编译器:不能忽视编译错误,但是可能忽略警告,而警告不能被忽略。

下一种可以帮助到您的工具是编译器清理器。对于 Clang GCC,使用具有不同选项的 -fsanitize 标志;对于 MSVC点击此处阅读地址清理器。要使用清理器!在我们从编译器中获得所有我们能够获得的信息之后,我们需要使用静态代码分析工具,有助于我们在代码中发现其他问题。许多静态代码分析工具将 CERT SEI CWE 列表作为查找安全错误的来源。在某些情况下,当工具报告检测到问题时,它实际上会指向相关的 CERT SEI 规则编号或 CWE 缺陷 ID

如前所述,所有问题的威胁级别都不同。在某些情况下,静态分析工具可能会报告被分析为呈假阳性的问题。因此,在忙着修改代码之前,要仔细检查报告的问题。许多静态分析工具可以通过适当备注将报告的问题标记为“不存在问题”,从而该工具不会在相同的代码位置再次进行报告。如果这样操作,确保在备注中正确记录关闭问题的原因,以及为什么该问题在这种特定情况下不成问题。

 

综述

编写不受入侵和攻击的安全代码不是一种奢侈。即使需求部分忘记提到安全性或者留到已知的待定部分进行说明,任何代码库都必须将安全性视为系统的所需功能。

开发人员有责任确保代码不会影响到安全性。由于设计不良和逻辑错误,或者由于存在可能被利用的缺陷、故障或潜在漏洞,会出现安全漏洞。

上述提及的许多编码实践在各种编程语言中都足够通用。这是我们预期之中的,这是因为安全性与实现系统的语言无关。但是存在关于 C++ 的具体问题,特别是关于内存管理、缓冲区溢出、类型转换和各种未定义行为的问题。

请记得在代码评审期间检查安全问题,这就像检查功能问题一样;确保使用适当的工具,这将帮助您确定易受攻击的潜在代码。

附言

在总结这篇博文时,我偶然发现了“PrintNightmare”。它是于 2021 7 1 日发现的存在于 Microsoft Windows 的打印机后台打印程序的安全漏洞。几乎所有版本的 Microsoft Windows 都可以利用这一漏洞来远程执行代码和提升权限。这一问题的根本原因是一段 Windows 代码,归结为某个特定函数:

__int64 __fastcall SplAddPrinterDriverEx(LPCWSTR lpString1, unsigned int a2, 
unsigned __int8* a3, unsigned int a4, __int64 a5, int a6, int a7) 

{ 
    // Do some initial work 
    int v11 = 0; 
    if (!_bittest((const int*)&a4, 0xFu) 
        v11 = a7; 
    if (v11 && !(unsigned int)ValidateObjectAccess(0i64, 1i64, 0i64) 
        // Do work … 
}

由于参数 a4 是以用户环境为基础的,因此用户执行的操作可以绕过 ValidateObjectAccess 检查。这带来了严重漏洞。Microsoft 忙着修复该漏洞,并发布了安全补丁。你可以点击此处点击此处点击此处阅读更多关于打印机后台打印程序漏洞的信息。

这应能提醒你要非常重视安全性问题!

订阅博客

阅读 Incredibuild 独家内容

Amir Kirsh

Amir Kirsh,Incredibuild 的开发推广师,在 Tel-Aviv-Yaffo 学院和 Tel-Aviv 大学任教,主讲 C++。 Amir 此前是一家创业公司的首席技术官和研发副总裁,后来这家创业公司成功被 Comverse 收购,Amir 则成为 Comverse 的首席程序员。此外他也是每年 Core C++ 论坛的组织者和 ISO C++ 以色列国家组织的成员。