VIA Configurator HID protocol (for QMK Keyboards)


As someone working in IT, I type a lot, what led me to switch to mechanical keyboards, what turned out to be a rabbit hole.

To remap keys on a keyboard using the firmware QMK, VIA appeared some time ago. The project allows remapping keys of a QMK keyboard with enabled VIA support using a nice looking GUI application without the need to flash the firmware again. This is very helpful, especially when switching to a keyboard with fewer keys, where you need to use multiple layers.

Sadly, the VIA Configurator is closed source and based on electron, two things I try to avoid when possible. To solve this problem, I had a look into the HID protocol, the configurator and the keyboard speak and created a POC to remap keys from a python script.

First approach

My first approach to reverse the communication was to look directly into the VIA Configurator application.

[nw:/opt/VIA]$ ls
chrome_100_percent.pak  libGLESv2.so            resources.pak
chrome_200_percent.pak  LICENSE.electron.txt    snapshot_blob.bin
chrome-sandbox          LICENSES.chromium.html  swiftshader
icudtl.dat              locales                 v8_context_snapshot.bin
libEGL.so               natives_blob.bin        via
libffmpeg.so            resources

The application seems to be built based on electron, so it has to be some sort of HTML, CSS, and JavaScript. These files are packed in an asar archive. Thankfully, there are extractors for this format. I used asar_extract to extract the resources. So I had a lot of minimized JavaScript code, nothing I want to deal with, if not necessary.

Looking into the wire

This brought me to my second approach; looking directly into the communication between the application and the keyboard. First, I figured out the bus and device ID of my keyboard using lsusb.

[nw:~]$ lsusb
[...]
Bus 001 Device 023: ID 4653:0001 foostan Corne
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub

To see the communication, I used Wireshark. In Wireshark, the keyboard traffic can be seen on the usbmon interface ending with the corresponding bus ID. In this case, that’s usbmon1.

The following packet was the first-packed transferred, when mapping the upper-left key to A.

Frame 33: 96 bytes on wire (768 bits), 96 bytes captured (768 bits) on interface usbmon1, id 0
USB URB
    [Source: host]
    [Destination: 1.23.3]
    URB id: 0xffff9e5d4d6d5900
    URB type: URB_SUBMIT ('S')
    URB transfer type: URB_INTERRUPT (0x01)
    Endpoint: 0x03, Direction: OUT
    Device: 23
    URB bus id: 1
    Device setup request: not relevant ('-')
    Data: present (0)
    URB sec: 1643739623
    URB usec: 865363
    URB status: Operation now in progress (-EINPROGRESS) (-115)
    URB length [bytes]: 32
    Data length [bytes]: 32
    [Response in: 34]
    [bInterfaceClass: HID (0x03)]
    Unused Setup Header
    Interval: 1
    Start frame: 0
    Copy of Transfer Flags: 0x00000000
    Number of ISO descriptors: 0
HID Data: 0500000000040000000000000000000000000000000000000000000000000000

The Destination address consists of three parts:

  1. the bus ID 1
  2. the device ID 23
  3. the endpoint ID 3

These three values describe the endpoint VIA Configurator communicates with.

The HID Data is the second interesting part and consists of the payload.

Understanding, what’s going on

To understand the protocol, I changed different keys on different positions of the matrix to different values.

The following table shows what I changed and how the HID Data changed.

HID DataAction
0500 0000 0004 ...set key 0:0 layer 0 to A
0500 0000 0005 ...set key 0:0 layer 0 to B
0500 0000 0006 ...set key 0:0 layer 0 to C
0500 0000 0004 ...set key 0:0 layer 0 to A
0501 0000 0004 ...set key 0:0 layer 1 to A
0502 0000 0004 ...set key 0:0 layer 2 to A
0500 0000 0004 ...set key 0:0 layer 0 to A
0500 0100 0004 ...set key 1:0 layer 0 to A
0500 0200 0004 ...set key 2:0 layer 0 to A
0500 0000 0004 ...set key 0:0 layer 0 to A
0500 0001 0004 ...set key 0:1 layer 0 to A
0500 0002 0004 ...set key 0:2 layer 0 to A

