Illusions of Safety

Buffer overruns—just maybe C's biggest bugaboo. Luckily, TR 24731 addresses the problem so that you can write fast, robust, and correct applications.


September 08, 2006
URL:http://www.drdobbs.com/cpp/illusions-of-safety/192700250

Safety is a major league buzzword in the industry today. In many conversations it refers to the dangers, real or imagined, that arise from using data supplied by someone else, through the keyboard, a file, or an Internet connection. The advice you'll get on how to avoid these dangers usually recommends two approaches: Always validate data, and never use dangerous functions. To convince you that this is important, you'll hear slogans whose tone ranges from benign ("I always look both ways before I cross a street, even if it's a one-way street") through condescending ("We shouldn't leave sharp knives around where children can play with them") to outright insulting ("Anyone who uses gets is incompetent").

In the midst of all this we have the C technical report TR 24731 [1], which provides a set of replacements for the Standard C string-handling functions that are intended to "promote safer, more secure programming" [2]. In this column, I look at the nature of the problem that the functions in TR 24731 address, how the TR addresses the problem, and other ways it can be addressed in real-world code.

Buffer Overruns

Remember when you wrote code like this?

char buf[4];
strcpy(buf, "abcd");


Most of the time it would work. That is, the program would run to completion, doing what you expected it to do. But once in a while, a program with code like this would crash, and you'd have to fire up the debugger [3].

Now that you're more sophisticated, this kind of error takes on a more subtle form:

char buf[MAX_LINE};
gets(buf);

This code assumes that standard input has been redirected from a file consisting of lines that hold no more than MAX_LINE characters. If there's a longer line, there's no telling what will happen. For example, if standard input has not been redirected, users at the terminal can type anything at all, and will almost certainly at some point type something that's longer than any reasonable value of MAX_LINE.

In both of these cases, the extra characters get written to the memory locations beyond the end of buf. If there are other auto variables in the function that execute this code, they might get overwritten. This, of course, puts your program into an unanticipated state, which it probably can't handle. If you're lucky, it will crash immediately. If not, it continues to run with corrupted data and produces results that don't make sense.

But it's not just data that's vulnerable. A function's return address is stored on the stack along with its auto variables, so instead of overwriting your program's data, the buffer overrun can overwrite the return address. When the function returns, the processor jumps back to an address that doesn't make sense, and the program crashes.

Malicious users can deliberately feed bad data to a program with this sort of error to make it crash. When that's done through the Internet, it's one of the forms of a Denial of Service attack: If a web site's programs keep crashing, the web site can't be used for much.

Crashing someone else's program is fun for a while, but it soon gets boring. A much more exciting kind of exploit requires more sophistication. The attacker puts some assembly code into a buffer somewhere, then overwrites the function's return address with a new address that points to the assembly code. When the function returns, it jumps to the intruder's code, and he's in control [4].

So, obviously, you shouldn't write code that allows buffer overruns. The problem, of course, is how to prevent them.

Prevention by Brute Force

The brute-force approach is to always check the size when you're going to write into a buffer. For example, to copy text into a buffer on the stack, you can check the length of the incoming text:

