PVS-Studio代码分析器中用于搜索错误和潜在漏洞的技术

技术与魔力

简要描述了PVS-Studio工具中使用的技术,这些技术可以有效地检测大量错误模式和潜在漏洞。 本文介绍了用于C和C ++代码的分析器的实现,但是,以上信息对于负责分析C#和Java代码的模块也有效。

引言


人们误解为静态代码分析器是非常简单的程序,它基于使用正则表达式搜索代码模式。 这与事实相去甚远。 此外,根本不可能使用正则表达式来识别绝大多数错误。

该错误是根据程序员在使用10到20年前存在的某些工具时的经验得出的。 实际上,工具的工作往往归结为寻找危险的代码和功能模式,例如strcpystrcat等。 作为此类工具的代表,可以称为RATS

尽管这些工具可能有用,但它们通常是愚蠢且无效的。 正是从那个时候开始,许多程序员仍然记忆犹新,静态分析器是非常无用的工具,对工作的干扰大于帮助。

随着时间的流逝,静态分析器开始构成复杂的解决方案,这些解决方案可以进行深入的代码分析,即使经过仔细的代码审查,也可以发现代码中仍然存在的错误。 不幸的是,由于过去的负面经验,许多程序员仍然认为静态分析方法没有用,并且不急于将其引入开发过程。

在本文中,我将尝试解决此问题。 我要求读者花15分钟来熟悉PVS-Studio静态代码分析器中用于检测错误的技术。 也许之后,您将重新研究静态分析工具,并希望将其应用到您的工作中。

数据流分析


通过分析数据流,您可以发现各种错误。 其中包括:超出数组范围,内存泄漏,始终为true / false条件,取消引用空指针等。

此外,数据分析可用于搜索使用从外部进入程序的未经验证的数据时的情况。 攻击者可以准备这样的一组输入数据,以使程序按其所需的方式运行。 换句话说,它可以将输入控制不足的错误用作漏洞。 为了在PVS-Studio中搜索未经验证的数据的使用, 实施了专门的诊断程序V1010,并将继续对其进行改进。

数据流分析( Data-Flow Analysis )是为了计算计算机程序中各个点上变量的可能值。 例如,如果指针已取消引用,并且已知此时它可以为零,则这是一个错误,并且静态分析器将报告该错误。

让我们看一个使用数据流分析查找错误的实际示例。 摆在我们面前的是协议缓冲区(protobuf)项目的功能,该功能旨在检查日期的正确性。

static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; bool ValidateDateTime(const DateTime& time) { if (time.year < 1 || time.year > 9999 || time.month < 1 || time.month > 12 || time.day < 1 || time.day > 31 || time.hour < 0 || time.hour > 23 || time.minute < 0 || time.minute > 59 || time.second < 0 || time.second > 59) { return false; } if (time.month == 2 && IsLeapYear(time.year)) { return time.month <= kDaysInMonth[time.month] + 1; } else { return time.month <= kDaysInMonth[time.month]; } } 

PVS-Studio分析仪立即检测到该功能中的两个逻辑错误,并显示以下消息:

  • V547 / CWE-571表达式'time.month <= kDaysInMonth [time.month] + 1'始终为true。 time.cc 83
  • V547 / CWE-571表达式'time.month <= kDaysInMonth [time.month]'始终为true。 time.cc 85

注意子表达式“ time.month <1 || time.month> 12“。 如果月份值超出[1..12]范围,则​​该函数停止工作。 分析器对此进行了考虑,并且知道如果第二条if语句开始执行,那么月份值恰好在[1..12]范围内。 同样,他知道其他变量的范围(年,日等),但现在对我们来说这些变量已不再引起我们的兴趣。

现在,让我们看一下用于访问数组元素的两个相同的运算符: kDaysInMonth [time.month]

该数组是静态设置的,分析器知道其所有元素的值:

 static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 

由于月份从1开始编号,因此分析仪不会在数组开头考虑0。 事实证明,可以从数组中提取[28..31]范围内的值。

视年份是否为a年而定,天数加1,但这对我们来说现在也没有意义。 比较本身很重要:

 time.month <= kDaysInMonth[time.month] + 1; time.month <= kDaysInMonth[time.month]; 

将范围[1..12](月数)与一个月中的天数进行比较。

考虑到在第一种情况下,月份始终是2月( time.month == 2 ),我们得到了以下范围的比较:

  • 2 <= 29
  • [1..12] <= [28..31]

