PsycoStea/Pi-Zero-2W-Bad-USB

GitHub: PsycoStea/Pi-Zero-2W-Bad-USB

Stars: 33 | Forks: 1

# Raspberry Pi Zero 2 W BadUSB HID Toolkit A programmable BadUSB / HID attack platform built on a Raspberry Pi Zero 2 W. The Pi enumerates as a USB composite device (keyboard plus an optional read-only mass-storage volume) and executes Ducky-Script-style payloads against the host it's plugged into. Designed for authorised red-team engagements, penetration tests, and CTFs. ## Table of contents 1. [Features](#features) 2. [How it works](#how-it-works) 3. [Hardware](#hardware) 4. [Install](#install) 5. [Daily operation](#daily-operation) 6. [Writing payloads](#writing-payloads) 7. [Configuration reference](#configuration-reference) 8. [Architecture notes](#architecture-notes) 9. [Tests](#tests) 10. [Troubleshooting](#troubleshooting) 11. [Repository layout](#repository-layout) 12. [Credits](#credits) ## Features ## How it works +----------------+ USB cable +-------------+ | Raspberry Pi | ===================>>> | Host PC | | Zero 2 W | (Pi emulates HID + | (target) | | (this device) | optional drive) | | +----------------+ +-------------+ | | /home/pi/pi-badusb/ | +-- badusb.service ----> monitor_and_run.py | | on `state == configured`: v run_payload.py | v /dev/hidg0 (USB HID gadget) 1. At boot, the `badusb.service` systemd unit runs `gadget_setup.sh`, which uses configfs/`libcomposite` to compose a USB gadget with an HID keyboard function and (optionally) a mass-storage LUN backed by a flat image file at `/var/badusb/storage.img`. 2. The unit then runs `monitor_and_run.py`. It polls `/sys/class/udc//state` and waits for `configured` — the USB-spec state that means a host has successfully enumerated the gadget. (We do **not** use `/dev/hidg0`'s existence as a signal: that's true the moment the gadget binds to the UDC at boot, regardless of whether anything is plugged in.) 3. When the host attaches, the listener executes `run_payload.py`, which parses `payload.txt` and writes HID reports to `/dev/hidg0`. 4. When the payload finishes, the listener **actively unbinds** the gadget from the UDC (the Pi Zero 2 W cannot detect physical disconnect via software — see [Architecture notes](#architecture-notes)), sleeps a cooldown, and rebinds. The device then waits for the next `configured` transition. ## Hardware | Component | Note | |-----------|------| | Raspberry Pi Zero 2 W | Tested on 2026-05 hardware revision. Older Pi Zero W with the BCM2835 dwc_otg driver also works in principle, but this README assumes 2 W with dwc2. | | micro-USB to USB-A cable | Or a "USB stick" form-factor adapter that lets the Pi plug straight into a host port. | | Optional: separate charger | If you want to power the Pi from a non-host source so the listener can boot before being plugged into a target (otherwise the host port supplies the power). | The Pi Zero 2 W has two micro-USB ports: - **`PWR IN`** — power only, doesn't expose USB data lines to the dwc2 OTG block. - **`USB`** — the OTG data port; this is where you plug into the target. ## Software | Requirement | Why | |-------------|-----| | Raspberry Pi OS (Debian Bookworm or Trixie, 64-bit Lite recommended) | The install script writes to `/boot/firmware/...` on Bookworm+ and falls back to `/boot/...` on older images. | | Python 3 | Comes with Pi OS. | | `mkfs.vfat` | For formatting the mass-storage backing image on first run. Skip if you disable mass storage. | | Root access for setup | Touches systemd, udev, and `/boot/firmware/config.txt`. | ## Install Clone or copy the repo into the Pi, then run the installer: git clone http://your-gitea/admin/Pi-Zero-2W-Bad-USB.git /home/pi/pi-badusb cd /home/pi/pi-badusb sudo ./install.sh sudo reboot After reboot, enable and start the service: sudo systemctl enable --now badusb.service journalctl -u badusb -f `install.sh` is **idempotent** — re-run it whenever you change project files. It: - Detects `/boot/firmware` (Bookworm+) vs `/boot` (older). - Ensures `dtoverlay=dwc2,dr_mode=otg` is active under an `[all]` block in `config.txt`. Raspberry Pi Imager defaults put this line inside a `[cm5]` filter that doesn't apply on Pi Zero 2 W; the installer appends a sentinel-marked override so re-runs don't duplicate it. - Ensures `modules-load=dwc2` is in `cmdline.txt`. - Warns if `g_ether` is still present in `cmdline.txt` (it steals the UDC from `libcomposite` and breaks gadget mode). - Installs the systemd unit at `/etc/systemd/system/badusb.service`. - Installs the udev rule at `/etc/udev/rules.d/99-badusb-hidg.rules` so `/dev/hidg0` is group-writable by `plugdev`. - Adds the `pi` user to `plugdev`. - Creates `/var/badusb/` for the mass-storage backing image. ## Daily operation # Start / stop / restart sudo systemctl start badusb sudo systemctl stop badusb sudo systemctl restart badusb # safe to do while plugged in # Watch live journalctl -u badusb -f # Disable autostart on boot sudo systemctl disable badusb # Tune timings (creates an override drop-in) sudo systemctl edit badusb # (paste an [Service] block with Environment="BADUSB_REARM_COOLDOWN_S=8" etc) sudo systemctl restart badusb The service depends on `sys-kernel-config.mount` and the presence of a UDC, so it can't fire payloads before the gadget is actually ready. **Editing the payload doesn't require a restart** — `payload.txt` is read fresh on every plug-in. ### Minimal example REM Open Run dialog and type a greeting via Notepad LAYOUT US GUI r DELAY 1500 STRING notepad ENTER DELAY 2500 STRINGLN Hello from the Pi Zero 2 W ### Variables, conditionals, loops VAR $USER="alice" VAR $COUNT=0 WHILE $COUNT < 3 STRINGLN Hello $USER (iteration $COUNT) VAR $COUNT = $COUNT + 1 END_WHILE IF $USER == "alice" STRINGLN matched ELSE STRINGLN missed END_IF Math expressions in `VAR` go through an `ast`-walker safe evaluator — no names, no calls, no attribute access, only numeric literals and `+ - * / // % **`. ### Holding modifiers HOLD SHIFT STRINGLN this line is in capitals RELEASE SHIFT ### Sending arbitrary modifier combinations REM Hold Ctrl+Shift (0x01 + 0x02) and tap A INJECT_MOD 0x03 STRING a REM Release all modifiers INJECT_MOD 0x00 ### Randomness RANDOM_LETTER 12 # 12 random mixed-case letters RANDOM_NUMBER 6 # 6 random digits RANDOM_SPECIAL 4 # 4 random ASCII symbols ### Keyboard layout LAYOUT UK # switch to UK ISO mappings for subsequent STRING/STRINGLN STRING @ " # ~ £ \ | # types correctly on a UK-locale host Drop another file into `keymaps/` (alongside `us.py` and `uk.py`) and the `LAYOUT ` directive will pick it up via `importlib`. ## Configuration reference ### Mass-storage gadget (top of `gadget_setup.sh`) | Variable | Default | Meaning | |----------|---------|---------| | `ENABLE_MASS_STORAGE` | `1` | `0` for an HID-only gadget. | | `BACKING_FILE` | `/var/badusb/storage.img` | Flat image exposed to the host. | | `BACKING_SIZE_MB` | `64` | Created on first run if missing. | | `BACKING_LABEL` | `BADUSB` | FAT volume label. | | `MASS_STORAGE_RO` | `1` | Read-only by default. | These can be overridden per-invocation by setting them in the environment when running `gadget_setup.sh` manually, or globally via the unit's `Environment=` directives. ### Listener tunables (`monitor_and_run.py`) | Env var | Default | Meaning | |---------|---------|---------| | `BADUSB_POST_PAYLOAD_FLUSH_S` | `0.5` | Sleep after payload before unbinding so HID writes drain. | | `BADUSB_REARM_COOLDOWN_S` | `5` | How long the gadget stays dark to the host between unbind and rebind. | | `BADUSB_MIN_INTER_FIRE_S` | `10` | Minimum seconds between two payload fires; under this, the fire is suppressed and the gadget re-unbinds. | | `BADUSB_MAX_FIRES_PER_MINUTE` | `6` | Hard cap; over this, pause for `BADUSB_RATELIMIT_PAUSE_S`. | | `BADUSB_RATELIMIT_PAUSE_S` | `60` | Pause duration after rate-limit trigger. | Override with: sudo systemctl edit badusb # In the editor: # [Service] # Environment="BADUSB_REARM_COOLDOWN_S=8" # Environment="BADUSB_MIN_INTER_FIRE_S=20" sudo systemctl restart badusb ## Architecture notes ### Why the listener uses UDC `state`, not `/dev/hidg0` The previous implementation tested `/dev/hidg0` existence + writability as the "host attached" signal. That device node is created the moment the gadget binds to the UDC at boot — long before any host has actually enumerated it. So payloads fired immediately on power-up regardless of where the Pi was plugged. The reliable signal is `/sys/class/udc//state`, which reports the USB-spec device state. Only `configured` means the host has issued `SetConfiguration(1)` — the device is now eligible to send HID reports. ### Why we force-unbind after each payload Detecting physical disconnect on the Pi Zero 2 W is **impossible from software**: the board doesn't wire VBUS sense to the SoC's dwc2 OTG block. After a physical unplug: - `/sys/class/udc//state` stays at `configured`. - `current_speed` stays at `high-speed`. - The dwc2 `GOTGCTL` register stays at `0x000d0000` (BSesVld bit set). - No udev events fire. So instead of waiting for a signal that will never come, the listener *actively causes* the disconnect: after each payload, it writes `""` to the gadget's `UDC` configfs file (which the kernel interprets as unbind), sleeps `BADUSB_REARM_COOLDOWN_S`, then writes the UDC name back to rebind. The next host plug-in produces a clean `configured` transition that the listener can detect. If the operator leaves the Pi plugged in after a payload, the rebind causes the host to re-enumerate the gadget. To prevent a runaway fire loop, two safeguards kick in: 1. `BADUSB_MIN_INTER_FIRE_S` — if a `configured` transition happens within this window of the previous fire, suppress it and unbind again. The gadget cycles silently in the background. 2. `BADUSB_MAX_FIRES_PER_MINUTE` — sliding-window hard cap. Over the cap, the listener pauses for `BADUSB_RATELIMIT_PAUSE_S` and logs a warning. ### Why we never use `rm -rf` on configfs configfs's kernel-managed attribute files (`bcdUSB`, `idVendor`, `webusb/*`, `os_desc/*`, …) cannot be removed by `rm(2)` — the kernel returns `EPERM`. They are released only when their parent directory is `rmdir`-ed. Both `gadget_setup.sh` and `teardown_gadget.sh` walk the configfs tree in canonical libcomposite order — `rmdir` only, never `rm` on attribute files — and the kernel cleans up the rest automatically. ### Why the Python helpers use `os.write` not `file.write` Writing an empty string via `open(path, "w").write("")` does **not** invoke `write(2)` with zero bytes — CPython's TextIOWrapper elides it. For configfs unbind (which the kernel interprets from a zero-length post-newline-strip write), we use `os.write(fd, b"\n")` directly so the syscall is always issued with at least one byte. ## Tests The Ducky parser has a 34-test pytest suite that runs against a `MockHIDEngine` (an in-memory drop-in for the real HID writer), so it needs no Pi and no USB hardware. cd /home/pi/pi-badusb python3 -m pytest tests/ Coverage includes: - `safe_eval_math` accepting arithmetic, rejecting names / calls / attribute access / string constants. - `evaluate_condition` for numeric and case-sensitive string compares. - `VAR` with `=`, `+=`, `-=`, `*=`, `/=`. - `IF` / `ELSE` / `END_IF` taking the correct branch. - `WHILE` / `END_WHILE` iteration counts for `<` and `<=`. - `RANDOM_*` length correctness; `RANDOM_` no-op + warning. - `INJECT_MOD` modifier byte persistence across subsequent keystrokes. - `HOLD SHIFT` capitalising each character in `STRINGLN abc`. - `LAYOUT US` vs `LAYOUT UK` producing different reports for `@` and `"`; unknown layout falls back to the previous one. - `STRING_BLOCK` joining lines; `STRINGLN_BLOCK` honouring min-indent. ## Troubleshooting ### "Payload never fires when plugged in" 1. `cat /sys/class/udc/*/state` — must reach `configured` when the host enumerates. If it stays at `not attached`, the host isn't talking: try a different cable (some are charge-only) or a different host port. 2. `lsmod | grep dwc2` — must be loaded. If only `dwc_otg` is there, `dtoverlay=dwc2,dr_mode=otg` isn't applying; re-run `install.sh` and reboot. 3. `journalctl -u badusb -f` while plugging in — should show `Host attached. Running payload.` within ~2s of host enumeration. ### "Payload fires in a loop with the LED blinking, even unplugged" This was a real bug that's now fixed. If it happens, you've reverted to a pre-`os.write` build. Make sure `monitor_and_run.py` matches the current main branch (search for `os.write(fd, payload)`). ### "Service won't restart — `Operation not permitted`" Pre-fix `gadget_setup.sh` used `rm -rf` on configfs. The current version uses `teardown_gadget()` — if you see those errors, you have an old copy. Re-deploy from main. ### "Permission denied on `/dev/hidg0`" The udev rule needs a hot-plug to apply, or `sudo udevadm trigger` and a re-login so the `pi` user picks up the `plugdev` group. ### "`g_ether` warning during install" Remove `g_ether` from `cmdline.txt`; it claims the UDC before `libcomposite` can bind. ### "Host shows a USB drive but it's not the size I expected" Mass-storage size is set by `BACKING_SIZE_MB` and only takes effect on first run when the backing image is created. To resize: sudo systemctl stop badusb sudo rm /var/badusb/storage.img sudo BACKING_SIZE_MB=256 /home/pi/pi-badusb/gadget_setup.sh sudo systemctl start badusb ### "I want to leave the Pi plugged in without it spamming the host" That's what `BADUSB_MIN_INTER_FIRE_S` and `BADUSB_MAX_FIRES_PER_MINUTE` are for. Set them higher via `systemctl edit badusb`. With the defaults, a Pi left plugged in re-fires every ~15s for the first minute, then pauses for 60s, then resumes. ## Repository layout . ├── README.md This file ├── LICENSE MIT ├── install.sh Idempotent installer (firmware config, systemd, udev, plugdev) ├── gadget_setup.sh Composes the USB gadget via configfs/libcomposite ├── teardown_gadget.sh Canonical configfs teardown (wired as ExecStop) ├── reload_gadget.sh Manual UDC unbind/rebind helper ├── autorun.sh Legacy manual-launch wrapper (systemd is preferred) ├── monitor_and_run.py Listener: waits for host attach, runs payload, forces re-arm ├── run_payload.py Ducky-Script-style interpreter ├── payload.txt Your payload — edit freely; re-read on each plug-in ├── payload_commands.md Full command reference ├── etc/ │ ├── badusb.service systemd unit │ └── 99-badusb-hidg.rules udev rule for /dev/hidg0 ownership ├── keymaps/ │ ├── __init__.py Dynamic layout loader │ ├── us.py US ANSI (default) │ └── uk.py UK ISO └── tests/ ├── __init__.py ├── conftest.py pytest path setup └── test_parser.py 34 parser tests against a MockHIDEngine ## License MIT — see [`LICENSE`](LICENSE).