My AI Wrote a Keylogger Into My Keyboard Driver. Another AI Caught It.


estimated read time: 12 minutes

My AI Wrote a Keylogger Into My Keyboard Driver. Another AI Caught It.

Nobody sets out to write a keylogger, and I certainly didn't. I was trying to fix a keyboard that kept dying on me, and somewhere along the way the AI I was working with wrote a one-line udev rule that let any program running as me read every key I pressed. It even left a comment above it explaining why it was safe, and I read that comment, nodded, and moved on.

It was live on my desktop for about six hours before another AI session caught it, and even then it took a couple more attempts and some late night testing before the hole was properly closed. I'll admit I've been a bit dazzled by what these tools can do, so this was a useful reminder, and I think it's worth sharing with anyone else who is too.


A keyboard that kept dying

For months my Razer BlackWidow V4 Pro had been dropping off the USB bus a few seconds after I logged in. I blamed the KVM, so I plugged the keyboard straight into a port on the back of the motherboard and rebooted. It still died. I blamed the USB hub. It wasn't that either.

One attempted fix in August went so well that I couldn't type at the login screen at all, and I had to get my own machine back from a live USB. After that I got a bit twitchy about anything that touched the keyboard. So before the new driver went anywhere near the real keyboard, I made the AI write me an emergency plan I could read on my phone, starting with "It is written for the case where you cannot type on the machine at all", and a recovery service I could trigger over SSH with no keyboard. Remember that, because it matters later.

After a lot of digging, eyebrow raising and chair arm gripping, the culprit turned out to be a single byte. At every login, a bug in the OpenRazer driver sent the keyboard a command with the wrong ID in it: 0xFF where this model expects 0x1F. The driver even has the correct value for this keyboard elsewhere in its code, it just doesn't use it on that one path. The keyboard's firmware didn't like the bad command and crashed.

Changing one setting stopped the command being sent and the keyboard has been fine since. But I'm not great at leaving things alone, so one evening at about six I sat down with my development AI and started openrazer_rust, a Rust replacement for the control side of OpenRazer (lighting, device mode, reading firmware versions) where the device IDs live in one table rather than being hardcoded all over the place.

Before anyone asks, typing doesn't go anywhere near it. Your keystrokes still go through the kernel and on to the compositor as normal. The Rust driver only sends control commands over a separate interface on the keyboard. To do that it needs to open the keyboard's /dev/hidraw* nodes, and by default only root can. I didn't want to need sudo just to change the colour of my keyboard, which is where the trouble started.

The comment that got me

The AI's solution was the one you'll find in plenty of guides:

SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", TAG+="uaccess"

