Let's talk about Memory Safety

Let's talk about Memory Safety

Memory safety is often tragically misunderstood. Claims like “it eliminates all memory errors” are at best misleading, and can be a recipe for disaster. In this article, I will explain what it really means, where it has limits, and how you can truly eliminate memory errors.

Now before you think I am against memory safety and the ecosystem of new languages and tools around it, let me start with a disclaimer:

  • Memory safety is great, it avoids many bad consequences!
  • I use and like memory-safe programming languages like SPARK and Rust!

Nevertheless, we have to get rid of common misunderstandings, otherwise we shoot ourselves in the foot, because we may not get what we expect.


In the next minutes, I want to discuss the following questions:

  1. Is memory safety enough to forget about memory errors? (Answer: No)
  2. Is memory safety a language feature? (Answer: Not only)
  3. Is memory safety a useful property? (Answer: Yes)
  4. Is memory safety a yes/no question? (Answer: Hmm…)

Also, I won’t be mincing words, but rather take a practical standpoint. In my daily job, I am working with safety-critical embedded software that needs to function properly and reliably under all circumstances. For example, flight control software, car stability control, pacemakers and the likes. Here we can only afford less effort if the safety remains intact. And when it comes to memory-safety...well, let’s see.

Memory Errors and Memory Safety

Why should we even bother about memory errors? Simple – because they are our toughest software nemesis for ages. Empirical data agrees from some of the biggest software projects show that memory-errors are the most frequent class of errors [1][2][3]. It has been acknowledged in academia [4][5]. It has been confirmed over and over by MITRE’s top list of CWEs (more on that later). Thus, being protected from these bugs would be an epic achievement.

Enter memory safety, a promise that goes in the right direction. However, the definition of memory-safety is unfortunately not easy. It is fuzzy at least, and most of the time discussed only in an example-driven way. Let’s look at a few definitions, starting with Wikipedia:

“Memory-safety is the state of being protected from various software bugs and security vulnerabilities when dealing with memory access […]”

Further down the Wiki page, we even get a list of memory errors that can occur – some examples:

Access errors:

  • Race conditions,
  • Unwanted interference/aliasing,
  • over-/under-read of buffers (like the CWE-125 I was quoting).

Initialization errors:

  • use of values/pointers which have not been initialized.

Resource management errors:

  • invalid/double/mismatched free,
  • heap/stack exhaustion.

This gives us a good understanding of what memory safety wants to achieve. Let’s be fair and forget about resource exhaustion, since this is beyond what a programming language can do. It is “physics”, in a way, and we will always have to deal with it.

If we want to have a more succinct and precise definition, we can take the one from this academic article, which neatly summarizes all of this as

“Memory safety claims to prevent or catch typical temporal and spatial [memory access] violations.”

Whereas “spatial” refers to the place in the address space (e.g., reading at the wrong address) and “temporal” refers to the time of access (e.g. before initialized, race conditions). This definition is also clearer about how these bugs are avoided. It says “prevent” or “catch”. This brings me to my first point…

There are different ways to reach Memory Safety

There are two fundamental approaches, as we have just learned. Let’s discuss them a bit more:

  1. Prevention: We could design a programming language which does not permit the developer to do anything that could as much as even lead to the problematic situation. For example, however hard she would try, the semantics of the language would not allow her to attempt to access an array out of bounds. It prevents it by design. Typically, this is achieved by constraining the language. Let’s say we don’t allow pointers, arrays and any form of memory management, then of course nothing bad can happen in memory. Or, less intrusive, we could design our language in such a way that every (memory-related) operation returns successful.
  2. Detection: We could design a programming language which catches all memory errors (typically during run-time) and implements a controlled reaction. This is the case for most “memory-safe” languages out there. Java throws exceptions. Rust panics. This means that the program reaches a state where it cannot continue and terminates (arguably) abnormally. This is still better than crashing. Some people use the metaphor of an emergency landing. The consequences are less dire, and it gives the developer an agency to do something about it – either during run-time (try/catch) or post-mortem (descriptive error message). Technically, this is achieved by inserting run-time checks, which has a minor performance penalty.

Note that there are more approaches to get (some) memory-safety, like the MMU/MPU or protection mechanisms in the OS. But these are not baked into the programming language, so I will not discuss them here.

An analogy – and why memory safety should be seen as a scale

To thoroughly understand the difference, let’s assume you want to avoid getting the flu. You are doing detection if you start feeling unwell and go to see a doctor. This will get you a sick certificate, and maybe some meds to lessen the symptoms. You will still suffer some consequences, but it is much better than ignoring it and risking more serious health problems. Most of the time, when people talk about memory-safety, they mean detection, with consequences like “controlled crash”. Avoiding something worse.

