Memory corruption is a common issue that can plague embedded systems developers. These problems arise when memory is accessed incorrectly or outside of its allocated bounds, leading to unexpected behavior and crashes. Preventing memory corruption requires strategies like proper memory management, input validation, and testing.
Use a Memory Protection Unit
A memory protection unit (MPU) is hardware that can prevent illegal memory accesses. The MPU divides memory into regions and sets access permissions for each region. Attempts to access restricted areas will trigger an exception rather than corrupt memory. For example, stack and heap regions can be set as read/write while code memory is read-only.
MPUs are available on many ARM Cortex-M and Cortex-R processor cores. The MPU registers must be properly configured at startup for each region’s base address, size, and access permissions. Setting this up takes some work but prevents a whole class of memory corruption issues.
Validate Inputs
One primary source of memory corruption is invalid data from external inputs. This includes inputs from sources like data buses, communication interfaces, user interaction, removable media, and sensors. All inputs should be validated before usage:
- Check for buffer overflows by validating length and null terminators.
- Validate the range of numeric values.
- Check for invalid encodings or characters.
- Verify checksums, magic values, protocol framing, and parity bits.
- Handle errors gracefully and safely.
Runtime memory checking tools like Valgrind can help identify buffer overflows during testing. But input validation should be used even with these tools to stop problems at the source.
Use Memory Safe Languages
Memory safe languages automatically prevent buffer overflows, invalid accesses, and other memory issues. For C and C++, functions like strncpy and snprintf are safer alternatives to unsafe functions like strcpy.
Managed languages like Java and C# bound array access and garbage collection prevents use-after-free bugs. Rust’s borrow checker ensures memory safety at compile time. Programming in these languages eliminates entire classes of memory corruption vulnerabilities.
Allocate Memory Correctly
Dynamic memory allocation is prone to issues if not used properly:
- Use calloc() to zero initialize allocated memory.
- Check malloc() return values to catch out-of-memory errors.
- Avoid memory leaks by freeing allocations when done.
- Use language features like smart pointers in C++.
- Beware of integer overflows when calculating buffer sizes.
Memory pools are safer and faster than repeated malloc/free calls for temporary buffers. Pool memory in fixed size blocks and enforce limits.
Avoid Dangling Pointers
Dangling pointers reference freed memory and can cause crashes or leaks if dereferenced. These arise from:
- Freeing memory while pointers still reference it.
- Returning pointers to stack-allocated local variables.
- Attempting to access deleted objects in languages like C++.
Use tools like AddressSanitizer to check code for these at runtime. Avoid dangling pointers by carefully managing object lifetimes and pointer scopes.
Enforce Memory Access Rules
Set memory access rules in your code:
- Initialize variables before usage.
- Do not read uninitialized memory.
- Do not write to read-only memory like string literals.
- Access arrays only within allocated bounds.
- Do not use freed memory.
The compiler can warn about many invalid memory accesses if the right flags are enabled (e.g. -Werror in GCC). Fix any warnings to avoid bugs.
Use Memory Safe Functions
Replace unsafe C standard library functions with safer versions:
| Unsafe | Safer Alternative |
|-|-|
| strcpy | strcpy_s, strncpy |
| gets | fgets |
| printf | snprintf |
| scanf | fscanf |
These check lengths, restrict reads, enforce null termination, and prevent buffer overflows.
Isolate Components
Isolate software components through techniques like virtual memory, privilege rings, and memory domains. This prevents bugs in one component from corrupting other unrelated components.
Virtual memory uses an MMU to provide separate address spaces for processes. Cortex-A and Cortex-R cores have MMUs.
ARM TrustZone divides hardware into secure and non-secure worlds. Security-critical software runs in the secure world, isolated from the rest.
Handle Errors Gracefully
Bugs will inevitably occur, so make software robust against memory corruptions:
- Enable stack protection, if available, to catch stack overflows.
- Handle exceptions and abort gracefully rather than try to continue execution.
- Safely restart components that may have been compromised.
- Halt unsafe operations before damage occurs.
Adopt a Security Mindset
Think adversarially – assume all external data and inputs are malicious until validated. Avoid trusting anything outside your code’s control. Identify high-risk areas like parsers, protocol handlers, external inputs, allocated memory, integer operations, array accesses, and pointer usage. Apply secure design principles like fail-safe defaults, least privilege, and reducing the attack surface.
Perform Extensive Testing
Thorough testing is required to weed out memory corruption issues:
- Run static analysis tools to detect problems at compile time.
- Use sanitizers like AddressSanitizer during testing to find runtime issues.
- Perform fuzz testing by sending invalid random inputs at interfaces.
- Monitor memory usage during long-running tests for leaks.
- Test on real hardware to detect issues not seen in simulators.
- Stress test border conditions like out of memory, max array sizes, etc.
Fix any identified issues before release. Ongoing testing will be required as new features are added.
Use Memory Protection Hardware
Dedicated memory protection units offer runtime monitoring of memory accesses:
- ARM CoreSight MPU monitors up to 16 memory regions.
- TrustZone Address Space Controller isolates secure memory.
- External memory protection units can detect invalid accesses.
This hardware can catch errors missed during testing and prevent exploitation. But software errors must still be fixed at the source for a robust solution.
Handle Memory Exhaustion
Out of memory conditions can lead to corruption as allocation fails but code still assumes success. Avoid this by:
- Checking for failed memory allocations.
- Only allocating what is needed, minimizing waste.
- Freeing memory promptly when no longer needed.
- Prioritizing critical tasks when memory runs low.
Monitor free memory levels during runtime and take appropriate action if it becomes too low.
Conclusion
Memory corruption undermines reliability and security if left unchecked. But with the right tools, design practices, and testing, these issues can be eliminated in embedded systems even on bare metal. Combining memory protection hardware with robust software will result in an embedded device resilient against memory related errors and vulnerabilities.