eBPF-实时捕获键盘输入
eBPF 实时捕获键盘输入
eBPF 实时捕获键盘输入
本文将带你一步步实现一个基于eBPF kprobe的键盘记录功能,通过
Go
语言配合
libbpfgo
,你将学会如何无损地监控系统键盘输入,并从中获取实时数据,进一步提高系统安全和监控能力。
1. 说明
本文属于专栏
,示例代码目录为
040
。
如何下载并运行代码,请参考 。
注: 老学员可以直接
git pull
拉取最新代码。
2. 引言
在本篇文章中,我们将利用 eBPF 的
kprobe
技术捕捉键盘输入事件,实现一个简单的键盘记录功能。你将学习如何在内核层面监控
input_handle_event
函数,并将捕获到的按键事件传递到用户空间进行处理。整个过程高效且非侵入式,适用于实时监控场景。💡
eBPF 允许在内核中动态加载和执行代码。借助
kprobe
,我们可以在
input_handle_event
函数入口处挂载探针,从而捕获关键事件。
- 键盘事件类型
:我们关注
EV_KEY
类型的按键事件,并仅记录按下(value=1
)时的事件。 - 事件传递方式
:通过
bpf_perf_event_output
将事件数据发送到用户空间,用户空间程序使用perf buffer
进行实时读取。
这样既能精准监控输入事件,又无需对系统进行侵入式修改。
3. 原理详解
3.1 当你按下一个键盘上的按键时,发生了什么?
流程图
硬件中断 → 键盘驱动 → 输入子系统 → TTY 行规程 → Shell 进程 → TTY 回显 → 终端渲染
在 Linux 系统中,从按下键盘到字符显示在终端上,涉及 硬件中断处理、内核子系统协作和用户空间进程交互 。以下是详细流程和关键函数。
1. 硬件中断触发
- 硬件层面 :键盘控制器(如 PS/2 或 USB)检测到按键动作,生成 硬件中断 (PS/2 键盘通常使用 IRQ 1)。
- 中断控制器 :将中断路由至 CPU,CPU 通过中断向量表调用对应的 中断处理程序 。
2. 内核中断处理
- 中断处理函数
:内核注册的键盘中断处理程序(
irq_handler
)被触发。- 关键函数
:
request_irq()
注册中断处理函数,如 PS/2 键盘的kbd_event
或 USB 键盘的usb_kbd_irq
。
- 关键函数
:
- 读取扫描码
:驱动从键盘控制器读取
扫描码(Scancode)
(如
inb(0x60)
读取 PS/2 键盘数据端口)。 - 转换为键码
:扫描码转换为
键码(Keycode)
(如
kbd_keycode
处理映射关系)。
3. 输入子系统(Input Subsystem)
- 生成输入事件
:键码通过输入子系统封装为
input_event
结构(包含时间戳、键值、动作等)。- 关键函数
:
input_event()
→input_handle_event()
。
- 关键函数
:
- 传递事件
:事件通过
/dev/input/eventX
设备节点传递,供用户空间程序(如终端)读取。- 关键结构
:
struct input_handler
负责事件路由(如evdev_handler
)。
- 关键结构
:
4. TTY 子系统处理
- 绑定到 TTY
:输入事件传递到当前活动的 TTY(如
/dev/tty1
)。- 关键函数
:
tty_insert_flip_char()
将字符写入 TTY 的 flip buffer。
- 关键函数
:
- 行规程(Line Discipline)
:处理特殊字符(如回车、退格)。默认行规程为
n_tty
。- 关键函数
:
n_tty_receive_char()
解析字符并执行回显逻辑。
- 关键函数
:
- 刷新缓冲区
:调用
tty_flip_buffer_push()
推送数据至 TTY 读队列。
5. 用户空间进程读取输入
- 前台进程
:Shell(如 Bash)通过
read()
系统调用读取 TTY 设备的输入。- 关键路径
:
read()
→tty_read()
→copy_from_read_buf()
。
- 关键路径
:
- 行编辑模式(Canonical Mode) :启用时,输入缓存在内核直到用户按下回车。
6. 字符显示到终端
- 回显(Echo)
:默认
n_tty
规程会自动回显字符到终端。- 关键函数
:
n_tty_receive_char()
调用echo_char()
进行回显。
- 关键函数
:
- 终端写入
:字符通过
write()
系统调用发送至终端显示。- 关键路径
:
write()
→tty_write()
→do_tty_write()
→ 终端驱动的写函数。
- 关键路径
:
- 终端渲染方式
:
- 物理终端
:通过显卡驱动(如
vt_console_print()
)直接输出。 - 伪终端(PTY) :如 SSH 或终端模拟器(如 GNOME Terminal),字符通过 PTY 主从设备传输,最终由终端模拟器渲染。
- 物理终端
:通过显卡驱动(如
3.2 在哪里捕获键盘输入事件?
在 Linux 系统中,键盘输入事件可以在不同层次进行捕获,主要包括 内核态 和 用户态 ,以下是常见的捕获位置及方法。
1. 内核态捕获
1.1 中断处理程序(IRQ 级别)
- 最底层的捕获方式,在键盘触发 硬件中断 时,内核的 中断处理函数 被调用。
- 相关代码位置:
drivers/input/keyboard/atkbd.c
(PS/2 键盘) 或drivers/hid/usbhid/usbkbd.c
(USB 键盘)。 - 关键函数:
irq_handler_t kbd_event()
(PS/2)usb_kbd_irq()
(USB)- 读取 扫描码 并传递至 输入子系统 。
1.2 输入子系统(Input Subsystem)
- 内核的
input_event
机制封装了键盘输入事件。 - 相关设备节点:
/dev/input/eventX
。 - 关键函数:
input_event()
生成键盘事件。input_handle_event()
处理事件并分发至用户空间。
2. 用户态捕获
2.1 通过
/dev/input/eventX
捕获原始输入事件
适用于读取底层输入设备(适用于键盘监听、按键统计等)。
代码示例(使用
evdev
接口):#include <stdio.h> #include <fcntl.h> #include <linux/input.h> int main() { int fd = open("/dev/input/event2", O_RDONLY); if (fd < 0) { perror("open"); return 1; } struct input_event ev; while (read(fd, &ev, sizeof(ev)) > 0) { if (ev.type == EV_KEY && ev.value == 1) { printf("Key %d pressed\n", ev.code); } } close(fd); return 0; }
2.2 通过
/dev/tty
读取终端输入
适用于读取当前终端的键盘输入(受 TTY 规程控制)。
代码示例(读取标准输入):
#include <stdio.h> int main() { char c; while (1) { c = getchar(); printf("Pressed: %c\n", c); } return 0; }
2.3 使用
libinput
监听键盘输入(Wayland 环境)
适用于现代桌面环境(X11/Wayland)。
监听系统级输入事件,适用于图形界面应用。
代码示例(Python):
from evdev import InputDevice, categorize, ecodes dev = InputDevice('/dev/input/event2') for event in dev.read_loop(): if event.type == ecodes.EV_KEY: print(categorize(event))
2.4 监听 X11 按键事件(X Window System)
适用于 GUI 应用,使用
Xlib
监听按键。代码示例(Python +
python-xlib
):from Xlib import display, X d = display.Display() r = d.screen().root r.grab_keyboard(True, X.GrabModeAsync, X.GrabModeAsync, X.CurrentTime) while True: event = r.display.next_event() if event.type == X.KeyPress: print("Key pressed")
3.3 选择合适的捕获方式
需求 | 推荐方法 |
---|---|
低级输入监控(扫描码) | 内核 input_event 或者 input_handle_event API |
监听所有键盘事件 | 读取 /dev/input/eventX |
监听终端输入 | 读取 /dev/tty |
GUI 应用按键捕获 | Xlib 或 libinput |
不同场景下,可以选择合适的方式进行键盘输入捕获。
本文将演示通过 hook 内核函数
input_handle_event
实现键盘记录功能。
4. 代码详解
4.1 bpf代码
4.1.1 整体逻辑
- 加载入口:
代码通过
SEC("kprobe/input_handle_event")
挂载到内核的input_handle_event
函数。 - 事件过滤:
判断传入的
type
是否为EV_KEY
且value
是否等于1,只在按键按下时进行处理。 - 数据发送:
如果满足条件且按键码小于
MAX_KEYS
,则利用bpf_perf_event_output
将事件数据发送到用户空间。
4.1.2 代码细节解析
头文件与宏定义:
- 包含了
vmlinux.h
、bpf_helpers.h
、bpf_tracing.h
和bpf_core_read.h
等头文件,保证代码能调用内核相关的API。 - 定义了
EV_KEY
和MAX_KEYS
,分别代表按键事件类型和允许的最大按键数量。
- 包含了
事件结构体:
struct event_t { u32 type; u32 code; u32 value; };
该结构体用于保存按键事件数据,包括事件类型、按键码和按键状态。
kprobe函数:
SEC("kprobe/input_handle_event") int BPF_KPROBE(hook_input_handle_event, struct input_dev *dev, unsigned int type, unsigned int code, int value) { struct event_t event = { 0, }; event.type = type; event.code = code; event.value = value; if (type == EV_KEY && value == 1) { if(code < MAX_KEYS) { bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event)); } } return 0; }
- 整体逻辑:
在每次
input_handle_event
调用时,将输入数据封装到event
中;判断条件满足时,将事件通过perf event
发送。 - 细节说明:
SEC("kprobe/input_handle_event")
:声明该函数为kprobe处理函数。BPF_KPROBE
宏用于自动生成探针入口。bpf_perf_event_output
负责将数据传递到用户空间,不涉及错误处理代码。
- 整体逻辑:
在每次
4.2 go代码
4.2.1 main.go
整体逻辑
- 信号注册:
利用
signal.NotifyContext
注册系统信号(如SIGINT
、SIGTERM
),确保程序能优雅退出。 - 加载BPF程序:
调用
BpfLoadAndAttach
加载并附加BPF程序文件bpf.o
。 - 创建perf buffer:
初始化
perf buffer
,通过eventsChannel
和lostChannel
接收事件数据及丢失事件统计。 - 事件处理: 启动一个事件处理循环,实时解析并打印键盘事件,直到收到退出信号。
代码细节解析
日志设置: 使用
logrus
设置日志级别和时间格式,使调试信息更直观。资源管理: 利用
defer
语句确保BPF模块和perf buffer在程序结束时被正确关闭。事件循环:
for { // 循环接收事件 select { // 接收到事件时打印事件信息 case data := <-eventsChannel: var event Event err := event.Parse(data) if err != nil { log.Printf("parse event error: %v", err) } else { log.Println(event.String()) } } }
该循环实时响应来自内核空间的事件数据。
4.2.2 event.go
整体逻辑
- 数据结构:
定义了
Event
结构体,用于与内核传来的event_t
数据一一对应。 - 数据解析:
Parse
方法利用binary.Read
将接收到的字节流转化为结构体数据。 - 数据展示:
String
方法调用keyStr
函数,将按键码转换为对应的按键名称,便于直观展示。
代码细节解析
package main
import (
"bytes"
"encoding/binary"
)
type Event struct {
Type uint32
Code uint32
Value uint32
}
// 解析event数据
func (e *Event) Parse(data []byte) error {
err := binary.Read(bytes.NewBuffer(data), binary.LittleEndian, e)
if err != nil {
return err
}
return nil
}
// 转换成字符串
func (e *Event) String() string {
return keyStr(int(e.Code))
}
const MAX_KEYS = 256
var keyNames = [MAX_KEYS]string{
0: "RESERVED",
1: "ESC",
2: "1", 3: "2", 4: "3", 5: "4", 6: "5", 7: "6", 8: "7", 9: "8", 10: "9", 11: "0",
12: "MINUS", 13: "EQUAL", 14: "BACKSPACE", 15: "TAB",
16: "Q", 17: "W", 18: "E", 19: "R", 20: "T", 21: "Y", 22: "U", 23: "I", 24: "O", 25: "P",
26: "LEFTBRACE", 27: "RIGHTBRACE", 28: "ENTER", 29: "LEFTCTRL",
30: "A", 31: "S", 32: "D", 33: "F", 34: "G", 35: "H", 36: "J", 37: "K", 38: "L", 39: "SEMICOLON",
40: "APOSTROPHE", 41: "GRAVE", 42: "LEFTSHIFT", 43: "BACKSLASH",
44: "Z", 45: "X", 46: "C", 47: "V", 48: "B", 49: "N", 50: "M", 51: "COMMA", 52: "DOT", 53: "SLASH",
54: "RIGHTSHIFT", 55: "KPASTERISK", 56: "LEFTALT", 57: "SPACE",
58: "CAPSLOCK", 59: "F1", 60: "F2", 61: "F3", 62: "F4", 63: "F5", 64: "F6", 65: "F7", 66: "F8", 67: "F9", 68: "F10",
69: "NUMLOCK", 70: "SCROLLLOCK", 71: "KP7", 72: "KP8", 73: "KP9", 74: "KPMINUS", 75: "KP4", 76: "KP5", 77: "KP6", 78: "KPPLUS",
79: "KP1", 80: "KP2", 81: "KP3", 82: "KP0", 83: "KPDOT",
85: "ZENKAKUHANKAKU", 86: "102ND", 87: "F11", 88: "F12", 89: "RO", 90: "KATAKANA", 91: "HIRAGANA", 92: "HENKAN", 93: "KATAKANAHIRAGANA", 94: "MUHENKAN",
95: "KPJPCOMMA", 96: "KPENTER", 97: "RIGHTCTRL", 98: "KPSLASH", 99: "SYSRQ", 100: "RIGHTALT", 101: "LINEFEED",
102: "HOME", 103: "UP", 104: "PAGEUP", 105: "LEFT", 106: "RIGHT", 107: "END", 108: "DOWN", 109: "PAGEDOWN", 110: "INSERT", 111: "DELETE",
112: "MACRO", 113: "MUTE", 114: "VOLUMEDOWN", 115: "VOLUMEUP", 116: "POWER", 117: "KPEQUAL", 118: "KPPLUSMINUS", 119: "PAUSE",
120: "SCALE", 121: "KPCOMMA", 122: "HANGEUL", 123: "HANJA", 124: "YEN", 125: "LEFTMETA", 126: "RIGHTMETA", 127: "COMPOSE",
128: "STOP", 129: "AGAIN", 130: "PROPS", 131: "UNDO", 132: "FRONT", 133: "COPY", 134: "OPEN", 135: "PASTE", 136: "FIND", 137: "CUT",
138: "HELP", 139: "MENU", 140: "CALC", 141: "SETUP", 142: "SLEEP", 143: "WAKEUP", 144: "FILE", 145: "SENDFILE", 146: "DELETEFILE", 147: "XFER",
148: "PROG1", 149: "PROG2", 150: "WWW", 151: "MSDOS", 152: "COFFEE", 153: "ROTATE_DISPLAY", 154: "CYCLEWINDOWS", 155: "MAIL", 156: "BOOKMARKS",
157: "COMPUTER", 158: "BACK", 159: "FORWARD", 160: "CLOSECD", 161: "EJECTCD", 162: "EJECTCLOSECD", 163: "NEXTSONG", 164: "PLAYPAUSE",
165: "PREVIOUSSONG", 166: "STOPCD", 167: "RECORD", 168: "REWIND", 169: "PHONE", 170: "ISO", 171: "CONFIG", 172: "HOMEPAGE", 173: "REFRESH",
174: "EXIT", 175: "MOVE", 176: "EDIT", 177: "SCROLLUP", 178: "SCROLLDOWN", 179: "KPLEFTPAREN", 180: "KPRIGHTPAREN", 181: "NEW", 182: "REDO",
183: "F13", 184: "F14", 185: "F15", 186: "F16", 187: "F17", 188: "F18", 189: "F19", 190: "F20", 191: "F21", 192: "F22", 193: "F23", 194: "F24",
200: "PLAYCD", 201: "PAUSECD", 202: "PROG3", 203: "PROG4", 204: "ALL_APPLICATIONS", 205: "SUSPEND", 206: "CLOSE", 207: "PLAY", 208: "FASTFORWARD",
209: "BASSBOOST", 210: "PRINT", 211: "HP", 212: "CAMERA", 213: "SOUND", 214: "QUESTION", 215: "EMAIL", 216: "CHAT", 217: "SEARCH", 218: "CONNECT",
219: "FINANCE", 220: "SPORT", 221: "SHOP", 222: "ALTERASE", 223: "CANCEL", 224: "BRIGHTNESSDOWN", 225: "BRIGHTNESSUP", 226: "MEDIA",
227: "SWITCHVIDEOMODE", 228: "KBDILLUMTOGGLE", 229: "KBDILLUMDOWN", 230: "KBDILLUMUP", 231: "SEND", 232: "REPLY", 233: "FORWARDMAIL", 234: "SAVE",
235: "DOCUMENTS", 236: "BATTERY", 237: "BLUETOOTH", 238: "WLAN", 239: "UWB", 240: "UNKNOWN", 241: "VIDEO_NEXT", 242: "VIDEO_PREV",
243: "BRIGHTNESS_CYCLE", 244: "BRIGHTNESS_AUTO", 245: "DISPLAY_OFF", 246: "WWAN", 247: "RFKILL", 248: "MICMUTE",
}
func keyStr(code int) string {
if code < 0 || code >= MAX_KEYS {
return "UNKNOWN"
}
name := keyNames[code]
if name == "" {
return "UNKNOWN"
}
return name
}
Parse
方法:- 通过
binary.LittleEndian
格式解析数据,确保与内核传输的数据格式一致。
- 通过
String
方法与键值映射:- 利用预定义的
keyNames
数组映射按键码到具体按键名称。 keyStr
函数通过判断码值范围返回对应的字符串,如果不存在则返回"UNKNOWN"
。
- 利用预定义的
5. 总结
本文通过
Go
语言结合
libbpfgo
演示了如何利用eBPF实现键盘记录功能,具体如下:
- 实时性:
直接利用内核
kprobe
捕捉键盘事件,无需轮询,实时性极佳⏱️。 - 高效性:
通过
perf buffer
将数据高效传递到用户空间,确保系统性能不受影响。
该方案不仅适用于键盘事件监控,还可以拓展到其他系统监控和安全检测场景。动手实践后,你也可以根据业务需求调整代码,实现更多高级功能。
6. 练习题
- 按键过滤:
修改代码使其只记录特定按键(如
ESC
或ENTER
)的事件,并在用户空间进行特别处理。 - 数据统计: 增加功能,对每种按键的出现次数进行统计,并定时输出统计结果。
- 扩展应用: 在BPF程序中增加其他类型事件(如鼠标事件)的监控,探索更多内核事件的捕捉方式。
🚀 动手试试吧!遇到问题欢迎留言交流,一起进步!