In contrast, you are doing prevention if you get vaccinated. Ideally, you will never get sick, and you have zero consequences. Some of you might be objecting right now, and you are right: Prevention also has a price, and it may not be a 100% protection, either. Thus, we need to talk about two things now:

  1. The cost of prevention.
  2. The remaining consequences.

Most people will agree that the second point matters more, especially for safety-critical software. We rather spend more effort to avoid errors (like learning a new programming language or using a testing tool) than deal with the consequences (many bugfixes, increased cost, maybe even life hazard).

Before we jump into this new topic, here is one insight that we can draw from the analogy: Neither detection nor prevention need to work 100% reliably. The doctor might misdiagnose you, the meds might not work, or your vaccination might not protect you from the latest variant of the flu, or it might only work for a limited time. In analogy, we should not see memory safety as a Boolean property (or as a “state”, as Wikipedia puts it).

If we allow memory-safety to have shades of grey (from “not safe at all” to “100% guaranteed”), we can see that many other programming languages, like Java and even C++, have features that are memory-safe. The caveat, just like in our analogy, is that they may only work under certain conditions, and maybe not on all access patterns. Keep this in mind, we will come back to this “idiomatic” argument later.

For now, let’s discuss the consequences of memory "unsafety" a bit more.

The consequences and vulnerabilities of memory errors

The black-box view: I am a hacker, and I am sending an input which shuts down your program. This may be acceptable for a (thread in a) web server, or for the Desktop software where you declare your taxes. But hopefully that software isn’t the engine controller in the airplane you are currently sitting in, or the pacemaker protecting the life of your loved one.

That’s a scenario that also applies to most memory-safe languages. If the language relies on detection rather than prevention, then the default effect is program termination. As an attacker, I really don’t care if it was a memory-safe panic or a crash coming from the MMU. The end result is the same – the program is dead, and I may have created some damage.

We could now argue that a program termination due to an attempted memory error is not an actual error but works as intended. Well, good luck explaining this to your boss, or the assessor that you need to certify your flight control software. I hope we can agree that in most cases, a controlled exception or panic is not done intentionally, but comes as an unwelcome surprise that will certainly make you uncomfortable (and hopefully not crash the plane you are sitting in, that would be ironic).

However, having detection is very very useful. If the attacker is really skilled, they could exploit your memory error to inject new code, and turn your engine controller into, let’s say a toaster. This cannot happen with languages like Rust, since they will detect the error when it happens, and prevent this sort of consequence. In the world of C programming, this would result in “Undefined Behavior”. That it is not only very annoying, but also capable of creating behavior that is completely against the language semantics. For example, I can use undefined behavior to overwrite a const variable:

Undefined Behavior in C breaks the language semantics


Memory-safe mechanisms will prevent such consequences, and thereby keep the program execution reliably within the semantics of the programming language. That is a big plus!

Last but not least, if the detection is very strong (like in Rust), then it helps to find many memory errors reliably during testing or operation. Another big plus!

In conclusion, we can say that memory safety reduces the likelihood and impact of memory errors, but it cannot not fully eliminate them or their consequences.


Memory Safety doesn't mean "not vulnerable to bad memory access"

Now let’s turn to the following example, which I posted earlier, saying “Rust is memory safe, yet is seems vulnerable to CWE-125 (Out-of-Bounds Read)”:

Attempted and detected memory-out-of-bounds read in a Rust program

In this simple program, I have created an array of length 5, and then I am trying to access the 7th element through a function call. Of course, this can’t work. We can see that the program compiles which means that a) the language does not prevent this and b) the compiler is not able to predict this panic and reject the program. It is debatable whether the compiler should detect this specific pattern. Having exhaustive compile-time checks could take a long time, but we all want our compilers to be fast. Therefore, it is a valid approach that the compiler leaves the checking of memory access for the run-time.

Now, can we say that this program is vulnerable to CWE-125? I would say yes, for two reasons. First, it does not eliminate all unwanted effects (as discussed earlier), and second, the CWE catalogue is about your code pattern. Its aim is to collect all patterns that may have adverse effects on confidentiality, integrity or availability (the Cybersecurity Triad). And in this case, we have this pattern in our code, and the effect impairs availability. If your program crashes, it is not available, at least for the time until it reboots.

(This is a good point to briefly mention mechanisms like exception handling and stack unwinding: Some languages have mechanisms that allow a graceful handling of non-recoverable errors. For example, exceptions can be caught, and unwinding cleans up the memory of the current thread. Both offer the developer a possibility to anticipate errors and react to it, for example by re-starting a fresh thread, or executing some error-handling code. However, this is only if the developer choses to use it, and it has some penalty to operational behavior, too.)

Back to my previous point: Even if you do not agree with my interpretation of “vulnerable”, I am sure you will agree that this coding pattern is to be avoided, and that it would be useful to make sure that this memory access pattern is not part of your code base.