如您所见,比较结果始终是真实的,这就是PVS-Studio分析仪所警告的。 实际上,该代码包含两个相同的错字。 表达式的左侧应使用day类的成员,而不是一个月

正确的代码应如下所示:

 if (time.month == 2 && IsLeapYear(time.year)) { return time.day <= kDaysInMonth[time.month] + 1; } else { return time.day <= kDaysInMonth[time.month]; } 

前面在文章“ 2月31日 ”中也描述了此处讨论的错误

符号执行


在上一节中,我们考虑了一种分析器计算变量的可能值的方法。 但是,要发现一些错误,就不必知道变量的值。 符号执行意味着以符号形式求解方程式。

我在我们的错误数据库中找不到合适的演示,因此请考虑一个综合代码示例。

 int Foo(int A, int B) { if (A == B) return 10 / (A - B); return 1; } 

PVS-Studio分析仪会生成警告V609 / CWE-369除以零。 分母'A-B'== 0. test.cpp 12

分析器未知变量AB的值。 但是分析器知道在计算表达式10 /(A-B)时,变量AB相等。 因此,将被0除。

我说过AB的值未知的。 对于一般情况,这是正确的。 但是,如果分析器看到带有特定实际参数值的函数调用,则它将对此加以考虑。 考虑一个例子:

 int Div(int X) { return 10 / X; } void Foo() { for (int i = 0; i < 5; ++i) Div(i); } 

PVS-Studio分析仪警告除以零:V609 CWE-628除以零。 分母'X'==0。'Div'函数处理值'[0..4]'。 检查第一个参数。 检查行:106、110。consoleapplication2017.cpp 106

混合技术已经在这里起作用:数据流分析,符号执行和自动方法注释(我们将在下一节中讨论该技术)。 分析器发现变量XDiv函数中用作除数。 基于此,将自动为Div函数构建一个特殊的注释。 还应考虑将值范围[0..4]作为参数X传递给函数 分析仪得出结论认为应该除以0。

方法注释


我们的团队已经注释了数千种功能和提供的类:

  • Winapi
  • C标准库
  • 标准模板库(STL),
  • glibc(GNU C库)
  • t
  • 制造商
  • zlib
  • libpng
  • Openssl
  • 依此类推

所有功能均手动注释,这使您可以设置许多对于发现错误很重要的特征。 例如,指定传递给fread函数的缓冲区的大小应不小于计划从文件读取的字节数。 还指出了第二,第三自变量与函数可以返回的值之间的关系。 一切看起来像这样:

PVS-Studio:功能标记

由于有了这个注释,下面使用fread函数的代码将立即显示两个错误。

 void Foo(FILE *f) { char buf[100]; size_t i = fread(buf, sizeof(char), 1000, f); buf[i] = 1; .... } 

PVS-Studio警告:
  • V512 CWE-119调用'fread'函数将导致缓冲区'buf'溢出。 test.cpp 116
  • V557 CWE-787阵列可能超限。 “ i”索引的值可能达到1000。test.cpp 117

首先,分析器将第二个和第三个实际参数相乘,然后计算出该函数最多可以读取1000个字节的数据。 在这种情况下,缓冲区大小仅为100字节,并且可能会溢出。

其次,由于该函数最多可以读取1000个字节,因此变量i的可能值的范围为[0..1000]。 因此,对数组的访问可能在错误的索引处发生。

让我们看一个错误的另一个简单示例,由于memset函数的标记,使得检测错误成为可能。 这是CryEngine V.项目的代码片段

 void EnableFloatExceptions(....) { .... CONTEXT ctx; memset(&ctx, sizeof(ctx), 0); .... } 

PVS-Studio分析仪发现了一个错字:V575'memset'函数处理'0'元素。 检查第三个论点。 crythreadutil_win32.h 294

混淆了函数的第二和第三参数。 结果,该函数处理0个字节且不执行任何操作。 分析仪会注意到此异常,并警告程序员。 我们先前在文章“ 期待已久的CryEngine V验证 ”中描述了此错误。

PVS-Studio分析仪不仅限于我们手动设置的注释。 此外,他通过研究功能主体独立尝试创建注释。 这使您可以发现错误使用功能的错误。 例如,分析器记住一个函数可以返回nullptr。 如果不经初步检查就使用了此函数返回的指针,则分析仪将对此发出警告。 一个例子:

 int GlobalInt; int *Get() { return (rand() % 2) ? nullptr : &GlobalInt; } void Use() { *Get() = 1; } 

