Developer Tools • iOS, macOS, tvOS, watchOS • 39:13
Undefined behavior in programming languages can introduce difficult to diagnose bugs and even lead to security vulnerabilities in your App. Learn more about undefined behavior, the tools available in Xcode 9 that address it, and why Swift is safer by design.
Speakers: Fred Riss, Ryan Govostes, Anna Zaks
Unlisted on Apple Developer site
Downloads from Apple
Transcript
This transcript has potential transcription errors. We are working on an improved version.
Good morning. Welcome to the understanding undefined behavior session. I know all of you have already spent countless hours debugging bugs that would just disappear when you switched from release to debug mode. You might even have lost users because you couldn't reproduce the bugs that happened only on their device. Those might be signs that you have undefined behavior in your code.
I'm Fred. I work on the client compiler team and today I'll start by explaining what undefined behavior is and why it exists. Then we'll dive into the compiler and see how its interactions with undefined behavior cause those subtle bugs. Those bugs might not only cost you a lot of debugging time. They might have security implications.
Ryan, from our security team, will tell you more about this and how you can use our tools to avoid those issues. Finally, my colleague Anna will come to the stage and tell you how SWF tackles this problem space. So, what is undefined behavior? Undefined behavior happens when your code has correct syntax but its behavior is not within the bounds of what the language allows.
The C and C++ standards have really similar definitions of undefined behavior in the standard. Let's have a look at what the C++ standard says. Undefined behavior: Behavior for which this international standard imposes no requirements. Well, that's helpful, right? To be fair, it comes with a note that gives more details but it's too long to put up on the screen so here's a summary.
So, what can the compiler do? If you knew about undefined behavior before coming to this session, you might have heard that if you have undefined behavior, the compiler is allowed to wipe your disc. I guarantee this is not going to happen. So, what can I do? The compiler can choose to diagnose the issues using warnings or errors.
This is by far our preferred solution and it is very actionable on your side and it prevents the issue and the source. The compiler can also choose to act in a documented manner. Basically, choosing to define what the standard left undefined. We do not do this a lot but there are some kinds of undefined behavior, which are way to common not to support.
And finally, the compiler can produce unpredictable results. This is the part we are going to focus on today. Note that unpredictable includes behaving as you intended, which is why some of those bugs will be really evasive. There is a lot of undefined behavior in the C family of languages. This is just a small sample. The C standard has a list in Annex J of all the known sources of undefined behavior. There are around 200 of them.
At this point, you might be wondering why is undefined behavior even a thing? Why is it defined in the standards? Were people just lazy? They didn't want to define everything? Of course, not. This is about tradeoffs. C has been designed to favor performance, affordability, and ease of implementation over safety. The C family of languages has inherited those tradeoffs.
Those were deliberate choices and they still make a lot of sense in many circumstances today. Our OSs run fast thanks to them. But as with every tradeoff, there is a price to pay and in this case it is you, the developers, who are paying it. This is why it is really important that you know that it exists and how to deal with it. As I said, there are way too many kinds of undefined behavior to go through all of them but let's just go through a few examples to make sure everybody's on the same page.
My first example is the use of an uninitialized value. In this function, we have a local variable, value. It is used in the return statement but it is initialized only if the condition to the if block is true. If you pass any positive number to this function, it will invoke undefined behavior as value will be used and initialized. In this simple case, the compiler will catch the issue and warn about it. The static analyzer would give you that information too and it would catch more complex cases of the same kind.
My second example is about misaligned pointers. In this function, we take a character pointer as an argument but inside the function, we use it as an integer pointer. The issue is that not every character pointer is a valid integer pointer. Integers have to be correctly aligned. Usually this means their address needs to be a multiple of four. This kind of code will often cause issues when porting code between different architectures, which have different alignment constraints at the hardware level. This year, in Xcode 9 we introduced the new Runtime tool, the Undefined Behavior Sanitizer, which would catch this issue.
My last example is about lifetimes. Variables are defined only within the scope -- our variables are valued only within the scope they are defined in. Here, we take the address of the default value variable. Default is defined within the if block and exists only there. But by using this pointer outside of the block, we invoke undefined behavior. Again, our tools will catch that.
Now that we have a better idea of the issues we are talking about, let's take a look at how they interact with the compiler and how they can produce those surprising bugs. First, let's look at what the compiler, what undefined behavior means to the compiler. It is not actively looking for it to transform it in weird ways. This is not happening. But the compiler makes the assumption that there is no undefined behavior in your code because otherwise the semantics of your code wouldn't be well defined. By making this assumption, the compiler gathers information to better optimize your code.
Here are a few examples. As it is undefined to overflow assigned integer, if X is assigned integer, the compiler can assume that X is lower than X + 1. This is a very simple but very powerful assumption to make when dealing, for example, with loop optimizations. As I said, pointers need to be aligned. And by making the assumption that they are, the compiler can use more powerful memory access instructions like vector instructions to make your code way faster.
And last example, it is undefined to dereference another pointer, so the compiler can assume that each pointer that is dereferenced cannot be now and use this information to further optimize your code. So, let's get a little bit more concrete and look at how a compiler works. At a very high level, the compiler takes your source code and transforms it into an intermediate representation. It then applies a pipeline of optimizations to generate optimizations to generate the binary. Each of those optimizations has one goal, generate a more efficient representation of its input while preserving the semantics.
But I introduced the session by talking about those bugs that would reproduce in release mode but not in debug mode. So, how is that behavior preserving? Let's look at a simple example. Here we have our compiler at the top. It has only one optimization - dead code elimination.
Dead code elimination looks for code that cannot be executed or that doesn't affect the result of your program in any way and it removes this code, thus making your apps smaller. Let's apply this compiler to a simple function. The function has only two statements, one variable assignment and a return statement.
We run dead code elimination. The variable is not used so let's get rid of it. And here, look at what we got. What happens if we pass another pointer to this function? The unoptimized version will crash but the optimized version will happily return 42. So, we have a difference in behavior. But by passing null to this function, you invoked undefined behavior as it is undefined to dereference another pointer.
I'll repeat that. It is undefined to dereference another pointer. It is not defined to crash. If dereferencing another pointer was defined to crash or if for some other reason the compiler couldn't make the assumption that dereferenced pointers were valid, it would be really hard to make any transformations on the memory accesses. Like, it couldn't reorder them. It couldn't merge them or it couldn't remove the useless ones, like we just saw.
Dealing with memory access is a huge part of the compiler job. So, here you have an example of how undefined behavior changes the behavior of your program between unoptimized and optimized code. But there's more I want to show you. Let's move to a slightly more complicated example. Here again we have our compiler that's up and our source at the bottom.
This example is actually derived from a real issue that happened in our big open source code days a few years ago. So, do not disregard it as completely theoretical. When you have a big function that is modified by multiple people over a long period of time, it's easy to end up with artifacts from the past, like this unused variable at the top of the function.
Now, let's compile this code. Our new compiler has one more optimization. Redundant null check elimination. This optimization is a specialized version of dead code elimination. It will look for pointers compared against now and tries to decide if statically at this point of the program it can prove that the pointer is either null or nonnull. And when it can do so, it just removes the code that can never be executed. In this case, P is dereferenced in the first line of the function. So, of course the pointer cannot be null. Let's remove the null check.
We then move on to our second optimization. We already know about dead code elimination. Unused is unused. It goes away. And here's the result of our compilation. Now, let's play the same game. What happens if we pass null to this function? The unoptimized version will crash. The optimized version will crash too. But note that they don't crash in the same spot. The unoptimized version crashes on the first line. The optimized version crashes on the last line. Those could be hundreds of thousands of lines away.
This is a very important lesson to learn about undefined behavior. When it causes an issue, whether it is another reference, an integer overflow, memory corruption due to an out of bound access or any other kind of undefined behavior, the symptom you see will often be very far away from the root cause of the issue. There is one more thing I want to show you. Let's restart the compilation with a slightly different compiler. As you see, we just see swapped the two optimizations. Let's compile the same code again.
Dead code elimination, unused is still unused. It goes away. Now we try to apply redundant null check elimination. There is nothing to - there is nothing to reason anymore about the value of the P pointer so the optimization just does nothing. And here's the result of our second compilation of the same code. Note that in this case if you pass a null pointer to the optimized version, it will not crash.
Now, imagine your app has the code on the left and the developer who added the null check to this function at some point added a few uses of the function with another argument. You might have never realized that it is an issue because your compiler is acting like compiler 2.
But there is no guarantee that in the future it will not act like compiler 1 and break your code. This is maybe the most important thing to remember about undefined behavior. The fact that you don't have an issue today doesn't mean that that change in the compiler will not cause it to break in the future. And your compiler might be changing behavior more than you think.
During a single day, each time you switch between debug and release mode or each time you change the optimization settings, you run a different instance of the compiler with a very different set of transformations applied to your code. Maybe more surprisingly, each time you switch from a real device to a simulator or vice versa, you are targeting a different architecture, which might react differently to undefined behavior. And, of course, each time you upgrade Xcode to a new major version, you get a brand new compiler. And we work hard all year long to make the compiler better, generate faster, smaller code. Many of those improvements could reveal undefined behavior in your code.
So, before moving along, just, let's just summarize what we learned about undefined behavior. Undefined behavior will not trigger bugs reliably. One of your configurations could be working while the other one breaks. When undefined behavior breaks, when it breaks your code, the symptom you are seeing might be thousands of lines away or maybe even hours of executions away from the real root cause of the issue. This could be really hard to debug if you are not prepared for it.
And lastly, the fact that you don't have any bugs today that you know of doesn't mean that you don't have any bugs due to undefined behavior. And if you have undefined behavior, it will break at some point in the future. When it breaks, it could cost you a lot of debugging time but it could also put your users' data at risk. Here's Ryan to tell you more about the security implications of undefined behavior.
[ Applause ]
Thanks, Fred. So, who here remembers the heartbleed vulnerability from a few years ago? Well, if you're like me, you probably had to go and change your password on like 100 different websites or maybe patch some of your own backend servers. Well, heartbleed was an out-of-bounds read in a widely used cryptographic library called open SSL. By sending just one packet to an affected server, an attacker would receive in reply a few kilobytes of the server process's heap memory, which turned into a pretty significant privacy and security exposure.
Now, that out-of-bounds read in heartbleed is an example of undefined behavior and it turns out that undefined behavior is at the core of many different types of security vulnerabilities. To name just a few, you could think of buffer overflows, uses of uninitialized variables, heat misuse bugs like use after free and double free. And also race conditions.
So, keep in mind that your users trust your app and potentially with their personal information such as their photos or their private messages. And so you should do everything you can to make sure that your app is as safe and secure as possible. And if you're a framework developer, remember that your client apps inherit all of your bugs, just like all those websites inherited the heartbleed vulnerability.
But the good news that there are tools that could help you. Now, too often we developers reach for our tools only after a bug has manifested some other way. Maybe it showed up in our users' crash logs. But by running tools early and often throughout development, we can catch these issues before they ever become a problem that affects our customers. So, I wanted to relate a story of how one of these tools, Address Sanitizer, saved macOS Yosemite.
So, about one month before the macOS Yosemite public release, many new crashes started appearing throughout the system. And we had a hunch that we had a heap corruption bug that was in one of the low-level system frameworks. Well, we were having a really hard time reproducing the issue. And without being able to reproduce it, we didn't have a smoking gun that was pointing to a specific function that was causing the heap corruption.
And so we turned to a tool that at the time was very new, called Address Sanitizer, and we thought it would help us catch this heap corruption bug. So, we instrumented some of the system frameworks and we loaded it up. And sure enough, Address Sanitizer did its job wonderfully and honed right in on this piece of code. So, to summarize it briefly, we had a CF string and we were constructing a path to a file inside the user's library cache's directory.
And then we needed to convert this C string, sorry, convert this CF string into a C string. And so, I mean, that's a pretty straightforward thing, right? We have to measure the length of the CF string, allocate buffer on the heap of that many characters and copy the bytes into it. And, oh yeah. We forgot one thing which is that C strings need to be null terminated. And so we have to add that too.
But we made a mistake, an off by one error. Because we didn't include that null byte when we were computing the size of the allocation that we needed. And so we actually overflowed our buffer. But most of the time this didn't have any impact on the user. And that's because the heap will round up the size of the allocation.
In this case, let's say we rounded it up to the next multiple of 16 bytes. And so when we write our null byte into that unused space at the end, that there's no consequence, right? But let's see what happens when one of the variables in that buffer changes, and that's the username.
Well, if the length of the username changes, the amount of unused space would also change. And it turned out that if the user's username was exactly 11 characters long, there wouldn't be any unused space and we would end up corrupting the adjacent object on the heap and causing some other part of the code to crash. And so this was the secret to why it was so hard to catch normally but Address Sanitizer did a great job of finding it right away.
Now, in this case, this off by one probably didn't have many security consequences. But many other similar bugs can result in exploitable vulnerabilities. And remember that security flaws often don't manifest until they've been exploited. So, running tools like Address Sanitizer early and throughout the development process can help you catch these before they ever reach your customer devices.
So, let's talk about the tools that you have at your disposal to catch undefined behavior. First we'll talk about the compiler, the static analyzer in Xcode, and the Sanitizers - Address Sanitizer, Thread Sanitizer and the Undefined Behavior Sanitizer. So, let's start with the compiler. So, the compiler alerts you to parts of your code that might be a little suspicious and it does this in the form of compiler warnings.
Believe it or not, they're not just there to annoy you. Now, every release of Xcode has better warnings and great features like fixits, so you can resolve them with just one click. To learn what's new in the compiler this year, check out the What's New in LLDM talk which is later this afternoon.
Now, you might be wondering, do I have the recommended set of warnings turned on for my project? Well, every time you upgrade Xcode, you'll be presented with the opportunity to modernize your project. And you can also do this at any time using the validate settings option and that'll help you get into a good state again.
And there's one more build setting that I think you should know about which is treat warnings as errors. And it does what it says on the tin. If your project already compiles with relatively few warnings, consider turning that on and enforcing the self-discipline to keep that compiler count low.
Now, let's talk about the static analyzer. The static analyzer can be thought of as a supercharged version of compiler warnings. It explores your code and finds bugs that only happen in very particular conditions, maybe conditions that aren't being hit when you normally test your app. So, what we recommend doing is analyzing during every build. There's a build setting for this and when you turn it on, Xcode will run a fast analysis pass every time you build your project.
And that makes sure that you can find bugs that you've just introduced as quickly as possible. But there's also a deeper mode that the analyzer can run in and you can use that at any time, and that's the mode that we recommend using under your continuous integration in order to make the most of the static analyzer's bug finding capabilities.
So, next I'm going to talk about the Sanitizers. But first to note, the Sanitizers are Runtime tools. Unlike the compiler or the static analyzer, to get the most out of the Sanitizers, you need to actually run an exerciser code that can only find bugs in code that's actually being executed. So, keep that in mind. But they offer a high degree of bug finding capabilities. So, first as I mentioned before, there's Address Sanitizer. Now, Address Sanitizer catches memory corruption bugs like buffer overflows and use-after-free bugs. And these ones are highly correlated with security vulnerabilities.
Then there's Thread Sanitizer, which catches data races. So, in your multithreaded app, if two threads tried to access the same piece of memory without proper synchronization, you have a data race. But a cool thing about Thread Sanitizer is that it catches even potential data races. So, even if in your execution of the app everything seems to be working great, Thread Sanitizer can tell you if two operations could potentially happen in a different order and cause your app to misbehave.
And new in Xcode 9 is the Undefined Behavior Sanitizer. It catches over 15 different types of undefined behavior and it extends either Address Sanitizer or Thread Sanitizer so you get even more bug-finding power. So, some of these types of undefined behavior that it catches include assigned integer overflows and tightness match bugs, which are also somewhat related to security vulnerabilities in some contexts. All of the sanitizers provide you with really rich and informative diagnostics that help you hone in on the root cause of a bug. You can find a lot of really helpful information in the Runtime Issue Navigator such as stack backtraces at important parts during the bug's execution.
So, we recommend turning on the sanitizers during development. You can do this in the scheme editor under the diagnostics tab. And this is where you can also turn it on for running your unit tests. And remember that the sanitizers need good code coverage in order to find bugs throughout your program, and that's something that your unit test can provide. You can learn more about the sanitizers and other Runtime tools that are new this year in Xcode at the Finding Bugs Using Xcode Runtime Tools talk.
So, those are five powerful tools that you have at your disposal to track down undefined behavior and address some of the security vulnerabilities that they may create. But before moving on, there's one more thing that I wanted to talk about, which is the language itself. So, you can think of your use of the language as your first line of defense in writing safe and secure code. And so with that in mind, you should prefer the safe constructs that your library and your language provide to you.
For instance, automatic reference counting in Objective C. Or smart pointers in C++ free you from the burden of having to do a lot of the manual memory management that results in bugs. And if your standard library provides you with container classes like NSarray from foundation, which check their bounds automatically, you don't have to worry so much about buffer overflows.
But it's just key to understand the tradeoffs that your language is making when it comes to safety and security. And when these are very important factors in your code, consider using SWF, a language that was designed from the ground up to eliminate entire categories of undefined behavior. And to tell you more about that, I'd like to invite up my colleague, Anna.
[ Applause ]
Thank you, Ryan. Now let's talk about undefined behavior and SWF. While you can write code fine-tuned for performance in SWF, this language makes different tradeoffs and was designed to be much safer by default. As you've seen from the previous examples, undefined behavior can introduce very subtle bugs that in turn could lead to security exploits. And this is simply summarized in this code from SWF.org. Undefined behavior is the enemy of safety.
Safety in SWF is important on many levels. Let's see how some of the major sources of undefined behavior that Ryan and Fred talked about are addressed in SWF using different techniques. The stricter type system gives us optional types, which statically prevent null point of dereferences. Use of an initialized variables is eliminated by guarantee of definite initialization. Buffer and integer overflows are checked at runtime and just like in Objective C, automatic reference counting is the SWF answer to use after freeze as it allows the developer not to focus on manual memory management issues. So, let's look into some of this in more detail.
Optional types is SWF answer to null point of dereferences. SWF has two kinds of types. Here we have a nonoptional cake and an optional cake, which you can think of as a box that may have a cake in it or might be empty. Now, as SWF tools, I can assure you a bug that may have a cake in it is definitely not the same thing as this delicious triple chocolate delight.
So, before using a value of optional type, you need to check for it. Suppose we have a function called receive package that is declared to return an optional cake type. Don't jump for joy unless you check and know for sure that it will not return nil. It's possible the cake is a lie.
Note that SWF's syntax provides affordances for easy checking of optional types, specifically to lessen the burden of using this types on the developer. Another important reminder is that you should not abuse the fourth unwrap operator, which will stop execution of the program if the value is nil. If the API has been declared to return an optional, it means that it might return nil so check for it.
Now, the fourth unwrap should only be used in rare cases when you, the developer, know for sure and can guarantee that the return value is never nil. However, that cannot be encoded in the type system. One example of that is when you're loading an image asset from the app bundle.
SWF also has a notion of implicitly unwrapped optional type. This type is similar to optional. However, here the compiler does not enforce that the values are used before, the values are checked before use, making no compile time guarantees. However, note that this type is still much safer than the C pointer type because using it is defined behavior. If the value's nil, the program is guaranteed to stop execution, making this model much more secure.
Now, this type should be used for properties that are guaranteed to have a value. However, they cannot be initialized in the constructor. Some of you might be using it for IB outlets. However, another source of implicitly unwrapped optionals are pointered types coming from Objective C and C APIs.
And this source subverts the type safety of SWF optionals. So, what can we do here? At the time SWF was released, we've also added nullability annotations to the Apple LLDM compiler. This annotation in C languages communicate the intent of the APIs but are also used to enhance their SWF interfaces. They allow us to map the unsafe C pointers onto the optional types.
Let's look at this example. Here we have ancestor shared with view method on NSview. As you can see here, it takes a non-null argument because it does not make sense to look for an ancestor between a nil and a value. On the other hand, its return value is nullable because it's possible the two views do not have the same ancestor.
Now, as you can see here, nullability directly maps onto the SWF interface. Non-null maps into the nonoptional value and nullable is mapped to the optional value. Good news is that most Apple APIs have been audited and annotated with nullability annotations. However, if you have APIs or just C or Objective C code that interoperates with SWF, you too can benefit from these annotations.
In addition, you can use tools such as the [inaudible] Static Analyzer, Warnings, and Undefined Behavior Sanitizer to find inconsistencies of how this these annotations are applied on your C code or Objective C code. Now, I really, really like this example because it highlights how the improvements to the LLDM compiler, the SWF compiler, and the frameworks work all together to benefit the whole ecosystem.
SWF definite initialization is a diagnostic feature based on deep code analysis. The SWF compiler guarantees that values are initialized before they are being used. And this checking is done along all branches through your program. Let's look at this example. Here, the compiler will check that my instance is initialized on both the if and the else branch of this code snippet before it allows you to go on and use this value. Now, let's talk about buffer and integer overflows, which are the biggest sources of security issues. Overflows only raise an integer and SWF terminate the execution of the program.
You might ask this question. Why is Runtime checking good? Well, while your program will still stop if you have a bug and your buffer overflows, this behavior is much better than the alternative. The behavior in SWF is much more consistent and debuggable than what you get in C and most importantly, it gives very high security guarantees. The buffer overflow is not exploitable. It will not lead to the attacker getting execution control of your program. Note that if you need to use integer-wrapping behavior, you can still do it using overflow operators, which are also safe and just perform modular arithmetic.
Now, a question a lot of you might be thinking about now is does undefined behavior exist in SWF? And the answer is yes, but this case is much rarer and often we know that we are opting into unsafe behavior. So, for example we needed C interoperability. So, we needed to traffic in these types. Unsafe pointer, unsafe mutable raw buffer pointer.
Note that you can tell that these types are unsafe by just looking at their names. So, if your applications use C or Objective C or otherwise they're using these types, I highly recommend using Address Sanitizer too. It will find memory corruptions that this unsafety could bring to your code.
Now, another example of unsafety in SWF are simultaneous accesses. And SWF is nailing the model down in this release with enforcement of exclusive memory access. Let's look at a very simple example to understand what this is all about. So, here we have a function that takes two in out arguments. In out means that the function may change the value of these arguments. Calling this function and passing it two values that point to the same memory could result in unpredictable behavior.
For those of you who are familiar with restrictive work in C, this is very similar. But in SWF, this behavior is on by default. Now, this one, this is a very simple and abstract example of this problem. However, I highly encourage you to watch Watch New in SWF talk for more examples of how this could be visible in your code and how it relates to your code. So, to address this problem, SWF could have chose to declare this to be undefined behavior. However, instead it decided to follow its mantra, that undefined behavior is the enemy of safety and implement solutions in the language that provide stronger guarantees.
Coming up with the right solution here is a balancing act. It's best to diagnose everything statically but often that's not possible without making the type system too difficult to use. Another solution are Runtime checks. However, the language Runtime has to be performant. And efficient. And the overhead of any extra checking cannot be too high. So, the solution that the SWF Project came up with consists of tightening the language to follow a slightly stricter rule and using a combination of static and dynamic checks to ensure that unintended sharing does not happen within the same thread.
Unfortunately, checking for exclusivity of accesses across threads is too expensive. And the tradeoff that was made here was to rely on tools, specifically Thread Sanitizer, to catch violations involving accesses from multiple threads. In general, using Thread Sanitizer is very beneficial for your SWF code because data races and access races are undefined behavior in SWF and they could lead to memory corruptions. For more information about this tool, watch the Finding Bugs Using Xcode Runtime Tools talk. Now, safety is a design choice in SWF. The language provides many solutions to avoid undefined behavior and prevent developers from introducing subtle and exploitable bugs.
Today we talked about undefined behavior and how different languages approach it. C languages use undefined behavior for portability and optimizations. However, we've seen that that could lead to very subtle and hard to debug bugs and even introduce security exploits. SWF chose to follow a different path and was designed to be safer by default.
Finally, regardless of your language of choice, use all the tools at your disposal as part of your app release and testing process. That will make your apps more secure and robust. Here are some of the related sessions that we've mentioned today. Thank you very much and enjoy the rest of your day.
[ Applause ]