Reducing software security vulnerabilities through unit testing

July 1, 2005
Security vulnerabilities in software for military and aerospace systems unfortunately can be just as dangerous as the functional problems that industry has developed so many controls to prevent, and systems integrators can bet that software with either type of weakness may not produce the expected results.

By Dr. Adam Kolawa

Security vulnerabilities in software for military and aerospace systems unfortunately can be just as dangerous as the functional problems that industry has developed so many controls to prevent, and systems integrators can bet that software with either type of weakness may not produce the expected results.

A functional software error can occur in a noncritical section of code, and have little to no influence on the system’s safety or success. An error that affects navigation or fuel, however, could have disastrous consequences. When an attacker manages to exploit a software security vulnerability, the unexpected behavior is usually troublesome. After all, someone attacking a system is typically set on being as malicious as possible.

Many of the same techniques used to prevent functional problems can also reduce security vulnerabilities. Buffer overflows are the most common type of attack to exploit military and aerospace systems, but common industry best practices like unit testing and coverage analysis can help prevent these attacks.

Buffer overflows

Buffer overflows represent a standard problem that all too often affects applications written in C and C++. Buffer overflow attacks occur when a hacker manages to break past an application’s perimeter security, pass an input through all the program’s built-in defenses, and write to the buffer. These attacks only occur if the attacker can find and exploit a memory corruption bug in the application.

Assume, for example, that a C++ application has an array or memory chunk on the stack, and a memory issue enables a hacker not only to write beyond the array or memory chunk, but also to overwrite the return address of the function. By exploiting this weakness, the hacker either can convert the function returns to a hacker-designated function, or alter the function so it executes a hacker-designated operation.

In fact, some recently discovered buffer overflows in major operating systems enable hackers to perform a variety of malicious actions, such as causing the system to fail, installing programs, viewing, changing, and deleting data, modifying any part of the system, and creating accounts with full privileges.

Developers traditionally try to handle these exploits by limiting the size of the input or by verifying the input. However, it is easy to miss cases if the developer has no procedure for identifying all the inputs that need to be checked. To remove the opportunity for these attacks, developers need to prevent the memory corruption bugs that allow them to occur.

Changing a function’s return address

The most dangerous type of buffer overflow typically overflows an array that is allocated on the stack. If the hacker can write beyond an array, he can also overwrite the return address of a function; if this occurs, the function can return to a place that the developer did not intend. An attacker trying to manipulate the system could exploit this weakness by providing inputs that overwrite the function’s return address, have the function return to a place of his choice instead, and then perform operations of his choice.

Unit testing to expose potential vulnerabilities can prevent this type of buffer overflow. To do this, the developer takes every routine that allocates arrays on the stack and generates a wide range of test cases for each array. The goal is to determine whether any potential argument can overwrite memory on the stack. Software developers can generate these test cases with a unit testing tool or create test cases manually.

To determine whether memory corruption occurs as these test cases execute, developers run the unit tests under a runtime error-detection tool that provides memory access checking.

For example, consider the following code:

