Secure Coding in Embedded C: Five Bugs We Keep Finding
Unchecked lengths, integer overflows, format strings, weak randomness and debug leftovers: the five bug classes we find most in firmware, and how to avoid them.
Zyberum Security Team · Published · 7 min read
Firmware is mostly C, and C does exactly what you tell it to. When we review embedded code or reverse engineer a device, the same five bug classes keep showing up. None of them are new. All of them still work.
1. The length field you trusted
A message arrives with a length field, and the code copies that many bytes:
void handle_frame(const uint8_t *frame) {
uint8_t payload[64];
uint8_t len = frame[1];
memcpy(payload, &frame[2], len); /* len is attacker-controlled */
}
If len can be 200, the copy writes far past the buffer. On a microcontroller without memory protection that is often straight code execution.
Fix: check every length against the destination size and against the number of bytes you really received, before you use it.
if (len > sizeof(payload) || len > received - 2) {
return ERR_LENGTH;
}
2. Integer arithmetic that wraps
Length checks themselves go wrong when the arithmetic overflows:
uint16_t total = header_len + body_len; /* wraps at 65535 */
if (total > sizeof(buffer)) return ERR;
With header_len = 65530 and body_len = 10, total becomes 4 and the check passes. Signed and unsigned comparisons cause the same kind of surprise.
Fix: check each operand before adding, use wider types for intermediate results, and treat compiler warnings about sign conversion as errors.
3. Format strings and other shortcuts
Logging external data directly is still common in diagnostic code:
printf(device_name); /* wrong */
printf("%s", device_name); /* right */
The same family includes strcpy, sprintf and gets. They have no idea how big the destination is.
Fix: ban the unbounded functions in your coding guidelines and let the build fail when they appear.
4. Randomness that is not random
Session tokens, challenge values for diagnostic access, nonces for encryption: all need unpredictable numbers. We regularly find them generated with rand(), seeded with the uptime, or taken from an uninitialised hardware generator.
If an attacker can predict the challenge, the best algorithm behind it does not help.
Fix: use the hardware true random number generator of your MCU, check its status flags, and feed a proper deterministic generator from it if you need many values.
5. Debug leftovers
The most productive findings are often not bugs at all, but features nobody removed:
- a serial console with a root shell
- a hidden diagnostic command that skips authentication
- test keys and default passwords in the production image
- verbose error messages that leak memory addresses
Fix: make the production build configuration explicit, review what differs from the development build, and check the shipped image, not the source tree.
What actually helps
Rules and tools are necessary, but they do not replace people who think like attackers:
- Coding guidelines (MISRA C, SEI CERT C) enforced by static analysis in CI.
- Fuzzing for every parser and protocol handler. Most of the bugs above fall out within hours.
- Code review focused on the places where external data enters.
- Training with your own code. Developers remember the bug they exploited themselves.
This is what our secure development work and our secure coding training are built around.
FAQ
Frequently asked questions
Is MISRA C enough for secure code?
MISRA C is a good base because it removes many sources of undefined behaviour. It was written for safety, though, not for attackers. For security, add SEI CERT C, code review with an attacker mindset and fuzzing of every interface that accepts external data.
Should we switch to Rust?
For new components that parse untrusted input, memory-safe languages remove whole bug classes and are worth considering. Most firmware will contain C for many years, so secure C coding and review stay necessary.
How do we find these bugs in existing code?
Combine three things: static analysis for the obvious patterns, fuzzing for parsers and protocol handlers, and manual review of the code that handles external input, authentication and updates.