Image of 4 major memory safety issues while coding - causes and solutions

ADVERTISEMENT

Table of Contents

Introduction

When developing in a so-called “unsafe” language, like C, C++, or a host of others, memory management is a huge issue. When you hear of bugs like Heartbleed, you can see that even in large, enterprise-level products, bugs just creep in and affect the integrity of the software. Thus, memory safety is a huge concern for those who don’t develop in a language that is memory-safe by design.

We’ll address four major memory safety issues in this article. Example code is given in C (which is notorious for memory security issues) but the principles apply to all unsafe languages, including C++ and others.

Not freeing memory after allocation

Cause: Losing access to allocated memory. This is demonstrated in the following code:

    int main() {
        int *memory;

        // Allocate 200 ints.
        memory = malloc(200 * sizeof(int));

        // Allocate 100 more ints.
        // ERROR: This will compile, but will leave the previously
        // allocated memory hanging, with no way to access it.
        memory = malloc(100 * sizeof(int));

        // Free second block of 100 ints.
        // The first block is not freed.
        free(memory);

        return 0;
    }

Danger: If memory is continually allocated, with no way to free it, this can lead to a denial-of-service attack on the offending software as memory runs out.

Solution: Verify that every allocation is matched to a deallocation. This can be hard to do in non-OOP languages, but relatively easy in OOP languages like C++, especially using the RAII idiom. Ensuring that memory is freed in object destructors provides a much greater assurance of memory safety.

Freeing already freed memory (double-freeing)

Cause: This is often caused by lack of knowledge of who owns what. It is especially present when working with poor API design, passing raw pointers around. The following code demonstrates:

    void use_pointer(int *array) {
        … // Perform calculations on array
        // Now, we think we own the array, so we’ll free it
        free(array);
    }

    int main() {
        int *memory;

        // Allocate 200 ints.
        memory = calloc(200, sizeof(int));

        // Perform calculations on array
        use_pointer(memory);

        // Free the array
        // ERROR: This will compile, but will free already
        // freed memory. The pointed-to memory has already been
        // freed in use_pointer().
        free(memory);

        return 0;
    }

Danger: Double-freeing can cause segmentation faults, or other undefined behavior. This can lead to DoS attacks. This behavior can be masked by setting pointers to NULL after freeing, but that is not the correct solution! Clearing freed pointers can mask the real problem, and lead to dereferencing a NULL pointer.

Solution: Verify ownership of all pointers. If developing new code, create an API policy that gives a standard indication of ownership. In the example above, ownership could be determined by investigating the use_pointer() function implementation.

Invalid memory access, either reading or writing

Cause: Use of uninitialized, NULL, or already-freed pointers. Here’s an example:

    void use_pointer(int *array) {
        // Set the pointed-to value to 0
        *array = 0;
    }

    int main() {
        int *memory;

        // Perform operations on uninitialized pointer
        // ERROR: This will compile, but the pointer is not
        // initialized and dereferencing can cause a segmentation
        // fault.
        use_pointer(memory);

        return 0;
    }

Danger: Invalid memory access can cause segmentation faults, or other undefined behavior. It allows DoS attacks that can crash the software, usually resulting in the host servers being out of commission and needing to be restarted.

Solution: Verify validity of all pointers, and make it a practice to check for NULL pointers before dereferencing them.

Buffer-overflow, either reading or writing

Cause: Reading or writing past the end of a buffer. Here’s an example:

    int main() {
        int memory[10]; // 10 element array, with indexes 0…9
        int idx;

        // Perform operations on memory
        // ERROR: It looks like it could be correct, but the loop
        // actually executes with the range 0 … 10, not 0 … 9.
        // This overruns the buffer by one index.
        for (idx = 0; idx <= 10; ++idx)
            memory[idx] = idx;

        return 0;
    }

Danger: The same as random access of an individual pointer. Segmentation faults or undefined behavior can result from buffer overflows, and buffer read overflows can allow attackers unrestricted access to sensitive information. It seems this is a particularly large factor in many expensive real-life bugs, such as Heartbleed, mentioned earlier.

Solution: Verify bounds-access of all buffers. This can be tedious, because buffer overflows are difficult to track down, but adds bullet-proofing.

Valgrind

Valgrind is an immensely useful tool that allows inspection of the entire memory model of software. Although currently only supported on Linux, and partially on MacOS, it provides excellent memory safety diagnostics. It reports conditional jumps or moves that test uninitialized values, invalid reads and writes (including buffer overflows), and leaked memory.

Running Valgrind without options specified will emit conditional jump diagnostics and a program memory overview after the program exits:

> valgrind program_to_test program_arguments

Rerunning Valgrind with --leak-check=full specified will also emit diagnostics about completely lost memory blocks (blocks not accessible by at least one pointer):

> Valgrind --leak-check=full program_to_test program_arguments

Rerunning Valgrind with --leak-check=full and --show-leak-kinds=all specified will also emit diagnostics about all non-freed memory blocks:

> valgrind --leak-check=full --show-leak-kinds=all program_to_test program_arguments

Valgrind provides an entire stack trace for where allocations occurred as well. This greatly helps in tracing the exact location of a memory bug. Using Valgrind to trace memory errors can reduce the amount of time spent on bugs and increase the amount of time spent on completing functionality.

Conclusion

In an unsafe language, memory safety is a problem. This problem will always exist as long as unsafe languages exist, and can cause much damage if not resolved, but can be mitigated by proper defense procedures. Using Valgrind where possible can reduce the time spent on resolving memory issues.

Final Notes