Sometimes you have a USB device. It is not an ordinary one like a flash drive or mouse, but something custom. It comes with its own Windows application. But we want an open-source Linux program, don’t we? In many cases like this, the communication protocol is quite simple; we just need to get to know it.
In this tutorial, I will demonstrate how to listen to USB communication between VirtualBox and your physical USB device. Once we gather this information, we will proceed to write an ultra simple user-space application in Python with the pyusb library.
For this demonstration, I’ll use a 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 eBay or Aliexpress for a few dollars. Your device might be different, but simple ones are usually similar to each other and share a basic communication protocol using USB control or HID transfers.
The basic idea of reverse engineering
- Plug the board into the Linux host.
- Attach the USB device to VirtualBox running Windows 10.
- Run the original Windows application under the virtual machine.
- Capture USB packets using Wireshark and analyze them.
- Write our own controlling application in Python using pyusb.
Examining the device
Run dmesg -w
and plug the board in. The relevant entries (valid for 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 a vendor ID of 16c0
and a product ID of 05df
. Let’s take a closer look at the device:
lsusb -d 16c0:05df -v
This command shows the entire USB device descriptor. The lines that are most relevant to us are:
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 by vendor string. The relay board has a vendor string of www.dcttech.com
and a product string of USBRelay2
.
Sometimes the kernel might recognize the device and load its own driver. In this case, you have to blacklist the driver since you want to talk to 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 a member of the vboxusers
group:
gpasswd -a your-user-name vboxusers
I assume that you have already configured a Windows 10 virtual machine, and that Guest Additions has been installed as well. If your device uses USB 2.0 or later, you also have to install the Oracle VM VirtualBox Extension Pack. For USB 1.1, the extension pack is not required.
To attach a USB device, you must enable USB in the virtual machine settings. To have the device automatically attached, you can create a filter. Open Machine -> Settings -> USB, and add a new filter:
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.
Run your Windows application and verify that your device works properly.
Capturing packets with Wireshark
Wireshark is a great tool for capturing and analyzing network packets. A lesser-known feature is the support for capturing packets transmitted via the USB subsystem. To do that in Linux, you have to load the usbmon
kernel module first. More information is provided on the Wireshark wiki.
modprobe usbmon
Run Wireshark, preferably as root. You should see several usbmon
devices on the interface list.
Each one represents one bus. To which one is our device connected?
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 are supposed to see some packets. Since you may have several devices on the bus, it’s a 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 a user-space library that provides generic access to USB devices. pyusb is a Python front-end for libusb.
Under Arch Linux, install them:
pacman -S libusb python-pyusb
Let’s 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 the first device handle that matches the defined specification. If you set find_all
to True
, it will return the list of all matched devices. I also provided a custom callback to filter by manufacturer and product name. If everything is okay, running this code should display the device descriptor.
If you got an Access denied
error, your user has no permission to access this specific device. You have to create a udev rule to give all users permission. You can also create a rule to give permission to a 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
Let’s 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:
Host communicates with the device using virtual data pipes called endpoints. If your device uses them, you should read more about them 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 the majority of simple devices (like the relay board) use only the default control transfer, which is always defined, let’s examine it. A control request has the following fields:
bmRequestType
bmRequest
wValue
wIndex
- data fragment - optional.
I noticed that when changing the relay state, the application always sends this request:
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 is better to use function build_request_type
. You can deconstruct this value, but it is quicker to look at the tests in pyusb. All the 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 must conform to the HID specification and it might be better to use a more high-level library like hid. We will stick to the generic libusb.
Running our code results in a crash with the following error:
usb.core.USBError: [Errno 16] Resource busy
What is going on? Since our device is an HID device, the kernel has attached it as an HID device. But we want to control it ourselves! Let’s 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 finally call our control request. It should execute without any issue.
Discovering the command format
Fiddle with the 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? The least significant bit of the first byte determines whether the relay is on or off, the second byte represents 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. Here is 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!