The table shows just the first six bytes of the HID Data, as the rest of the 32-byte long data payloads stayed all zero.

Some assumptions can already be derived easily:

  • The first byte was always 0x05, so it maybe means “Change the mapping of a key!”, maybe there are other actions.
  • The second byte correlates to the modified layer.
  • The third byte correlates to the modified row.
  • The fourth byte correlates to the modified column.
  • The sixth byte changes with the value mapped to the key.

The mapped value is interesting. A is neither the fourth character of the alphabet, in the fourth position of any keyboard layout, nor is the hex representation 0x04.

To find out what this values mean, I had a look into the QMK firmware side of the VIA protocol and found their definition in the file qmk_firmware/quantum/via_ensure_keycode.h.

_Static_assert(KC_NO                  == 0x0000, "");
_Static_assert(KC_TRANSPARENT         == 0x0001, "");

_Static_assert(KC_A                   == 0x0004, "");
_Static_assert(KC_B                   == 0x0005, "");
_Static_assert(KC_C                   == 0x0006, "");
_Static_assert(KC_D                   == 0x0007, "");
_Static_assert(KC_E                   == 0x0008, "");
_Static_assert(KC_F                   == 0x0009, "");
_Static_assert(KC_G                   == 0x000A, "");

This file also shows that two bytes are used for the values, so that it’s very obvious that the fifth and the sixth bytes both are used for the set value.

There are further packets in Wireshark, but as most of them are communication back from the keyboard to the PC or not correlating directly to the event of changing the mapping of a key, I ignored them so far.

POC

Based on this information, I created a POC in python, which replays captured HID-Data. Therefore, I used the python library pyusb.

The code to do so is not more than this:

#!/usr/bin/python

vendor = 0x4653
product = 0x0001

test = "\x05\x00\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"

import usb.core
import usb.control

dev = usb.core.find(idVendor=vendor, idProduct=product)[0]
if dev is None:
    raise ValueError('Device not found')

bInterfaceNumber = [i.bInterfaceNumber for i in dev[0].interfaces() if any(e.bEndpointAddress == 0x03 for e in i)][0]

if dev.is_kernel_driver_active(bInterfaceNumber):
    try:
        dev.detach_kernel_driver(bInterfaceNumber)
    except usb.core.USBError as e:
        sys.exit("Could not detatch kernel driver from interface({0}): {1}".format(bInterfaceNumber, str(e)))

endpoint = [e for e in dev[0].interfaces()[bInterfaceNumber] if e.bEndpointAddress == 0x3][0]

endpoint.write(bytearray(test.encode()))

As it took some time for me to understand how to interact with USB devices using HID, this is how the code works:

  • In line 11 the USB device is selected using the vendor and product ID. Both stay the same for a USB device and can be found using for example lsusb.
  • A single USB device can have multiple interfaces, each of which can have multiple endpoints for different purposes. In line 15 the ID (bInterfaceNumber) of the interface with the endpoint 0x03 is selected. The endpoint ID is the third tuple of the Destination in Wireshark.
  • Line 17 to 21 try to prevent the system from handling the endpoint.
  • Then in line 23 the correct endpoint object is selected.
  • Last in line 25 the raw bytes from the string test are sent to the USB device as HID data.

The Wireshark dump of the communication shows further communication that seems to confirm the change of the keymap, but as the success can be proven just by typing the key and verifying it in fact changed, this is not part of this POC.

What’s next?

While this article mainly tries to show how easy it can be to reverse a USB devices and give a foothold and inspiration for other projects, I plan to explore further the VIA protocol and create a python library to change keymaps. If you want to get notified, when I publish this, just follow me on GitHub.