警告:V522 CWE-690可能会取消引用潜在的空指针“ Get()”。 test.cpp 129

注意事项 您可以采用相反的方法来寻找刚刚检查过的错误。 什么都不记得了,每次遇到对Get函数的调用时,都要在知道实际参数的情况下对其进行分析。 从理论上讲,这种算法可让您发现更多错误,但它具有指数级的复杂性。 程序分析时间增长了十万倍,从实际的角度来看,我们认为这种方法是死胡同。 在PVS-Studio中,我们正在开发自动标注功能的方向。

模式匹配


乍一看,与模式匹配的技术似乎是对正则表达式的搜索。 实际上,事实并非如此,而且一切都更加复杂。

首先,正如我已经说过的 ,正则表达式通常毫无价值。 其次,分析器不使用文本行,而是使用语法树,这使人们可以识别更复杂和更高级的错误模式。

考虑两个例子,一个简单,一个复杂。 我在检查Android的源代码时发现的第一个错误。

 void TagMonitor::parseTagsToMonitor(String8 tagNames) { std::lock_guard<std::mutex> lock(mMonitorMutex); if (ssize_t idx = tagNames.find("3a") != -1) { ssize_t end = tagNames.find(",", idx); char* start = tagNames.lockBuffer(tagNames.size()); start[idx] = '\0'; .... } .... } 

PVS-Studio分析器可以识别与程序员对C ++中操作优先级的误解有关的经典错误模式:V593 / CWE-783考虑查看“ A = B!= C”类型的表达式。 该表达式的计算公式如下:“ A =(B!= C)”。 TagMonitor.cpp 50

仔细看看这一行:

 if (ssize_t idx = tagNames.find("3a") != -1) { 

程序员假定在开始时执行了赋值,然后才与-1进行比较。 实际上,比较是第一位的。 经典版 有关Android验证的文章中对此错误进行了更详细的描述 (请参见“其他错误”一章)。

现在考虑更高级别的模式匹配选项。

 static inline void sha1ProcessChunk(....) { .... quint8 chunkBuffer[64]; .... #ifdef SHA1_WIPE_VARIABLES .... memset(chunkBuffer, 0, 64); #endif } 

PVS-Studio警告:V597 CWE-14编译器可能会删除“ memset”函数调用,该函数调用用于刷新“ chunkBuffer”缓冲区。 RtlSecureZeroMemory()函数应用于擦除私有数据。 第189章

问题的实质是使用memset函数将缓冲区填充为零后,此缓冲区在任何地方都不会使用。 在编译带有优化标志的代码时,编译器将确定此函数调用是多余的,并将其删除。 他有权这样做,因为从C ++语言的角度来看,调用函数在程序上没有任何可观察到的行为。 填充chunkBuffer缓冲区后, sha1ProcessChunk函数立即结束。 由于缓冲区是在堆栈上创建的,因此退出函数后,它将无法使用。 因此,从编译器的角度来看,用零填充是没有意义的。

结果,堆栈上的某个地方将保留私有数据,这可能会导致麻烦。 在文章“ 安全清除私有数据 ”中将详细讨论此主题。

这是高级模式匹配的示例。 首先,分析器应意识到此安全漏洞的存在,该漏洞根据“常见漏洞枚举”分类为CWE-14:清除代码的编译器删除缓冲区

其次,它必须在代码中找到在堆栈上创建缓冲区的所有位置,并使用memset函数将其擦除,并且不能在其他任何地方使用它。

结论


如您所见,静态分析是一种非常有趣且有用的方法。 它使您可以尽早消除大量错误和潜在漏洞(请参阅SAST )。 如果您仍然不完全了解静态分析,那么我邀请您阅读我们的博客 ,我们在其中定期分析使用PVS-Studio在各个项目中发现的错误。 您根本不能保持冷漠。

我们很高兴在您的客户中看到您的公司,并帮助您改善应用程序,使其更可靠,更安全。



如果您想与说英语的读者分享这篇文章,请使用以下链接:Andrey Karpov。 PVS-Studio代码分析器中用于查找错误和潜在漏洞的技术

Source: https://habr.com/ru/post/zh-CN430604/


All Articles