目录

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 应用按键捕获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程序中增加其他类型事件(如鼠标事件)的监控,探索更多内核事件的捕捉方式。

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