この記事のスタンス
本記事は 自分の MacBook に対する HID 注入の学習メモです。
「他人のPCに挿す」「攻撃ペイロードを撒く」といった用途は一切扱いません。
Qiita で公開する以上、誰が読んでも参考になる「仕組みの解説」だけに留めています。
🪪 何を作ったのか
USB-A 直挿しの超小型ボード Adafruit RP2040 QT Trinkey (P5056) に CircuitPython を載せ、
挿すだけで自分のホームページ (masafy.org) をデフォルトブラウザで開く
"Bad USB" デモを段階的に作りました。
最終形はこんな動作です:
- 🔴 USB に挿す → 赤点滅 3 秒(救済ウィンドウ)
- ⚪ 白の呼吸光 → "armed"(待機)状態
- BOOT ボタンを押す → 発動
- 🟠 CapsLock / NumLock の LED を見て Mac / Windows / ChromeOS を自動判定
- 🟢 / 🔵 / 🟣 各 OS 用のルートで
https://masafy.orgを開く
NeoPixel で状態が常に色として見えるので、デモ映えするし、自分が今どこで詰まってるかも一目でわかります。
ソース一式は GitHub に置いてあります → masafykun/bad-usb-trinkey-lab
🛠️ 用意したもの
| 項目 | 内容 |
|---|---|
| ボード | Adafruit RP2040 QT Trinkey (P5056) |
| 母艦 | MacBook (Apple Silicon, macOS Tahoe) |
| USB-C ハブ | Trinkey は USB-A 直挿しなので、USB-C しかない Mac はハブ必須 |
| ファーム | CircuitPython 10.2.1 (adafruit_qt2040_trinkey) |
| ライブラリ |
adafruit_hid / neopixel(CircuitPython Library Bundle 10.x) |
Trinkey QT2040 は、見た目は「ただの USB メモリのガワを剥いだ基板」みたいなサイズ感ですが、
RP2040 + 8MB Flash + STEMMA QT (I2C) + BOOT ボタン + NeoPixel ×1 という、HID 専用機としては
かなりリッチな構成です。GPIO はほぼ引き出されていないので「HID で遊ぶ専用」と割り切った設計。
| 表面 | 裏面 | 開封時 |
|---|---|---|
![]() |
![]() |
![]() |
🧪 ステップ 1: 工場出荷状態を確認
挿しただけでは Mac の Finder に何もマウントされませんでした。
USB ツリーを見るとデバイスは認識されていて、こんな情報が出ます:
USB Product Name = "PicoArduino"
USB Vendor Name = "Raspberry Pi"
idVendor:idProduct = 0x2E8A:0x8109
つまり工場出荷状態は Arduino-Pico のファームウェア が書き込まれた状態で、
CircuitPython ではなく Arduino IDE 系の HID + CDC として認識されています。
/dev/cu.usbmodem1201 でシリアル接続もできましたが、出力は何もありませんでした。
これは Adafruit の出荷ロットによって CircuitPython がプリインの場合もあれば
Arduino-Pico の場合もあるようなので、最初に必ず確認する のがおすすめです。
🧪 ステップ 2: CircuitPython に書き換える
BOOT ボタンを押しっぱなしで USB を挿し直す と、Mac に RPI-RP2 という FAT16 の
小さなドライブが現れます。ここに CircuitPython の .uf2 を放り込めば書き込み完了。
# CircuitPython UF2 を取得
curl -fL -o trinkey-cp.uf2 \
"https://downloads.circuitpython.org/bin/adafruit_qt2040_trinkey/en_US/adafruit-circuitpython-adafruit_qt2040_trinkey-en_US-10.2.1.uf2"
# RPI-RP2 にドロップ → 自動再起動
cp trinkey-cp.uf2 /Volumes/RPI-RP2/
数秒で自動再起動して、今度は CIRCUITPY という名前でマウントされます。
$ cat /Volumes/CIRCUITPY/boot_out.txt
Adafruit CircuitPython 10.2.1 on 2026-05-13; Adafruit QT2040 Trinkey with rp2040
Board ID:adafruit_qt2040_trinkey
macOS では「キーボード設定アシスタント」のダイアログが立ち上がるかもしれません。
これは HID として認識された証拠 なので、安心して終了で OK です。
🧪 ステップ 3: Hello HID(最小の HID 注入)
CircuitPython Library Bundle (10.x) から
adafruit_hid/ フォルダを CIRCUITPY/lib/ にコピーします。
そして CIRCUITPY/code.py をこれにする:
import time
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS
time.sleep(5) # 救済ウィンドウ:壊れたコードを書いたとき抜く猶予
kbd = Keyboard(usb_hid.devices)
layout = KeyboardLayoutUS(kbd)
layout.write("Hello from Trinkey\n")
while True:
time.sleep(60)
メモアプリを開いてフォーカスを置いた状態で待っていると、5 秒後にちゃんと
"Hello from Trinkey" がタイプされます。人生最初の HID 注入 が成立した瞬間でした。
💡 「最初の
time.sleep(5)」は超重要
挿した瞬間に何かタイプし始めると、CIRCUITPY を編集中の自分のエディタに
誤爆して大惨事になります。最初の数秒は何もしないことで、書き間違えたコードを
抜いて差し戻して直す時間が確保できます。これ実用上めちゃくちゃ大事。
🌈 ステップ 4: NeoPixel で状態を可視化
Trinkey には NeoPixel が 1 個載っています。コードの段階を色で表示すると、
「今どこで止まってる/何やってる」がパッと見える のでデバッグが激速になります。
import board, neopixel
pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2)
pixel[0] = (255, 0, 0) # 🔴 起動直後
pixel[0] = (255, 90, 0) # 🟠 判定中
pixel[0] = (0, 255, 0) # 🟢 完了
これだけでも体験が一段階上がります。
完成版では「起動 → armed → 判定 → OS別カラー」と最大 6 色を使い分けています。
🧪 ステップ 5: Hak5 流の OS 自動判定
ここが一番面白いところ。HID キーボードは「ホスト→デバイス」方向で
ロックキーの LED 状態(CapsLock / NumLock / ScrollLock) を送ってきます。
CircuitPython では Keyboard.led_status でこのバイト列が読めます。
Hak5 USB Rubber Ducky の os_detect 拡張 を参考にすると、
こんな枝分かれで OS が判別できます:
1. CapsLock を 1 回送信 → LED が反応するか観測
反応なし → macOS (synchronous lock-LED reporting が不完全)
反応あり → 2 へ
2. NumLock を 1 回送信 → LED が反応するか観測
反応なし → ChromeOS
反応あり → Windows / Linux
CircuitPython では Configuration Request Count が低レベル API として
取れないため Windows と Linux の区別は付きませんが、Mac / Windows / ChromeOS の
3 OS を区別するだけならこの 2 段プローブで十分 です。
ヘルパー関数にまとめるとこんな感じ:
def probe_lock(keycode, bit_mask):
"""ロックキーを送って LED が反応したかを返す。反応した場合は元に戻す。"""
before = kbd.led_status[0] & bit_mask
kbd.send(keycode)
time.sleep(0.3)
after = kbd.led_status[0] & bit_mask
responded = (before != after)
if responded:
kbd.send(keycode) # 元の状態に戻す
time.sleep(0.15)
return responded
if not probe_lock(Keycode.CAPS_LOCK, 0x02):
os_id = "mac"
elif not probe_lock(Keycode.KEYPAD_NUMLOCK, 0x01):
os_id = "chromeos"
else:
os_id = "windows"
実行すると Mac では CapsLock LED が一瞬チラッと光って消える挙動が肉眼で見えます。
これが「Hak5 流の OS フィンガープリント」が動いている証拠です。
🍣 ステップ 6: macOS の日本語 IME を切る
Mac だと、Spotlight (Cmd+Space) を開いて https://masafy.org と打つと、
日本語 IME が ON のままだとローマ字入力扱いされて変換される という落とし穴があります。
僕は最初これでハマりました。
解決策:Spotlight を開いた直後に 「英数」キー(JIS Eisu, HID Usage 0x91) を送って IME を OFF にする。
kbd.send(Keycode.COMMAND, Keycode.SPACE) # Spotlight
time.sleep(0.6)
kbd.press(0x91) # JIS Eisu (IME OFF)
time.sleep(0.05)
kbd.release_all()
adafruit_hid の Keycode には LANG2(Eisu)が定数として定義されていませんが、
HID Usage ID を整数で直接 press() に渡せます。US 配列の Mac では無視されるだけなので無害です。
💡 もう一つの落とし穴
layout.write("Safari\n")だと「文字列の最後の\n」が Enter として送られますが、
Spotlight は 検索結果を更新する暇なく Enter してしまう ので、前回検索した
メモが選ばれて誤起動します。"Safari"とEnterの間に 0.5 秒くらい寝かせる
ことで安定します。
layout.write("Safari")
time.sleep(0.5) # Spotlight の検索結果が確定するまで待つ
kbd.send(Keycode.ENTER)
time.sleep(2.5) # Safari の起動を待つ
kbd.send(Keycode.COMMAND, Keycode.T) # 新規タブ(アドレスバー自動フォーカス)
time.sleep(0.4)
layout.write(URL)
time.sleep(0.3)
kbd.send(Keycode.ENTER)
ポイントは Cmd+L(アドレスバー移動)ではなく Cmd+T(新規タブ)を使う こと。
新規タブだと既存ウィンドウの状態に依存せず、必ずアドレスバーにフォーカスが当たるからです。
🔘 ステップ 7: BOOT ボタン発動で誤爆防止
ここまでで「挿したら勝手に発動する」スクリプトはできましたが、これだと
CIRCUITPY/code.py を編集して保存した瞬間に再ロード→誤発動 が頻発します。
作業中の自分のキーボード入力にも混入して非常にうざい。
そこで、BOOTボタンを押したときだけ発動する モードを追加しました。
import digitalio
button = digitalio.DigitalInOut(board.BUTTON)
button.switch_to_input(pull=digitalio.Pull.UP)
# 起動の赤点滅のあと、白の呼吸光でボタンを待つ
phase, direction = 0.0, 1
while button.value: # 押されていない = True
val = int(phase * 60)
pixel[0] = (val, val, val)
phase += 0.02 * direction
if phase >= 1.0: phase, direction = 1.0, -1
elif phase <= 0.0: phase, direction = 0.0, 1
time.sleep(0.02)
# ここから判定と実行
これで「Trinkey は 挿しただけでは何もしない → 意図して BOOT を押した瞬間に発動」
という安全な動作になりました。デモで人前に出すときの安心感が段違いです。
⚠️ Trinkey QT2040 には小さな SMD ボタンが 2 つあり、BOOT と RST です。
CircuitPython からboard.BUTTONで読めるのは BOOT のほうです。
RST は押すと CircuitPython そのものが再起動するだけです。
💻 完成形(抜粋)
最終形のコードはこちら → 04-button-armed-launch/code.py
NeoPixel の動作対応表:
| 色 | 状態 |
|---|---|
| 🔴 赤点滅 | 起動・救済ウィンドウ |
| ⚪ 白の呼吸光 | armed(BOOTボタン待ち) |
| 🟠 オレンジ | OS 判定中 |
| 🟢 緑 | macOS 確定 → Safari → URL |
| 🔵 青 | Windows 確定 → Win+R → URL |
| 🟣 紫 | ChromeOS 確定 → Ctrl+T → URL |
📝 まとめと、次にやりたいこと
ハードが届いてから完成まで 半日くらい で組み上がりました。
CircuitPython は本当に手軽で、HID デバイスを作る学習教材として最高でした。
次にやりたいことのメモ:
-
DuckyScript インタプリタを自作 —
.txtでペイロード書けるようにする - STEMMA QT 拡張 — センサーやエンコーダを追加してマクロパッド化
-
boot.pyで複数モード切替 — BOOTボタン押しながら挿すとストレージ専用、通常挿しは HID 専用、みたいな - 3D プリントのケース — 基板むき出しだと指が痛い。Printables の Spacehuhn さんのモデル を発注予定
⚠️ 倫理について
最後にもう一度。HID 注入の技術は、他人のPCに使った瞬間に「不正アクセス」になる可能性が高い ので、
- 自分のMacBook / 自分のVM / 自分のWindows機 のみが検証対象
- ランサム・データ窃取・C2 通信を埋め込むようなペイロード作成は対象外
- 検知回避を目的化した実装は対象外
このスタンスを守った上で、「USB がどう PC を騙すか」の仕組みを理解するための学習として
こういう小さなラボを作るのは、防御側を考える視点を養うのにも有用だと思います。
🆕 追記 (2026-05-25): ChromeOS 編 — 自動判定の限界と物理ボタンによる解決
記事公開後、手元の ASUS Chromebook で実機検証したら 完成版が Mac として誤動作 することが判明しました。仮説検証 → 業界調査 → 物理ボタンで解決、までの顛末を共有します。
1️⃣ ChromeOS 実機で Mac と誤判定された
Chromebook に挿すと、🔴 → 🟠 (判定中) → 🟢 緑 (Mac判定) と遷移して Cmd+Space が走り、Launcher (Spotlight 相当) が開いてしまいました。
Hak5 流の判定は「NumLock 反応ありで ChromeOS を分ける」前提なので、これは想定外。
2️⃣ 4色プローブで原因究明
両ロックキーの反応を 独立に 観測する診断スクリプトを書き、4 色で結果を表示しました:
| 色 | 観測 | 推定OS |
|---|---|---|
| 🔴 赤 | Caps:無 / Num:無 | Mac (仮説) |
| 🟢 緑 | Caps:有 / Num:有 | Windows/Linux |
| 🔵 青 | Caps:無 / Num:有 | ChromeOS (仮説) |
| 🟡 黄 | Caps:有 / Num:無 | 未知 |
結果は驚きで、ChromeOS でも 🔴 赤(CapsLock も NumLock も LED 反応なし)。
つまり ChromeOS は HID キーボードに対するロックLEDの同期報告を一切しない ことが実機で確認されました。物理キーボードに NumLock/CapsLock がそもそも存在しない設計(Search キーに置き換わっている)と整合的です。
3️⃣ 業界全体の限界を調査
「USB レベルで別の手段はないか?」と思って最新研究を調べてみると:
-
keyboardio/FingerprintUSBHost — Arduino 系で USB
GET_DESCRIPTOR要求パターンを観察する手法 - QMK Firmware の OS Detection — 同じ手法を採用
両方とも次の記述が:
"ChromeOS and Android are both identified as Linux (since they're both using the Linux USB stack.)"
つまり 業界全体で ChromeOS と Linux の区別は不可能 と認められている領域でした。加えて CircuitPython には USB Setup Packet を観察する API すら無いため、この手法すら CircuitPython では実装できません。
| 手段 | CircuitPython 可否 | ChromeOS 区別 |
|---|---|---|
| (A) ロックLED 同期報告 (Hak5 流) | ✅ Keyboard.led_status
|
❌ Mac と同じに見える |
| (B) USB Descriptor 観察 (FingerprintUSBHost 流) | ❌ 低レベル API 不在 | ❌ Linux と同じに見える |
| (C) ホスト側エージェントとの CDC 通信 | ✅ | ✅ だが Bad USB の前提に反する |
4️⃣ 解決:BOOT ボタンの押し方で OS を「ユーザーが宣言する」
完全な自動判定を諦め、ユーザーが意図を物理ボタンで表明する設計 に切り替えました。
| BOOT ボタン | NeoPixel 遷移 | モード |
|---|---|---|
| 短押し (< 0.5s) | 🟡 黄色 → 離す → 🟢 緑 | Mac |
| 長押し (≥ 0.5s) | 🟡 黄色 → 🟠 オレンジ → 離す → 🟣 紫 | ChromeOS |
押下中の NeoPixel が 🟡黄色 → 🟠オレンジ に変わるので、「今長押し領域に入った」と離す前に視覚確認できる。「自動判定の限界」を「物理操作の触感」で補う、ある意味エンジニアリングらしい解決だと思います。
press_start = time.monotonic()
pixel[0] = (255, 255, 0) # 🟡 短押し領域
crossed_threshold = False
while is_pressed():
elapsed = time.monotonic() - press_start
if elapsed >= 0.5 and not crossed_threshold:
pixel[0] = (255, 100, 0) # 🟠 長押し領域に突入
crossed_threshold = True
time.sleep(0.02)
mode = "chromeos" if time.monotonic() - press_start >= 0.5 else "mac"
完成版 → firmware/06-button-mode-select/code.py
5️⃣ もう一つの落とし穴: JIS 配列の : 問題
ChromeBook で動かしたら、今度は : が + に化けて https+//masafy.org がアドレスバーに入りました。これは ChromeOS のキーボード入力方式が JIS 配列 だったため。
| 配列 |
: を打つキー |
HID Usage |
|---|---|---|
| US | Shift + ;
|
0x33 + Shift |
| JIS | Quote 位置キー(Shift なし) |
0x34 (Keycode.QUOTE) |
adafruit_hid の KeyboardLayoutUS は US 配列前提なので、JIS 環境では : だけ keycode 直送りすればOK:
def jis_write_url(url):
for c in url:
if c == ":":
kbd.send(Keycode.QUOTE) # JIS: Quote 位置 = `:`
else:
layout.write(c)
/ . などは US/JIS で HID Usage が同じなので影響なし。
6️⃣ この章の教訓
- HID キーボードからの OS 自動判別は業界全体で限界がある。特に Mac/ChromeOS の区別は困難
- 自動化を諦める判断も技術。「物理ボタンの押し方で意図を表明する」 UX は、限界を逆手に取った設計
- キーボードレイアウト依存の文字化け はテスト環境を増やすと初めて見えてくる落とし穴
- NeoPixel で「今どっちのモードに入ろうとしているか」を視覚化 すると、手動分岐でも誤操作がほぼゼロになる
完璧な自動判定を追求するより、「限界を受け入れて、人間との協調をデザインする」方が現実解になることもある — そんなことを学んだ章でした。
🔗 リンク
- ソースコード → masafykun/bad-usb-trinkey-lab
- ハードウェア → Adafruit RP2040 QT Trinkey (P5056)
- 参考にした手法 → Hak5 USB Rubber Ducky / os_detect.txt
- 関連: USB OS フィンガープリント → keyboardio/FingerprintUSBHost / QMK OS Detection
- CircuitPython 公式 → adafruit_qt2040_trinkey


