# 🔌 GhostKey
### 内联 USB 击键拦截器 — 一个自制的键盘中间人
*一种透明的硬件植入物,潜伏在任何 USB 键盘与主机之间,它
**捕获每一次击键**,**镜像键盘精确的 USB 身份**,使得主机
无法将其与真实设备区分开来,并且能够**注入自身的击键。***






成品设备,内联串联在机械键盘与主机之间。对 PC 而言,它就是键盘。
## 它是什么
商业硬件键盘记录器(如 KeyGrabber、Hak5 Key Croc、O.MG 数据线)是广为人知的红队
基础工具。**GhostKey 是该工具的从零实现** — 包括原理图、定制 PCB、
固件和外壳,全部由个人独立完成 — 外观如同一个胖版 U 盘大小的 USB 加密狗。
有趣的部分不在于“它能记录按键”,而在于它在记录按键的同时,通过在单一微控制器上同时
运行**两个独立的 USB 协议栈**,从而在**电气和描述符上与真实键盘毫无二致**:
- 一个 **USB *主机***(通过 SPI 连接的 MAX3421E 控制器),用于读取真实键盘的 HID 报告,以及
- 一个 **USB *设备***(ESP32-S3 的原生 USB-OTG),将这些报告重新呈现给 PC —
使用**真实键盘自己的 VID/PID 和描述符字符串**,这些信息在插入时被学习,并在重启后依然记忆。
键盘发送的每一个报告都会直接透传。每一个报告也都会被记录。在隐藏热键的触发下,
设备可以停止转发,并改为**输入它自己的击键**。
## 🎯 能力模型
| 能力 | 它的作用 | 所在位置 |
|---|---|---|
| **拦截** | 通过专用的 USB 主机控制器读取真实键盘的每一个 HID 报告。 | `tuh_hid_report_received_cb()` · MAX3421E 主机协议栈 |
| **记录** | 将扫描码解码为 UTF-8 文本(包含变音符号、AltGr、死键的完整德语布局);16 KB 缓冲区 ≈ 2 200 个单词。 | `RecordBuffer`, `de_layout.h` |
| **伪装** | 插入时学习键盘的 VID/PID + 制造商/产品字符串,持久化保存它们,并以*该键盘的身份*向主机重新枚举。 | `tuh_mount_cb()` → `config_save_usb()` → 实时重新枚举 |
| **注入** | 向主机合成任意的、布局正确的击键(BadUSB 级别的 HID 注入)。 | `type_string()`, `send_key()` |
| **设备外通道** | 板载 Wi-Fi (HTTPS) + microSD 提供网络出口路径和本地存储。 | `ai_client.cpp`, `SD_MMC` |
## 🧠 工作原理
```
┌───────────────────────── ESP32-S3-WROOM-1U ─────────────────────────┐
real │ │ host
keyboard│ MAX3421E ──reports──▶ capture ──▶ RecordBuffer (UTF-8 / DE layout) │ PC
──────▶│ (USB host, SPI) │ │──────▶
USB-A♀ │ │ ┌─ state ─┐ │ USB-A♂
│ ├────▶│ IDLE │ transparent forward │ (OTG)
│ │ │ RECORD │ + capture │
│ │ │ PAYLOAD │ inject own keystrokes │
│ │ └────┬────┘ │
│ 16-slot ring queue ───────┴──▶ device HID ───────────────▶│
│ (spoofed identity, native PHY) │
│ microSD (config + capture) Wi-Fi ──HTTPS──▶ off-device process │
└──────────────────────────────────────────────────────────────────────┘
```
**泵循环。** `loop()` 在每次迭代中为两个协议栈提供服务 — `USBHost.task(0)`(主机)和
`tud_task_ext(0, false)`(设备)— 然后将一个排队的报告刷新给 PC。来自真实键盘的
击键由主机回调推入一个 16 插槽的环形队列,并在每个循环中排空一个报告,因此快速连击
永远不会阻塞任何一个协议栈。
**三种状态**(`buffer.h::AKState`):
- **`IDLE`** — 纯透传。每个报告按 1:1 转发;设备是完全隐形的。
- **`RECORDING`** — 通过隐藏热键(默认为 `Ctrl+Alt+Space`)激活。每一个*新按下*的键
(与之前的报告进行差异对比,因此按住的键不会被重复计算)都会被解码并追加到缓冲区中 —
同时仍会被转发,因此输入看起来完全正常。
- **`PAYLOAD`** — 设备停止转发,发送退格键,并通过德语布局重放表向主机**输入它自己
的字符串**。热键报告本身*永远不会*被转发(否则它们会泄露给 PC)。
**身份镜像。** 当键盘插入主机端口时,`tuh_mount_cb()` 会提取其
`idVendor`/`idProduct` 以及其制造商/产品字符串。如果它们与 GhostKey 当前呈现的信息
不同,它会将它们保存到 SD 卡并执行实时重新枚举
(`tud_disconnect()` → `setID` / `setManufacturer` / `setProduct` → `tud_connect()`)。
经过一次插拔周期后,PC 会将 GhostKey **作为真实键盘**重新枚举。默认的后备身份是
Logitech K120 (`046D:C31C`)。
**验证其隐蔽性。** [`tests/test_passthrough.py`](tests/test_passthrough.py) 使用 `pyusb` 枚举
设备,并断言 VID/PID、制造商字符串、产品字符串、HID 接口类和中断 IN 端点
全部与真实键盘匹配 — 这是一项程序化检查,确保透传无法被区分。
## 🛠️ 构建 — 从面包板到装进外壳的设备
这个项目始于面包板,最终演变成具有定制双层 PCB 和 3D 打印外壳的设备。整个历程 —
概念、原理图、布局、组装、外壳、固件 — 都是端到端完成的。
| 1 · 原型 | 2 · 定制 PCB | 3 · 封装 |
|:---:|:---:|:---:|
|

