Seventeen Ways Into One USB Stack

We tested one widely deployed embedded USB stack across seventeen surfaces, once per class driver and role. Ten held up completely. The fourteen defects in the other seven were three shapes, repeating.

Product A is an open-source USB stack that ships inside several major microcontroller vendors' SDKs and toolchains, and from there into development boards, audio interfaces, debug probes and a long tail of consumer hardware. If a microcontroller presents itself over USB, there is a fair chance it is running a stack like this one, and a reasonable chance it is running this one.

We tested it across seventeen surfaces at a single firmware revision: once for each class driver on the device side, once for the host side, and once for USB Power Delivery. Fourteen defects came out of seven of those surfaces.

The first thing worth reporting is the other ten.

Ten surfaces found nothing

MTP, the device control-transfer layer, audio, USBTMC, HID, CDC ACM, the vendor-specific class, printer, Bluetooth HCI and USB Power Delivery each survived their audit with no finding. Those runs are not blank pages. Each one ships a test suite that records the inputs that were tried and did not break the target, so a future regression in any of them reads as a new finding.

That matters more than it sounds. A security article that reports only hits tells you nothing about the denominator. HID and CDC ACM took 44 negative cases each, Power Delivery took 60, MIDI took 72. The stack is, in most places, careful.

The attacker is whatever you plugged in

USB inverts the usual threat model, and it inverts differently depending on which end you are.

On the device side, your microcontroller is the target and the attacker is the host it is plugged into. Every malicious charging kiosk, every borrowed laptop, every dock. On the host side, the roles swap: your board is reading descriptors from a device that may have been built to lie about them. USB Power Delivery is neither, and the attacker there is a charger, a dock or the cable itself.

Those three threat models were audited separately because they are separate surfaces, and the defects are not the same shape in each.

Shape A: two numbers that were never related

Five of the seven host-side findings are one defect, repeated in five different class drivers.

A class driver's open() validates the descriptor it was handed by checking a length against a bound computed from sizeof() of the descriptor structs. It then walks that descriptor using the device's own declared bLength to advance. The checked number and the walking number have nothing to do with each other. A device that declares a bLength covering the whole remaining configuration puts the next read off the end of the 264 byte buffer the descriptor was read into.

HID, MSC, the hub driver, MIDI 1.0 and MIDI 2.0 all do this. The drivers that are safe are safe because they pin bLength to a known struct size, or run it through a validity helper, before walking. The sweep for this is mechanical, which is the useful part: for every registered driver, does it pin bLength, or does it only bound a sizeof()-derived total?

Shape B: the driver hands your application a number it never checked

This is the one to read carefully if you build products on a third-party USB stack, because whether it is a vulnerability in your device is not decided by the stack.

Six times in the tree, a driver takes a host-controlled value and forwards it to an application callback without bounding it. Whether that becomes a memory safety fault depends entirely on what the application does next.

Two became findings. The DFU driver stores the host's wValue as an alternate setting, 0 to 255, and the in-tree example indexes a two-element array with it. The MSC driver passes the application an offset derived from the host's own command block, and one of the two shipped examples checks the block address but not the offset.

Two are safe, but only because the application defends itself. The audio driver's entity path takes a channel number from wValue and bounds it explicitly, with a comment naming the hazard. The MSC logical unit number is used in a ternary rather than as an index.

Two sit in between, noted but not findings, because the shipped examples happen not to use the value dangerously.

The distinguishing question is never which driver you enabled. It is what your firmware does with the number the driver gave it. If you took an example as a starting point, that decision was made for you by whoever wrote the example.

Shape C: a host byte indexing a table inside a log line

Six places in the tree index a small string table to pretty-print a request in a debug log. Only one file is unguarded, and it takes a control selector of 0 to 255 straight into arrays of 3 and 10 entries. The rest either bound the index first, or take it from the USB controller rather than from the wire.

It only bites on builds with debug output turned up, which is to say development builds and a surprising number of shipped ones.

The three that are worst

An out-of-bounds write with attacker-chosen content, in the mass storage driver, reachable from a single 31 byte command block. It is the only attacker-controlled write in the series, and non-disk bytes come back to the host as well.

A 4 GB read primitive in the RNDIS driver, where the host picks a 32 bit offset and collects the result through a later query. RNDIS configures the adapter with encapsulated control messages, which is the mode Windows uses.

The cheapest one, in the MIDI 2.0 driver: a single bulk transfer of anything from 1 to 15 bytes. No descriptor manipulation, no timing, no cooperation from the application. Its effect is terminal rather than per-message. After it, the interface is deaf until the host issues SET_INTERFACE or resets the bus.

Finding these with Penzzer

Select the USB module and point it at the device under test. Penzzer plays the malicious host: it drives the target's control endpoint across all request types (standard, class, vendor and reserved), all recipients (device, interface, endpoint and other), both directions, the full request-code range, and the wValue, wIndex and wLength fields. That sweep is independent of device class, so it runs against a product whose firmware you did not write and whose classes you have not been told about.

The DFU finding above is exactly that test. A class request to an interface, with wValue walked through its range, is what reaches it. The two video findings and the RNDIS one are also control-endpoint surfaces.

The control endpoint is where this concentrates, because it is the one surface every USB device has to expose. It is reachable before any class driver is involved, before enumeration finishes, and independent of what the device claims to be, so a run reaches it on hardware whose firmware you did not write and whose class list you have not been told. The DFU, video and RNDIS defects above all sit on that surface, and a control-transfer run uncovers them.

What this article is, and is not

The fourteen defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's USB coverage. They are what one stack happened to get wrong at one revision.

The field walk is generated from the structure of a USB control transfer rather than from this list, so it goes looking for shapes nothing here exhibits. The attack library as a whole spans defect classes well beyond what this article shows: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling.

The same limit applies in reverse. What a test looks for is a property of the test, not a claim about your device. Ten of the seventeen surfaces here came back clean, and that is exactly what a clean result means: those cases did not provoke anything.

If you ship something with a USB port

Three questions, in the order they are worth asking.

Does your firmware bound every value a class driver hands it, or did you inherit that decision from an example? That is Shape B, and it is the one where the defect is yours rather than the stack's.

If you act as a USB host, does every driver pin the descriptor length it walks by, or only check a total? That is Shape A, and it was wrong in five drivers at once.

And is debug output compiled into what you ship? That is Shape C, and it is the difference between a log line and an out-of-bounds read.

A stack being widely deployed and generally careful, which this one is, does not make the products built on it safe. Most of what we found is reachable by someone who can plug in a cable.

Other Post
Uncover Hidden Vulnerabilities

Identify security flaws before attackers do, automatically and at scale with Penzzer's intelligent fuzzing engine.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.