project 07 · anti-cheat research

Detecting Hardware Input Emulation

HID securityanti-cheatUSB ATmega32u4USB host shieldbehavioural detectionfirmware fingerprinting

Start — the research question

Most game cheats live on the computer: they read game memory, inject a DLL, or hook the renderer. Kernel anti-cheat is built to find exactly that. So the most interesting class of cheat, from a defender's point of view, is the one that moves the logic off the host entirely and feeds its decisions back in through a channel the operating system already trusts completely: a USB mouse.

A microcontroller with a native USB stack — here an Arduino Leonardo (ATmega32u4) — can present itself to the PC as an ordinary HID mouse. Pair it with a USB host shield so the player's real mouse plugs into the Arduino instead of the PC, and you have a device that sits physically in the input path. A second process watches the screen, decides where the crosshair should be, and whispers correction deltas to the Arduino over a serial link. The Arduino adds those movements on top of the real mouse's, and emits one clean HID report stream. To Windows, it is a mouse.

The question I set out to answer was not "how do I build this" — that part is well-trodden and public. It was the defender's question: if the input arrives through a genuine HID device, what is actually left to detect? To answer it honestly I had to build the thing end-to-end, on my own bench, against bots and offline targets only — because you can't reason credibly about a technique's detection surface until you've seen exactly where its seams are. This write-up is the result: the hardware, the interfacing code that matters, and — the real point — every place the build fought me, because each of those friction points turned out to be a signal a defender can key on.

The hardware

The whole technique rests on one feature of the microcontroller: a native USB device controller. A classic Arduino Uno (ATmega328P) can't do this — it reaches the PC through a separate USB-to-serial chip and can only ever look like a COM port. The Leonardo / Pro Micro family uses the ATmega32u4, whose USB peripheral is wired straight into the chip, so the firmware itself decides what kind of device to enumerate as. With the stock Mouse.h library it declares an HID mouse interface — the same descriptor class a retail mouse uses.

MCU
ATmega32u4 — 8-bit AVR with an on-die full-speed USB device controller. The reason a $10 board can be a mouse rather than just talk to one.
Mouse.h (core HID)
Exposes Mouse.begin(), Mouse.move(dx, dy, wheel), Mouse.press/release. Movement deltas are signed bytes.
USB Host Shield
A MAX3421E-based shield that makes the Arduino a USB host, so the player's real mouse plugs into it. Driven by hidboot.h + usbhub.h.
Serial link
The Leonardo's USB also enumerates a CDC virtual COM port (115200 baud). This is the back-channel Python uses to push correction deltas — and, as it turns out, a fingerprint.

The device therefore plays two roles at once, which is the clever part of the topology:

From the host's side there is only one cursor, moving smoothly, from one USB mouse. The blend of human and machine has already happened in firmware, below the level any software API can see.

The signal pipeline

1 · Capturea square region centred on the crosshairscreen → frame
→
2 · Detectobject detection finds targets, returns boxesframe → df
→
3 · Aim vectorpick nearest target, compute pixel delta to itdf → dx,dy
→
4 · Serialencode delta, send over COMdx,dy → bytes
→
5 · HIDArduino emits Mouse.movebytes → USB

Stages 1–3 are ordinary computer vision and run on the PC; they are the "brain." Stages 4–5 are the hardware bridge — the part this project actually exists to study, because that boundary is where the detection problem is won or lost. The walkthrough below follows a delta through that boundary and stops at each seam to ask: what does this leave behind?

Walkthrough — the code that matters

These are trimmed, representative excerpts to explain the mechanism, not a build kit — the detection discussion is the payload. Each block is paired with what it costs the attacker in detectability.

STEP 1 — turning a target into an aim vector

After detection returns candidate boxes, the brain picks one target and works out how far the crosshair has to travel. The capture region is centred on the crosshair, so screen-centre is just half the region (cWidth, cHeight); the move is simply target-minus-centre, nudged up toward the head:

# nearest target to the crosshair = smallest distance from centre
targets["dist"] = np.sqrt((targets.mid_x - cWidth)**2 + (targets.mid_y - cHeight)**2)
target = targets.sort_values("dist").iloc[0]

# aim a little above box-centre so the shot lands on the head
head_offset = target.height * (0.38 if headshot_mode else 0.20)

