Author: Chris lattner
Original: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_14.html
In the first part of our series, we discussed what undefined behavior is and how it allows C and C ++ compilers to generate applications with higher performance than "secure" languages. This post discusses how C is actually "insecure" and explains some amazing effects that can be caused by undefined behaviors. In part 2, we discuss what a friendly compiler can do to mitigate some of the surprises, even if they are not required.
I tend to call it"Why is undefined behavior a scary and terrible thing for C programmers?". J
Unexpected results caused by mutually influential Compiler Optimization
A modern compiler optimizer contains many optimizations that run in specific order, sometimes iterate, and change with the evolution of the compiler (that is, the emergence of new releases. Likewise, different compilers usually have essentially different optimizers. Because the optimization runs in different stages, unexpected results will occur because the previous optimization changes the code.
Let's take a look at a stupid example (simplifying a usable vulnerability found in the Linux kernel) to make it more specific:
void contains_null_check(int *P) {
int dead = *P;
if (P == 0)
return;
*P = 4;
}
In this example, the code "explicitly" checks the NULL pointer. If the compiler runs "deadcode elimination" just before "Repeat NULL pointer check elimination (redundant nullcheck elimination)", we will see the code evolved in these two steps:
void contains_null_check_after_DCE(int *P) {
//int dead = *P; // deleted by the optimizer.
if (P == 0)
return;
*P = 4;
}
Then:
void contains_null_check_after_DCE_and_RNCE(int *P) {
if (P == 0) // Null check not redundant, and is kept.
return;
*P = 4;
}
However, if the compiler is constructed differently, it can run rnce before DCE. This will give us the two steps:
void contains_null_check_after_RNCE(int *P) {
int dead = *P;
if (false) // P was dereferenced by this point, so it can't be null
return;
*P = 4;
}
Then run the dead code to eliminate:
void contains_null_check_after_RNCE_and_DCE(int *P) {
//int dead = *P;
//if (false)
// return;
*P = 4;
}
For many (reasonable !) Programmer, It is very unexpected to delete the NULL pointer check from this function (they may report it as a bug in the compiler ). However, according to the standard "contains_null_check_after_dce_and_rnce" and "contains_null_check_after_rnce_and_dce", they are completely effective optimization methods and are important for the performance of various applications.
Although this is a deliberate and simple example, this always happens with Inline: inline a function usually exposes several secondary optimization opportunities. This means that if the optimizer decides to inline a function, various local optimizations can be involved and they change the code behavior. According to the standard, this is completely legal and is important to performance in practice.
Undefined behaviors are incompatible with security
The C programming language is used to write a wide range of key security code, such as the kernel, setuid daemon, and Web browsers. This code is exposed to malicious input, and a bug may lead to various security issues that are readily available. A widely cited benefit of C is that it is relatively easy to understand what is happening while reading the code.
However, undefined behaviors deprive this property. After all, most programmers will think
"Contains_null_check" performs the NULL pointer check above. Although this case is not surprising (if a null pointer is passed in, the Code may crash in the storage, which is relatively easy to debug), there are a lot of C snippets that seem very reasonable but completely illegal. This issue restricts many projects (including Linux kernel, OpenSSL, glibc, etc.), and even causes Cert to send a vulnerability notification (Vulnerability Note) to GCC) (although I personally believe that all the widely used C-optimized compilers are affected by this issue, not just GCC ).
Let's look at an example. Consider the carefully written C code:
void process_something(int size) {
// Catch integer overflow.
if (size > size+1)
abort();
...
// Error checking from this code elided.
char *string = malloc(size+1);
read(fd, string, size);
string[size] = 0;
do_something(string);
free(string);
}
Check the Code to ensure that the malloc memory is sufficient to store the data read from the file (because you need to add an NUL Terminator). Exit if an integer overflow error occurs. However, this is actually the example we provided earlier (the previous post), which allows the compiler (legally) to optimize the check. This means that the compiler converts this
void process_something(int *data, int size) {
char *string = malloc(size+1);
read(fd, string, size);
string[size] = 0;
do_something(string);
free(string);
}
It is completely possible. When building on a 64-bit platform, when "size" is int_max (may be the size of a file on a hard disk), this may be a usable bug. Imagine how terrible this is: a code reviewer who reads the code may reasonably think that a correct overflow check has occurred. Someone who tests the code will not discover the problem until they specifically test the Error Path. The Defense Code seems to work until someone takes the lead to exploit this vulnerability. All in all, this is a surprising and Terrible bug type. Fortunately, in this case, the remedy is simple: you only need to use "size = int_max" or similar statements.
It turns out that integer overflow is a security problem for many reasons. Even if you are using fully defined integer Arithmetic (using-Fwrapv, Or use an unsigned integer), there may be completely different integer overflow bug categories. Fortunately, this category is visible in the code, and knowledgeable Security reviewers are generally aware of this issue.
The code for debugging optimization may be meaningless.
Some people (for example, underlying embedded programmers who tend to look at the generated machine code) Turn On Optimization for all development. In the Development era, Code usually has bugs, and these brothers eventually find it difficult to debug the code during runtime. The surprising number of optimizations is not proportional. For example, in the "zero_array" example of the first article, you would like to leave "I = 0" to allow the compiler to discard this loop completely (compile zero_array as "return ;"), because it is an uninitialized variable.
Another interesting case that has recently plagued some people occurs when they have a (global) function pointer. A simplified example looks like this:
static void (*FP)() = 0;
static void impl() {
printf("hello\n");
}
void set() {
FP = impl;
}
void call() {
FP();
}
Clang optimized it:
void set() {}
void call() {
printf("hello\n");
}
This is allowed because the call of a null pointer is undefined, which allows the assumption that set () must be called before call. In this case, the developer forgets to call "set" and fails to parse the reference with a null pointer. When others perform a debugging build, their code becomes faulty.
The main point is that it is a problem that can be solved: If you suspect something strange like this happens and try to build with-O0, then the compiler is unlikely to perform any optimization.
"Workable" code using undefined behaviors will evolve as the compiler becomes "problematic"
We have seen many cases where the "seemingly workable" application suddenly went wrong when building an updated llvm or moving from GCC to llvm. Although llvm itself occasionally has two bugs :-), this is most often because the hidden bugs in the application are now exposed by the compiler. This can happen in various ways. Two examples are as follows:
1. an uninitialized variable initialized by Lucky 0 "before", but now it shares some other registers that are not 0. This is usually exposed by changes in register allocation.
2. An array overflow on the stack begins to destroy a very important variable, rather than a dead object. This is exposed when the compiler reschedules packets on the stack or becomes more radical in sharing the stack space of variables that do not overlap during the life cycle.
The important and terrible thing to know is:AnyOptimization Based on undefined behaviors will trigger error codes at any time in the future. Inline, loop expansion, memory pick-up and other optimizations will get better and better. An important reason for their existence is to expose the primary optimizations as above.
For me, this is very unsatisfactory, in part because the compiler is inevitably eventually accused, and because it means that the huge C code is waiting for the outbreak of mines. This is even worse, because ......
There is no reliable way to determine whether a large piece of code contains undefined behavior
What makes mines much worse is the fact that there is no good way to determine that a large-scale application has no undefined behavior, so it will not be prone to problems in the future. There are many useful tools to help find outSomeBut there is nothing to ensure that your code will not go wrong in the future. Let's take a look at these options, along with their strengths and weaknesses:
1. valgrind
Memcheck tool is a wonderful way to find out various uninitialized variables and other memory bugs. Valgrind is limited because it is quite slow. It can only find bugs that still exist in the generated machine code (so it cannot find anything that the optimizer deletes ), I do not know whether the source language is C (so it cannot find out the out-of-bounds offset or the bug of signed integer overflow ).
2. Clang has an experimental-Fcatch-undefined-BehaviorMode, which insertsRun-time checks to find out violations such as out-of-boundary offsets and some simple array out-of-boundary errors. This is limited because it slows down the application runtime and is not helpful in terms of random pointer resolution reference (as valgrind can ), but it can identify other important bugs. Clang is fully supported.-FtrapvMark(Do not-FwrapvObfuscation), which causes the signed integer overflow to fall into the kernel at runtime (GCC also has this mark, but in my impression it is completely unreliable/problematic ). This is-Fcatch-undefined-BehaviorA quick demonstration:
$ cat t.c
int foo(int i) {
int x[2];
x[i] = 12;
return x[i];
}
int main() {
return foo(2);
}
$ clang t.c
$ ./a.out
$ clang t.c -fcatch-undefined-behavior
$ ./a.out
Illegal instruction
3. compiler warning messages are good for finding some types of these bugs, such as uninitialized variables and simple integer overflow. It has two major limitations: 1) It does not have dynamic information about the code during execution, 2) It must run very fast, because any analysis executed prolongs the Compilation Time.
4. clangstatic analyzer performs a much deeper analysis to try to identify bugs (including the use of undefined behaviors, such as null pointer decoding ). You can think of it as generating compiler warning messages that increase horsepower, because it is not limited by the Compilation Time Constraints of common warnings. The main disadvantage of this static analyzer is: 1) It does not have dynamic information about your program at runtime, and 2) for many developers, it is not integrated into common workflows (although it is incredible to integrate into xcode3.2 and later versions ).
5. The llvm "Klee" sub-project uses Symbolic analysis in a piece of code to "try every possible path" to find the bug in the code, and itGenerate a test case. It is a great small project, mainly restricted by the fact that it cannot actually run on large-scale applications.
6. Although I have never tried it, the C-semanticstool of chuckyellison and Grigore Rosu is a very interesting tool that clearly finds some types of bugs (such as sequential point violations ). It is still a prototype of research, but can be used to find bugs in (small, self-contained) programs. I suggest reading John Regehr's post on this.
The final result is that we have many tools to find some bugs, but there is no good way to prove that an application has no undefined behavior. Considering that there are a lot of bugs in real-world applications and C is widely used in key applications, this is terrible. In our final article, I focused on the various options of the C compiler when dealing with undefined behaviors, with particular attention to clang.