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.
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.
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.
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.