0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Adafruit Trinkey QT2040 で人生最初の Bad USB を作った 〜 CircuitPython で HID 注入と OS 判定まで

0
Last updated at Posted at 2026-05-24

この記事のスタンス
本記事は 自分の MacBook に対する HID 注入の学習メモです。
「他人のPCに挿す」「攻撃ペイロードを撒く」といった用途は一切扱いません。
Qiita で公開する以上、誰が読んでも参考になる「仕組みの解説」だけに留めています。

🪪 何を作ったのか

USB-A 直挿しの超小型ボード Adafruit RP2040 QT Trinkey (P5056) に CircuitPython を載せ、
挿すだけで自分のホームページ (masafy.org) をデフォルトブラウザで開く
"Bad USB" デモを段階的に作りました。

最終形はこんな動作です:

  1. 🔴 USB に挿す → 赤点滅 3 秒(救済ウィンドウ)
  2. ⚪ 白の呼吸光 → "armed"(待機)状態
  3. BOOT ボタンを押す → 発動
  4. 🟠 CapsLock / NumLock の LED を見て Mac / Windows / ChromeOS を自動判定
  5. 🟢 / 🔵 / 🟣 各 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 で遊ぶ専用」と割り切った設計。

表面 裏面 開封時
front back arrived

🧪 ステップ 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_hidKeycode には 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 つあり、BOOTRST です。
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 レベルで別の手段はないか?」と思って最新研究を調べてみると:

両方とも次の記述が:

"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_hidKeyboardLayoutUS は 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️⃣ この章の教訓

  1. HID キーボードからの OS 自動判別は業界全体で限界がある。特に Mac/ChromeOS の区別は困難
  2. 自動化を諦める判断も技術。「物理ボタンの押し方で意図を表明する」 UX は、限界を逆手に取った設計
  3. キーボードレイアウト依存の文字化け はテスト環境を増やすと初めて見えてくる落とし穴
  4. NeoPixel で「今どっちのモードに入ろうとしているか」を視覚化 すると、手動分岐でも誤操作がほぼゼロになる

完璧な自動判定を追求するより、「限界を受け入れて、人間との協調をデザインする」方が現実解になることもある — そんなことを学んだ章でした。


🔗 リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?