分类 调试 下的文章

Visual C++编译器的分析功能是提高代码质量的一个好方法。它基本上是一个21世纪的“lint”来识别许多编码错误。许多错误对于程序员来说是很难看到的,但是,通过对代码进行适当的注释,/analyze的不屈不挠的眼睛将可靠地找到它们。
我最近在一个大型代码库上使用/analyze,发现了大约500个值得修复的bug。这些错误包括超出范围的读写、printf参数错误、逻辑错误等等。这些错误有可能导致内存损坏、崩溃和所有类型的意外行为。在我进行代码清理时,有几个崩溃bug咬了我的同事,因此证明了这些bug不仅仅是理论上的问题。
列出/analyze发现的所有bug,创建起来很乏味,阅读起来也很无聊。相反,我创建了示例代码来演示/analyze在哪些方面做得最好。分析工作做得很糟糕的地方会被保存到以后的文章中。

缓冲区溢出

当调用需要传递指针和缓冲区大小的函数时,很容易传递错误的大小。我看到的一些错误包括传递错误的硬编码常量、传递错误的命名常量或传递错误的缓冲区大小。在本例中,当需要_countof()时使用sizeof(),这意味着该计数是应该的两倍,并且可能存在缓冲区溢出:

void TestBufferWarnings(const wchar_t*pSource)
{
wchar_t buffer[
10];
wcscpy_s(buffer,
sizeof(buffer), pSource);
}

这是一个很常见的问题,我们几乎总是遇到。想象一下这样一种情况,我们从某个地方得到一个内存转储,想看看在那里运行的是什么操作系统,安装了什么SP。。为此,有一个非常简单的命令。

0:000>vertarget
Windows 7 Version 7601 (Service Pack 1) MP (8 procs) Free x86 compatible
Product: WinNt, suite: SingleUserTS
kernel32.dll version: 6.1.7601.24475 (win7sp1_ldr.190516-0600)
Machine Name:
Debug session time: Fri Dec  6 09:36:00.000 2019 (UTC + 8:00)
System Uptime: 0 days 0:51:15.649
Process Uptime: 0 days 0:00:41.000
  Kernel time: 0 days 0:00:05.000
  User time: 0 days 0:00:13.000

符号服务器允许Windows上的开发人员工具自动查找符号。他们做得很好,以至于大多数开发人员都不必担心内部机制。然而,当事情出了问题时,了解它们是如何工作的是有帮助的,事实证明,这一切都非常简单。

我对Windows符号服务器的讨论利用了我笔记本电脑上的符号服务器,用于我自己的个人项目。每当我发布一个新版本的分形极限(64位优化,多核,快速和流动的分形探索,这里的演示版本)我把符号和二进制文件放在我的符号服务器上,这样我就可以轻松地调查我收到的任何崩溃报告。这对于一个家庭项目来说似乎有点过头了,但实际上本地符号服务器只是文件的副本,以特定的方式排列以便于检索,而且设置起来很简单。对于我公开发布的可执行文件,比如UIforETW,我将PE和PDB文件发布到Google存储上的一个公共符号服务器上——详细信息在这里。

WinDbg、ntsd、cdb、kd和其他任何使用DbgEng打开转储文件的工具都有一个鲜为人知的特性。
事实证明,使用DbgEng提供的任何功能,在任何可以打开转储文件(用户转储、内核转储等)的地方,都可以打开PE映像(.exe/.dll/.sys/etc),并让调试器将其视为只包含所选PE映像内容的转储。
这实际上是一个相对有用的特性。当您将PE映像作为转储文件打开时,调试器将其映射为映像,就好像它是作为可执行代码加载到内存中一样(尽管它实际上不运行任何代码,但只是将其映射为可执行文件而不是数据文件)。这将获得exe/dll/sys/other PE文件的内存表示形式,就好像您正在调试加载有问题图像的实时进程(或转储)。
与转储调试会话一样,这本质上是一个只读会话;您不能真正修改任何内容,因为没有要控制的目标。此外,也没有真正的寄存器上下文(或堆栈或堆),尽管初始化和零填充的全局变量以及属于该模块的可执行代码将在内存中。(在这种情况下,模块的首选映像库用于将请求的PE模块建立在为调试会话构造的虚拟地址空间中。)
加载完目标后,您可以执行通常对转储执行的大部分操作,只要检查符号并分解目标即可。如果您需要一个具有符号支持的反汇编程序,并且不能启动一个进程或什么不包含PE映像,那么这个特殊的技巧是一个功能更全面的反汇编程序的quick-n-dirty替换。
注意,在转储模式下打开PE映像的一个副作用是符号服务器用于检索二进制文件(这可能看起来有点奇怪,直到您考虑到对于转储文件,通常情况是您没有将整个二进制文件保存在内存中;只有足够的头信息从符号服务器检索二进制文件)。因此,在尝试此特殊技巧之前,请确保您的符号路径设置正确。

