Reverse Engineering the Kensington Verimark Fingerprint Scanner

Intro

I've got a Kensington fingerprint scanner that I used to use on Windows, but since I've switched to Linux, it's just been sitting on my desk, largely unused. Unfortunately, there really aren't any fingerprint scanners for Linux that plug into USB, the supported ones are all built into laptops.

So I figured, why not try to make it work on Linux? There's the fprint library which provides a unified interface, but my model is not supported. There's even an issue for it. To do this, the first step is to understand how the reader works and then implement it for fprint.

In this post (and hopefully follow-up posts), I'll document my progress. They will be light on editing and I aim to put a decent amount of detail into it if I can, because there's only very few resources out there on fingerprint scanners. So even if I don't succeed and give up at some point, other people might find it helpful. It'll also be a useful reference if I want to pick it up again in the future.

Where to even begin? Well the issue provides a starting point: Some people have already uploaded .pcap files that contain both "read" and "register" traffic. If you're familiar with Wireshark, this is the same thing, it's just USB instead of network traffic. You can open these files in Wireshark.

Wireshark pcap of fingerprint reader traffic

As you can see in the image it's just like regular Wireshark only it's the USB protocol. This is the start of the traffic to register a new fingerprint using Windows Hello, downloaded from the issue. As you can see, it doesn't show us much, but it's clear that one thing we need to learn about a bit is how the USB protocol works, so that's the first step. For some general advice on this topic the blog post USB Reverse Engineering: Down the rabbit hole was quite helpful! It's a big collection of links & tips, which pointed me at Drive It Yourself: USB Car. This post did a good job of explaining some of the basics.

Writing a USB Driver Prototype

The next step is to try and replay the exact same messages to get the same behaviour. This is quite easy to do in Python using pyusb. So far, throughout my research I keep thinking how much we are standing on the shoulders of giants here with all of the tools that are already available. In this instance, a library that makes it trivial to speak the USB protocol and abstracts away a few specifics about the USB protocol. Using this, I quickly ended up with a little bit of code:

from array import array
import usb.core
import usb.util
import hid
import time
from threading import Thread


