DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on

The packets a vendor app sends before you click anything: finding USB handshakes and session tokens

You capture a Windows-only gadget's USB traffic, work out that 5a 10 0a 5a sets the fan to 2650 rpm, write a tiny Python script that sends exactly that, and... nothing happens. The fan ignores you.

Very often the reason is that the vendor app said something to the device first. A hello, an unlock, a "PC control on", sometimes a session token the device hands out and expects to see again. Until it hears that, the device happily drops your commands.

This is the third post about usb-rea, an open-source tool that diffs USB captures per labelled click and writes a pyusb driver. The first one covered commands, the second one covered naming what the device reports back. Since v0.7.0 it also finds the handshake and replays it in the generated driver.

What counts as a handshake

The rule is deliberately boring. A host-to-device message (or a class/vendor control read) is part of the handshake when it was captured before your first action marker, it is not a background poll (usb-rea already sets aside messages repeated on a steady rhythm), and it is never sent again later in the capture.

So the only thing you have to do differently is start the capture before you open the vendor app:

sudo modprobe usbmon
usb-rea capture start -o fan.pcapng
# now open the vendor app, wait until it's ready
usb-rea mark -m fan.pcapng.actions.json "fan 1200"   # then click it in the app
...
Enter fullscreen mode Exit fullscreen mode

Each handshake step is paired with the reply that follows it on the same device, exactly like command replies.

Tokens, echoes and checksums

Once you have the steps and their replies, three checks explain most of the bytes:

  • Session token: a run of 3+ reply bytes that shows up again in a later handshake request, or at the same offset in every captured copy of a command. That's the device handing out an id and the app quoting it back.
  • Echo: a run of 3+ request bytes that the reply repeats.
  • Checksum: one algorithm (sum8, xor8, the CRC8/CRC16 family) that fits the end of every handshake request, or every reply.

Here is the demo fan, whose synthetic vendor app opens a session with a token:

handshake (3 messages sent once when the app opens the device):
  1. 5a 00 01 00 00 00 0d 1d -> a5 01 ?? ?? ?? ?? ??
  2. 5a 00 02 38 b4 e6 52 1e 1e -> a5 02 01 ??, bytes 3-6 = token from step 1
  3. 5a 00 03 01 00 00 5d 65 -> a5 03 01 ??
Enter fullscreen mode Exit fullscreen mode

And the matching part of report.md, where every claim points at Wireshark frame numbers:

Step Request Frame Reply Frame Expected
1 5a 00 01 00 00 00 0d 1d 3 a5 01 38 b4 e6 52 ca 6 a5 01 ?? ?? ?? ?? ??
2 5a 00 02 38 b4 e6 52 1e 1e 7 a5 02 01 a8 10 a5 02 01 ??
3 5a 00 03 01 00 00 5d 65 11 a5 03 01 a9 14 a5 03 01 ??
  • Step 2 request bytes 3-6 repeat bytes 2-5 of the reply to step 1: a session token or device id.
  • Every handshake request ends in crc16_modbus of the bytes before it.
  • Every handshake reply ends in sum8 of the bytes before it.

The ?? bytes are the ones that will differ next time: the token and the checksums.

Replaying it in the driver

The generated driver now has an open() that Device() calls for you. It walks the steps in order, copies the token from this session's live reply into the next request (and into any command that carried it), recomputes request checksums, and checks each reply against the captured one:

def open(self, check=True, timeout=1000):
    self.session = []
    for i, step in enumerate(HANDSHAKE, 1):
        ...
        data = encode(step, (), 0, (), self.session)   # token copied from earlier replies
        self.dev.write(step["endpoint"], data)
        reply = bytes(self.dev.read(r["endpoint"], max(64, r["length"]), timeout))
        if check and step["reply"]:
            _check_reply(i, step["reply"], reply)      # fixed bytes and checksum
        self.session.append(reply)
Enter fullscreen mode Exit fullscreen mode

A reply whose fixed bytes or checksum don't match raises RuntimeError instead of carrying on and sending commands into the void. Device(handshake=False) skips the whole thing if you want to experiment by hand.

What it can't do (yet)

I'd rather list these than have you find them:

  • Every handshake message is seen once per capture, so a byte that changes per session but is never reused (a random nonce the device only reports) looks fixed. open(check=False) replays without checking.
  • Only fixed, linear sequences: no retries, no branching on the reply, no handshake that repeats after a mode switch.
  • A token has to be copied verbatim. A challenge answered with a hash of the token is not recognised.
  • With --labels (Windows captures without markers) there are no timestamps, so the handshake ends up inside the first action. Use markers for this.

Real state machines (retries, branches, commands that only work in some states) are the next item on the roadmap.

Try it

pip install -U usb-rea   # 0.7.0 or newer
usb-rea demo --device fan
Enter fullscreen mode Exit fullscreen mode

It's also an MCP server (usb-rea mcp, in the official MCP Registry) if you'd rather have an agent drive the capture while you click. Sending anything to real hardware still needs explicit human confirmation.

If you have a gadget whose app does something clever when it opens the device, a capture that starts before the app does would be really useful. Issues are open: https://github.com/emirmrkaljevic/usb-rea

I built usb-rea with help from AI coding tools; the inference itself is deterministic and every claim in its reports points at captured frames.

Top comments (0)