martin-beneder/ghostkey

GitHub: martin-beneder/ghostkey

一款从零自制的 ESP32-S3 USB 键盘中间人硬件设备,能够透明拦截记录击键、镜像真实键盘身份并向主机注入击键。

Stars: 0 | Forks: 0

# 🔌 GhostKey ### 内联 USB 击键拦截器 — 一个自制的键盘中间人 *一种透明的硬件植入物,潜伏在任何 USB 键盘与主机之间,它 **捕获每一次击键**,**镜像键盘精确的 USB 身份**,使得主机 无法将其与真实设备区分开来,并且能够**注入自身的击键。*** ![Platform](https://img.shields.io/badge/MCU-ESP32--S3--WROOM--1U-E7352C?logo=espressif&logoColor=white) ![Host IC](https://img.shields.io/badge/USB%20host-MAX3421E-0b7285) ![Firmware](https://img.shields.io/badge/firmware-C%2B%2B%20·%20PlatformIO-00979D) ![Hardware](https://img.shields.io/badge/hardware-custom%202--layer%20PCB-2b8a3e) ![Status](https://img.shields.io/badge/status-working%20prototype-success) ![Use](https://img.shields.io/badge/use-authorized%20testing%20only-critical)
GhostKey inline between a keyboard and the PC 成品设备,内联串联在机械键盘与主机之间。对 PC 而言,它就是键盘。
## 它是什么 商业硬件键盘记录器(如 KeyGrabber、Hak5 Key Croc、O.MG 数据线)是广为人知的红队 基础工具。**GhostKey 是该工具的从零实现** — 包括原理图、定制 PCB、 固件和外壳,全部由个人独立完成 — 外观如同一个胖版 U 盘大小的 USB 加密狗。 有趣的部分不在于“它能记录按键”,而在于它在记录按键的同时,通过在单一微控制器上同时 运行**两个独立的 USB 协议栈**,从而在**电气和描述符上与真实键盘毫无二致**: - 一个 **USB *主机***(通过 SPI 连接的 MAX3421E 控制器),用于读取真实键盘的 HID 报告,以及 - 一个 **USB *设备***(ESP32-S3 的原生 USB-OTG),将这些报告重新呈现给 PC — 使用**真实键盘自己的 VID/PID 和描述符字符串**,这些信息在插入时被学习,并在重启后依然记忆。 键盘发送的每一个报告都会直接透传。每一个报告也都会被记录。在隐藏热键的触发下, 设备可以停止转发,并改为**输入它自己的击键**。
GhostKey in its 3D-printed enclosure    The assembled custom PCB
## 🎯 能力模型 | 能力 | 它的作用 | 所在位置 | |---|---|---| | **拦截** | 通过专用的 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)。出于研究和教育目的提供。**请负责任且合法地使用。**
标签:ESP32-S3, USB中间人, 按键注入, 硬件安全, 硬件工程, 键盘记录