内容提要:本文针对微软的Visual C++2005发布版本中语言和库的一些新的特点进行了讨论,这将帮助你更高效地创作安全、可靠的代码。
一、 简介
微软的Visual C++2005发布版本对于有志于轻松、迅速地编写安全可靠的应用程序的编程爱好者来说是正确地选择。正如你所听到的那样,Visual C++中语言和库的新特点使开发安全、可靠的应用程序比以前更容易。它即提供了功能强大并且灵活的标准C++,又提供了适于.NET框架下编程的最强大的开发语言。
本文中,我主要探讨Visual C++2005发布版本中部分语言和库的新特色,无论是对于教学项目还是大的应用工程,这都将帮助你在编写安全可靠的代码时提高工作效率。
二、C运行时库的安全特点
如果你正在使用Visual C++创建使用C运行时库的应用程序,你将非常欣慰地了解到现在你所依赖的许多库函数都有了更安全的版本。对于需要一个或多个缓冲作为输入的函数来说,已经添加了长度参数,以此让函数来确信不会超越缓冲的边界。现在更多的函数开始对参数进行合法性检查,必要时将调用无效参数处理器。让我们来看一些简单的例子:
C运行时库中最不可靠的是gets函数,它从标准输入中读取一行。思考下面的一个简单的例子:
gets(buffer);
第一行代码声明了一个缓冲变量,并将缓冲区中的字符初始化设置为0。为避免意外情况发生将变量初始化为一个众所周知的值是一个非常好的主意。紧接着,看似清白无辜的gets函数从标准的输入流中读取一行并且写入到buffer缓冲区内。这有什么错误吗?对于函数来说C类型的数组不能实现值传递,而是传递了指向数组第一个元素的指针。所以在函数看来,char[ ]相当于char*指针,并且是一个不附带可以决定所指向的缓冲区大小尺寸的任何额外信息的原始指针。那么gets函数是怎么作的呢?它假设缓冲区无限大(UINT_MAX 是有精确尺寸的),并将持续地从输入流中拷贝字符到缓冲区内。攻击者可以轻易地使用这个弱点,这种不广为人知的类型错误被称为缓冲溢出。
很多最初的C运行时库函数遭受同样的与参数确认有关的问题,并且现在因此受到抨击。一定要牢记对于当前所要写的应用程序来说,性能处于次要地位,我们现在生活在一个安全第一的世界。每一个受到批评的函数已经被一个提供同样函数功能,但添加了安全特点的函数所代替。当然,根据你在已经存在的代码中所使用的旧的库函数的多少,你可能需要花费一些时间来代码更替到新的、更安全的版本。这些新的函数有一个_s后缀,例如,gets函数被gets_s函数代替;遭受抨击的strcpy函数被strcpy_s函数代替。这里有一个例子:
gets_s(buffer, sizeof (buffer) / sizeof (buffer[ 0]));
gets_s函数有一个额外的参数,用来显示可以写入的最大字符数量,这里包括一个NULL终结符。我使用了 sizeof操作符,它能决定数组的长度,因为编译器在编译时决定sizeof操作符返回的结果。记住,sizeof返回操作数的长度是以字节为单位的,所以用数组长度来除以数组中第一个元素的长度将返回数组中元素的个数。这种简单的方法可以移植到Unicode编码下使用_getws_s的情况,这个函数也需要得知以字节为单位的缓冲区长度。
正如我所提到的,另外一个接受安全检查的常用函数strcpy函数,就象gets函数一样,它没有方法来保证有效的缓冲区尺寸,所以它只能假定缓冲足够大来容纳要拷贝的字符串。在程序运行时,这将导致不可预料的行为,正如我所提到的,为了安全需要避免这些不可预料的行为,这有一个使用安全的strcpy_s函数的例子。
char destination[ 20] = { 0 };
strcpy_s(destination, sizeof (destination) / sizeof (destination[ 0]), source);
有很多原因来喜欢这个新的strcpy_s函数。最明显的区别是的额外的、以字节为单位的参数,它用来确认缓冲区大小。这允许strcpy_s函数可以进行运行时检查,以确定写入的字符没有超过目标缓冲区的边界。还有一些其它的检查方法来确定参数的有效性。在调试版本中这些检测方法,包括显示调试报告的"断言"(assertions)方法,如果它们的条件没有满足,它们将显示调试报告。无论是调试还是发行版本,如果一个特定的条件没有得到满足,一个无效的参数管理器将被调用,它默认的行为是抛出一个访问冲突来终止应用程序。这非常好的实现了让你的应用程序持续运行,而不会产生不可预期的结果。当然,这种情况完全可以通过确保类似于strcpy_s的函数不调用无效参数来避免。
前一个例子可以通过新的_countof宏来进一步简化,这个宏移抛开了对有错误倾向的sizeof操作符的需要。_countof宏返回C类型数组的元素数量。这个宏本身对应了一个模版,如果传递一个原始指针的话,它将无法通过编译。这有一个例子:
三、使用C++标准库
已经看了C运行时库新增强的安全特性,让我们来看一下如何使用C++标准库来进一步减少你的代码中的相似错误。
当你从C运行时库转向C++的标准库,让你从C++开始受益的一个最有效的方法是使用库中的矢量类(Vector class)。矢量类是C++标准库中的一个模仿一维T数组的容器类,这里T可以是事实上的任何类型,你的代码中使用缓冲区的地方都可以用矢量对象来代替。让我们来考虑上一节的例子,第一个例子我们使用gets_s函数来从标准输入中读取一个行,考虑用下面的代码代替:
gets_s(&buffer[ 0], buffer.size());
最值得注意的一个区别是缓冲区变量现在是一个带有可用方法和操作符的矢量对象,这个矢量对象初始化为10个字节长度,并且构造函数将每个元素都初始化为0,表达式&buffer[0]用于得到矢量对象的第一个元素的地址,向期待一个简单缓冲区的C函数传递一个矢量对象是一种正确的方法。与sizeof操作符不同的是,所有的容器的尺寸测量是基于元素的,而不是基与字节的。例如,矢量的size方法返回的是容器的元素数量。
在上节的第二个例子里,我们使用strcpy_s函数从源缓冲区向目标缓冲区拷贝字符。应该清楚矢量对象是如何代替原始的C类型的数组,为了更好的说明这一点,让我们来考虑另外一个非常有用的C++标准库的容器。
提供的basic_string类使得字符串在C++中可以作为正常的类型来操作。它提供了各种各样的重载操作符,为C++程序开发人员提供了自然的编程模式。由于优于strcopy_s及其它操作字符串的函数,你应该首选basic_string函数。basic_string以字节为单位的T类型容器。这里T是字符类型。C++标准类库对于常用的字符类型提供类型定义。string和wstring中的元素类型分别被定义为char和wchar类型。下面的例子说明basic_string类是多么简单和安全:
std::string destination = source;
basic_string类也提供了你所希望的、常用的字符串操作的方法和操作符,象字符串联合及子串的搜索。
最后,C++标准库提供了一个功能非常强大的I/O库,用来安全、简单地与标准输入输出、文件流进行交互操作。虽然对于gets_s函数来说使用矢量对象比使用C类型的数组更好,但你可以通过使用定义的basic_istream 和 basic_ostream类进一步简化。实际上,你可以书写简单并且类型安全的代码从流中来读取包括字符串在内的任何类型。
int number = 0;
std::cin >> word >> number;
std::cout << word
<< std::endl
<< number
<< std::endl;
cin被定义成一个basic_istream流,从标准的输入中提取字符类型的元素。wcin是用于 wchar_t元素。另一方面,cout被定义为一个basic_ostream流,用于向标准的输出流写入操作。正如你能想象的,这种模式比起 gets_s和puts函数来可以无限的扩展。但是,真正的价值是在于它非常难以产生让你的应用程序出现安全裂痕的错误。
四、C++标准库中的边界检查
默认情况,C++标准库中大量的容器对象和迭代对象没有提供边界检查。例如,矢量的下标操作符通常是一个比较快,但有潜在的危险性的操作单独元素的方法。如果你正在寻找得到确认检查的操作方法,你可以转向"at"方法。安全性的增加是以牺牲性能为代价的。当然,绝大情况下性能的降低是可以忽略不计的,但是对于性能要求第一位的代码来说,这可能是非常有害的,思考一下下面的简单函数:
{
for (size_t index = 0; index < numbers.size(); ++index)
{
std::cout << numbers[index] << std::endl;
}
}
void PrintN( const std::vector< int>& numbers, size_t index)
{
std::cout << numbers.at(index) << std::endl;
}
PrintAll函数使用了下标操作符,因为索引由函数控制,并且可以确认是安全的。另一方面,PrintN函数不能保证索引的有效性,所以它使用了更安全的"at"方法来代替。当然,并不是所有的容器的存取操作都象这么简洁明了。
在保证C++标准库的安全特性的同时,Visual C++2005继续坚持并在很多情况下改进了C++标准库的运行特性,同时提供了调节C++标准库安全性的特色。一项受人欢迎的改进是在调试版本中添加了范围检查,这对你的发行版本性能并不构成影响。但这确实帮助你在调试阶段捕获越界错误,甚至是使用传统上不安全的下标操作符的代码。
不安全的函数,象vector的下标操作算子,和其他的函数,象它的front函数,如果不恰当的调用,通常会导致不明确的行为。如果你幸运的话,它将很快导致一个存取冲突,这将使你的应用程序崩溃。如果你不那么走运的话,它可能默默地持续运转并导致不可预知的副效应,这将破坏数据并可能被进攻者利用。为了保护你的发行版本的应用程序,Visual C++2005引入了_SECURE_SCL符号,用来给那些非安全的函数添加运行时检查。象下面的代码那样在你的应用程序中简单地定义这个符号可以添加额外的实时检查并阻止不确切的行为。
紧记定义这个符号对你的程序冲击很大,大量的合法的,但是具有潜在非安全的操作将在编译时将无法通过,以避免在运行时出现潜在BUG。思考下面的使用Copy运算的例子:
其中,first和last是定义拷贝范围的迭代参数,destination是输出迭代参数,指示了目标缓冲区的位置,这个位置用来拷贝范围之内的第一个元素。这里有一个危险是destination所对应的目标缓冲区或容器不足够大,无法容纳所要拷贝的元素。如果Destination是一个需要安全检查的迭代参数,类似的错误将被捕获。但是,这仅仅是一个假设。如果destination是一个简单的指针,将无法保证copy运算函数正确运转。这时当然会想到_SECURE_SCL符号来避免这一问题,这种情况下,代码甚至是不能编译,以此避免任何可能的运行时错误。就象你想象的那样,这将需要重写更完美有效的代码。所以,这是一个更好的理由支持C++标准库容器,避免使用C类型数组。
五、编译器的安全特点
虽然对于Visual C++2005来说并不是全新的,但大量编译器特色仍然需要了解。与以前版本的显著区别是编译器的安全检查当前默认情况下是打开的,让我们来看一下编译器的特点及在某些情况下它们是如何阻止在某些情况下受到攻击。
Visual C++编译器很久以前就开始提供严格的运行时安全检查选项,包括栈校验,下溢和上溢检查以及未初始化变量的识别。这些运行时检查由编译器的/RTC选项来控制。虽然在早期的发展中捕获错误非常有用,但是对于发布版本性能上的损失却是不能接受的。微软的Visual C++.NET引入了/GS编译开关,对于发行版本来说它添加了有限的运行时安全检查。/GS开关在编译开关中插入代码,通过检测函数的栈数据来检测通常基于栈的缓冲溢出。如果发现问题,应用程序将被终止。为了减少运行时检查对性能的影响,编译器辨别哪个函数易于攻击并且仅针对这些函数来进行安全检查。安全检查涉及到在函数的栈框架上增加一个cookie,在缓冲溢出的情况下它将被重写。函数指令的前后都添加了汇编指令。在函数执行以前,源自cookie 模块的函数cookie先执行计算。当函数结束但在栈空间被收回前,cookie的栈拷贝被检索以判断它是否被更改。如果cookie未被更改,函数结束并继续执行程序的下一步,如果cookie被更改了,一个安全错误句柄将被调用,它将结束应用程序。
为了在Visual C++ 2005发布版本中控制这些编译选项,打开工程的属性页,单击C/C++标签,在代码发生属性页中,你将发现两个属性对应于我刚刚描述的特点。Basic Runtime Checks属性对应于开发时/RTC编译选项,在编译版本中应设置为"BOTH"。Buffer Security Check属性相当于编译器的/GS选项,对于发布版本应设置为"YES"。
对于使用Visual C++ 2005的开发人员来说,这些编译特点在默认情况下打开,这意味着你可以确信编译器正在尽其可能阻止你代码中的漏洞。然而,这并不意味着我们可以完全不关心安全问题。开发人员需要继续为正确的代码而努力,并且要考虑各种不同的、可能发生的安全威胁。编译器仅仅可以阻止部分类型的错误发生。
要牢记这些编译器提供的特殊的安全检查仅适用于本地代码,幸运的是,托管代码很少犯此类的错误。这里甚至于有更好的消息,Visual C++ 2005引进了C++/CLI设计语言,它提供了.NET框架下最强有力的开发语言。
六、新的C++编程语言
Visual C++ 2005发布版本提供了C++/CLI设计语言的一流的实现。C++/CLI是为.NET设计的系统编程语言。相对于其他语言来说,它在创建和使用.NET模块和汇编上有更多的控制。C++/CLI对于C++开发人员来说更精细和自然,无论你是否熟悉C++或.NET框架,你将发现使用C++书写托管代码是对ANSI C++自然文雅的扩展,学习起来非常容易。
对于开发应用程序来说,有许多强制性的原因让你来选择托管代码而不是本地C++。两个最重要的原因是安全性和效率。通用运行时语言(CLR)给你的代码提供了一个安全的运行环境。作为一个程序开发人员,你不需要关心缓冲区溢出及因为你在使用前未初始化变量等问题。安全问题没有完全消失,但是使用托管可以避免通常发生的一些错误。
另外一个使用托管的原因是.NET框架下丰富的类库。虽然标准C++库更适合于C++类型编程,但是.NET框架包含了一个功能强大的函数库,这是标准C++库所无法比拟的。.NET框架包括很多有用的集合类、一个强大的数据操作库、执行很多流行的通讯协议的类,从SOCKETS到HTTP和网络服务等等。虽然本地C++ 程序开发人员可以以各种形似使用这些服务,但通过使用.NET框架获取的生产力主要因为它的统一性和连贯性。无论你是用 System::Net::Sockets还是用System::Web名字空间,你将面对同样的类型,描述广泛应用的概念,例如流和字符串。这是.NET框架具有开发高效率的最主要的原因。这让程序人员更快速地书写更强有力的应用程序,同时代码更可靠。
Visual C++ 2005自然地准许你在一个工程中混合本地与托管代码,你可以继续使用已经存在的本地函数及类的同时,开始使用越来越多的.NET框架下的类库,甚至是写你的托管类型。你可以将你的托管类型定义为一个引用类型或一个值类型,虽然Visual C++编译器允许你为了方面选择使用栈语法或是为了控制管理资源使用通常的作用域规则,但值类型在栈上而引用类型位于CLR的托管堆上。
通过在你定义的class 和 struct 前添加ref来形成一个关键词,定义了一个引用类型。获取和释放资源按通常的方式完成,通过使用构造和析构函数,正如这里说明的:
{
public: Connection(String^ server)
{
Server = server;
Console::WriteLine( "Aquiring connection to server.");
}
~Connection()
{
Console::WriteLine( "Disconnecting from server.");
}
property String^ Server;
};
编译器负责Connection引用类型的IDisposable接口的实现,所以使用类似C#、Visual Basic.NET的开发人员可以使用任何对他们可用的资源管理结构。对于C++开发人员,有着与以前一样的选择。为了简化资源管理,并书写"异常"安全代码,你可以简单地在栈上声明一个Connection对象。当一个对象超过其作用范围后,执行Dispose方法的析构函数将被调用。下面是一个例子:
{
Connection connection( "sample.kennyandkarin.com");
Console::WriteLine( "Connection to {0} established!",
connection.Server);
}
这个例子中,通过在函数返回调用前调用析构函数来关闭这个Connection,这正如你在C++希望的那样。如果你希望自己控制对象的生命期,仅仅需要使用gcnew这个关键词来获取connection对象的句柄。这个指针可以看作通常的指针(不含有通常的缺陷),并且这个对象的析构函数可以简单地通过delete操作来调用。这个例子代码如下 :
{
Connection^ connection = gcnew Connection( "sample.kennyandkarin.com");
try
{
Console::WriteLine( "Connection to {0} established!",
connection->Server);
}
finally
{
delete connection;
}
}
正如你所看到的,从本地C++到托管代码,Visual C++ 2005带来了简单灵活的资源管理方式,可以书写强壮的资源管理代码对于书写正确、安全的代码是非常重要的。
七、小结
无论是对于一个小的程序还是一个大的应用,Visual C++ 2005发布版本都是一个功能强大的开发工具,C运行时库和C++标准库提供了一个强大的工具集,来发布功能强大的、强壮的本地应用程序,同时,对用 C++书写托管代码有着一流的支持,Visual C++ 2005在微软的Windows开发平台上是独一无二的强大的开发工具。