当前位置: 首页 > news >正文

eBPF 实时捕获键盘输入

eBPF 实时捕获键盘输入

本文将带你一步步实现一个基于eBPF kprobe的键盘记录功能,通过Go语言配合libbpfgo,你将学会如何无损地监控系统键盘输入,并从中获取实时数据,进一步提高系统安全和监控能力。


1. 说明

本文属于专栏 Go语言+libbpfgo实战eBPF开发,示例代码目录为 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 应用按键捕获Xliblibinput

不同场景下,可以选择合适的方式进行键盘输入捕获。

本文将演示通过 hook 内核函数 input_handle_event 实现键盘记录功能。


4. 代码详解

4.1 bpf代码

4.1.1 整体逻辑
  • 加载入口: 代码通过SEC("kprobe/input_handle_event")挂载到内核的input_handle_event函数。
  • 事件过滤: 判断传入的type是否为EV_KEYvalue是否等于1,只在按键按下时进行处理。
  • 数据发送: 如果满足条件且按键码小于MAX_KEYS,则利用bpf_perf_event_output将事件数据发送到用户空间。
4.1.2 代码细节解析
  • 头文件与宏定义:

    • 包含了vmlinux.hbpf_helpers.hbpf_tracing.hbpf_core_read.h等头文件,保证代码能调用内核相关的API。
    • 定义了EV_KEYMAX_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注册系统信号(如SIGINTSIGTERM),确保程序能优雅退出。
  • 加载BPF程序: 调用BpfLoadAndAttach加载并附加BPF程序文件bpf.o
  • 创建perf buffer: 初始化perf buffer,通过eventsChannellostChannel接收事件数据及丢失事件统计。
  • 事件处理: 启动一个事件处理循环,实时解析并打印键盘事件,直到收到退出信号。
代码细节解析
  • 日志设置: 使用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. 练习题

  1. 按键过滤: 修改代码使其只记录特定按键(如ESCENTER)的事件,并在用户空间进行特别处理。
  2. 数据统计: 增加功能,对每种按键的出现次数进行统计,并定时输出统计结果。
  3. 扩展应用: 在BPF程序中增加其他类型事件(如鼠标事件)的监控,探索更多内核事件的捕捉方式。

🚀 动手试试吧!遇到问题欢迎留言交流,一起进步!

相关文章:

  • Day2 导论 之 「存储器,IO,微机工作原理」
  • 【测试篇】打破测试认知壁垒,从基础概念起步
  • 零基础上手Python数据分析 (5):Python文件操作 - 轻松读写,数据导入导出不再是难题
  • 【SpringMVC】常用注解:@RequestHeader
  • sentinel限流算法
  • 《DeepSeek深度使用教程:开启智能交互新体验》Deepseek深度使用教程
  • 第五章 树、2叉树
  • 這是我第一次寫關於aapenal服務器管理控制面板的文章
  • “个人陈述“的“十要“和“十不要“
  • 1、操作系统引论
  • Certbot实现SSL免费证书自动续签(CentOS 7 + nginx/apache)
  • (undone) 梳理 xv6-lab-2023 fs.img 生成过程,以及文件系统结构
  • QT编程之QStackedWidget
  • 自定义tiptap插件
  • obsidian中Text Generate的使用教程(以DeepSeek为例)
  • TTS语音模型调用出错
  • 【前端实战】一文掌握响应式布局 - 多设备完美适配方案详解
  • Vuex 高级技巧与最佳实践
  • IMX6ULL学习整理篇——Linux驱动开发的基础3:向新框架迁移
  • LabVIEW棉花穴播器排种自动监测系统
  • 五一首日出沪高峰,G1503高东收费站上午车速约30公里/小时
  • 从“长绳系日”特展看韩天衡求艺之路
  • 经营业绩持续稳中向好,国铁集团2024年度和2025年一季度财务决算公布
  • 滨江集团:一季度营收225.07亿元,净利润9.75亿元
  • 当老年人加入“行为艺术基础班”
  • 农行一季度净利润719亿元增2.2%,不良率微降至1.28%