# pixel delta from crosshair to aim point
mouseMove = [target.mid_x - cWidth, (target.mid_y - head_offset) - cHeight]

# scale by an "aim amplitude" — lower = softer/slower, less obvious
dx = mouseMove[0] * aaMovementAmp
dy = mouseMove[1] * aaMovementAmp

target selection & aim-vector math — the "brain" output, before it ever reaches hardware

The interesting variable is aaMovementAmp. At 1.0 the crosshair snaps onto the target in a single frame — lethal, and trivially superhuman. Every attempt to make the cheat look human is really an attempt to tune this value (and add smoothing) downward until the motion sits inside a plausible reaction-time and acceleration envelope. That tension is the entire detection thesis: the knob that makes the cheat effective is the same knob that makes it detectable, and you cannot turn both down at once. This is where behavioural detection gets its grip — long before anyone inspects the device.

STEP 2 — across the wire: the serial protocol

The PC and the Arduino agree on a tiny protocol. The simplest is two signed bytes — one for X, one for Y — but the build originally tried to send a human-readable "dx:dy" string, which is where the boundary first bit back:

ser = serial.Serial('COM5', 115200, timeout=0.01)
...
# activation gate: only steer while the player holds a key (Shift = 0x10)
if win32api.GetKeyState(0x10):
    payload = struct.pack('bb', clamp8(int(round(dx))), clamp8(int(round(dy))))
    ser.write(payload)

Python side — gated, integer-clamped, byte-packed serial write

Where the boundary bites
Three real errors stood between idea and working link, and all three are the same lesson — a serial wire carries bytes, not Python objects. numpy.float64 values can't be glued to a ":" string (UFuncNoLoopError); a str won't go down the wire un-encoded ("unicode strings are not supported, please encode to bytes"); and Serial.write() takes one bytes argument, not two. The fix is to round to integers, clamp, and pack to bytes before writing. Mundane — but it forces you to confront that everything past this point is a hardware protocol with hard limits, not a function call.

Note the GetKeyState gate: the cheat only steers while a key is held. That keeps it subtle, but it also means the device produces a very particular trace — bursts of correction perfectly correlated with a held button, separated by stretches of untouched human motion. That correlation is itself a pattern a server can learn.

STEP 3 — the firmware: bytes become HID movement

On the Arduino, the loop services the host shield (so the real mouse keeps working) and, whenever correction bytes arrive, turns them into HID movement. The catch is the one in the hardware table: Mouse.move() takes a signed byte, range −128…127. A flick across the screen is far larger than 127 pixels, so a single delta has to be walked out in steps:

#include <hidboot.h>
#include <usbhub.h>
#include <Mouse.h>

USB Usb;
HIDBoot<USB_HID_PROTOCOL_MOUSE> HidMouse(&Usb);   // real mouse → host shield

void moveBy(int dx, int dy) {
  // split into <= 127px steps because HID deltas are signed bytes
  while (dx != 0 || dy != 0) {
    int sx = constrain(dx, -127, 127);
    int sy = constrain(dy, -127, 127);
    Mouse.move(sx, sy);
    dx -= sx; dy -= sy;
  }
}

void loop() {
  Usb.Task();                          // pass the real mouse through
  if (Serial.available() >= 2) {       // injected correction from Python
    int8_t dx = Serial.read();
    int8_t dy = Serial.read();
    moveBy(dx, dy);
  }
}

Arduino sketch — pass-through + injection in one loop, with byte-limit stepping

That while loop is forced by the hardware, and it leaves a fingerprint. A genuine high-end mouse reports physically continuous motion at its own polling rate; this firmware emits aim corrections as a burst of equal-or-127-pixel quantised steps, clocked to the serial arrival rate rather than to a hand. The quantisation and the timing regularity are exactly the kind of micro-signature behavioural models are built to notice — the movement is "too clean" in a way that has a concrete, mechanical cause.

STEP 4 — reading the real mouse, and an accidental lesson in fingerprinting

The pass-through half has to parse the HID reports coming off the player's mouse. The textbook layout is four signed bytes — buttons, X, Y, wheel — and a naive parser assumes exactly that:

void Parse(USBHID *hid, bool rpt_id, uint8_t len, uint8_t *buf) {
  // assumed report: buf[0]=buttons  buf[1]=X  buf[2]=Y  buf[3]=wheel
  int8_t x = (int8_t)buf[1];
  int8_t y = (int8_t)buf[2];
  Mouse.move(x, y, (int8_t)buf[3]);
}