|

|

|
| Adafruit MAX3421E FeatherWing + ESP32-S3 开发板,跳线连接以验证双协议栈概念。 | ESP32-S3-WROOM-1U, MAX3421E + 晶振, microSD,两个 USB 接口,电源和 ESD 集成在一块板上。 | 最终的加密狗,已通电(状态 LED 亮起),内联在连接主机的 USB 延长线上。 |
**硬件亮点** — 完整的[物料清单](docs/hardware/bom.csv) ·
[原理图](docs/hardware/schematic.svg) · [组装与调试笔记](docs/HARDWARE.md):
| 模块 | 零件 | 作用 |
|---|---|---|
| MCU / 射频 | **ESP32-S3-WROOM-1U-N8R8** | 双核 + Wi-Fi/BLE,8 MB flash / 8 MB PSRAM,**原生 USB-OTG 设备 PHY**,外接 IPEX 天线 |
| USB 主机 | **MAX3421E** (VQFN-32) + 12 MHz 晶振 | 独立的 SPI USB 主机 — 读取真实键盘 |
| 键盘端口 | USB-A **母口** (THT) | 真实/目标键盘插入的位置 |
| 主机端口 | USB-A **公口** (SMD) | 插入目标 PC |
| 存储 | microSD (SDMMC 1-bit) | `config.json` + 捕获存储 |
| 电源 / 保护 | AMS1117-3.3 LDO, 1N5819 肖特基二极管, LESD/WS05/PGB ESD 阵列, 22 Ω 串联电阻, 磁珠 | 单板供电路径 + USB ESD 保护 |
| 编程 | 6-pin 2.54 mm 排针 | UART / CH340 烧录 |
📐 PCB 布局与 3D 渲染
EasyEDA 3D render · copper layout · assembled underside (microSD) · board out of its enclosure with the IPEX antenna.
## ⚔️ 工程实战故事
这类项目最困难的部分从来不是一帆风顺的主线流程 — 而是没有任何东西能成功枚举,且万用表
与数据表给出相反结果的整整一周。真实的精彩细节(详见
[docs/HARDWARE.md](docs/HARDWARE.md)):
1 · PCB 封装中 D+ 和 D− 接反了
定制的 USB-A 公口封装**D+ 和 D− 颠倒了**。万用表检查发现:USB
上拉电阻接在了引脚 2 (D−) 而不是引脚 3 (D+),导致 Linux 将设备误检为 Low-Speed,
缓存了端口状态,并抛出 `device descriptor read/64, error -71`。
**无需重新打板的修复方法:** ESP32-S3 的 USB 包装器具有一个 `exchg_pins` MUX。在
`USB_WRAP.OTG_CONF` 中设置 `exchg_pins_override` + `exchg_pins` 即可在*硬件层*互换这两
根线。配合在启动时干净的 `tud_disconnect()` / `tud_connect()` 周期,设备现在能可靠枚举。
*(封装的修复已排入下一个 PCB 版本;权宜之计已记录在代码中。)*
2 · MAX3421E 看起来像挂了 — 其实没有
`REVISION` 读数为 `0x00`/`0xFF`,SPI 毫无反应,最轻易得出的结论是“我用 5 V 电压把芯片
击穿了”。但这两点都错了:
- **MOSI/MISO 接反了。** Adafruit 的 `MI`/`MO` 丝印是从*主机视角*出发的
(`MI` = MISO, `MO` = MOSI)。交叉接错后,SPI 只会永远返回同一个垃圾字节。
- **RST 引脚悬空。** MAX3421E 具有低电平有效复位且没有内部上拉,因此振荡器/PLL
从未启动(`OSCOK` 卡在 0)。
**保存在项目笔记中的教训:** 对于“SPI 能通但 USB 引擎挂了”的情况,在断定芯片死亡之前,
先检查接线和复位。
3 · 两个 USB 协议栈,一个链接器,一场符号战
在运行 Adafruit TinyUSB 的同时运行 Arduino-ESP32 核心捆绑的 `libarduino_tinyusb.a` 会
导致符号冲突。一个[构建前补丁脚本](firmware/ai_keyboard/scripts/patch_tinyusb.py)解决了这个问题,
并且因为剥离该库也会移除自动的 PHY 初始化,固件现在通过 `usb_new_phy()` **手动**启动
USB PHY。同一脚本将 MAX3421E SPI 时钟从 26 MHz 降至 4 MHz(用于跳线原型设计),并为
`OSCOK` 等待增加了 100 ms 超时,因此无响应的主机控制器不会挂起启动过程。
4 · 花了十几次烧录才找到的 setup() 顺序
USB 初始化顺序对顺序的敏感到了严酷的地步。两条来之不易的规则:
- **绝不要调用 `TinyUSBDevice.begin()`** — 它内部会调用 `clearConfiguration()`,并悄无声息地
丢弃你刚添加的 HID 接口。请改用 `tud_init(0)`。
- **在触碰 MAX3421E 之前完全枚举设备。** 它那 120 ms 的阻塞式复位如果运行得太早,
会引入超时,导致 Linux 将端口缓存为 Low-Speed。修复方法是:先开启一个 500 ms 的
纯设备 USB 泵窗口,*然后*再启动 SPI 主机。
5 · TLS 握手 = 栈溢出(旁系固件)
在处理设备外捕获文本的变种中,HTTPS/mbedTLS 握手导致默认的 8 KB Arduino 循环任务栈溢出
(`Stack canary watchpoint triggered`)。修复方法:
`SET_LOOP_TASK_STACK_SIZE(24*1024)`,将网络工作绑定到其专属核心,这样 USB 就永远不会
阻塞;同时使用一个小型的无状态代理,使得服务端长时间的任务不会触发 的 HTTP 读取超时。
## 🛡️ 检测与防御
一个透明的拦截器*很*难被发现 — 但并非不可能。记录这些内容正是关键所在:正是这些
知识将一个“间谍小工具”转化为了一项安全研究产物。
**蓝队如何发现它**
- **物理检查** — 键盘与 PC 之间出现意外的内联加密狗是最可靠的单一
破绽。防篡改端口封条使其变得可审计。
- **描述符指纹识别** — 身份字符串虽然被镜像了,但诸如 **缺失
iSerialNumber**、`bcdDevice` 或被强制设定的 2 ms 轮询间隔等细节可能与真实设备不同。
- **总线拓扑** — USB 设备控制 / 允许名单工具(Linux 上的 `usbguard`,Windows Device
Control / 组策略)会标记任何与已知良好硬件 ID 列表不匹配的键盘。
- **行为 / EDR** — 注入的文本以机器速度到达。“不可能的打字节奏”和
突发的 HID 输入是可检测的启发式特征。
- **射频 (RF)** — Wi-Fi 无线电可通过频谱监控和恶意关联扫描被检测到。
**使其失效的防御加固措施**
- USB 设备允许名单(仅绑定已注册的键盘硬件 ID)。
- 禁用 / 物理封锁未使用的 USB 端口;锁定 BIOS/OS USB 策略。
- 防篡改封条,并定期对公用/自助服务终端机进行物理审计。
- 在您的 EDR 中监控新的 HID 枚举和异常的输入时序。
## 🤖 附录:主动载荷演示
为了端到端地证明 **inject(注入)** 能力 — *捕获 → 在设备外处理捕获的文本 →
将结果输入回去* — 固件内置了一个无害的载荷:它通过 Wi-Fi 将缓冲区文本发送到
LLM API 进行语法/拼写检查,并将结果重放出来,演练了退格键 + 完整的德语布局注入。
它还演示了**双 USB 协议栈在阻塞式网络调用期间保持存活**,这是该部分并不明显的工程价值所在。
演示载荷使用的系统提示词
```
Correct the spelling and grammar of the following text. Return ONLY the corrected text.
```
该提示词是可配置的(`config.json` / 代理请求)并且刻意设计得非常简单 — 该载荷只是
演示注入 + 设备外闭环的载体,而不是项目的目的。
一个单独的固件分支树(`firmware/ai_mouse/`)探索了**鼠标**的相同基础功能,添加了 USB
**大容量存储** 模式(设备通过 SCSI 介质就绪握手按需表现为闪存驱动器,且无需重新
枚举)— 这对于暂存/渗出文件非常有用。详见
[`tasks/concept_3modi_maus.md`](tasks/concept_3modi_maus.md).
## 📦 仓库结构
```
firmware/ai_keyboard/ ← main firmware (this project)
src/
main.cpp dual-stack init, callbacks, state machine, capture, injection
config.{h,cpp} SD-card JSON config (Wi-Fi, hotkey, USB identity, payload)
buffer.{h,cpp} RecordBuffer: scancode → UTF-8, hotkey detection
de_layout.h German keyboard layout tables (for decode + replay)
ai_client.{h,cpp} Wi-Fi + HTTPS off-device channel
scripts/patch_tinyusb.py pre-build patches (symbol strip, SPI clock, OSCOK timeout)
firmware/ai_mouse/ ← sibling experiment: mouse + USB mass-storage variant
proxy/ ← stateless Next.js proxy for long off-device jobs
tests/test_passthrough.py ← proves the passthrough is identity-identical (pyusb)
docs/ ← images, schematic, BOM, hardware notes
```
## 🔧 构建与烧录
```
cd firmware/ai_keyboard
pio run # build
pio run -t upload --upload-port /dev/ttyUSB0 # flash via CH340
```
运行时配置位于 microSD 卡上的 `/config.json` 中(Wi-Fi、热键组合、USB 身份、
载荷设置) — 详见 [`config.example.json`](config.example.json)。更深层的调试笔记 — 准确的
USB 设置顺序、引脚映射、硬件权宜之计 — 请见 [`docs/HARDWARE.md`](docs/HARDWARE.md)。
## ❓ 常见问题解答
**这不就是 USB Rubber Ducky / BadUSB 吗?**
不。Rubber Ducky *只能注入*,并会将自己显示为一个全新的通用 HID 设备 — 这是
明显的破绽。GhostKey **内联**潜伏,透明地透传真实键盘,**镜像其精确身份**,并且*同时*
进行捕获。这是从零开始构建的 Hak5 Key Croc / KeyGrabber 类设备。
**为什么用定制 PCB 而不是现成的模块?**
外形因素(一个单一的 USB 加密狗),将主机 + 设备 + SD + Wi-Fi + 电源整合在一块板子上,而且 —
老实说 — 是为了学习完整的硬件流程:原理图 → 布局 → 制造 → 手工组装(0.5 mm 间距
QFN,0402 无源器件) → 外壳。
**为什么要用 ESP32-S3 *加上*一个单独的 MAX3421E?**
S3 只有一个 USB-OTG 核心:它可以作为设备**或**主机,但不能同时兼具两者。为了在向
PC 呈现的同时读取真实键盘,你需要第二个独立的 USB 主机控制器 —
即 MAX3421E(基于 SPI 的 USB 主机)。额外好处:S3 免费带来了 Wi-Fi 和 PSRAM。
**它能捕获多少内容?**
一个 16 KB 的 UTF-8 缓冲区(约 2 200 个德语单词),包括变音符号和 AltGr 字符;相同的
数据可以流式传输到 microSD 或通过 Wi-Fi 传出。
**它能被检测到吗?**
是的 — 请参阅[检测与防御](#-detection--defense)。一个透明的拦截器很难被捕获,但并非
不可能,假装不是这样是不诚实的。
**使用它合法吗?**
只有在获得授权的情况下(您自己的硬件、有书面授权范围的测试项目、CTF 或实验室)。
请参阅顶部的警告。
## 👤 关于
由 **Martin Beneder** 独立构建 — 覆盖每一层:原理图、PCB 布局、制造和手工组装、
3D 打印外壳,以及在单块芯片上同时驱动两个 USB 协议栈的固件。
我在硬件方面很大程度上是自学成才的,这个项目是我最看重的证明:它不是一个
开发板演示,而是一个从创意走向你可以拿在手里、插上电源、并安静地做它所声称的
一切的东西。那些调试过程 — 接反的 USB 线、一个“假死”其实不然的控制器、
一个与自身打架的链接器 — 对我来说,才是最有趣的部分。这才是真正蕴含学问的地方。
## 📄 许可证
MIT — 详见 [`LICENSE`](LICENSE)。出于研究和教育目的提供。**请负责任且合法地使用。**