class Verimark:
    def __init__(self, sleep_delay=0.01) -> None:
        self.sleep_delay = sleep_delay
        idVendor = 0x047D
        idProduct = 0x00F2

        # import usb.backend.libusb1 as libusb1
        # import usb.backend.libusb0 as libusb0
        # import usb.backend.openusb as openusb

        self._dev = usb.core.find(
            idVendor=idVendor,
            idProduct=idProduct,
            # backend=openusb.get_backend(),
        )
        if self._dev is None:
            raise ValueError("Device not found")

        self._had_kernel = False
        # Detach kernel driver if necessary
        if self._dev.is_kernel_driver_active(0):
            self._had_kernel = True
            self._dev.detach_kernel_driver(0)

        # self.release()

        # self._hid = hid.Device(idVendor, idProduct)
        # print(self._hid.get_feature_report(0, 111))
        # self._hid.close()

        self._dev._get_full_descriptor_str()

        # Set the active configuration
        self._dev.set_configuration()

        self._cfg = self._dev.get_active_configuration()
        self._intf0 = self._cfg[(0, 0)]
        self._intf1 = self._cfg[(1, 0)]

        # Kernel mentions we're not claiming it properly
        usb.util.claim_interface(self._dev, 1)

    def rebind(self):
        bus = self._dev.bus
        address = self._dev.address
        usb_path = f"{bus}-{address}"

        # Unbind
        self.sleep()
        with open(f"/sys/bus/usb/drivers/usb/unbind", "w") as f:
            f.write(usb_path)

        # Rebind
        self.sleep()
        with open(f"/sys/bus/usb/drivers/usb/bind", "w") as f:
            f.write(usb_path)

    def release(self):
        usb.util.release_interface(self._dev, 0)
        if self._had_kernel or True:
            self._dev.attach_kernel_driver(0)

    def sleep(self):
        time.sleep(self.sleep_delay)

    def print_endpoints(self):
        # Endpoint: 1, Type: 3
        # Endpoint: 129, Type: 3
        for ep in self._intf0.endpoints():
            print(
                f"Endpoint: {ep.bEndpointAddress}, Type: {usb.util.endpoint_type(ep.bmAttributes)}"
            )

    def clear_feature_endpoint_halt(self):
        self.sleep()
        # CLEAR FEATURE ENDPOINT HALT
        response = self._dev.ctrl_transfer(
            0x02,
            1,
            0,
            131,
            0,
        )
        assert response == 0

    def set_idle(self):
        self.sleep()
        # SET_IDLE
        response = self._dev.ctrl_transfer(
            0x21,
            0x0A,
            0x0000,
            0,
            0,
        )
        assert response == 0, "Unexpected response to SET_IDLE"

    #
    # # Now wait for the interrupt IN response from the device
    # ep_in = usb.util.find_descriptor(
    #     intf,
    #     custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
    #     == usb.util.ENDPOINT_IN,
    # )
    # assert ep_in
    #
    # ep_out = usb.util.find_descriptor(
    #     intf,
    #     custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
    #     == usb.util.ENDPOINT_OUT,
    # )
    # assert ep_out

    # Set up a thread to do the async read

    def ep_in_read_start(self):
        self.sleep()
        self._ep_in_data = None
        self._ep_in_thread = Thread(target=self._async_ep_in_read)
        self._ep_in_thread.start()

    def ep_in_read_finish(self):
        self._ep_in_thread.join()
        return self._ep_in_data

    def _async_ep_in_read(self):
        try:
            data = self._dev.read(1, 64, timeout=1000)  # Adjust buffer size if needed
            print(f"Interrupt IN Response: {data}")
            self._ep_in_data = data
        except usb.core.USBError as e:
            print(f"Error reading from interrupt endpoint: {e}")

    def vendor_protocol(self):
        # Vendor request, unknown what it does
        response = self._dev.ctrl_transfer(
            0x40,  # Host to device vendor message
            0x19,
            0,
            0,
            0,
        )
        assert response == 0, f"Unexpected response to 0x40 / 0x19 message: {response}"
        self.sleep()

        # Next vendor request
        response = self._dev.ctrl_transfer(
            0xC0,  # Device to host vendor message
            0x1A,
            0,
            0,
            1,  # Length one, we expect a result?
        )
        assert len(response) == 1, f"Unexpected response length: {response}"
        assert response[0] == 1, (
            f"Unexpected response to 0xc0 / 0x1a message: {response}"
        )
        self.sleep()

        response = self._dev.ctrl_transfer(
            0x40,  # Host to device vendor message
            0x16,  # 22
            0x6,
            0,
            array("B", b"\x01" + 7 * b"\x00"),
        )
        # Make sure 32 bytes were written
        assert response == 8
        self.sleep()

        # Now presumably requesting the response to the previous message?
        response = self._dev.ctrl_transfer(
            0xC0,  # Device to host vendor message
            0x17,  # 23
            0,
            0,
            38,
        )
        # assert len(response) == 137
        print(response)
        self.sleep()

        response = self._dev.ctrl_transfer(
            0xC0,
            0x14,
            0,
            0,
            2,
        )
        print(response)
        self.sleep()


def main():
    # input("Start capture, then hit enter")
    vmark = Verimark()

    print(str(vmark._dev))
    # print(str(vmark._hid))
    # import pdb

    # pdb.set_trace()

    # vmark.rebind()

    vmark.print_endpoints()
    vmark.set_idle()

    vmark.ep_in_read_start()

    vmark.clear_feature_endpoint_halt()
    vmark.vendor_protocol()

    print(vmark.ep_in_read_finish())

    vmark.release()


# ep_in_thread.join()
# print(ep_in_data)

if __name__ == "__main__":
    main()