naive 4-byte HID report parser — correct for a generic mouse, wrong for a fancy one

This worked perfectly with one mouse and failed on a Logitech G Pro X Superlight: vertical movement came through, horizontal didn't. The cause is the whole point of the project in miniature. A high-end mouse doesn't use the generic boot-protocol layout — its HID report descriptor declares a custom format (wider 16-bit axis fields, different byte offsets, vendor usages), so reading "X" from buf[1] grabbed the wrong half of a two-byte field. Making the pass-through robust meant actually reading the device's report descriptor and locating the axis fields instead of assuming them.

The defender's gift
That bug is descriptor fingerprinting handed to you from the other side. If a Superlight and a generic mouse are distinguishable by their report descriptors alone, then so is an emulator. A stock Leonardo's HID descriptor, its default VID 0x2341 (Arduino LLC), its reporting interval, and — the giveaway — the fact that it also enumerates a CDC serial interface (a real mouse is HID-only; it has no COM port) form a composite signature an anti-cheat can read at enumeration time. The very wire used to command the device is what exposes it.

Why device-layer detection fails first

The defender's instinct — "find the cheat process / the injected code" — finds nothing, by construction:

So detection can't ask "is a cheat present?" It has to ask the two harder questions the rest of this project kept circling back to: "is this device what it claims to be?" and "is this input being produced by a human?"

Where detection actually lives

LayerSignalWhere it came from in this build
BehaviouralReaction time under human latency, snap/overshoot signatures, quantised step cadence, correction bursts gated to a held keySTEP 1 (aaMovementAmp) + STEP 3 (127-px stepping) + STEP 2 (the activation gate)
Descriptor / firmwareHID report descriptor, VID/PID (default 0x2341), reporting interval, an HID device that also exposes CDC serialSTEP 4 (the Superlight descriptor bug, and the serial back-channel)
TimingInter-report intervals clocked to a serial link rather than to a hand or a fixed polling rateSTEP 2 → STEP 3 (serial-arrival-driven emission)
Server-sideShot timing, crosshair position at the instant of fire, kill-consistency, reaction-latency distributions across many roundsaggregate of the behaviour above, observed off the trusted client

The hierarchy is the lesson. Device and firmware signals are real and useful, but they are an arms race of fingerprints — an attacker can reflash the VID/PID, clone a retail descriptor, and drop the serial interface in favour of a wireless link. Behaviour is the signal of last resort precisely because it is bound to the cheat's usefulness, not to its implementation. A descriptor can be forged for free; human-looking aim can only be forged by making the aim worse.

The Vanguard arms race

This isn't hypothetical — it's an active front. Public reporting on Riot's Vanguard describes exactly these setups: a microcontroller masquerading as a mouse or keyboard, running the cheat off-host, and Riot targeting Arduino-class devices for years, with detection reported since roughly 2021–2022 and understood to work in part through firmware-level identification — while cheat developers keep finding circumvention paths. It is textbook detection engineering: defenders ship detection for a hardware-evasion class's observable fingerprints, attackers file off the fingerprints, and the durable signal keeps migrating toward behaviour, which is far harder to fake convincingly than a device ID. Everything in the table above is one turn of that wheel, reproduced on a bench so I could see the seams myself rather than take them on faith.

What I took from it

The transferable skill wasn't "make a mouse move" — it was bridging a real-time vision pipeline, a serial protocol, and a physical HID peripheral into one closed loop, and being forced, by the hardware, to understand every layer it crosses. The reasoning generalises well past games:

Gallery

Arduino + USB Host Shield
Arduino Leonardo with USB Host Shield on top
Arduino + USB Host Shield
Arduino Leonardo connected to pc, while USB Mouse dongle connected to the USB Host Shield

Context & ethics. A personal project for educational and research purposes only, studying the detection problem — how anti-cheat identifies hardware-emulated input. Built and bench-tested on my own equipment, against bots and offline targets only; never used against live players and never distributed. The code excerpts are trimmed for explanation, not assembled as a usable tool, and no firmware, model, or end-to-end program is published here. The aim throughout is the defender's: to understand why "it looks like a real mouse" defeats device-layer checks, and to map exactly where that forces detection up the stack into behaviour and server-side analysis.