Host Identification With USB Devices

by Ji-Yong Han

When I first saw the video from Hak5 introducing the new Rubber Ducky and its OS detection function, it immediately gave me that itch: "How does that work?!  I wasn't going to drop $80 on one, nor do they publish the code for these anymore, but I had to find out.

Before I go any further, let me say that I was not the one to come up with the core concept in which all of this works.  This expands on the work of Jesse Vincent: github.com/keyboardio/FingerprintUSBHost

This is one of what are probably many methods of implementing this.

How Does This Work?

Every USB device has a number of descriptors that describe everything about the device.

These hold things like the Vendor ID, Product ID, the class of the device (storage, HID, printer etc.), power configuration, and number of interfaces, just to name a few.  These descriptors are communicated via "control transfers" messages that are used for control and status tasks with device enumeration being one such task.

It's important to note that control transfer communications are always driven by the host, with no real handling for corrupt or bad transfers other than the device just ignoring the request until it is sent again by the host and processed successfully.

Devices can also have optional string descriptors which are used to provide Unicode-formatted human readable identifiers for manufacture, product, and serial number.

Provided that a device has string descriptors configured, host systems can request them from the device.

The device will store the length of the descriptor, the language used (as there can be more than one language used and the host can query what languages are available and request a specific one), and the Unicode string itself.

If no descriptors are configured, the device should have the index for the string descriptors set to 0 so the host knows not to request them.

However, each system handles these requests differently so they can request these strings multiple times in different lengths; each request in a different length is telling the device how many bytes the host is currently willing to accept.

For example, Linux always just asks for the full 255 bytes and, provided that it gets an answer, will just carry on with the rest of device enumeration.

However, if it doesn't get an answer or gets a bad answer, it will then ask the device how long the descriptor it is interested in actually is and will ask for just that number of bytes.  We can use the knowledge of how many string descriptors that our device has to know that a Linux machine will ask the device via a control transfer message for each string at 255 bytes.

Other systems don't follow the same logic as a part of device enumeration and that's how we can mostly tell them all apart, just by looking for a pattern or order in the number of bytes the host requests when asking for the string descriptors.

I have read a few theories as to why systems implement these requests differently and the common train of thought seems to be that it allows older devices or devices that don't stick to the full specification to still work.

Implementation

Most of the open-source USB stacks will have a means of handling the requests for the string descriptors which makes implementing this quite simple.

This will either be a dedicated function or could be implemented as a filter to process just the string requests as a part of the numerous control transfer messages ignoring the rest.  Once we have those messages, we can process the wLength where the number of bytes requested by the host or the number of bytes being sent back to the host is stored.

In TinyUSB, control transfer requests are handled in usbd.c as a part of the process_get_descriptor method.  (This file is located in the Device path.)

The p_request variable holds all of the data from that control request including the wLength the host is looking for or the device is sending back, and TinyUSB already has a series of filters implemented using C cases that allow us to only grab the string descriptors.

We can extend the request to pass the wLength over into the tud_descriptor_string_cb callback code that we have to provide when using the TinyUSB stack to build a device and then we can pass it back to our main application code for processing and handling to do the final detection.

We will also need to update the usbd.h to reflect the change made in usbd.c.

It should be possible to do the same thing in other USB stacks or even custom written ones if you have gone down that path.  All you need do is to look at the wLength variable of the string descriptor control messages.

Taking This Further

Been able to guess the host OS is great, but I wanted to be able to replicate what I saw in the Hak5 demo where it could determine the difference between a standard Linux machine and ChromeOS, which also uses a Linux kernel.

I wasn't able to figure out any other way currently to be able to do more with the detection in other device class types.  Knowing that the Rubber Ducky emulates a keyboard, I figured that how the two systems deal with external keyboards must be different, as anyone who has used a Chromebook will know that the Caps Lock key is replaced with the search key.

So I took that and figured that it wouldn't work on an external keyboard, but sadly it does.

Hitting Num Lock, however, doesn't trigger the LED to change status on the keyboard, whereas it does on a Linux system.

With this in mind, I made my device also emulate an HID and, if it sees a Linux kernel, it sends a couple of simulated key presses of the Num Lock key to see if the host requests the LED be toggled as a means of detecting a Linux system and if not assuming it's a ChromeOS system.

Use Cases

I think the most useful use case for this technique is the one I already mentioned in Hak5's latest Rubber Ducky being able to deploy a different payload depending on the detected OS without the need to swap it out in the field.

I think it could also be used for devices that also emulate a CD drive when plugged in to offer driver/software installation so that it only shows the software relevant to the host it's connected to, although I personally haven't come across anything that does that for quite some time now.

Perhaps a more edge case for this would be to test public USB charging points for anything that might be inside it that's able to try and "juice jack" a connected device.

Known Issues

Host identification from USB devices isn't perfect.

Systems such as Linux and BSD can usually be identified quite reliably, as the kernel directly controls the queries for the device descriptors so they always follow the same pattern and behaviors.  In high security environments where this could be a risk, there is always the option to change the way that it requests the strings so that it masks the "known" way this identification works.

This doesn't seem to be the case in Windows-powered machines where it appears to be controlled by the drivers of the USB chipset, so different versions of the driver on the same hardware can present different results.  It might be possible to identify the chipset type as a result of these behaviors.

But here again, we have some option to modify the drivers to make it behave in a way that is similar to another platform to defend them against these attacks.

macOS and iOS also present some interesting issues due to the shared code base between the two platforms, so the fingerprint of those devices can show up identically.  Strangely, the iPads with Apple M1 chips presented the same fingerprint as Intel Macs, but a different fingerprint is presented from an M1 Mac.  I am not sure what could be done to alter the behavior of these devices to protect them.

Machines running virtualization platforms like VMware Workstation or VirtualBox can end up querying the device descriptor strings endlessly, most likely because they are checking for new devices that can be passed into the virtual machines they host.

Further Reading

Beyond Logic (USB in a NutShell) has got a great summary of the full USB 2.0 specification, should you want to read more about it and it was a key reference point while I was working on this.

Proof-of-Concept Code

I've published my code that implements this on a Raspberry Pi Pico (but it should also work for any other RP2040-powered boards) for this on my GitHub: github.com/ji-yonghan/pico-os-detect

Return to $2600 Index