Słomkowski's technical musings

Playing with software, hardware and touching the sky with a paraglider.

Eavesdropping USB communication for reverse-engineering using Wireshark and VirtualBox and writing user-space driver in Python


If you have an unusual USB device which comes with its own Windows application, chances are that Linux software does not exist. What if tell you that is fairly easy to examine USB communication with Wireshark and write simple driver in Python?

Sometimes you have a USB device. It is not ordinary one, like pendrive or mouse, but something custom. It goes with its own Windows application. But we want open source Linux program, don’t we? In many such cases, the communication protocol is quite simple, we just need to get to know it. I demonstrate how to listen to USB communication between VirtualBox and your physical USB device. Having this information gathered, we will proceed to write ultra simple user-space application in Python and pyusb library.

For the demonstration I’ll use cheap relay board marked USB-Relay-2, HW-343. It has two 10 A 250 V relays. It is a common module widely available on eEbay or Aliexpress for few dollars. Your device might be different, but the simple ones are usually similar to each other and share basic communication protocol using USB control or HID transfers.

The basic idea of reverse engineering

  1. Plug the board to Linux host
  2. Attach USB device to VirtualBox running Windows 10
  3. Run original Windows application under virtual machine
  4. Capture USB packets using Wireshark and analyze them
  5. Write our own controlling application in Python and pyusb

Examining the device

Run dmesg -w and plug the board in. The relevant entries (kernel 5.4.20):

[ 4560.000392] usb 10-1: new low-speed USB device number 7 using uhci_hcd
[ 4560.449478] usb 10-1: New USB device found, idVendor=16c0, idProduct=05df, bcdDevice= 1.00
[ 4560.449482] usb 10-1: New USB device strings: Mfr=1, Product=2, SerialNumber=0
[ 4560.449484] usb 10-1: Product: USBRelay2
[ 4560.449486] usb 10-1: Manufacturer: www.dcttech.com
[ 4560.461818] hid-generic 0003:16C0:05DF.0010: hiddev2,hidraw6: USB HID v1.01 Device [www.dcttech.com USBRelay2] on usb-0000:00:1d.3-1/input0

Our relay board has vendor ID 16c0 and product ID 05df. Lets look at the device more closely:

lsusb -d 16c0:05df -v

This command shows the whole USB device descriptor. The lines most relevant to us:

idVendor       0x16c0 Van Ooijen Technische Informatica
idProduct      0x05df HID device except mice, keyboards, and joysticks
iManufacturer  1 www.dcttech.com
iProduct       2 USBRelay2

Vendor 16c0 is particularly popular among hobby or semi-professional devices. Van Ooijen Technische Informatica made it available for every developer. They just need to differentiate their devices not by vendor ID, but vendor string. The relay board has vendor string: www.dcttech.com and product string USBRelay2.

Sometimes the kernel might recognise the device and load the driver. You have to blacklist the driver since you want to talk with the device directly.

echo 'blacklist unwanted-driver' > /etc/modprobe.d/unwanted-driver.conf

Attaching physical USB device to VirtualBox

Under Arch Linux, you have to be the member of vboxusers group:

gpasswd -a your-user-name vboxusers

I assume you already have configured Windows 10 virtual machine. Guest Additions has to be installed too. If your device uses USB 2.0 or later, you have to install Oracle VM VirtualBox Extension Pack. For USB 1.1, extension pack is not required.

To attach USB, you have to have USB enabled in virtual machine settings. To have the device automatically attached, you can create filter. Open Machine -> Settings -> USB. Add new filter:

Configuring USB device filter on VirtualBox.

After that your device should be on the list if you click on the USB icon on the status bar, as shown in the screenshot:

USBRelay2 connected to VirtualBox instance.

Run your Windows application and verify that your device works properly.

Capturing packets with Wireshark

Wireshark is great tool for capturing and analyzing network packets. Lesser-known feature is capturing packets transmitted via USB subsystem. To do that under Linux, you have to load usbmon kernel module first. More information on Wireshark wiki.

modprobe usbmon

Run Wireshark, preferably as root. You should see several usbmon devices on interface list.

Wireshark interface list. There should be usbmon interfaces.

Each one represents one bus. Which one is our device connected to?

lsusb | grep '16c0:05df'

Returns:

Bus 010 Device 002: ID 16c0:05df Van Ooijen Technische Informatica HID device except mice, keyboards, and joysticks

It’s bus 10, device 2. Select usbmon10 then. Fiddle a bit with the Windows program. You’re supposed to see some packets. Since you may have several devices on the bus, it’s good idea to filter out packets not belonging to our device. Apply the following filter:

usb.addr contains "10.2"

Writing driver using pyusb

libusb is user-space library that provides generic access to USB devices. pyusb is Python front-end for libusb.

Under Arch Linux, install them:

pacman -S libusb python-pyusb

Lets write some Python code. Remember to detach the device from VirtualBox before running Python code. First import pyusb:

import usb.core
import usb.util

We can now detect our device:

import usb.core
import usb.util

VENDOR_ID = 0x16c0
DEVICE_ID = 0x05df

MANUFACTURER_NAME = "www.dcttech.com"
PRODUCT_NAME = "USBRelay2"


def check_manufacturer_and_product(dev):
    """Look only for devices which match our description strings."""
    return dev.manufacturer == MANUFACTURER_NAME and dev.product == PRODUCT_NAME