这是一个相当简单的错误。使用字节计数而不是字符计数调用了宽字符串函数,从而导致缓冲区溢出。找到问题后,修复方法很简单,只需将sizeof更改为_countof,很容易的。但像这样的BUG浪费时间。由于崩溃,playtest被取消了,而且由于缓冲区溢出破坏了堆栈,因此找到错误代码并非易事。我知道这种类型的错误是可以避免的,我知道还有很多工作要做。

我所做的工作包括:

尽量早点诊断

如果程序在发出错误的宽字符函数调用的函数内部崩溃了,那么找到错误将是微不足道的——代码检查会很快发现它。但是,一旦执行从该函数返回,垃圾堆就模糊了bug的位置,使其调查变得更加棘手。

事实证明,有一个VC++编译器开关可以防止在破坏堆栈后返回。所以,我做的第一件事就是打开开关。这个开关告诉VC++在堆栈上添加一个标志,并在返回之前进行检查。当这个开关打开的时候,溢出的功能被抓住了,找到它花了几秒钟。GS开关是作为一种安全特性,用于防止恶意的缓冲区溢出,但它也可以作为开发人员的生产力工具很好地工作。它确实有一些运行时开销,但这种权衡通常是值得的,尤其是在内部构建上,推荐使用。

修改bug

一旦我打开/GS并重新调试了这个bug,找到这个bug就很简单了,所以下一步就是修复它,如前所述。

保护证据

当缓冲区溢出破坏堆栈时,buggy函数返回到一个垃圾地址,这个地址正好在堆中。抹掉bug所在位置的证据已经够糟糕的了,但更糟糕的是,在返回堆中的地址后,会将堆中的数据作为指令执行。这扰乱了寄存器和堆栈,通常会使事情变得混乱。

还有,一个可执行堆?真正地?这是一个首要的安全漏洞。因此,下一个任务是将链接器设置更改为/NXCOMPAT。这将告诉Windows使堆和堆栈不可执行。这大大提高了安全性,还可以简化调试。而且,这个选项没有运行时成本,推荐使用。实际上,这应该被认为是必要的。

 

我还打开了我们的发布分支中的/DYNAMICBASE链接器开关,以进一步提高安全性,同时也没有运行时开销。

规避崩溃风险

在这一点上,我已经修复了这个错误,使以后的此类错误更容易调查,并提高了安全性。但还有很多事情要做。原来这种错误很容易犯。当开发人员传递缓冲区和大小时,传递错误大小的方法至少有六种。将来避免这些错误的最好方法是避免传递大小。创建以数组为参数并以100%的精度推断大小的模板函数非常容易。与其写这个:

mywprintf(buffer, _countof(buffer), …); // Verbose and dangerous

你可以写成下面的:

mywprintf_safe(buffer, …); // Compact and safe

它打字少,阅读少,而且保证是正确的。接受数组引用的模板化函数的语法有点粗糙,但您只需要在几个地方正确地使用它。如果有一个原始指针,模板技术就不起作用,但是对于任何其他目标,您应该能够创建一个重载来处理它。手动传递的大小应该很少。因此,我的下一个任务是为所有字符串函数添加模板重写,并鼓励每个人在所有新代码中使用它们,从而使避免再次编写此类错误变得非常简单。

避免过去的风险

虽然safe模板函数可以让我们在将来避免编写这种类型的bug,但它们对现有的数百万行代码却无能为力。可以肯定的是,有更多的尺寸不匹配等着咬我们。所以我在字符串函数中添加了SAL注释,启动了visualstudio的/analyze,并开始编译。到目前为止,这是最大的任务。任何没有运行静态分析的大型代码库都会有很多可检测的bug。缓冲区溢出、逻辑错误、格式字符串不匹配、释放后使用等等。分析了上千个项目,修复了5个主要的机器故障。这是几个月的工作分散了几年,但时间是值得花的。今天它仍在发现新的编码错误。