If you're just joining, the index post has the full origin story:
A wireless Rubber Ducky I built out of an ESP32-S2 to prank my roommate, ~60 recruits at a cybersecurity club demo, and the question that showed up a few days late like it always does:
If a ₹500 chip can convince a computer it's a keyboard, what's actually stopping it?
Short answer: nothing. Long answer: this post, and the extremely humbling three months that produced it.
while(naive) { believe(); }
Here's the thing your computer trusts, no questions asked: when something plugs into a USB port and announces "I'm a keyboard, and here's a keypress," the OS just believes it. No handshake, no proof, no captcha. It's the security equivalent of a bouncer who lets anyone in as long as they're wearing a shirt that says STAFF.
A Rubber Ducky is that shirt. It's a tiny chip that shows up, claims keyboard-hood, and immediately starts firing a pre-written script of keystrokes at whatever's foolish enough to trust it — which, per the paragraph above, is everything.
But a script isn't a person, and for anyone trying to catch this, that's good news: it leaks into the numbers. Measure the exact gaps between keystrokes and a real human has noise in there — tiny, involuntary jitter in how long a key is held and how long the pause is before the next one, because fingers are just imprecise machinery. A microcontroller replaying a script has none of that. It fires keys with a mechanical evenness no human has ever produced outside of a Black Mirror episode. The gap is small in absolute terms — single-digit milliseconds versus tens of milliseconds — but it's there.
I didn't have to take my own word for it: USBlock: Blocking USB-Based Keypress Injection Attacks (Neuner et al., DBSec 2018) shows you can catch injected keystrokes purely by watching timing patterns. So the core idea wasn't mine — it had actual peer review behind it. What I got wrong was where, physically, in the system, to go do the catching.
What I actually built
You don't need to know a single thing about the Linux kernel to see why this was doomed. You just need one picture: my code was two separate programs talking to each other through a shared queue called a BPF ring buffer.
Think of it as a mailbox with one strict rule: one side only ever drops letters in, the other side only ever picks them up, and neither has to stand there waiting on the other.
The kernel-space half was a tiny piece of code living right up against the hardware, close enough to see every keystroke the instant it happened. Its entire job was to grab the keystroke, timestamp it, drop it in the mailbox, and refuse to elaborate further.
The userspace half was a completely ordinary program — Python, Rust, C, whatever. It handled figuring out which device had appeared, reading keystrokes back out, running timing analysis, deciding whether something looked malicious, and disconnecting suspicious devices.
Even device discovery turned into more machinery than any sane person should need. On startup it snapshotted every already-connected input device — a known-good guest list — and then polled every 200 ms looking for newcomers, one loop for USB and one loop for Bluetooth. Once it found a new device, it attached its tiny keystroke watcher and started reading events.
Very proud of myself at the time. Should not have been.
if (byte_count == 9) { yolo(); }
Here's the actual logic:
// "9 bytes? Must be THIS layout."
// future me: it was not.
if (packet_length == 9) {
modifier_byte = packet[1];
key_byte = packet[3];
} else {
modifier_byte = packet[0];
key_byte = packet[2];
}
// two keys closer together than 5ms?
// get suspicious.
if (gap_since_last_key_ms < ATTACK_THRESHOLD_MS) {
suspicion++;
if (suspicion >= ALERT_THRESHOLD)
block_this_device();
}
Two crimes committed here. I'll take the stand for both.
Crime #1: Magic numbers everywhere
ATTACK_THRESHOLD_MS and the alert threshold were just fixed numbers — 5 ms and 30 ms — reverse engineered from exactly two data points: one Rubber Ducky, and my own hands. There was no per-device baseline, no statistical model, no adaptation, no acknowledgement that humans type differently. A fast typist and a scripted attack could easily land on the same side of a hardcoded threshold. I wasn't modeling a distribution. I was drawing a line in the sand and hoping the tide agreed.
Crime #2: This isn't parsing. It's vibing.
if (packet_length == 9) is not HID parsing.
Every HID device ships with something called a report descriptor — think of it as a tiny instruction manual handed to the kernel that says "here's exactly how every packet I send will be formatted." The kernel is supposed to read this manual. My code did not. It simply counted bytes.
It worked because my ESP32-S2 always emitted exactly the packet shape I'd hardcoded. Anything else; a different keyboard, a different layout, a different report ID arrangement, silently broke it.

My ESP32-S2's actual HID report descriptor. Every device draws this differently.
None of this crashed. None of it errored. It simply worked on exactly one device forever...which is arguably the most dangerous kind of bug: the kind that looks like success.
If you want the actual instruction manual instead of my fake success, read the kernel docs on HID before writing HID code. I did not. You're reading the consequences.
O(too_slow): The latency problem
Suppose the parsing had been perfect. Suppose the thresholds were mathematically divine. There was still a deeper problem baked into the design: detection requires waiting to see a pattern, which means some keystrokes always land before the verdict comes in.
The pipeline looked like this:
Keystroke
↓
Kernel program timestamps event
↓
Ring buffer
↓
Userspace wakes up
↓
Timing analysis
↓
Threshold check
↓
Device disconnect
That's not unique to my setup, even USBlock, the paper this whole idea is built on, makes the same trade explicitly. Their own detection rule needs three suspicious keystrokes in a row before it acts, precisely because reacting on the first one alone would flag too many fast human typists. They measured their actual decision loop at under a millisecond, which is fast. But "fast" and "before the first few keystrokes land" are different guarantees, and their own paper is upfront that some earliest keystrokes get through by design.
Where my prototype actually lost, badly, wasn't kernel-vs-userspace speed; it was everything upstream of the decision. Broken packet parsing, no per-device baseline, and a discovery loop polling every 200ms meant my "three suspicious keystrokes" test could take way longer than a millisecond to even trigger, on top of an already-generous three-keystroke head start for the attacker.
A Rubber Ducky payload often completes in under a second. My detector needed multiple suspicious keystrokes just to get nervous, then more time on top of that to actually react while carrying all the extra latency my own bad architecture added along the way.
If the attack runs in O(instant), my defense was running in roughly O(please wait, buffering).
Why I finally let it go
The more I understood HID report descriptors, composite devices, and Linux input plumbing, the less my architecture made sense. The real solution wasn't "make userspace smarter" — it was "run detection where the events first appear." I just didn't know what that place was called.
A few months later I found hid-omg-detect, a kernel module attempting similar timing analysis, and then a review of it by Benjamin Tissoires (the person who maintains large parts of Linux HID subsystem). The teardown was brutal: wrong assumptions about report layout, races with other HID drivers, HID_ANY_ID matching problems, ownership issues. And then the actual verdict — this kind of logic belongs in HID-BPF, not as a standalone module, not fighting every other driver, but running before any driver even sees the raw events.
He wasn't talking to me. He didn't know I existed. Yet he'd independently arrived at exactly the same conclusion I had spent three months stumbling toward.
Three months of work, and the honest epitaph is this:
The prototype proved the signal was real, and then spent every remaining day of its life teaching me everything wrong with it.
A perfectly respectable way for a bad prototype to die.
Up next
Part 3 is the rebuild: running detection inside the kernel's own HID path, reading each device's actual report descriptor, reimplementing human-vs-script timing analysis from scratch, and learning how to do statistics in an environment that has never once heard of a decimal point.
The archived prototype, magic numbers and all, is available on GitHub if you want to see exactly how bad it was.
Anyone else built something clever that turned out to be solving the problem in exactly the wrong place? Curious how you found out.
Top comments (2)
Explained with such detail. Looking forward to your 3rd part.
Interesting first 2, eagerly waiting for 3rd.