def find_device_handle():
    return usb.core.find(idVendor=VENDOR_ID, idProduct=DEVICE_ID,
                         custom_match=check_manufacturer_and_product)


dev_handle = find_device_handle()

print(dev_handle)

Function usb.core.find returns first device handle which matches defined specification. If you set find_all to True, it will return the list of all matched devices. I also provided custom callback to filter by manufacturer and product name. If everything is OK, running this code should display the device descriptor.

If you got Access denied error, your user has no permission to this specific device, you have to create udev rule to allow all users. You can also crate rule to allow specific group. Create and edit file /etc/udev/rules.d/100-usbrelay.rules:

SUBSYSTEM=="usb", ATTR{idVendor}=="16c0", ATTR{idProduct}=="05df", MODE="0666"

And reload the rules (as root):

udevadm control --reload-rules

Lets fiddle with the Windows application for a bit. We can see control packets in Wireshark when changing the relay state.

What do we want to achieve, anyway? Look at the USB architecture diagram:

USB architecture layers.

Host communicates with the device using virtual data pipes called endpoints. If your device uses them, you should read about them more in USB in a NutShell. It is important to note that any kind of data transfer is initialized by the host. The device cannot initialize data transfer on its own.

Since majority of simple devices (so does the relay board) uses only default control transfer, which is always defined, lets examine it. Control request has following fields:

I noticed that when changing relay state, the the application always sends this request:

Captured USB control packets visible in Wireshark.

Let’s emulate it:

dev_handle.ctrl_transfer(0x21, 9, 0x300, 0, (0xff, 0x01, 0, 0, 0, 0, 0, 0))

bmRequestType determines the type of the request. These are standard and for writing clear code it’s better to use function build_request_type. You can deconstruct this value, but it is quicker to look at the tests in pyusb. All combinations of valid values are covered there.

requestType = usb.util.build_request_type(usb.util.CTRL_OUT,
                                          usb.util.CTRL_TYPE_CLASS,
                                          usb.util.CTRL_RECIPIENT_INTERFACE)

It is worth mentioning that values 0x21 and 0xa1 are so called HID reports. If your device uses them, it should conform to HID specification and might be better to use more high level library like hid. We will stick to generic libusb.

Running our code results in crash with following error:

usb.core.USBError: [Errno 16] Resource busy

What is going on? Since our device is HID, the kernel attaches it as HID device. But we want to control it ourselves! Lets detach it:

if dev_handle.is_kernel_driver_active(0):
    try:
        dev_handle.detach_kernel_driver(0)
        print("kernel driver detached")
    except usb.core.USBError as e:
        sys.exit("Could not detach kernel driver: %s" % str(e))

Kernel should detach the device now. Sometimes setting the default configuration is required, it won’t hurt:

dev_handle.set_configuration()

Now we can call our control request. It should execute without any issue.

Discovering the command format

Fiddle with Windows application and look for any changes. I gathered all OUT requests. I noticed they always have the same type, only data fragment varies.

Command: Bytes:
turn relay 1 on ff:01:00:00:00:00:00:00
turn relay 1 off fd:01:00:00:00:00:00:00
turn relay 2 on ff:02:00:00:00:00:00:00
turn relay 2 off fd:02:00:00:00:00:00:00

Do you notice a pattern here? Least significant bit of first byte determines on/off, second byte is the number of the relay. To test it, I did a stupid blinkenlight with relay 1:

while True:
    dev_handle.ctrl_transfer(requestType, 9, 0x0300, 0, (0xfd, 0x01, 0, 0, 0, 0, 0, 0))
    time.sleep(1)
    dev_handle.ctrl_transfer(requestType, 9, 0x0300, 0, (0xff, 0x01, 0, 0, 0, 0, 0, 0))
    time.sleep(1)

Relay 1 should click. The whole example code:

#!/usr/bin/env python3
# -*- encoding: utf-8 -*-

import time
import sys

import usb.core
import usb.util

VENDOR_ID = 0x16c0
DEVICE_ID = 0x05df

MANUFACTURER_NAME = "www.dcttech.com"
PRODUCT_NAME = "USBRelay2"


def check_manufacturer_and_product(dev):
    return dev.manufacturer == MANUFACTURER_NAME and dev.product == PRODUCT_NAME


def find_device_handle():
    return usb.core.find(idVendor=VENDOR_ID, idProduct=DEVICE_ID,
                         custom_match=check_manufacturer_and_product)


dev_handle = find_device_handle()

if dev_handle.is_kernel_driver_active(0):
    try:
        dev_handle.detach_kernel_driver(0)
        print("kernel driver detached")
    except usb.core.USBError as e:
        sys.exit("Could not detach kernel driver: %s" % str(e))

requestType = usb.util.build_request_type(usb.util.CTRL_OUT,
                                          usb.util.CTRL_TYPE_CLASS,
                                          usb.util.CTRL_RECIPIENT_INTERFACE)


def set_relay(relay_number, enabled: bool):
    b1 = 0xff if enabled else 0xfd
    dev_handle.ctrl_transfer(requestType, 9, 0x0300, 0, (b1, relay_number, 0, 0, 0, 0, 0, 0))


while True:
    set_relay(1, True)
    time.sleep(1)
    set_relay(1, False)
    time.sleep(1)
    set_relay(2, True)
    time.sleep(1)
    set_relay(2, False)
    time.sleep(1)

I hope this extremely simple example will push you in the right direction to reverse engineer your own device. Let me know if you achieve anything!