I didn't bother cleaning that up, partly because I'm lazy, but it's also a good example of the reverse engineering process: A lot of trial and error. As you can tell, I didn't get all that far. Before I explain why, there's another piece to this puzzle: Observing the live traffic going across to the device.

Capturing USB Traffic with Wireshark

To know whether our prototype is producing the same traffic, we need to observe it and compare it with the pcap from a Windows capture. This is quite easy using Wireshark, but requires a bit of setup.

First a quick modprobe usbmon so that the kernel allows us to observe traffic on USB devices. Then we can check that it worked:

% sudo ls -l /dev/usb*
crw------- 1 root root 511, 0 Apr 11 21:38 /dev/usbmon0
crw------- 1 root root 511, 1 Apr 11 21:38 /dev/usbmon1
crw------- 1 root root 511, 2 Apr 11 21:38 /dev/usbmon2
crw------- 1 root root 511, 3 Apr 11 21:38 /dev/usbmon3
crw------- 1 root root 511, 4 Apr 11 21:38 /dev/usbmon4

To find out which of these we need to listen on, we run lsusb:

Bus 003 Device 002: ID 047d:00f2 Kensington VeriMark DT Fingerprint Key

The "Bus" number is 003 so we listen on usbmon3. Note that other devices might be connected to the same device and you'll also capture their traffic. Either unplug everything else and find USB ports for another bus or filter it out in Wireshark (see below).

As you can see, the /dev/usbmon3 is owned by the root user but running Wireshark as root is a bad idea. Instead, you're supposed to do something with groups, however, I couldn't get that to work and it's much easier to just run sudo chown <user> /dev/usbmon3 and then the user is allowed to observe USB traffic so that's easy enough.

Wireshark list of capture devices

In Wireshark it's now possible to select usbmon3 and start observing. If there's another device connected to the same hub, you can filter it out. For example, with the above device, it's numbered as "2" so once you started capturing, enter the filter usb.addr~"3\\.2.+" and you won't see other devices. Note that you cannot filter out other traffic when selecting the capture device, that doesn't seem to be supported.

Now, using the script above, I can replay the traffic and observe it in Wireshark.

Wireshark prototype USB traffic

This screenshot shows a snippet of the USB traffic. I created the script by slowly figuring out how to recreate each message I saw in the original capture, learn what it was for (so far, it's mostly standard USB stuff) and to try and get the device into the right state.

Custom USB Protocol

The vendor-specific protocol are all the URB_CONTROL messages that get passed around. At the start it's fairly easy, just some small values. But then comes this:

bmRequestType: 0x40
bRequest: 22
wLength: 8
Data: 01 00 00 00 00 00 00 00

This is a message from the host (computer) to the USB device. In USB, to receive responses the host needs to request them, which it does next:

bmRequestType: 0xc0
bRequest: 23
Response: 0000522ddf5ef55d31000a01014101c100009310e5e063370ba1000000000000000000000003

Huh? Now this might just be a serial number or something that we can ignore for now, but we've now hit a point where it's not clear what the traffic means. Looking at this traffic and also what's captured from the libfprint issue, it's time to investigate it.

Often reverse engineering is looking for known or familiar patterns in the objects we are reversing, and matching patterns is actually something that LLMs are pretty good at, so I started pasting some of the messages into ChatGPT and at some point it pointed out that a message like 170303005a9b... might indicate encrypted traffic (something about the 1703 at the start, I think?). I hadn't thought that there might be encryption involved, but it actually makes some sense given the purpose of the device (auth via biometrics).

Searching the web, I found Reversing a Fingerprint Reader Protocol and A Touch of Pwn - Part I. The former talks about encryption, the latter actually gives a high level description of three readers they reversed and exploited. One of them is a Synaptics, which the Kensington seems to be under the hood. Both of these posts provide quite a bit of insight into what's necessary to get further: Due to the encryption, it's going to be virtually impossible to do this just by observing traffic over USB. We'll have to reverse engineer the driver. But I'll leave that for another time.