O'Reilly Book Excerpts: Practical Unix & Internet Security, 3rd Edition
Secure Programming Techniques
Editor's note: In this first installment in a multipart series of excerpts from Practical Unix & Internet Security, 3rd Edition, you'll find tips and general design principles to code by that will help you avoid security-related bugs. Over the next few weeks, we'll offer additional tips on topics ranging from writing network programs to writing SUID/SGID programs to using passwords to generating random numbers; all from Chapter 16 on "Secure Programming Techniques."
Tips on Avoiding Security-Related Bugs
Software engineers define errors as mistakes made by humans when designing and coding software. Faults are manifestations of errors in programs that may result in failures. Failures are deviations from program specifications. In common usage, faults are called bugs.
Why do we bother to explain these formal terms? For three reasons:
To remind you that although bugs (faults) may be present in the code, they aren't necessarily a problem until they trigger a failure. Testing is designed to trigger such a failure before the program becomes operational...and results in damage.
Bugs don't suddenly appear in code. They are there because some person made a mistake--from ignorance, from haste, from carelessness, or from some other reason. Ultimately, unintentional flaws that allow someone to compromise your system were caused by people who made errors.
Almost every piece of Unix software (as well as software for several other widely used operating systems) has been developed without comprehensive specifications. As a result, you cannot easily tell when a program has actually failed. Indeed, what appears to be a bug to users of the program might be a feature that was intentionally planned by the program's authors.
When you write a program that will run as superuser or in some other critical context, you must try to make the program as bug-free as possible because a bug in a program that runs as superuser can leave your entire computer system wide open.
Even when your program will run as an unprivileged user, it's important to design and implement it carefully, especially if it will be accessed by anonymous or untrusted others. Bugs become vulnerabilities through privilege escalation; an untrusted remote user exploits a bug in a network daemon to gain access as an ordinary local user, and then uses that access to exploit bugs that allow him to act as a privileged user, or even as the superuser.
Of course, no program can be guaranteed to be perfect. A library routine can be faulty, or a stray gamma ray may flip a bit in memory to cause your program to misbehave. Nevertheless, there are a variety of techniques that you can employ when writing programs that will tend to minimize the security implications of any bugs that may be present. You can also program defensively to try to counter any problems that you can't anticipate now.
Here are some general rules to code by.
Carefully design the program before you start. Be certain that you understand what you are trying to build. Carefully consider the environment in which it will run, the input and output behavior, files used, arguments recognized, signals caught, and other aspects of behavior. Try to list all of the errors that might occur, and how you will deal with them.
Remember: you will need to design your program. Either you will design the program before you start writing it, or you will design it while you are writing it. You might as well design as much of the program before you write the code. That way, if you decide to change your design in the process, there will be less code to change.
Document your program before you start writing the code. Write a theory-of-operation document for your code, describing what it will do and how it will do it. Outline the major modules. Most importantly, revise this document while you write your program. If you can't or won't do that, at least consider writing documentation that includes a complete manual page before you write any code. Doing so can serve as a valuable exercise to focus your thoughts on the code and its intended behavior.
Make the critical portion of your program as small and as simple as possible.
Resist adding new features simply because you can. Add features and options only when there is an identified need that cannot be met by combining programs (one of the strengths of Unix). The less code you write, the less likely you are to introduce bugs, and the more likely you are to understand how the code actually works.
Resist rewriting standard functions. Although bugs have been found in standard library functions and system calls, you are much more likely to introduce newer and more dangerous bugs in your versions than in the standard versions.
Be aware of race conditions. These can manifest themselves as a deadlock, or as failure of two calls to execute in close sequence.
Remember: more than one copy of your program may be running at the same time. Consider using file locking for any files that you modify. Provide a way to recover the locks in the event that the program crashes while a lock is held. Avoid deadlocks or "deadly embraces," which can occur when one program attempts to lock file A and then file B, while another program already holds a lock for file B and then attempts to lock file A.
Be aware that your program does not execute atomically. That is, the program can be interrupted between any two operations to let another program run for a while--including one that is trying to abuse yours. Thus, check your code carefully for any pair of operations that might fail if arbitrary code is executed between them.
In particular, when you are performing a series of operations on a file, such as changing its owner, stating the file, or changing its mode, first open the file and then use the fchown( ), fstat( ), or fchmod( ) system calls. Doing so will prevent the file from being replaced while your program is running (a possible race condition). Also avoid the use of the access( ) function to determine your ability to access a file: using the access( ) function followed by an open( ) is a race condition, and almost always a bug.
Write for clarity and correctness before optimizing the code. Trying to write clever shortcuts may be a stimulating challenge, but it is a place where errors often creep in. In practice, most optimizations have little visible effect unless the code is executed in time-critical places (e.g., interrupt handling) or is invoked tens of thousands of times per day. Meanwhile, the penalties for writing dense, difficult-to-understand code can include longer testing time, increased maintenance effort, and more lurking bugs. Spending two days of hacking to save 100 instruction cycles per day is also a very poor return on investment.
When Good Calls Fail
You may not believe that system calls can fail for a program that is running as root. For instance, you might not believe that a chdir( ) call could fail, as root has permission to change into any directory. However, if the directory in question is mounted via NFS, root usually has no special privileges. The directory might not exist, again causing the chdir( ) call to fail. If the target program is started in the wrong directory and you fail to check the return codes, the results will not be what you expected when you wrote the code.
Also consider the open( ) call. It can fail for root, too. For example, you can't open a file on a CD-ROM for writing because CD-ROM is a read-only media. Or consider someone creating several thousand zero-length files to use up all the inodes on the disk. Even root can't create a file if all the free inodes are gone.
The fork( ) system call may fail if the process table is full, exec( ) may fail if the swap space is exhausted, and sbrk( ) (the call that allocates memory for malloc( )) may fail if a process has already allocated the maximum amount of memory allowed by process limits. An attacker can easily arrange for these cases to occur. The difference between a safe and an unsafe program may be how that program deals with these situations.
If you don't like to type explicit checks for each call, then consider writing a set of macros to "wrap" the calls and do it for you. You will need one macro for calls that return
-1 on failure, and another for calls that return
0 on failure.
Here are some macros that you may find helpful:
#include <assert.h> #define Call0(s) assert((s) != 0) #define Call1(s) assert((s) >= 0)
Here is how to use them:
Call0(fd = open("foo", O_RDWR, 0666));
Note, however, that these simply cause the program to terminate without any cleanup. You may prefer to change the macros to call some common routine first to do cleanup and logging.
Pages: 1, 2