基于eBPF的实时、全系统USB传输嗅探器——通过两个通用的URB钩子内联解码USB流量(控制SETUP、SCSI、HID)。无需usbmon,无需硬件嗅探器。CO-RE可移植。

一个实时、彩色化的 USB 流量全系统馈送——构建于每个主机控制器驱动程序都会流经的两个通用 URB 瓶颈点之上,因此它同样适用于 xHCI/EHCI/OHCI/dwc,无需每个控制器的跟踪点,也无需 usbmon。完全 CO-RE 可移植。
| fentry 钩子 | 它告诉我们什么 |
|---|---|
usb_submit_urb | 传输已排队(设备、端点、类型、负载) |
usb_hcd_giveback_urb | 传输已完成(状态、移动字节数、延迟、负载) |
一个以 URB 指针为键的 lru_hash 将两者缝合在一起:提交时打上开始时间戳,完成时读回该时间戳以获取提交到完成的延迟,然后将其删除。这镜像了 httpbody 的请求/响应配对——SUBMIT 是“请求”(主机发送的内容),COMPLETE 是“响应”(设备返回的内容)。
控制传输会将其 8 字节 SETUP 数据包解码为标准请求名称(GET_DESCRIPTOR、SET_CONFIGURATION、……);数据阶段在看起来像文本时渲染为文本,否则渲染为十六进制转储。
输出是每事件一行(紧凑)。设备首次出现时会显示一行 ▸ 图例行(bus-dev、vid:pid、产品、链接速度);之后每行仅携带简短的 DEV 标签,因此左侧列在高流量下保持对齐且易于扫描。每行显示时间、类型(SUBMIT/CMPLT)、传输类型、epNdir、方向箭头(← 设备→主机 IN,→ 主机→设备 OUT)、字节数、状态、延迟以及所属内核驱动程序,然后是一个 · 和最有用的细节(解码后的 SETUP、SCSI 命令或短负载预览)。传递 --hex 以使用完整的多行十六进制转储。十六进制字节在 TTY 上按值类别着色(null 蓝色、可打印 ASCII 青色、空白绿色、其他控制字符洋红色、高位/非 ASCII 黄色);管道输出为纯文本。
usbmon 设置。在您操作设备时,SETUP 数据包和负载被解码。READ(10) lba=… blocks=…、WRITE(10)、CSW PASS/FAIL)。--errors-only 显示所有设备上的停止(EPIPE)、超时、胡言乱语和 CRC 错误。INT 报告或 SET_REPORT 控制写入。--json 输出 NDJSON;通过管道传给 jq 或文件以比较不同运行间的负载。curl -fsSL https://yeet.cx | sh
然后直接从 GitHub 运行它——yeet 会获取示例并为您构建,无需克隆:
yeet run github:yeet-src/usbsnoop
要从本地 checkout 构建,请运行:
make
将内核的 BTF 转储到 vmlinux.h(用于 struct urb、usb_device 和设备描述符),然后编译。需要 clang、bpftool 和带有 BTF 的内核。
yeet run . # 所有设备,运行直到 Ctrl-C
yeet run . -- --secs 30 # 30 秒后停止(打印摘要)
yeet run . -- --vid 0x320f # 一个厂商
yeet run . -- --vendor-id 0x046d --product-id 0xc52b # 按 ID 指定一个设备
yeet run . -- --bus 3 --dev 4 # 按总线地址指定一个设备
yeet run . -- --type control,int # 仅这些传输类型
yeet run . -- --no-data # 仅元数据,跳过负载捕获
yeet run . -- --max-data 64 # 将渲染的负载限制在 64 字节
yeet run . -- --errors-only # 仅显示失败的完成(停止、超时)
yeet run . -- --hex # 每次传输完整的多行十六进制转储
yeet run . -- --json | jq . # NDJSON,每事件一个对象
所有过滤都发生在内核端,因此被过滤掉的流量从不会到达用户空间。
每个事件行以括号中的所属内核驱动程序结尾([hid_irq_in]、[usb_api_blocking_completion])——urb->complete 在内核中通过 bpf_snprintf("%ps") 符号化,因此无需查找 /proc/kallsyms。大容量存储批量传输将其批量仅传输包装器解码为 SCSI 命令(CBW READ(10) lba=… blocks=… / CSW PASS)。在定时退出(达到 --secs)时,会打印每个设备的摘要和 log2 延迟直方图;Ctrl-C 退出则跳过(没有 JS 可见的信号钩子)。
批量流量(大容量存储等)通常向栈传递一个 struct scatterlist 数组(urb->sg)而不是单个线性 transfer_buffer,因此负载分散在多个页面中。usbsnoop 遍历该数组并复制每个段的字节,但要访问它们需要将页面转换为其内核虚拟地址——x86-64 的 page_to_virt 的逆操作,这需要正在运行的内核的 page_offset_base 和 vmemmap_base(两者都是 KASLR 随机化的)。
JS 隔离区无法读取 /proc/kallsyms,且加载器没有 ksym 支持,因此您传入两个符号地址,BPF 端会解引用它们:
yeet run . -- \
--page-offset-base 0x$(sudo awk '$3=="page_offset_base"{print $1}' /proc/kallsyms) \
--vmemmap-base 0x$(sudo awk '$3=="vmemmap_base"{print $1}' /proc/kallsyms)
没有这些标志时,SG 传输仍然显示完整的元数据,只是没有负载字节——这是之前的行为。此路径仅适用于 x86-64:在其他架构上请省略这些标志。
actual/requested 长度。每个环形记录携带完整的 data[16384],因此 8 MiB 的环形缓冲区可容纳约 512 个事件。--page-offset-base / --vmemmap-base 标志和 x86-64 主机;每个段最多捕获一个页面,并且仅遍历传输的前 64 个段。| 标志 | 默认值 | 含义 |
|---|
--secs | 永久 | 运行时长;省略则运行直到 Ctrl-C(数字则停止并打印摘要) |
--vid, --vendor-id | 任意 | 按厂商 ID 过滤(十六进制 0x1d6b 或十进制) |
--pid, --product-id | 任意 | 按产品 ID 过滤 |
--bus | 任意 | 按总线号过滤 |
--dev | 任意 | 按设备地址过滤 |
--type | 全部 | iso, int, control, bulk 的 CSV |
--no-data | 关闭 | 不读取传输缓冲区(仅元数据) |
--max-data | 4096 | 每事件渲染的负载最大字节数 |
--errors-only | 关闭 | 仅显示非 OK 的完成(跳过 SUBMIT 和 OK) |
--hex | 关闭 | 每次传输完整的多行十六进制转储(否则以紧凑内联预览显示) |
--json | 关闭 | 输出 NDJSON(每事件一个对象)而不是 TTY 视图 |
--page-offset-base | 关闭 | 内核 page_offset_base 地址(十六进制)——启用 SG 负载捕获(x86-64) |
--vmemmap-base | 关闭 | 内核 vmemmap_base 地址(十六进制)——与 --page-offset-base 配对 |