Safer C++

I am an unrepentant advocate for migrating away from memory-unsafe languages (C and C++) to memory safe languages in security-relevant contexts. Many people reply that migrating large code bases to new languages is expensive, and we’d be better off making C++ safer. This is a reasonable response, after all there’s an enormous amount of C++ in the wild. Even on an incredibly aggressive timeline for replacing every line of C++ in the world with Rust or Swift or Go, we’ll have a lot of C++ attack surface for a long time. So even if you’re a memory-safe languages advocate, achieving a safer C++ is a worthwhile goal.

When we talk about making C++ safer this could mean something like: provides the same security guarantees as memory safe languages for existing C++ code. However, that’s an incredibly narrow definition, and so in this post I’ll also consider things that require changes to source code to adopt, but which can be adopted in an incremental manner across existing C++ code bases.

In order to evaluate a proposal for improving C++’s safety, we need to consider what it’d cost to implement such a proposal. Proposals that cost more than it’d cost to migrate to another language make little sense. The primary cost we’re concerned with is the cost for individual projects to adopt the proposal (which range from trivial, as in the case of a new compiler flag, to substantial, as in the case of significant new language features). We also care about the degree of safety actually achieved.

While I will consider how practical these changes are, I won’t discuss how likely they are. That’s ultimately a question for the C++ standard committee.

In considering the scope of potential improvements, there’s a few things that are out of scope:

First, anything that compromises on the reasons people use systems languages in the first place is not under consideration. For example, techniques that impose significant performance or memory costs are out of scope. While it’s possible there’s some domains in which people have chosen C++, but have no meaningful performance requirements, in general I assume if you’re using C++ it’s because you care about the things C++ is good at.

We’re also not going to consider exploit mitigations, such as stack cookies or hardened allocators. This post is about things that can be done to the C++ language itself to improve its safety. Similarly, I’m not going to cover sandboxing or other non-language-level security approaches.

Analysis

The first place to start is where C++ has the most potential for dramatic improvement, and that’s spatial safety.

As a first step, the C++ standards committee could define all “default” indexing operations (e.g., operator[]) on STL containers to be bounds checked. This would require some work for the standards committee and standard library authors, but effectively no work for C++ programmers (libc++’s hardening mode implements many of these checks). It would also comprehensively address buffer overflows on STL containers. In practice there would probably need to be an escape hatch to allow unchecked indexing as well (though using [[assume()]] to assert, and assume, the invariant may be the best way to do this).

A follow-up would be adopting something like Apple’s proposed -fbounds-safety to enable bounds checking on raw pointers. Adopting this requires greater work, as each individual C or C++ program needs to modify their programs to take advantage of it. However, it similarly offers comprehensive protection against buffer overflows for code that takes advantage of it.

Taken together, these dramatically improve the spatial safety of C++ for many programs. However, these also need to be matched by a change in expectations, such that implementers of other data structures (e.g., small vectors) similarly follow suit and adopt safe-by-default APIs. Similarly, it’d be necessary to ensure iterators are made safe as well (such as by implementing them in terms of the safe, bounds-checked APIs).

Spatial safety reflects a high point in what is point in the possibilities for a safer C++. The combination of making the STL’s data structures bounds checked by default, and introducing -fbounds-safety (and gradually moving to require it!) provide fairly comprehensive protection to C++, nearly on par with what would be expected from a memory safe language.

Another category of vulnerability which can be addressed somewhat comprehensively is uninitialized memory. This can be divided into two categories. The first is the stack, where some compilers offer flags to automatically initialize it (generally to either zero or a pattern; see clang’s -ftrivial-auto-var-init). Then there are heap allocations, where one would need a malloc implementation to return only initialized memory.

One of the complications, however, is that while these can prevent the undefined behavior of accessing uninitialized memory, they have no way to guarantee that the values they use to initialize memory are semantically meaningful or safe. In practice, initializing values to zero tends to be much safer than uninitialized values, because it prevents information leaks and wild pointers, but it is not without risks itself. It also imposes modest performance overhead, although this overhead can often be reduced (though not eliminated) through compiler optimizations. This is why I say this class of vulnerability can only be addressed somewhat comprehensively – while these improvements require almost no effort from individual application developers, the level of safety provided is less than that of languages which statically enforce an initialization requirement.

The next class of vulnerability to consider is temporal safety, principally use-after-free vulnerabilities. For many years, C++ advocates have noted that smart pointers (e.g., std::unique_ptr, std::shared_ptr) can be used to make ownership more clear. While it is true that these can be used to more accurately model ownership, and thus in principle prevent these sorts of vulnerabilities, in practice these have been available for years, so they are not sufficient to consider this problem mitigated (if they were, I wouldn’t be writing about it).

There are proposals in varying states of completeness for C++ to obtain a “lifetime” syntax and semantics, ala Rust’s. In my review, these seem to primarily model relatively simple ownership models, akin to those covered by Rust’s “lifetime elision” rules (i.e., primarily cases where all arguments to a function have the same lifetime, and the return value’s lifetime matches those). This models many common scenarios, but is far from complete in allowing a programmer to model all ownership scenarios. For lifetime semantics that cannot be represented with this syntax, ownership semantics remain unchecked. It is unclear what percent of code in a typical C++ code would be covered by such a lifetime syntax.

It goes without saying that adopting any of these proposals will be a non-trivial investment in existing programs. Estimating how expensive it is to retrofit existing C++ code bases to use lifetimes depends on the proposals expressivity and the extent to which there is tooling to (partially) automate adopting it. It is presently too early to speculate as to either of these. (I am not aware of any at-scale code base which has adopted any of the lifetimes proposals. If you are, please let me know!) It is extremely likely that achieving high levels of lifetime-coverage for large programs will require extending these proposals to more fully model possibly lifetime relationships.

The final category of security issue to consider are data races. I am not aware of any proposals for C++ that would address these. Importantly, a “borrow checker” for enforcing lifetimes is not sufficient to get data race safety. A critical part of the safety of Rust’s borrow checker is that it also enforces a “mutable XOR shared” rule.

Conclusions

It is clear, I think, that there are substantial safety improvements possible for C++. In particular, entirely solving spatial safety appears to be within reach. Alas, I think it is equally clear that making C++ as safe as Swift or Go or Rust is not something we know how to do, nor does it appear likely that we’ll be able to find a simple solution.

Moreover, it’s important to recognize that we face trade-offs between the amount of work to adopt a proposal and how much safety it can provide. Put another way: this problem would be easy if we could break backwards compatibility, but our entire premise is “we have a lot of existing code”, simply breaking it defeats the point.

For engineering and security teams, it is useful to think of safety approaches from the perspective of a portfolio. If one has an existing, large, C++ program, it’s clear that your portfolio should include an investment in approaches that make C++ safer. I believe, however, that it’s equally clear that a large portion of your portfolio should be investments in migration to safer languages.