现代 C++ 救不了程序员

常常有程序员为C++辩护说:“只要你不使用任何从C继承过来的功能,C++就是安全的”!但事实非如此。ios

根据本文做者在大型C++项目上(听从现代的惯用作法)的经验来看,C++提供的类型彻底不能阻止漏洞的泛滥。本文中就会给出一些彻底根据现代C++的惯用作法编写的代码,你会发现这些代码仍然会引起漏洞。git

如下为译文:程序员

我常常批评内存不安全的语言,主要是C和C++,以及它们引起的大量安全漏洞。根据大量使用C和C++的软件项目的审查结果,我得出了一个结论:软件行业应该使用内存安全的语言(例如Rust和Swift)。github

人们经常在回复我时说,这个问题并非C和C++自己的问题,而是使用这两种语言的开发者的错。编程

具体来讲,我常常听到人们为C++辩护说:“只要你不使用任何从C继承过来的功能,C++就是安全的”(我理解这句话指的是原始指针、数组做为指针使用、手动malloc/free以及其余相似功能。但我认为有一点值得注意,因为C的特性明确地融入了C++,那么在实践中,大部分C++代码都须要处理相似的状况。),或者相似的话,好比只要听从现代C++的类型和惯用作法,就不会引起内存方面的漏洞。数组

我很感谢C++的智能指针类型,由于这种类型的确很是有用。不幸的是,根据我在大型C++项目上(听从现代的惯用作法)的经验来看,光靠这些类型彻底不能阻止漏洞的泛滥。我会在本文中给出一些彻底根据现代C++的惯用作法编写的代码,你会发现这些代码仍然会引起漏洞。安全

掩盖“释放后使用”的引用bash

我想说的第一个例子最初是Kostya Serebryany提出的(github.com/isocpp/CppC…数据结构

#include <iostream>
#include <string>
#include <string_view>

int main() {
  std::string s = "Hellooooooooooooooo ";
  std::string_view sv = s + "World\n";
  std::cout << sv;
}

复制代码

在这段代码中,s + "World\n"分配了一个新的std::string,而后将其转换成std::string_view。此时临时的std::string被释放,但sv依然指向它原来拥有的内存。任何对sv的访问都会形成“释放后使用”的漏洞。ide

天啊!C++的编译器没法检测到sv拥有某个引用,而该引用的寿命比被引用的对象还要长的状况。一样的问题也会影响std::span,它也是个很是现代的C++类型。

另外一个有意思的例子是使用C++的lambda功能来掩盖引用:

#include <memory>
#include <iostream>
#include <functional>


std::function<int(void)> f(std::shared_ptr<int> x) {
    return [&]() { return *x; };
}

int main() {
    std::function<int(void)> y(nullptr);
    {
        std::shared_ptr<int> x(std::make_shared<int>(4));
        y = f(x);
    }
    std::cout << y() << std::endl;
}
复制代码

上述代码中,f中的[&]代表lambda用引用的方式来捕获值。而后在main中,x超出了做用域,从而销毁了指向数据的最后一个引用,致使数据被释放。此时y就成了悬空指针。即便咱们谨慎地使用智能指针也没法避免这个问题。没错,人们的确会编写代码来处理std::shared_ptr&,做用之一就是设法避免引用计数无谓的增长或减小。

std::optional解引用

std::optional表示一个可能存在也可能不存在的值,一般用来替换哨兵值(如-1或nullptr)。它提供的一些方法,如value(),可以提取出它包含的T,并在optional为空的时候抛出异常。可是,它也定义了operator*和operator->。

这两个方法能访问底层的T,但它们并不会检查optional是否包含值。

例如,下面的代码就会返回未初始化的值:

#include <optional>

int f() {
    std::optional<int> x(std::nullopt);
    return *x;
}
复制代码

若是用std::optional来代替nullptr,就会产生更加严重的问题!对nullptr进行解引用会产生段错误(这并非安全漏洞,只要不是在旧的内核上)。而对nullopt进行解引用会产生未初始化的值做为指针,这会致使严重的安全问题。尽管T*也可能拥有未经初始化的值,可是这种状况很是罕见,远远不如对正确地初始化成nullptr的指针进行解引用的操做。

并且,这个问题并不须要使用原始的指针。即便使用智能指针也能获得未初始化的野指针:

#include <optional>
#include <memory>

std::unique_ptr<int> f() {
    std::optional<std::unique_ptr<int>> x(std::nullopt);
    return std::move(*x);
}
复制代码

std::span索引

std::span能让咱们方便地传递指向一片连续内存的引用以及长度值。这样针对多种不一样类型进行编程就很容易:std::span<uint8_t>能够指向std::vector<uint8_t>、std::array<uint8_t, N>拥有的内存,甚至能够指向原始指针拥有的内存。不检查边界就会致使安全漏洞,而许多状况下,span能帮你确保长度是正确的。

与其余STL数据结构同样,span的operator[]方法并不会进行任何边界检查。这是能够理解的,由于operator[]是最经常使用的方法,也是访问数据结构的默认方法。而至少从理论上,std::vector和std::array能够安全地使用,由于它们提供了at()方法,该方法会进行边界检查(在实践中我历来没见人用过这个方法,不过能够想象一个项目,经过静态分析工具来禁止调用std::vector::operator[])。span不提供at()方法,也不提供任何进行边界检查的方法。

有趣的是,Firefox和Chromium移植的std::span都会在operator[]中进行边界检查,因此这两个项目也没法安全地移植到std::span上。

结论

现代C++的惯用作法带来了许多改变,可以改善安全性:智能指针能更好地表示预想的生命周期,std::span能保证永远有正确的长度,std::variant为union提供了安全的抽象。可是,现代C++也引入了一些新的漏洞祸根:lambda捕获致使的释放后使用,未初始化的optional,以及没有边界检查的span。

以我编写比较现代的C++的经验,以及审查Rust代码(包括使用了大量unsafe的Rust代码)的经验来看,现代C++的安全性彻底比不上那些保证内存安全的语言,如Rust、Swift(或者Python和JavaScript,尽管我不多见到可以合理地用Python或C++编写的程序)。

不能否认,将现有的C和C++代码移植到其余语言依然是个难题。但不管如何,问题应该是咱们应该怎样作,而不是咱们是否应该作。事实证实,即便最现代的C++惯用作法,也不可能保证C++的正确性。

相关文章
相关标签/搜索