static const int stPacketArraySize = 1024; ... ErrorStatus processUserDataStream (int userStreamLength, UserStream& stream) { ErrorStatus status = OK; Packet packetArray[stPacketArraySize]; if (userStreamLength >= stPacketArraySize) // (*) { status = BUFFER_TOO_SMALL; } else { Packet* packet = processPacket(stream); for (int i = 0; packet; i++) { packetArray[i] = packet; packet = processPacket(); } } // further process the packetArray ... return status; }

Although this function has a legitimate check on the array size, the check (*) will not trigger if the array size does not correspond to the actual stream length or is negative (for example, -1). The result may be a buffer overflow. This situation is a cause for concern, especially if the user provides the stream length as a part of the stream.

Using unit testing, software developers could identify this critical security vulnerability. For example, testing the function with the typical set of boundary conditions for an integer variable (-MAX_INT, -1, 0, 1, MAX_INT) and adding another test condition when the supplied stream length parameter is shorter than the actual would expose this problem.

Once the developer locates the error, an apparent fix is not only to check the size of the array against the stream length up front, but also to check the array index in the loop where the processed packets are stored:

for (int i = 0; i < stPacketArraySize && packet; i++) { packetArray[i] = packet; packet = processPacket(); }

By leveraging unit testing for security verification purposes, developers could identify and remove this flaw as soon as the unit is completed. In this way, the developer would prevent the security vulnerability from ever reaching the integrated application. As study after study shows, the earlier a flaw is found, the easier, faster, and least expensive it is to correct.

Changing execution paths

Even if an attacker cannot overwrite memory on the stack, he can nevertheless perform serious damage by altering the expected execution path. Any overwritten memory could be dangerous if that memory might affect the direction of the program execution.

For instance, say the developer has code with an if/else branch statement and two arrays. The values in the second array influence the branch taken.

AccessCode RequestAccess (ResourceID resource, const char* username) { AccessCode permission; char nameBuf[USERNAME_FIELD_LENGTH]; bool accessFlags[NUMBER_OF_FLAGS]; // for a bogus username the access flags should be set to “deny” RetrieveAccessFlags(username, resource, accessFlags); // nameBuf buffer may overflow and spill into the accessFlags // array, overwriting the data there and possibly allowing access // to the requested resource strcpy(nameBuf, username); if (accessFlags[0] && accessFlags[2]) { permission = DENY_ACCESS; DenyAccessMessage(nameBuf); } else if (accessFlags[3]) { permission = REQUIRE_CONFIRMATION; RequireConfirmationMessage(nameBuf); } else permission = GRANT_ACCESS; }

Using username of the maximum field length will result in a memory overwrite by strcpy, as it will need to copy a terminating 0 into the nameBuf as well. Thus, this memory bug allows an attacker to alter the branch selection that the program execution will normally take. Assuming that the branch selection statement controls the assignment of security privileges, an attacker could exploit this vulnerability to upgrade his security privileges-without even inserting any special instructions into the exploit string passed as a username.

To determine whether overwriting memory is possible in this code, the developer creates unit tests, then runs the unit tests under a runtime error-detection tool that provides memory access checking. The goal is to exercise the first array as thoroughly as possible to determine an attacker can possibly write beyond that array.

For instance, the following simple test case would expose the security vulnerability in the previous piece of code:

const char* username = “username of at least the input field length”; RequestAccess(resourceID, username);

On catching the memory error, the first fix is to size nameBuf properly by increasing it by 1. Then the rules on checking on the length of the string being copied should apply. Preferably, the nameBuf buffer should be sized by using strlen(username):

unsigned int bufLength = strlen(username) + 1; char nameBuf[bufLength];

Additional considerations

As the developer applies unit testing to expose security vulnerabilities, memory corruption that permits security attacks can go unnoticed if unit tests do not thoroughly exercise the unit. It is critical to monitor the coverage of tests. When performing unit testing in an attempt to expose security vulnerabilities, a goal of 100 percent coverage of each function is desirable and achievable.

This degree of coverage is possible at the unit level because designing inputs that reach all parts of the function when testing in isolation (apart from the rest of the application) is so much easier than testing at the application level. Total coverage may not be possible in all situations, but it is the goal that developers should strive for.

Another important point to consider is memory; every instance of memory corruption should be considered a potential security vulnerability. In many cases, human subjectivity is actually more of a hindrance than a help in evaluating potential security vulnerabilities.

For instance, assume that values in an array control the switch statement direction, but the developer does not expect memory to lay out in such a way that this critical array follows the first array. If the developer had these expectations and learned that the first array might overwrite memory, he would probably assume that this overwrite would not affect the critical array and decide against correcting the memory corruption.

This might be a bad decision, however. If memory layout fails to meet expectations, the code could be vulnerable to security attacks. It is difficult to predict how compilers will lay out memory, and different compilers lay out memory in different ways. If the developer can’t be certain how memory lays out, he can’t be certain how writing beyond the bounds of an array will influence the application. Thanks to the variation among compilers, the exact same code that seems secure when compiled on one compiler could be vulnerable to attack when compiled on another compiler.

Two key lessons are to be learned here. One, because memory layout is compiler-dependent, the developer cannot accurately identify potential memory corruption (and the security vulnerabilities they bring) by simply examining or parsing the source code; it must be compiled, executed, and monitored as it executes. Two, the developer can never be too thorough or too paranoid when it comes to identifying and removing the possibility for memory corruption. With software designed for use in military and aerospace systems, a security attack can have severe and potentially deadly consequences. To reduce the risk of attackers being able to gain unauthorized control over mission-critical systems, it is crucial that you identify and correct every potential memory corruption in the systems’ software.

Dr. Adam Kolawa is the cofounder and chief executive officer of Parasoft Corp., a provider of automated-error-prevention (AEP) software, in Monrovia, Calif.

Voice your opinion!

To join the conversation, and become an exclusive member of Military Aerospace, create an account today!