In plain English: for any hidraw device made by Razer (1532 is Razer's USB vendor ID), give access to whoever is logged in at the machine. Steam does something similar for game controllers, so it didn't look out of place.

Above it was this comment:

# NOTE ON SAFETY: this rule only sets permissions. It contains no RUN+=, does
# not bind or unbind drivers, and cannot affect whether your keyboard types.

"Cannot affect whether your keyboard types." After everything that keyboard had put me through, that was exactly what I wanted to hear, so I read it and moved on. All of this is technically true. The problem is it answers my worry, whether the rule could break the keyboard, when the question that mattered was: who else could read from it?

There's a bit of irony in how it went live, too. The first version of the file was named 99-razer-rust.rules, which meant it ran after the systemd rule that actually applies these permissions, so it didn't do anything. Half an hour later it was renamed to 60-, in a commit called "fix the udev rule's number". That fix is what switched the hole on.

Past midnight: WTF?

Later that evening, with the driver working, I opened a fresh session of the same development AI and asked it to review the whole project: the Rust code, the udev rule, the systemd units and all the scripts.

At about twenty to one in the morning it came back with nineteen findings. Top of the list, rated high, was the udev rule.

My reaction was three letters: WTF? I'd left a caravan-sized hole in my own desktop. Any app, script or dodgy npm package running as me, even something sitting on a desktop I hadn't looked at in days, could have tracked every key going through my keyboard. Passwords, messages, the lot.

The reason is that a USB keyboard shows up as several devices, not one. The BlackWidow V4 Pro has five HID interfaces, each with its own /dev/hidrawN node:

Read from a hidraw node and you get the raw reports the device sends, which on interface 0 is an 8-byte report every time a key goes up or down. The rule matched Razer's vendor ID, so it opened up all five. It also goes straight around Wayland. One of Wayland's selling points is that one app can't snoop on another's keyboard input, but that protection lives in the compositor, and something reading /dev/hidraw* gets the key before the compositor ever sees it. And it was permanent, granted the moment the keyboard was plugged in, whether the driver was running or not.

The review also pointed out that my own README described OpenRazer not granting this access as an oversight. It's at least as likely they left it out on purpose. I'd assumed upstream had missed something, when they'd probably thought about it more than I had.

It was the same model that wrote the rule, just in a different session. What caught this wasn't a smarter model with a paranoid disposition, just an untainted context. The first session had spent all evening with me on "why does my keyboard keep dying?", so it checked whether the rule could break the keyboard and stopped there. The second came in with no history and was asked what was wrong. It's the same reason we get someone else to review our code at work.

Typing into my own keylogger

I wasn't going to change anything on the word of an AI that had just told me another AI got it wrong, and it's a good job I didn't. The review had the right problem but the wrong detail. It said the keyboard's interface 0 was hidraw0, but on my machine hidraw0 was the mouse and the keyboard was on hidraw8. It had worked that out from a test file rather than the real machine.

So I checked four things:

  1. The permissions. getfacl /dev/hidraw8 showed my user had read and write access.
  2. What the device says it is. The report descriptor for interface 0 starts 05 01 09 06, which is the HID way of saying "I'm a keyboard".
  3. What the kernel thinks it is. /proc/bus/input/devices had that interface down as a keyboard.
  4. Actually reading it. As my normal user, no sudo, I ran a script against /dev/hidraw8 and typed. It caught 713 reports, every one of them 8 bytes, a standard keyboard report. The script only counted them and threw the contents away.

Before the rule, nothing on my machine that could see keystrokes was readable by my user. After it, three nodes were.

The fix that didn't fix anything

The review's fix narrowed the rule down to the exact product and the exact interface:

SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="028d", ATTRS{bInterfaceNumber}=="03", TAG+="uaccess"

It looks right and it got through review, but when I checked, it didn't match anything. udev needs all the ATTRS{} in one rule to come from the same parent device, and these live on two different ones:

/sys/bus/usb/devices/1-5.3       idVendor, idProduct     (the USB device)
/sys/bus/usb/devices/1-5.3:1.3   bInterfaceNumber        (the USB interface)

The fix for the fix matches the vendor and product using ENV{} instead, which udev sets on the hidraw device itself, leaving only one ATTRS{} in the rule:

SUBSYSTEM=="hidraw", ENV{ID_VENDOR_ID}=="1532", ENV{ID_MODEL_ID}=="028d", ATTRS{bInterfaceNumber}=="03", TAG+="uaccess"

At least the broken version failed safe. The driver lost access rather than anything else gaining it. But that was two udev rules in a row that looked fine in review and were wrong on the actual hardware. The project had 243 passing tests and none of them could test a udev rule.

Still readable

With the corrected rule loaded, I checked interface 0 again. Still readable.

It turns out systemd only applies the permissions while the tag is there. Take the tag away and nothing goes back and removes the access it gave out earlier. It survives reloading the rules, udevadm trigger and udevadm settle. The only ways to clear it are to unplug the device, reboot, or remove it yourself:

sudo setfacl -b /dev/hidrawN

So if you ever tighten up a udev rule for security reasons, reloading isn't enough. Run getfacl afterwards and make sure the access has actually gone.

Once I'd cleared it, only the control interfaces had access and everything that could see keystrokes was back to root only. Later that night, gone one in the morning, I was plugging the mouse in and out to make sure the rule behaved on a real plug-in, which it did.

I didn't want to call it done until it had survived a reboot, so I checked again after booting up today. Still only the control interfaces, still nothing readable that can see keys. Interestingly, the keyboard's interface 0 is now hidraw7 rather than hidraw8. The numbers can change between boots, so any script or AI that tells you the keyboard is "hidraw0" is guessing.

Check your own machine

This isn't just a Razer thing. Any udev rule that opens up hidraw nodes by vendor alone can do the same, and there are plenty of RGB tools, macro pad utilities and "get your mouse working without root" guides out there that do it. This script lists any hidraw node you can read that describes itself as a keyboard:

#!/usr/bin/env bash
# List every hidraw node the current user can read whose HID report
# descriptor declares a keyboard (Usage Page 0x01, Usage 0x06).
for dev in /sys/class/hidraw/hidraw*; do
  node="/dev/${dev##*/}"
  [ -r "$node" ] || continue
  if od -An -tx1 -v "$dev/device/report_descriptor" | tr -d ' \n' | grep -q '05010906'; then
    echo "READABLE KEYBOARD: $node ($(sed -n 's/^HID_NAME=//p' "$dev/device/uevent"))"
  fi
done

It doesn't need root. If it prints nothing, you're fine. If it does print something, find the rule with grep -rn hidraw /etc/udev/rules.d /usr/lib/udev/rules.d, narrow it down to the interface you actually need, then either run setfacl -b on the node or unplug and replug the device, because the old access won't go away on its own. It's a quick check rather than a guarantee, but it'll catch the obvious cases.

What I took from it

A few things I'll be doing differently:

  1. Don't trust the comment. The safety note was written by the same thing that wrote the code, and it happened to say exactly what I wanted to hear. AI is very good at writing convincing explanations, which makes it easier to nod along to them.
  2. Review in a fresh session. It cost me nothing and it found the most serious problem in the project in one go.
  3. Check the reviewer as well. It found the right problem on the wrong device, and its fix didn't work. What actually closed it was testing on the real machine.
  4. Know what your tests can't reach. 243 passing tests and none of them touched the file that mattered. The repo didn't have CI either, which is my fault.

There's been a lot of talk about AI-generated code and supply chain security lately, and this was a small version of it. There was no malicious package or compromised account, just a tool I trusted writing a sensible looking, well commented line of config that opened up my keyboard, and I waved it through because it told me what I wanted to hear.

AIs are great, without a doubt, but they're not perfect. They make the same human-tier dumb mistakes we all do, so stay in the conversation. Read what they write like you'd read a colleague's PR, ask for a second opinion, and check it yourself. I was dazzled, and it took a second AI and a late night to catch what I'd missed.

The keyboard has been fine ever since, and what I type on it is back to being my business.

Related Articles