Memory-Safety through Prevention

A better way to achieve memory safety, is to write the program in a way, that no control and data flow can lead to a situation that would require an exception or a panic in the first place. This is easier said than done, but, luckily, it is not impossible.

As before, we have several approaches to prevent memory errors:

  1. Language that prevents memory errors: As discussed before, we could use a language that does not permit even attempting any abnormal operation. This would likely be a very restrictive language. If somebody who is reading this has a suggestion, let me know. Typical languages can only prevent a subset of memory errors. For example, the Rust language can prevent dangling pointers due to its ownership paradigm, but it cannot prevent out-of-bounds accesses.
  2. Use safe(r) language subset: Some language constructs are safer than others and should be preferred or even enforced. This is sometimes called the “idiomatic way” to do things. Looking back at my Rust example, I should have used the Result enum, which requires developers to handle errors explicitly. In that case, no panic would have occurred, because I would have been forced to anticipate the out-of-bounds case. Coding guidelines, like the famous and useful MISRA C and CERT C guidelines, have been created exactly for this purpose, and have nudged millions (if not more) developers to a safer coding style. Not surprisingly, many safety and security regulations strongly recommend this approach, which is typically achieved with static code analysis tools.
  3. Model-based design: This may be seen as a crossover of the two former points. Typically, the application is designed and implemented in a graphical framework which provides safe “building blocks” that are put together somewhat like Lego, and which are then translated to a compilable source code with a code generator. The modeling “language” often restricts certain constructs and typically does not expose a memory model to the user, thereby preventing certain classes of errors. The code generator typically only uses safe(r) coding constructs, further reducing the likelihood of memory (and other) errors. An example for this approach is Simulink.
  4. Formal verification: Last but most importantly, we can employ formal methods to identify memory (and many other) errors thoroughly and reliably, before we run the program. It works somewhat like static code analysis, but the underlying “analysis engine” is more sophisticated. It is a special type of static analysis which, if implemented consistently and used correctly, can not only detect many error classes reliably, but moreover, even prove their absence. This sounds like rocket science (and, in fact, it was), but is nothing to fear. It will point at every construct which is not provably safe. An example for this approach is Polyspace.

As a side note, compilers are also clever and can identify a subset of errors. However, they need to be fast, and therefore they are only discovering shallow errors.

Conclusion

So, here we are. Memory-safe programming languages generally cannot guarantee that your program is free from memory-related errors. Memory-safety reduces the likelihood of bad consequences, as well as their impact. Depending on the programming language that you are choosing, they have different degrees of memory safety, and they can either choose to prevent or detect, or a combination thereof. Crucially, no (sufficiently flexible) language prevents all memory-errors, but merely some, and detects the remainder during run-time. This means that we still have to care about memory-related code patterns and their consequences.

However, by no means should we discredit memory safety through detection. Languages with a high degree of detection, like Rust, are a great achievement, because they can reduce the consequences by eliminating effects like memory corruption and remote code execution. Moreover, some memory-safe features give the developer the agency to catch and handle the error cases at run-time (exception handling, stack unwinding...). This is better than being oblivious to memory errors, and a step in the right direction.

To truly prevent memory-related errors and all duck their consequences and without betting on the improbable infallibility of the developer, we currently only have one practical choice: We must analyze our source code for programming errors. At the least, we should check that our code adheres to coding guidelines, to nudge developers towards safer, more idiomatic patterns. Even better, and this is where prevention can become complete, we can make use of static code analysis tools that can mathematically prove the absence of certain memory (and other) errors. Only then we can be sure that the program behaves as expected, under all possible conditions, and that there are no hidden surprises.

Matthew Rhodes

Polyspace: because green code is safer and more secure

1 年

I'd love to hear from the embedded systems community on pragmatic approaches to memory safety. I keep seeing the belief that using languages like Rust equate to being completely memory safe - a misleading oversimplification. The #NSA has echoed the mischaracterization of "memory safe language" in its?Cybersecurity Information Sheet on Software Memory Safety (https://media.defense.gov/2023/Apr/27/2003210083/-1/-1/0/CSI_SOFTWARE_MEMORY_SAFETY_V1.1.PDF), though, they also acknowledge that "even with a memory safe language, memory management is not entirely memory safe." Unfortunately, the mischaracterization has become common, and overemphasizes the importance of memory safe language. How do you see Rust adoption in the embedded community? What lessons can we take from the embedded community with C, C++ and attempts at embedded Java, to make Rust more successful in the embedded community? What lessons do we have from creating coding guidelines would be beneficial for the Rust community? What should hardware vendors consider as they add Rust APIs and compilers to their toolchains? How do we improve memory safety for existing embedded deployments still being supported, developed and patched in C or C++?

回复

要查看或添加评论,请登录

社区洞察

其他会员也浏览了