void process(const char *txt)
{
char buf[MAX_LINE];
if (MAX_LINE <= strlen(txt))
   fatal("buffer overrun");
strcpy(buf, txt);
/* continue normal processing */


This copy operation will never overrun the buffer. But for each copy, it makes two passes through the input text: one to get its length, and one to copy it. That's redundant, and if you do it often enough your application will be too slow. In some cases, though, it may be just what's needed.

Another form of this brute-force approach is to replace strcpy with a function that takes a buffer size in addition to the source and destination pointers and stops copying at the end of the buffer. This can be much cheaper than the separate check that I just looked at. The code for strcpy might look like this:

char *strcpy(char *s1, const char *s2)
{
char *res = s1;
while (*s1++ = *s2++)
      ;

return res;
}

To add the internal length check, you need an additional argument that takes the size of the buffer. You then decrement it each time you copy a character and quit if its value reaches 0. While you're at it, change the return type to tell you whether an error occurred:

unsigned safe_strcpy(char *s1, unsigned s1max, const char *s2)
{
while (s1max-- && (*s1++ = *s2++))
	;
return s1max == UINT_MAX ? 1 : 0;
}


Now the calling code looks like this:

void process(const char *txt)
{
char buf[MAX_LINE];
if (safe_strcpy(buf, MAX_LINE, txt))
	fatal("buffer overrun");
/* continue normal processing */

That's shorter than what you had to write before, but writing all those tests and calls to fatal is tedious, and easily forgotten. So the copy function ought to do that for you. And since you're rewriting a standard library function, you ought to try to make it as broadly useful as the standard library function that it replaces. So instead of automatically aborting the application, you'll provide another library function that calls a user-specified handler function when a constraint violation is detected:

unsigned safe_strcpy(char *s1, unsigned s1max, const char *s2)
{
while (s1max-- && (*s1++ = *s2++))
	;
if (s1max == UINT_MAX)
	{
	constraint_error("buffer overrun");
	return 1;
	}
return 0;
}

Now you have the best of both worlds. If a constraint violation occurs and the user-specified constraint handler, called by the function constraint_error, doesn't terminate the program, the new version of safe_strcpy returns an error code. If you know that the constraint handler terminates the program, the calling code looks like this:

void process(const char *txt)
{
char buf[MAX_LINE];
safe_strcpy(buf, MAX_LINE, txt);
/* continue normal processing */


If you know that the constraint handler doesn't terminate the program, or if you don't know what it does, you have to check the value that safe_strcpy returns, so the calling code looks like the previous version.

That's the essence of the approach that TR 24731 takes to providing safer versions of standard C functions. It provides a new set of functions with the suffix "_s"—such as strcpy_s—that take additional arguments giving the sizes of buffers that can't be directly determined by the function. It also provides runtime constraints on those buffer sizes, and if any runtime constraint is violated, the function calls a user-installed handler. If the handler returns, the function returns an error code.

In many cases, the runtime check is trivial, so there is no noticeable overhead from using the TR's version of a C function. And, as we've seen, it's pretty easy to minimize the cost of doing nontrivial runtime checks. Still, overhead is overhead, and if you care about the size and speed of your application, these checks ought to annoy you. Besides, brute force is usually a symptom of design failure.

Prevention by Design

Prevention by design means, simply, designing your application so that low-level code doesn't need to check buffer sizes. If you check the data at the point where it enters your application, you can call low-level functions without worrying about buffer overruns. The code to copy text into a buffer simply copies text into a buffer:

void process(const char *txt)
{ /* strlen(txt) must be less than MAX_LINE */
char buf[MAX_LINE];
assert(strlen(txt) < MAX_LINE);
strcpy(buf, txt);
/* continue normal processing */

The design of this function pushes the responsibility for avoiding buffer overruns up into its caller. By putting an assert in the function, you provide a debugging check to help assure that you've properly validated the data higher up. Functions that call this function will, in turn, push the checking up into their callers [5], all the way to the function that creates the text string. By design, each intermediate function must be called only with a text string that fits in this buffer:

void middle(const char *txt)
{ /* strlen(txt) must be less than MAX_LINE */
process(txt);}

void creator(FILE *fp)
{ /* read and process lines of up
      to MAX_LINE-1 characters */
char buf[MAX_LINE];
while (fgets(buf, MAX_LINE, fp))
	middle(buf);
}


This function uses the same size buffer for its input as we used deeper down, so the check it has to make to ensure that its buffer doesn't overflow does double duty by also ensuring that the low-level function's buffer doesn't overflow. By pushing the checking up to the function that creates the text string, we've eliminated one validity check entirely. In a real application, this approach eliminates far more than one check, and the application becomes simpler, smaller, and faster.

The biggest drawback of eliminating buffer overruns by design is that it requires careful attention from the application's designers to ensure that all buffer sizes are properly specified, so that the high-level tests are sufficient. Validating data at the point where it's used instead of where it's created doesn't require as much thought. It's easier to find places where the check is missing, so it's easier to enforce the discipline of validation.

Slogans

I'm not a big fan of design by slogan. I think it's far more important for programmers to understand what they're doing and why, than for them to be able to repeat catchy sayings that give overly broad guidelines and discourage thinking. Let's look at the slogans I mentioned earlier to see how helpful they are.

As we saw, careful design eliminates the need for many validity checks. So while it's tempting to compare programming to crossing streets, the safety rules are obviously different. Don't fall for this one.

Comparing functions that don't check buffer sizes to sharp knives is a graphic way of saying that some programmers can't be trusted to make safety checks on their own. That may be true, but it doesn't mean that such functions should be banned. In fact, most people know that dull knives are more dangerous than sharp ones, because you have to push harder on them, and so they're much more likely to slip and cut you. In programming terms, if you always have to check buffer sizes, then you always have to know the size of the buffer when you write to it. If that value isn't available, what do you do? Of course, what you ought to do is redesign the call chain all the way from the creator of the buffer down to its user, and pass that information down, along with the buffer itself. If you're pressed for time, you might give in to the temptation to just use the value that you know is right. Of course, it's only right until the next time it's changed, and then it's wrong. Now the safety check isn't checking anything meaningful.

The claim that anyone using gets is incompetent is rooted in the idea that there is no way that gets can be used safely. That was one of the early arguments for providing a safer version. However, in a suite of applications, one application can write data to be read by another application, with the assurance that the designed-in assumptions about line lengths are valid. The counterargument is that even this isn't safe, because a malicious user could modify the file between the time it's written and the time it's read. That's not something I stay up nights worrying about: My computer sits on my desk, and no malicious user can sneak in during the night and modify my saved files. It is possible to use gets safely, and it is not a sign of incompetence to do so.

TR 24731 and Safety

The functions in TR 24731 are useful in some situations. They're not the ultimate answer to the problem of buffer overruns. As always, careful design, thorough testing, and code reviews are the steps needed to write fast, robust, and correct applications.

Notes

  1. [1] Officially, that's ISO/IEC TR 24731, "Information Technnology—Programming languages, their environments and system software interfaces—Specification for Safer, More Secure C Library Functions." It's still being worked on, so it's a draft and not a final document. You can get a copy at http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1135.pdf.
  2. [2] It also provides functions that, unlike their Standard C counterparts, can be called from multiple threads without worrying about one call overwriting static data in use by another thread. But that's a subject for a different column.
  3. [3] When Borland was working on its first compiler that supported Windows, we had a mysterious crash in one of the programming examples from Microsoft's Windows SDK. The program ran fine with Microsoft's compiler and library, and crashed with ours. Since our compiler and library were incomplete, we naturally focused on them. But it turned out that the problem was in the example, which had a buffer overrun.
  4. Borland's compiler arranged auto variables in a different order than Microsoft's did, turning this symptom-free overrun into a program crash.
  5. [4] Commodore 64 programmers should recognize this technique. It's how we did assembly language programming from BASIC—stuff opcodes into an array and jump. Tedious, but it worked.
  6. [5] But intermediate functions shouldn't have an assert to check the length of the string. There's nothing to be gained by testing there, since the function that does the copy will make the debugging check.

DDJ

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.