Buffer Overflow via Crafted SCSI Commands: How a Missing Bounds Check Almost Bricked Your ESP32
Introduction
Imagine plugging your custom ESP32-based device into a computer — maybe it's a niche writing gadget, a portable logger, or a DIY data recorder — and the USB host quietly sends a specially crafted SCSI command that overwrites critical memory on your microcontroller. No warning. No crash dialog. Just silent corruption, a potential device brick, or worse: arbitrary code execution on hardware you thought you controlled.
This is not a hypothetical. A high-severity out-of-bounds memory access vulnerability was recently discovered and patched in the FatFSUSB library, a component that enables ESP32 devices to appear as USB Mass Storage devices to a connected host computer. The root cause? A missing bounds check before a memcpy — one of the oldest and most well-documented classes of bugs in systems programming.
If you're writing firmware, embedded C/C++, or any code that processes externally-supplied length and offset values, this post is for you.
The Vulnerability Explained
What Is FatFSUSB?
FatFSUSB is a library that bridges the FatFS filesystem (common in embedded systems) with the USB Mass Storage Class (MSC) protocol. When your ESP32 device is connected via USB, a host computer can issue SCSI commands — standard block-level read/write operations — to access the device's filesystem as if it were a USB thumb drive.
The library maintains an internal sector buffer (_sectBuff) of a fixed size (typically 512 bytes, matching a standard disk sector). When the host requests a read or write, the library copies data to or from this buffer using memcpy.
The Vulnerable Code
At lines 130, 243, and 253 of FatFSUSB.cpp, the code performed operations similar to:
// BEFORE (vulnerable)
// 'offset' and 'bufsize' come directly from SCSI command parameters
memcpy(buffer, _sectBuff + offset, bufsize);
// ... and for writes:
memcpy(_sectBuff + offset, buffer, bufsize);
The critical problem: offset and bufsize are derived directly from SCSI command fields sent by the USB host. There is no validation that offset + bufsize stays within the bounds of _sectBuff.
How Could It Be Exploited?
The USB Mass Storage Class protocol is designed to be spoken by a trusted host operating system. But "trusted" is a dangerous assumption. Consider these realistic attack scenarios:
Scenario 1: Malicious Host Software
A piece of malware running on the connected computer gains control of the USB stack and issues crafted SCSI WRITE commands with a valid-looking sector address but an oversized bufsize. The memcpy happily writes attacker-controlled data past the end of _sectBuff, corrupting adjacent heap or stack memory.
Scenario 2: Rogue USB Host Device
A Raspberry Pi or other microcontroller masquerading as a USB host (using USB OTG) sends crafted SCSI commands directly. This is a physical-access attack vector but highly realistic for devices that might be left unattended, like a shared writing device in a library or office.
Scenario 3: Integer Overflow Wraparound
An attacker sends offset = 0xFFFFFFFF and bufsize = 2. On a 32-bit system, the unchecked addition offset + bufsize wraps around to 1, which looks safe — but the pointer arithmetic _sectBuff + 0xFFFFFFFF points to a wildly out-of-bounds memory location.
What's the Real-World Impact?
On a microcontroller like the ESP32, memory is a precious and tightly-packed resource. The heap, stack, and various peripheral control structures often live in close proximity. An out-of-bounds write into this space can:
- Crash the device (best case — a watchdog reset)
- Corrupt filesystem metadata, causing data loss
- Overwrite function pointers or return addresses, enabling code execution
- Disable security features by overwriting configuration flags in memory
This vulnerability is classified under CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer and is related to CWE-787 (Out-of-bounds Write) and CWE-125 (Out-of-bounds Read).
The Fix
What Changed
The fix is conceptually simple but critically important: validate that offset + bufsize does not exceed the size of _sectBuff before calling memcpy.
// AFTER (fixed)
// Validate bounds before any memcpy operation
if (offset < 0 || bufsize < 0 || (offset + bufsize) > SECTOR_SIZE) {
// Reject the malformed SCSI command gracefully
return false; // or signal an error to the USB stack
}
memcpy(buffer, _sectBuff + offset, bufsize);
And for write operations:
// AFTER (fixed) - write path
if (offset < 0 || bufsize < 0 || (offset + bufsize) > SECTOR_SIZE) {
return false;
}
memcpy(_sectBuff + offset, buffer, bufsize);
Why This Works
The fix enforces a security invariant: the memory region accessed by memcpy must always fall entirely within the allocated buffer. Let's break down each check:
| Check | What It Prevents |
|---|---|
offset < 0 |
Negative offset used as a large unsigned pointer offset |
bufsize < 0 |
Negative size interpreted as a huge unsigned value |
offset + bufsize > SECTOR_SIZE |
Combined overflow beyond buffer end |
Note that on systems where these are unsigned integers, the negative checks may be replaced with overflow-safe arithmetic (e.g., checking bufsize > SECTOR_SIZE - offset to avoid the addition itself overflowing).
The Integer Overflow Subtlety
A naive check like if (offset + bufsize <= SECTOR_SIZE) can itself be vulnerable if offset and bufsize are unsigned 32-bit integers and their sum wraps around. The safer pattern:
// Safe against unsigned integer overflow
if (bufsize > SECTOR_SIZE || offset > SECTOR_SIZE - bufsize) {
return false; // reject
}
This avoids ever computing the potentially-overflowing sum directly.
The Regression Test Suite
The fix was accompanied by a comprehensive Python test suite that validates the security invariant. Here's what makes it exemplary:
def validate_scsi_bounds(offset, bufsize, sector_size=SECTOR_SIZE):
"""
Security invariant: offset + bufsize must never exceed sector_size.
"""
if offset < 0 or bufsize < 0:
return False
return (offset + bufsize) <= sector_size
The tests cover:
- Clearly out-of-bounds cases: offset=0, bufsize=513
- Subtle off-by-one attacks: offset=511, bufsize=2
- Integer overflow/wraparound: offset=2^32-10, bufsize=20
- Negative values: offset=-1, bufsize=512
- Valid cases that must still work: offset=0, bufsize=512 (exact fit is fine)
This last category is crucial — security fixes that break legitimate functionality are just as harmful as the vulnerability itself.
Prevention & Best Practices
1. Never Trust Externally-Supplied Lengths or Offsets
Any value that crosses a trust boundary — from a network packet, USB command, file header, or user input — must be validated before use in memory operations. This is non-negotiable.
// ❌ Dangerous: trusting external data directly
memcpy(dst, src + external_offset, external_size);
// ✅ Safe: validate first
if (external_size > MAX_SIZE || external_offset > MAX_SIZE - external_size) {
return ERROR_INVALID_PARAMETER;
}
memcpy(dst, src + external_offset, external_size);
2. Use Safe Memory Functions Where Available
Modern C libraries offer safer alternatives:
- memcpy_s() (C11 Annex K) — takes a destination buffer size
- memmove_s() for overlapping regions
- In C++, prefer std::copy with iterators that encode bounds
// C11 bounds-checking version
errno_t err = memcpy_s(dst, dst_size, src + offset, bufsize);
if (err != 0) { /* handle error */ }
3. Define and Enforce Security Invariants in Code
Write your invariants as assertions or early-return guards at the top of functions:
bool processSCSIRead(uint32_t offset, uint32_t bufsize) {
// Security invariant: access must be within sector buffer
assert(bufsize <= SECTOR_SIZE);
assert(offset <= SECTOR_SIZE - bufsize);
// ... rest of function
}
4. Enable Compiler and Runtime Protections
For embedded targets where possible:
- Stack canaries (-fstack-protector-strong)
- AddressSanitizer during development/testing (-fsanitize=address)
- Undefined Behavior Sanitizer (-fsanitize=undefined) catches integer overflows
- Static analysis: tools like cppcheck, clang-tidy, or Coverity can flag unchecked buffer operations
# Build with sanitizers for testing
gcc -fsanitize=address,undefined -g -o firmware_test test.c
5. Fuzz Your Protocol Parsers
Any code that processes structured binary input (SCSI commands, network packets, file formats) should be fuzz-tested. Tools like AFL++ or libFuzzer are excellent at generating the exact kind of edge-case inputs that trigger bounds violations:
# Example: fuzz the SCSI command handler
afl-fuzz -i scsi_seeds/ -o findings/ -- ./scsi_handler_harness @@
6. Apply the Principle of Least Privilege to USB
If your device doesn't need to support arbitrary SCSI commands from a host, restrict which commands are accepted. Validate the command opcode, LBA range, and transfer length against known-good values before processing.
Relevant Standards and References
- CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer
- CWE-787: Out-of-bounds Write
- CWE-125: Out-of-bounds Read
- OWASP: Buffer Overflow
- CERT C Coding Standard: ARR38-C: Guarantee that library functions do not form invalid pointers
- USB Mass Storage Class Specification: Defines the SCSI command set used over USB
Conclusion
A single missing bounds check — a few lines of validation code — was all that stood between a well-intentioned embedded library and a remotely-exploitable memory corruption vulnerability. This is the nature of systems programming: the rules are unforgiving, and the consequences of trusting external input without verification can be severe.
The key lessons from this vulnerability:
- Every external input is potentially hostile. SCSI commands from a USB host are no different from HTTP requests from the internet — validate them.
- Buffer arithmetic is dangerous. Always check that
offset + sizecannot exceed your buffer, and be aware of integer overflow in the check itself. - Simple fixes can close serious holes. The patch here is a handful of lines, but it eliminates an entire class of memory corruption attacks.
- Write tests that encode your security invariants. The regression tests included with this fix will catch any future regression that accidentally removes the bounds check.
Embedded and firmware developers often operate under the assumption that their devices are in a "trusted" environment. As USB-connected devices become more prevalent — and as attacks on embedded systems grow more sophisticated — that assumption is increasingly dangerous. Treat every external interface as a potential attack surface, validate all inputs, and let your compiler and sanitizers help you catch mistakes early.
Stay safe, and keep your buffers bounded. 🔒
This vulnerability was identified and fixed as part of an automated security scanning pipeline. The fix was verified with a full re-scan and LLM-assisted code review before merging.