#Three ways in through peripherals
The devices on your desk can be a way in. This module triggers three well-known routes with practice devices.
| Route | Example |
|---|---|
| USB drive | A USB drive someone found carries malware |
| A device posing as a keyboard | Something that looks like a USB drive types commands as a keyboard (BadUSB) |
| Bluetooth keyboard | Someone nearby poses as a keyboard that was never paired |
The USB guards are features of the client edition (the roamswitch package). USB storage control and BadUSB prevention are checked here with virtual USB devices. The Bluetooth config check, Bluetooth control and check, also runs on Server Edition.
The client edition cannot sit on the same machine as Server Edition: installing it removes Server Edition. On a network it does not know, it switches to Away Protection (Lockdown) and stops sharing services such as SSH. If you reached the VM over SSH, the connection drops. Work from the VM's console. Times in the output are UTC.
#Make practice USB devices
You cannot plug real USB devices into a VM. Linux's dummy_hcd module helps here: it creates a virtual USB port inside the same machine. Devices that behave like a USB drive or a keyboard (gadgets) are attached to it. To the kernel they look the same as real USB devices.
This loads a kernel module and installs packages (this changes the system). Use a throwaway Ubuntu 24.04 VM.
Ubuntu's kernel does not ship dummy_hcd, so build just that one module from the kernel source. Install ClamAV and the client edition too (the repository is registered as in Installing Linux Server).
sudo apt install build-essential linux-headers-$(uname -r) dosfstools roamswitch clamav clamav-daemon
mkdir -p ~/dhcd && cd ~/dhcd
curl -fsSL -o dummy_hcd.c https://raw.githubusercontent.com/torvalds/linux/v$(uname -r | cut -d. -f1,2)/drivers/usb/gadget/udc/dummy_hcd.c
echo "obj-m := dummy_hcd.o" > Makefile
make -C /lib/modules/$(uname -r)/build M=$PWD modules
sudo mkdir -p /opt/fakeusb && sudo cp dummy_hcd.ko /opt/fakeusb/
sudo systemctl stop clamav-freshclam && sudo freshclam && sudo systemctl restart clamav-daemonMake the contents of the practice USB drive. EICAR is a public, harmless string used to test antivirus software.
sudo truncate -s 64M /opt/fakeusb/stick.img
sudo mkfs.vfat -n PRACTICE /opt/fakeusb/stick.img
sudo mkdir -p /mnt/img /mnt/stick && sudo mount -o loop /opt/fakeusb/stick.img /mnt/img
printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' | sudo tee /mnt/img/eicar.com >/dev/null
echo hello | sudo tee /mnt/img/readme.txt >/dev/null
sudo umount /mnt/imgThis script plugs and unplugs the devices. Put it at /usr/local/bin/fakeusb and make it executable with sudo chmod 755. The USB IDs are the practice IDs 1209:0001 (USB drive) and 1209:0002 (keyboard).
#!/bin/sh
# fakeusb storage|keyboard plug|unplug : a virtual USB device on dummy_hcd (practice only)
set -e
G=/sys/kernel/config/usb_gadget
kind=$1; act=$2
modprobe libcomposite
mountpoint -q /sys/kernel/config || mount -t configfs none /sys/kernel/config
lsmod | grep -q '^dummy_hcd' || insmod /opt/fakeusb/dummy_hcd.ko
case "$kind" in
storage) d=$G/practice_stick; pid=0x0001; prod="Practice Stick"; modprobe usb_f_mass_storage;;
keyboard) d=$G/practice_kbd; pid=0x0002; prod="Practice Keyboard"; modprobe usb_f_hid;;
*) echo "usage: fakeusb storage|keyboard plug|unplug"; exit 2;;
esac
if [ "$act" = unplug ]; then
[ -e $d/UDC ] && echo "" > $d/UDC 2>/dev/null || true
echo unplugged; exit 0
fi
if [ ! -d $d ]; then
mkdir -p $d && cd $d
echo 0x1209 > idVendor; echo $pid > idProduct; echo 0x0100 > bcdDevice; echo 0x0200 > bcdUSB
mkdir -p strings/0x409
echo "Practice" > strings/0x409/manufacturer; echo "$prod" > strings/0x409/product; echo "PRACTICE0001" > strings/0x409/serialnumber
mkdir -p configs/c.1/strings/0x409; echo "practice" > configs/c.1/strings/0x409/configuration; echo 100 > configs/c.1/MaxPower
if [ "$kind" = storage ]; then
mkdir -p functions/mass_storage.0
echo 1 > functions/mass_storage.0/lun.0/removable
echo /opt/fakeusb/stick.img > functions/mass_storage.0/lun.0/file
ln -s functions/mass_storage.0 configs/c.1/
else
mkdir -p functions/hid.usb0; cd functions/hid.usb0
echo 1 > protocol; echo 1 > subclass; echo 8 > report_length
python3 -c 'import sys; sys.stdout.buffer.write(bytes.fromhex("05010906a101050719e029e71500250175019508810295017508810395057501050819012905910295017503910395067508150025650507190029658100c0"))' > report_desc
cd $d; ln -s functions/hid.usb0 configs/c.1/
fi
fi
cd $d
# one UDC: unbind the other gadget first
for o in $G/*; do [ "$o" != "$d" ] && [ -n "$(cat $o/UDC 2>/dev/null)" ] && echo "" > $o/UDC; done
echo dummy_udc.0 > UDC
echo pluggedPlug one in.
sudo fakeusb storage plug
sleep 3
lsusb | grep 1209plugged
Bus 003 Device 002: ID 1209:0001 Generic pid.codes Test PIDThe practice USB drive shows up in lsusb. Right after plugging in, it may not be listed yet, so wait 3 seconds first. To unplug it, run sudo fakeusb storage unplug.
#Hold an unknown USB drive, then allow and scan it
On a network it has not registered, the client edition switches to Away Protection (Lockdown). In Lockdown, unapproved USB drives and keyboards are held even when the USB guard settings are off. It also sets the kernel's authorized_default to 0, so new USB devices come up unusable until they are allowed.
Approved devices go into allowed_usb_devices in ~/.config/roamswitch/config.json. Normally you add them from the app. This exercise does not use the desktop app, so the file is written directly.
This writes the config file and restarts the service (this changes the system).
First, put the keyboard you use (05ac:8105 on this VM) on the allow-list. A keyboard that is not on the list is held even if it was already attached: it could have been swapped in before a reboot (a note in the code).
mkdir -p ~/.config/roamswitch
cat > ~/.config/roamswitch/config.json <<'J'
{
"language": "en",
"allowed_usb_devices": [
{"deviceIdentifier": "05ac:8105", "displayName": "Virtual USB Keyboard", "dateAdded": "2026-11-05 09:00"}
]
}
J
chmod 600 ~/.config/roamswitch/config.json
sudo systemctl restart roamswitchPlug in the practice USB drive and look.
sudo fakeusb storage plug
sleep 3
sudo dmesg | tail -1
cat /run/roamswitch/state.json
cat /sys/bus/usb/devices/usb3/authorized_default
cat /sys/bus/usb/devices/3-1/authorized
lsblk -S
roamswitch notifications | head -8plugged
[ 168.746689] usb 3-1: Device is not authorized for usage
{"active_level":"lockdown","air_gap":false,"fanotify_ready":true,"network_trusted":false,"usb_zero_trust":true}
0
0
=====================================================
🔔 Notification History (past 7 days)
=====================================================
2026-11-12T02:25:03.019626434+00:00
⏸ Unauthorized USB Storage Held
'Practice Practice Stick (VID:1209 PID:0001)' was held due to Away Protection (Lockdown). Allow it in the approval dialog to use it.How to read it:
active_levelislockdown, because the network is not registeredauthorizedis 0: the kernel found the device but has not made it usablelsblk -Sshows nothing. No disk appears, so nothing can be mounted
The pending request is in /run/roamswitch/approvals.json. The app reads it and shows Allow and Deny.
python3 -c "
import json
for a in json.load(open('/run/roamswitch/approvals.json')): print(a['kind'], a['device_id']); print(a['title']); print(a['body'])"usb_storage 1209:0001
Allow this unregistered USB drive?
"Practice Practice Stick" (VID:1209 PID:0001) was connected. Details: Practice
It isn't on the allow-list, so it's held un-mounted for now. "Allow" makes it usable and adds it to the allow-list. If you don't recognize it, choose Deny.Putting the drive on the allow-list releases it. When a USB drive is mounted, ClamAV reads it in a read-only scan (usb_mount_scan_enabled, on by default). A match raises a critical notification. Nothing is deleted or ejected.
This edits the config file (this changes the system).
Make a small function that adds to the allow-list, and add the practice USB drive. Config changes take effect in a few seconds without a restart.
allow_usb() { python3 - "$1" "$2" <<'P'
import json,os,sys
p=os.path.expanduser('~/.config/roamswitch/config.json')
c=json.load(open(p))
c['allowed_usb_devices'].append({"deviceIdentifier":sys.argv[1],"displayName":sys.argv[2],"dateAdded":"2026-11-05 09:40"})
json.dump(c,open(p,'w'),ensure_ascii=False,indent=2)
P
}
allow_usb 1209:0001 "Practice Stick"
sleep 6
cat /sys/bus/usb/devices/3-1/authorized
lsblk -S
sudo mount -o ro /dev/sda /mnt/stick && ls /mnt/stick
sleep 15
roamswitch notifications | sed -n 4,9p1
NAME HCTL TYPE VENDOR MODEL REV SERIAL TRAN
sda 0:0:0:0 disk Linux File-Stor Gadget 0700 PRACTICE0001 usb
eicar.com
readme.txt
2026-11-12T02:25:19.295712309+00:00
🦠 Malware found on a USB drive
ClamAV found 1 infected file on /mnt/stick (/dev/sda).
eicar.com (Eicar-Test-Signature)
Do not open it; unplug the drive (notification only; nothing was deleted or ejected).authorized turned 1 and sda appeared. Within a second of the mount, ClamAV found EICAR. eicar.com is still there. The allow-list identifies a device by its IDs (VID:PID), such as 1209:0001. Any device can claim those IDs. So the device's fingerprint at approval time (built from the manufacturer, product name, serial number and more) is remembered too. The fingerprints are in /var/lib/roamswitch/usb_fingerprints.json.
Change only the serial number of the practice USB drive and plug it in again.
sudo umount /mnt/stick
sudo fakeusb storage unplug
sleep 5
echo PRACTICE9999 | sudo tee /sys/kernel/config/usb_gadget/practice_stick/strings/0x409/serialnumber
sudo fakeusb storage plug
sleep 6
cat /sys/bus/usb/devices/3-1/authorized
sudo journalctl -u roamswitch --since -15s -o cat | grep fingerprint
roamswitch notifications | sed -n 4,7p
python3 -c "
import json
for a in json.load(open('/run/roamswitch/approvals.json')): print(a['kind'], a['device_id']); print(a['title']); print(a['body'])"PRACTICE9999
plugged
0
2026-11-12T02:25:38.996822Z WARN roamswitch_daemon::sentinel: ⚠️ [BadUSB] 1209:0001 is allow-listed but a device with a different descriptor fingerprint is attached — treating it as unapproved
2026-11-12T02:25:39.021386116+00:00
A registered USB drive's identity has changed
'Practice Practice Stick (VID:1209 PID:0001)' has the VID:PID of a USB drive on the allow-list, but its internal identity (descriptors) differs from the one registered, so it was held. Allow it in the approval dialog only if you can confirm it is the same device.
usb_storage 1209:0001
A registered USB drive's identity has changed
"Practice Practice Stick" (VID:1209 PID:0001) has the VID:PID of a USB drive on the allow-list, but its internal identity (descriptors) differs from the one registered. Details: Practice
It may be a different device from the one you registered, so it is held un-mounted. Choose Allow only if you can confirm it is the same device. If you don't recognize it, choose Deny.The IDs are on the allow-list, but the fingerprint differs, so the drive is held as unapproved. Even when it is plugged back in about 5 seconds after unplugging, it gets a notification. Both the notification and the approval request say that the identity differs from the registered one, and ask you to allow it only if you can confirm it is the same device. The journal line is in English whatever the display language. The fingerprint does not tell apart a device that copies every descriptor (a note in the code).
#Stop a device that poses as a keyboard
In BadUSB, a device that looks like a USB drive or similar announces itself as a keyboard and types commands. It types right after it is plugged in, faster than a person can.
When an unapproved keyboard is attached, RoamSwitch takes its input for itself (an exclusive evdev grab). Its keystrokes reach no other program. The device keeps its power, so a mouse on the same receiver keeps working. If no other usable keyboard is left, it only notifies and does not grab, so you are not locked out (a note in the code).
/usr/local/bin/typekeys.py sends echo practice and Enter from the practice keyboard, one key every 5 ms. /usr/local/bin/readkeys.py reads the keyboard's input alongside and counts the keys that reach it. Make both executable with sudo chmod 755.
#!/usr/bin/env python3
# typekeys.py: send "echo practice" + Enter through /dev/hidg0, one key every 5 ms (a script, not a person)
import sys, time
KEYS = {c: 0x04 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")}
KEYS[" "] = 0x2c; KEYS["\n"] = 0x28
gap = float(sys.argv[1]) if len(sys.argv) > 1 else 0.005
with open("/dev/hidg0", "wb", buffering=0) as f:
for ch in "echo practice\n":
f.write(bytes([0, 0, KEYS[ch], 0, 0, 0, 0, 0]))
f.write(bytes(8))
time.sleep(gap)
print("sent", len("echo practice\n"), "keys")#!/usr/bin/env python3
# readkeys.py EVENT SECONDS: count key presses that reach this reader (no grab of its own)
import os, select, struct, sys, time
fd = os.open(sys.argv[1], os.O_RDONLY | os.O_NONBLOCK)
end = time.time() + float(sys.argv[2]); n = 0
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.2)
if r:
data = os.read(fd, 24 * 64)
for i in range(0, len(data), 24):
_, _, typ, code, val = struct.unpack("llHHi", data[i:i + 24])
if typ == 1 and val == 1:
n += 1
print("key presses seen:", n)Plug in the practice keyboard and type.
sudo fakeusb keyboard plug
sleep 6
EV=$(grep -A6 'Name="Practice Practice Keyboard"' /proc/bus/input/devices | sed -n 's/.*Handlers=.*\(event[0-9]*\).*/\1/p')
(sudo readkeys.py /dev/input/$EV 5 &) ; sleep 1; sudo timeout 10 typekeys.py
sleep 6
roamswitch notifications | sed -n 4,13pplugged
sent 14 keys
key presses seen: 0
2026-11-12T02:25:51.241973616+00:00
🚨 This keyboard shows signs of automated (scripted) typing
The keyboard awaiting approval has already sent 13 keystrokes at an unnaturally uniform ~6ms interval since connecting — consistent with a scripted injection attack (BadUSB), not a person typing. Its keystrokes are already blocked, but if you don't recognize this device, be sure to choose Deny and unplug it.
VID:1209 PID:0002
2026-11-12T02:25:45.131931377+00:00
🛑 Unauthorized USB Keyboard (BadUSB) Blocked
Input from 'Practice Practice Keyboard (VID:1209 PID:0002)' was blocked and isolated. Authorize it in the RoamSwitch "USB Guard" tab to use it.None of the 14 keys got through. There are two notifications. One says the unapproved keyboard was grabbed. The other says the keystrokes that arrived while it was held came at a speed and evenness no hand produces. The timing is a supporting clue: the block was already in place, whatever the timing.
Next, allow this keyboard and type at a human-like pace (one key every 150 ms).
allow_usb 1209:0002 "Practice Keyboard"
sleep 6
(sudo readkeys.py /dev/input/$EV 5 &) ; sleep 1; sudo timeout 10 typekeys.py 0.15
cat /run/roamswitch/approvals.jsonsent 14 keys
key presses seen: 14
[]Once allowed, all 14 keys got through, and the pending request is gone. The keys go to the VM's console (it just shows echo practice).
#Settings on a trusted network
On a registered network, or with the away level set to Standard (balanced), the USB guards follow their settings. They live in ~/.config/roamswitch/config.json. These are the defaults, checked in the code.
| Key | Default | What it does |
|---|---|---|
usb_storage_guard_enabled | false | Hold unapproved USB drives |
usb_keyboard_guard_enabled | false | Grab the input of unapproved keyboards |
usb_zero_trust_enabled | false | New USB devices come up unusable until allowed |
usb_mount_scan_enabled | true | Scan a mounted USB drive with ClamAV (when clamav_enabled is on too) |
usb_keyboard_timeout_release | false | Release an unanswered keyboard hold after 3 minutes |
away_protection_level | lockdown | The protection level on unregistered networks |
This edits the config file and restarts the service (this changes the system).
Unplug the practice keyboard and take the practice USB drive off the allow-list (unallow_usb). Set the away level to Standard. A change of protection level takes effect on a service restart (or a network change).
setk() { python3 - "$1" "$2" <<'P'
import json,os,sys
p=os.path.expanduser('~/.config/roamswitch/config.json')
c=json.load(open(p))
k,v=sys.argv[1],sys.argv[2]
if v=='DEL': c.pop(k,None)
else: c[k]=json.loads(v)
json.dump(c,open(p,'w'),ensure_ascii=False,indent=2)
P
}
unallow_usb() { python3 - "$1" <<'P'
import json,os,sys
p=os.path.expanduser('~/.config/roamswitch/config.json')
c=json.load(open(p))
c['allowed_usb_devices']=[d for d in c['allowed_usb_devices'] if d['deviceIdentifier']!=sys.argv[1]]
json.dump(c,open(p,'w'),ensure_ascii=False,indent=2)
P
}
sudo fakeusb keyboard unplug
unallow_usb 1209:0001
setk away_protection_level '"balanced"'
sudo systemctl restart roamswitch; sleep 10
cat /run/roamswitch/state.json
roamswitch guards | grep -i "protection level"unplugged
{"active_level":"balanced","air_gap":false,"fanotify_ready":true,"network_trusted":false,"usb_zero_trust":false}
Active protection level: Standard [BALANCED]roamswitch guards also shows the level the daemon is using right now.
Plug in the USB drive you just took off the allow-list. Then turn on only the USB drive guard and plug it in again.
sudo fakeusb storage plug; sleep 6
cat /sys/bus/usb/devices/3-1/authorized
sudo fakeusb storage unplug; sleep 5
setk usb_storage_guard_enabled true; sleep 4
sudo fakeusb storage plug; sleep 6
cat /sys/bus/usb/devices/3-1/authorized
roamswitch notifications | sed -n 4,7pplugged
1
unplugged
plugged
0
2026-11-12T02:26:39.408764995+00:00
⏸ Unauthorized USB Storage Held
'Practice Practice Stick (VID:1209 PID:0001)' was held by the USB storage guard setting. Allow it in the approval dialog to use it.At Standard with the guard off, the drive was usable (1). With usb_storage_guard_enabled on, it was held (0). This setting takes effect without a restart. The title is the same "Held" as in Lockdown, and the body gives the reason (Lockdown, or the USB storage guard setting). With usb_storage_guard_enabled on, a device whose fingerprint differs was also held, with the same "identity has changed" notification as in the previous exercise. At Standard with the guard off, it was usable even with a different fingerprint, with only a connection notification.
To undo, delete the added keys and restart. The level goes back to Lockdown. Finally, unplug the practice device and remove the module.
setk away_protection_level DEL
setk usb_storage_guard_enabled DEL
sudo systemctl restart roamswitch
sudo fakeusb storage unplug
sudo rmmod dummy_hcd- A laptop you carry: keep Lockdown away from home. Unknown USB drives and keyboards are held whatever the settings say
- A desk PC at home or at work: if people can plug USB devices into it, turn on
usb_keyboard_guard_enabled. Put the keyboard you use on the allow-list first - A machine with no screen: no approval dialog appears, so a keyboard hold lasts until someone answers (the default, checked in the code).
usb_keyboard_timeout_releasereleases it after 3 minutes; weigh the risk of being locked out against the weaker guard
#Check for Bluetooth keyboard spoofing
Bluetooth keyboards had a flaw that accepted keystrokes from devices that were never paired (CVE-2023-45866, known from BlueDucky). In Linux's BlueZ, ClassicBondedOnly in /etc/bluetooth/input.conf is the guard. roamswitch bluetooth-audit reads the BlueZ config files and looks for settings that remove it. It only reads files: nothing is run and nothing is sent.
This edits the BlueZ config files (this changes the system). This exercise was run on a Server Edition VM.
sudo apt install bluez
sudo cp /etc/bluetooth/input.conf /etc/bluetooth/input.conf.orig
sudo cp /etc/bluetooth/main.conf /etc/bluetooth/main.conf.orig
sudo sed -i 's/^#ClassicBondedOnly=true/ClassicBondedOnly=false/' /etc/bluetooth/input.conf
sudo sed -i 's/^#AlwaysPairable = false/AlwaysPairable = true/' /etc/bluetooth/main.conf
roamswitch bluetooth-audit=====================================================
🔵 Bluetooth keyboard-injection check
=====================================================
[high] input.conf: ClassicBondedOnly=false
ClassicBondedOnly=false makes this machine accept keyboard and mouse connections from devices that were never paired. That is the way in for someone nearby who poses as a keyboard and types on it (BlueDucky, CVE-2023-45866). Delete the line or set it to true (the BlueZ default is true).
[medium] main.conf: AlwaysPairable=true
AlwaysPairable=true accepts pairing requests from nearby devices even when no pairing agent is running (the default is false). Enable it only when you need it.
Keyboards and mice connected over Bluetooth right now:
none
This only reads config files and /proc: nothing is run and nothing is sent. If you see a device you do not recognise, remove its pairing in the Bluetooth settings.Each of the two edited lines is reported with a severity. Settings that are not written are taken as the BlueZ defaults. With --json, the same result comes out as JSON.
roamswitch bluetooth-audit --json{
"installed": true,
"findings": [
{
"id": "classic_bonded_only_off",
"severity": "high",
"detail": "input.conf: ClassicBondedOnly=false"
},
{
"id": "always_pairable",
"severity": "medium",
"detail": "main.conf: AlwaysPairable=true"
}
],
"input_devices": [],
"unreadable": []
}Undo the edit and check again.
sudo mv /etc/bluetooth/input.conf.orig /etc/bluetooth/input.conf
sudo mv /etc/bluetooth/main.conf.orig /etc/bluetooth/main.conf
roamswitch bluetooth-audit | sed -n 5p No setting in the BlueZ config lets unpaired devices in (anything not set falls back to the BlueZ default).#What this does not show
This was checked on a throwaway Ubuntu 24.04 VM, with virtual USB devices on dummy_hcd. Some things differ from real hardware.
- The practice devices go through the kernel's USB stack, but physical plugging and electrical attacks are not reproduced
- ClamAV finds only what it knows. This module used only the public test string (EICAR). The scan only notifies; it deletes nothing
- The fingerprint does not tell apart a device that copies every descriptor. It is there to catch a device that only copies the IDs
- When no other usable keyboard is left, the keyboard is not grabbed, only notified, so you are not locked out (a note in the code)
- The Bluetooth check is a check of config files. This VM has no Bluetooth hardware, so real pairing and the list of connected devices were not tried
- Turn on the keyboard guard before the keyboard you use is on the allow-list
- Open a USB drive you found before you see the scan result
- Treat a device as safe because its IDs (VID:PID) are on the allow-list
Away from home (on an unregistered network), you plug in an unknown USB drive. The USB guard settings are off. What happens?
Show the explanation
In Lockdown, unapproved USB drives and keyboards are held whatever the settings say.
authorizedstays 0 and nothing shows inlsblk. Once on the allow-list, the drive becomes usable.
An unapproved keyboard sent keys 5 ms apart. Which is right?
Show the explanation
An unapproved keyboard's input is grabbed as soon as it is attached. The timing notification is extra information that points to automated typing.
#What to learn next
- Switching and choosing protection levels is covered in "Setting and choosing protection levels" (coming soon)
- Handling malware that was found is covered in "Suspicious execution and malware" (coming soon)
- USB and Bluetooth in the desktop app are covered in the Linux manual
Verified on 2026-11-12. Target: Linux 1.17.39. The USB labs ran in Japanese and English, each on a fresh disposable Ubuntu 24.04 VM with the 1.17.39 client from the public apt repository and virtual devices on dummy_hcd; the Bluetooth audit ran on a Server Edition 1.17.39 VM.
Command output varies with the product version and the environment. If yours differs, check the version first.