Retrofitting Temporal Memory Safety on C++
v8.dev
v8.dev
Interestingly, the mention MTE. Could that be used in GC as well? To in place mark the generation of an object without having to copy it maybe?
> MTE (Memory Tagging Extension) is a new extension on the ARM v8.5A architecture that helps with detecting errors in software memory use.
Vale is taking a similar approach at the language level, by putting a generation at the top of every allocation [0] and it's proving pretty fast. It's also proving more accurate, since its generations are larger; MTE uses 4-8 bits, and generational references use 32-64.
It would be cool to see C++ itself embrace generational references in the standard library (perhaps via a std::gen_ptr?), it would give us some memory safety without needing to use the heap, which would be a big improvement over modern C++'s encouragement of std::shared_ptr.
I’ve implemented this in my current C++ game engine, and works wondrously well. You can even pack that generational index in the unused 16-bits of a 64-bit pointer, if you have further constraints (like a C library only allowing void* as userdata)!
C++ might be getting hazardous pointers thought, although most likely they will only make it to C++26.
I'd also be interested in knowing whether they compared against a single-threaded reference counter or a multi-threaded reference counter. The atomics in a thread-safe RC severely impact performance; I hope they didn't compare that against GR.
Yep, it was compared to single-threaded RC for fairness.
As an analogy, imagine comparing a safety knife, a kitchen knife, and (a safe version of?) an axe with each other, and touting how much lighter/safer/whatever the first one is compared to the others. Of course that's correct, and you can make the comparison, but there isn't (or at least shouldn't be?) a huge market of people out there using an axe in the kitchen who could've just used a knife instead, so it's a weird choice of a market to sell an otherwise-great product to.
(And thanks for clarifying the single-threaded aspect.)
And ownership - shared or otherwise - is a great way to eliminate the use-after-free bugs that would otherwise be caused by naively attempting to create a non-owning, borrowing reference to something else. It is in fact the primary use of refcounted pointers in many C++ codebases I've worked with - perhaps with a single std::shared_ptr and several std::weak_ptr s, meaning you're not really sharing ownership per se despite the refcounted nature - or perhaps with multiple std::shared_ptr s out of sheer laziness.
I've had my share of C++ code reviews dinged for perfectly valid code containing non-owning references, simply because such code could be fragile and prone to use-after-free heisenbugs in the face of refactoring, and my reviewers were right to do so. Things are safer in Rust - you can play fast and loose with storing &str s and rely on the borrow checker to catch your mistakes - but playing fast and loose with storing std::string_view s will lead to slipped release dates as you chase 11th hour heisenbugs. Store a std::string or std::shared_ptr<std::string> instead, even if it's "unnecessary".
> there isn't (or at least shouldn't be?) a huge market of people out there using an axe in the kitchen who could've just used a knife instead
Some of us are out camping in the wilderness that is C++ gamedev, and want to make kindling. Do I need to pack a whole axe, or would a knife I need for other reasons anyways make do and lighten my backpack?
And even if that's a small market, it's still entirely a reasonable market, and for advertizing to that audience - however niche - comparing said tools would be an entirely justifyable approach to selling the product.
I also think you underestimate the size of said market.
Uh, I've been wondering at the use case for rc::Weak, that seems like an interesting halfway point if you think borrowing should work but you can't get the compiler to accept it.
One actionable alternative is explicit owner/borrowing smart pointers, which assert if an owner is destroyed while borrowers are pending - which while not eliminating bugs, at least makes them much shallower to debug. I don't have hands on experience with that style - I inherit codebases and their existing coping patterns too often - but I hear good things from those who do. Another actionable alternative is resorting to a proper garbage collector. Or using a proper COM pointer type. Or RIIR. I resort to Rc/Arc in Rust much less than I resort to shared_ptr in C++ (although it still has its niches.)
Then again, I used Arc twice just yesterday:
https://github.com/MaulingMonkey/rust_http_chat_server/blob/...
`Common` (shared between request-handling worker threads) could be made static instead of Arc (although this would eliminate having separate isolated state for, say, different hosts?) Or I could use a scoped thread API that ensures the worker threads are cleaned up before the main thread does, and leave it on the stack instead. The standard library's scoped threads are nightly-only, but there are third party crates - or I could roll my own with `unsafe { ... }`.
`Arc<String>` messages (shared between chat history broadcasting threads until every recipient has sent out a reply) could be simply deep copied. Given the small typical message sizes, that might even be cheaper (although with the heap allocs I wouldn't bet on it.) Or a proper thread safe mpmc FIFO container could be used, although none is in the standard library.
A 200 LOC monolithic main.rs isn't exactly well designed, but it didn't need to be. That's a cop-out, but I suspect your pull requests won't be forthcoming either ;).
C++ is all about stack-bound resource management.
This is a common misunderstanding.
The key insight of this work is actually "morphing dangling pointers into memory leaks is WAY EASIER than automatic memory management (a.l.a. GC or refcount)". As long as we still do memory management by hand, we are able to avoid most of the complexity of GC (or defects of RC) and convert one bug class to another without much overhead.
Yes, this work can't eliminate bugs in your code (unlike, say, Rust, or other GC languages). But some bugs matter more than others, and, it works on existing code, it makes significant contribution to security without rewriting the world with Rust or JavaScript.
Forever is a long time.
> any help to improve their security is welcomed.
I'm less certain of this than you are, after decades of watching "We may not have really solved the problem, but this helps 99%" almost immediately followed by "Oh, our adversaries apparently aren't idiots and so our 99% mitigation did not actually reduce our burden significantly".
For domains where you don't have security considerations, this is fine. If you make single player video games a mitigation which means you have 4 crash bugs to fix in your game instead of 400 is excellent news. The bugs aren't trying to catch you, so mitigations are effective. But in security your attackers will put no effort into cutting through your expensive thick steel reinforced concrete wall, and focus their effort instead on picking the $4 padlock on your gate in that wall or vaulting over the wall, or just say they're a package delivery and drive in past your guards or a dozen other things you never thought of.
Your game development example, other than Embark Studios, very few care about.
Not to mention that Unreal, Unity, Godot are the bare minimum to beat.
AAA game companies that are security conscious are already using .NET, Java, Go on their game servers.
That's not how forever works.
As to the specifics, there's some sign that things like the GPU APIs are realising they did care about memory safety after all - when the problem caused by a memory safety bug is that some particle effects look a bit wonky every tenth frame in a game and you find zeroing the Z buffer fixes it even though it logically shouldn't that's never getting off the backlog, but when it causes your $10M compute cluster to spit out bogus results unless you reboot it between runs that's $$$ wasted.
I fully expect that for a while they will be enthusiastic about all the other things that attracted people who eventually end up with Rust - they'll purchase static checkers, they'll write code in a different style, they'll invest in more sophisticated debugging tools, but there's a reason Rust happened.
For the compilers, well, I'm old, twenty years ago I was a full-time C programmer, today nobody would do what we did in C back then, maybe they'd try in C++? But the thing which initially attracted people to C++ was that whole "Won't do better without hand-rolled machine code" and that has rotted pretty badly even in twenty years because of... drum roll, ABI compatibility.
The compiler vendors care about performance, long after apparently many C++ programmers are content to produce code with similar performance to Python for ten times the cost and a fraction of the safety, the compiler vendors are still under pressure to ship stuff that has decent performance but also correctness. Rust looks like a better fit for that every year.
Investing in mitigations is not free. The time and effort it takes to develop them is not available for constructing bug-free code. And once you have them, there will be even less reason to construct bug-free code. After all the magic hardware-assisted mitigations will save us now.
We've been through this before multiple times now. Someone will find a way to exploit around your mitigation, and you will then double down and come up with the next mitigation that will also be circumvented eventually.
Personally I believe that "retrofitting" security can never work well. Security must be constructed, not retrofitted. Why is this example code using raw allocations? Construct secure code by using containers, not manual allocations. This has been the recommendation for years now.
I feel like this is a bit of a false dichotomy. Writing bug-free code is one approach to getting bug-free software. Attempting to detect UAF at run time is another approach. And there are many other options beyond those two (static analysis, sanitizers, testing, ...).
In theory, we need only the ability to write bug-free code. If we could do this reliably, repeatedly, in a reasonable amount of time and at a large scale (Chromium, in this case), then there would be no value in investing in any of these other approaches.
In practice, fixing every last bug in a large codebase can take a truly immense amount of time. And that is for a codebase that is not changing at all (including bug fixes!), which certainly does not describe Chromium or most large projects for that matter.
Given all that, it's entirely reasonable to think about how one can find the most bugs with the least amount of effort. And that's basically what this is--another form of automated bug detection. I really don't think it's intended as a substitute for correct code.
I'm not advocating for "fixing all the bugs". On the contrary. I argue that retrofitting security does not work.
I argued that we need to find a way to construct secure software, not to make existing insecure software more secure retroactively. We have tried writing shitty software first and then try to make it more secure for decades now. The track record is not good. We should try something else. Like constructing secure software from the start.
Perhaps the longer-term solution is to use languages that are memory safe by design, such as Rust, to avoid that class of bugs. But there will still be a huge deal of legacy C++ code to contend with in the meantime, so we do still need exploit mitigations.
Clearly you can't expect programmers to just write secure code. If that worked, we would have seen evidence for it working by now.
By construct I mean: follow a clear method or path, that is a) feasible to follow and b) will lead to bug-free code. Maybe not bug free in all respects but I posit it should be possible to construct code without memory safety issues (just look at Perl or Rust).
My point is: It should not be up to the programmer to "simply not make mistakes". The method should be clear and have little ambiguity, and it should be obvious to see if code actually follows it or not.
I can't be more concrete or specific than that because I think we still need to find that method. We should be working on it, though.
Any software with complex object ownership written in a language without GC has to balance between user-after-free (freeing an object too early) and memory leaks (keeping an object alive longer than needed). Maybe constructing secure code here would mean simplifying object ownership, but that doesn't seem practical for a beast such a modern browser with millions of lines of code.
MTE with the heap scanning sounds like a real solution for the user-after-free problem, not merely a mitigation. The trade-off is that the memory is not immediately freed, but the measured increase of only 1% is promising.
- you have some AST object and want to lower it in the compiler into an intermediate representation
- you want to add some interesting parts of the AST to your state while lowering and refer to them later in the lowering process
- This seems fine: the AST is first created, then you lower to the intermediate representation, then you can destroy the AST, so while you’re doing the lowering, the AST objects should all be alive and therefore ok to store references to in the lowerer-state
- However there is some mostly unnoticed code that is roughly desugaring some syntax by constructing temporary AST nodes
- So your addition may have your state including references to these temporary nodes
- And those temporary nodes are freed shortly after being created rather than after the lowering is all done
- Giving a quite subtle use-after-free.
Something like C++ Core Guidelines, which include your remarks, is what should be looked into.
Naturally Chrome follows Google's C++ guidelines, so.
Certainly the efforts are welcome in any context in regards to C++ security improvements in mitigations, and C as well.
Now, the guidelines aren't the best up to date in what concerns writing secure C++, as per security standards.
See:
https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines...
and specifically, guidelines R.10, R.1 and R.3.
See also this answer:
https://stackoverflow.com/a/48046118/1593077
to the question of why C++ doesn't have a garbage collector.
To be more concrete, the example would be written today as:
{
auto foo = std::make_unique<Foo>();
}
foo->Process();
the last line won't compile - foo is gone already; and if you moved it inside the block scope, then foo would be valid.Now, the guidelines I mentioned can also be partially statically-checked, and some IDEs have begun to do so, so that the original snippet would give you at least 3 or 4 warnings. Interestingly, this will be true in a larger codebase as well, despite the claim in the article that
> UAFs are often hard to spot in larger codebases where ownership of objects is transferred between various components.
and that's because if you adhere to the coding guidelines _locally_, but throughout your application, including also I.11 about ownership transfer, then you can never free a pointer you don't own (unless you explicitly decide to do so), and you can even avoid dereferencing null pointers. Yes, I meant what I said: You can simply avoid ever dereferencing a null pointer.
All of this is supported by static analysis, so you absolutely don't need to be a virtuous and skilled coder to avoid such conundrums.
---
There's a caveat though: If some of the code is not under your control, but is rather some 3rd-party library or header, then what I said only applies if you properly "sanitize" at the boundaries of your well-checked.
The shared_ptr is an optional garbage collection based on reference counting. Slow, leaky, deterministic (that's an advantage and a disadvantage). C++ needs optional garbage collection if needs shared_ptr.
The way C++/CLI, C++/CX and Unreal C++ work isn't based on those APIs (naturally, given their domain), and are even used on the C++23 motivation paper as why those GC APIs should be removed.
The article specifically mentions smart pointers and their limitations.
The article describes what it claims to be limited mitigation efforts; but what I commented about is how they make the problem go away, if you actually use them. If you have some smart pointers but also allocate and deallocate memory manually in all sorts of places in your app, and transfer ownership via raw pointers, then - yeah, you might have UAF problems. In 2011 you could have blamed the lack of language features and library facilities to avoid this; but those days are over. UAF problems are now pitfalls you have to actively and knowingly walk into, they don't just happen.
There are other issues with safety which still happen, like out-of-bounds access, which don't have this kind of "effective inoculation"; so it's not as though C++ is now a completely safe language. But UAFs are now mostly a non-issue.
C++98-style actually-existing large production C++ codebases, you mean.
I suppose there's use in mechanisms for improved safety for outdated code, sure.
With a much smaller size, say, 128 bytes, almost every new allocation could be put into a new page at a new address, with one or more guard pages behind it.
Yes, it would increase the size of the page tables, and the lookup time, but maybe it's worth it.