linux源码解读(十三):内核驱动module加载kprobe&字节跳动Elkied简要分析
要想在计算机里干点事,权限肯定是越高越好的。正常情况下,cpu硬件层面保证了运行在0环的操作系统和运行在3环的用户app互相隔离,3环app要想进入0环执行代码只能通过中断或系统调用的形式,执行最多代码的应该就是硬件的驱动了,常见的屏幕打印、磁盘读写、网卡/wifi收发数据都要执行硬件驱动。因为需要被保护(防止被恶意篡改),同时也需要在多个3环进程间互斥,所以驱动都是被操作系统加载到0环的,天然拥有和操作系统其他内核代码一样的权限,因此很多需要高权限运行的功能都是以驱动的形式落地的。这里先以window为例:
windows早期防护措施不够,杀毒或逆向外挂等软件都喜欢hook SSDT来监控3环的应用程序;SSDT被hook多了容易导致蓝屏,严重影响用户体验;微软直接怒了,搞了驱动签名来严控操作系统对驱动的加载(还有pathguard监控内核代码,一旦发现被更改,直接蓝屏);同时如果发现厂家还在通过驱动恶意hook SSDT,直接吊销签名执照,不再给驱动签名;但是部分厂家从正常的业务角度考虑确实需要hook SSDT咋办了? 比如杀毒软件、驱动保护等业务确实需要监控系统调用,如果一刀切不让hook了,怎么保证windows系统和3环应用的安全了?微软也没赶尽杀绝,提供了回调注册的接口ObRegisterCallbacks,让厂家注册自己的回调函数。每当系统调用被执行时,就调用用户注册的回调函数执行,由此达到和hook一样的效果!
1、回到linux,毕竟也是和windows齐名的os,也是基于x86硬件架构的os,原理和windows是一样的:驱动是以模块(module)的形式加载到内核0环的,代码和操作系统内核其他代码拥有同样的权限,自然也能读写内核其他代码,这就让通过驱动hook内核代码的方式水到渠成了!那么linux面临了和早期windows一样的问题:如果放任开发人员通过驱动随意hook系统调用,可能造成的恶果:(1)不同的hook程序可能会“互相踩踏”,导致hook失效,甚至错误修改内核代码;(2)hook的代码如果没能完整地还原被破坏的机器码,或则跳转回来的地址填写出错,都会导致内核执行出错。为了避免开发人员hook内核带来的出错,linux和windows一样提供了完整的内核驱动代码hook机制:kprobe,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错3种回调方式,分别是pre_handler、post_handler和fault_handler,其中pre_handler函数将在被探测指令被执行前回调,post_handler会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler会在内存访问出错时被调用;主要优势特点:
- 允许在同一个被探测位置注册多个kprobe,避免多个第三方程序同时hook同一段内核代码时发生“互相踩踏”,做到“有序hook”;
- 除了kernel/kprobes.c和arch/*/kernel/kprobes.c程序中用于实现kprobes自身的函数,外加do_page_fault和notifier_call_chain外,其他内核函数都能hook;do_page_fault和notifier_call_chain被调用太频繁,hook会降低操作系统的运行效率;并且这两个都是在中断的handler代码,需要尽快执行完毕;而且hook的业务意义也不大,所以没必要hook了!
- 同一个hook点的回调只会被执行一次,比如hook printk后在回调函数中再调用printk,回调函数只执行一次,避免无限嵌套递归,导致栈资源耗尽;
- kprobes的注册和注销过程中不会使用mutex锁和动态的申请内存,避免占用mutex阻塞其他线程,或sleep让出cpu,导致回调长时间执行,严重影响原函数的效率;
2、kprobe工作hook流程总结如下:
- 当用户注册一个探测点后,kprobe首先备份被探测点的对应指令,然后将原始指令的入口点替换为断点指令,该指令是CPU架构相关的,如i386和x86_64是int3,arm是设置一个未定义指令(目前的x86_64架构支持一种跳转优化方案Jump Optimization,内核需开启CONFIG_OPTPROBES选项,该种方案使用跳转指令来代替断点指令);
- 当CPU流程执行到探测点的断点指令时(把原机器码用int 3替代),就触发了一个trap,在trap处理流程中会保存当前CPU的寄存器信息并调用对应的trap处理函数,该处理函数会设置kprobe的调用状态并调用用户注册的pre_handler回调函数,kprobe会向该函数传递注册的struct kprobe结构地址以及保存的CPU寄存器信息;
- 随后kprobe单步执行前面所拷贝的被探测指令,具体执行方式各个架构不尽相同,arm会在异常处理流程中使用模拟函数执行,而x86_64架构则会设置单步调试flag并回到异常触发前的流程中执行;
- 在单步执行完成后,kprobe执行用户注册的post_handler回调函数;
- 最后,执行流程回到被探测指令之后的正常流程继续执行。
和其他内核功能类似,kprobe功能的实现也是由结构体+函数构成了(这里顺便多说几句:C语言没有对象的语法,所以类的继承采用了结构体包含结构体的形式;也没有成员函数的语法,只能采用结构体包含指针函数的形式实现),结构体字段如下:
struct kprobe { struct hlist_node hlist://被用于kprobe全局hash,索引值为被探测点的地址; struct list_head list://用于链接同一被探测点的不同探测kprobe; kprobe_opcode_t *addr://被探测点的地址; const char *symbol_name://被探测函数的名字; unsigned int offset://被探测点在函数内部的偏移,用于探测函数内部的指令,如果该值为0表示函数的入口; kprobe_pre_handler_t pre_handler://在被探测点指令执行之前调用的回调函数; kprobe_post_handler_t post_handler://在被探测指令执行之后调用的回调函数; kprobe_fault_handler_t fault_handler://在执行pre_handler、post_handler或单步执行被探测指令时出现内存异常则会调用该回调函数; kprobe_break_handler_t break_handler://在执行某一kprobe过程中触发了断点指令后会调用该函数,用于实现jprobe; kprobe_opcode_t opcode://保存的被探测点原始指令; struct arch_specific_insn ainsn://被复制的被探测点的原始指令,用于单步执行,架构强相关(可能包含指令模拟函数); u32 flags://状态标记。 }
最关键的字段:addr、symbol_name、offset、handler、opcode等;这里居然还有list,明显是用来连接其他hook点的!相关配套初始化、使用、卸载探测点的函数如下:
int register_kprobe(struct kprobe *kp) //向内核注册kprobe探测点 void unregister_kprobe(struct kprobe *kp) //卸载kprobe探测点 int register_kprobes(struct kprobe **kps, int num) //注册探测函数向量,包含多个探测点 void unregister_kprobes(struct kprobe **kps, int num) //卸载探测函数向量,包含多个探测点 int disable_kprobe(struct kprobe *kp) //临时暂停指定探测点的探测 int enable_kprobe(struct kprobe *kp) //恢复指定探测点的探测
3、由于需要hook内核代码,权限也必须是0环的,所以也要被加载到内核。windows下的api是driver_entry和driver_exit,linux类似,用的api是module_init和moud_exit;这里以字节跳动的Elkied工具为例,在driver\LKM\src\init.c文件中,加载驱动的module的代码如下:
static int __init do_kprobe_initcalls(void) { int ret = 0; struct kprobe_initcall const *const *initcall_p; for (initcall_p = __start_kprobe_initcall; initcall_p < __stop_kprobe_initcall; initcall_p++) { struct kprobe_initcall const *initcall = *initcall_p; if (initcall->init) { ret = initcall->init(); if (ret < 0) goto exit; } } return 0; exit: while (--initcall_p >= __start_kprobe_initcall) { struct kprobe_initcall const *initcall = *initcall_p; if (initcall->exit) initcall->exit(); } return ret; } static void do_kprobe_exitcalls(void) { struct kprobe_initcall const *const *initcall_p = __stop_kprobe_initcall; while (--initcall_p >= __start_kprobe_initcall) { struct kprobe_initcall const *initcall = *initcall_p; if (initcall->exit) initcall->exit(); } } static int __init kprobes_init(void) { int ret; ret = do_kprobe_initcalls(); if (ret < 0) return ret; return ret; } static void __exit kprobes_exit(void) { do_kprobe_exitcalls(); } module_init(kprobes_init); module_exit(kprobes_exit);
上面的代码只看到了通过驱动加载和初始化kprobe,但是具体hook了哪些系统调用了还不清楚,继续看driver\LKM\src\smith_hook.c代码就能发现端倪了:重要的系统调用都被hook了,https://github.com/bytedance/Elkeid/blob/main/driver/README-zh_CN.md 这里也有hook的系统调用列举;
struct kretprobe execve_kretprobe = { .kp.symbol_name = P_GET_SYSCALL_NAME(execve), .entry_handler = execve_entry_handler, .data_size = sizeof(struct execve_data), .handler = execve_handler, }; struct kprobe call_usermodehelper_exec_kprobe = { .symbol_name = "call_usermodehelper_exec", .pre_handler = call_usermodehelper_exec_pre_handler, }; struct kprobe rename_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(rename), .pre_handler = rename_pre_handler, }; struct kprobe renameat_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(renameat), .pre_handler = renameat_pre_handler, }; #if LINUX_VERSION_CODE >= KERNEL_VERSION(3,15,0) struct kprobe renameat2_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(renameat2), .pre_handler = renameat_pre_handler, }; #endif struct kprobe link_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(link), .pre_handler = link_pre_handler, }; struct kprobe linkat_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(linkat), .pre_handler = linkat_pre_handler, }; struct kprobe ptrace_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(ptrace), .pre_handler = ptrace_pre_handler, }; struct kretprobe udp_recvmsg_kretprobe = { .kp.symbol_name = "udp_recvmsg", .data_size = sizeof(struct udp_recvmsg_data), .handler = udp_recvmsg_handler, .entry_handler = udp_recvmsg_entry_handler, }; #if IS_ENABLED(CONFIG_IPV6) struct kretprobe udpv6_recvmsg_kretprobe = { .kp.symbol_name = "udpv6_recvmsg", .data_size = sizeof(struct udp_recvmsg_data), .handler = udp_recvmsg_handler, .entry_handler = udpv6_recvmsg_entry_handler, }; struct kretprobe ip6_datagram_connect_kretprobe = { .kp.symbol_name = "ip6_datagram_connect", .data_size = sizeof(struct connect_data), .handler = connect_handler, .entry_handler = ip6_datagram_connect_entry_handler, }; struct kretprobe tcp_v6_connect_kretprobe = { .kp.symbol_name = "tcp_v6_connect", .data_size = sizeof(struct connect_data), .handler = connect_handler, .entry_handler = tcp_v6_connect_entry_handler, }; #endif struct kretprobe ip4_datagram_connect_kretprobe = { .kp.symbol_name = "ip4_datagram_connect", .data_size = sizeof(struct connect_data), .handler = connect_handler, .entry_handler = ip4_datagram_connect_entry_handler, }; struct kretprobe tcp_v4_connect_kretprobe = { .kp.symbol_name = "tcp_v4_connect", .data_size = sizeof(struct connect_data), .handler = connect_handler, .entry_handler = tcp_v4_connect_entry_handler, }; struct kretprobe connect_syscall_kretprobe = { .kp.symbol_name = P_GET_SYSCALL_NAME(connect), .data_size = sizeof(struct connect_syscall_data), .handler = connect_syscall_handler, .entry_handler = connect_syscall_entry_handler, }; struct kretprobe accept_kretprobe = { .kp.symbol_name = P_GET_SYSCALL_NAME(accept), .data_size = sizeof(struct accept_data), .handler = accept_handler, .entry_handler = accept_entry_handler, }; struct kretprobe accept4_kretprobe = { .kp.symbol_name = P_GET_SYSCALL_NAME(accept4), .data_size = sizeof(struct accept_data), .handler = accept_handler, .entry_handler = accept4_entry_handler, }; struct kprobe do_init_module_kprobe = { .symbol_name = "do_init_module", .pre_handler = do_init_module_pre_handler, }; struct kretprobe update_cred_kretprobe = { .kp.symbol_name = "commit_creds", .data_size = sizeof(struct update_cred_data), .handler = update_cred_handler, .entry_handler = update_cred_entry_handler, }; struct kprobe security_inode_create_kprobe = { .symbol_name = "security_inode_create", .pre_handler = security_inode_create_pre_handler, }; struct kretprobe bind_kretprobe = { .kp.symbol_name = P_GET_SYSCALL_NAME(bind), .data_size = sizeof(struct bind_data), .handler = bind_handler, .entry_handler = bind_entry_handler, }; struct kprobe mprotect_kprobe = { .symbol_name = "security_file_mprotect", .pre_handler = mprotect_pre_handler, }; struct kprobe setsid_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(setsid), .pre_handler = setsid_pre_handler, }; #if LINUX_VERSION_CODE >= KERNEL_VERSION(3, 17, 0) struct kprobe memfd_create_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(memfd_create), .pre_handler = memfd_create_kprobe_pre_handler, }; #endif struct kprobe prctl_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(prctl), .pre_handler = prctl_pre_handler, }; struct kprobe open_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(open), .pre_handler = open_pre_handler, }; struct kprobe kill_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(kill), .pre_handler = kill_pre_handler, }; struct kprobe tkill_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(tkill), .pre_handler = tkill_pre_handler, }; struct kprobe nanosleep_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(nanosleep), .pre_handler = nanosleep_pre_handler, }; struct kprobe exit_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(exit), .pre_handler = exit_pre_handler, }; struct kprobe exit_group_kprobe = { .symbol_name = P_GET_SYSCALL_NAME(exit_group), .pre_handler = exit_group_pre_handler, }; struct kprobe security_path_rmdir_kprobe = { .symbol_name = "security_path_rmdir", .pre_handler = security_path_rmdir_pre_handler, }; struct kprobe security_path_unlink_kprobe = { .symbol_name = "security_path_unlink", .pre_handler = security_path_unlink_pre_handler, };
这里就看的很清楚了,下面找几个重点函数的回调分析;
(1)execve_handler:execve可以执行指定位置的文件,hook execve后可以监控操作系统运行的所有文件,属于大杀器级别的hook,整个操作系统在干啥看得一清二楚,特别适合监控病毒、木马的运行!
int execve_handler(struct kretprobe_instance *ri, struct pt_regs *regs) { int sa_family = -1; int dport = 0, sport = 0; __be32 dip4; __be32 sip4; pid_t socket_pid = -1; char *pname = DEFAULT_RET_STR; char *tmp_stdin = DEFAULT_RET_STR; char *tmp_stdout = DEFAULT_RET_STR; char *buffer = NULL; char *pname_buf = NULL; char *pid_tree = NULL; char *socket_pname = "-1"; char *socket_pname_buf = NULL; char *tty_name = "-1"; char *exe_path = DEFAULT_RET_STR; char *pgid_exe_path = "-1"; char *stdin_buf = NULL; char *stdout_buf = NULL; struct in6_addr dip6; struct in6_addr sip6; struct file *file; struct execve_data *data; struct tty_struct *tty; data = (struct execve_data *)ri->data; buffer = kzalloc(PATH_MAX, GFP_ATOMIC); /*当前进程所执行文件的路径*/ exe_path = smith_get_exe_file(buffer, PATH_MAX); tty = get_current_tty();//得到当前终端 if(tty && strlen(tty->name) > 0) tty_name = tty->name; //exe filter check and argv filter check if (execve_exe_check(exe_path) || execve_argv_check(data->argv)) goto out; /*得到当前进程的socket,从这里也可以看出是不是中了木马的反弹webshell*/ get_process_socket(&sip4, &sip6, &sport, &dip4, &dip6, &dport, &socket_pname, &socket_pname_buf, &socket_pid, &sa_family); //if socket exist,get pid tree if (sa_family == AF_INET6 || sa_family == AF_INET) pid_tree = smith_get_pid_tree(PID_TREE_LIMIT); else pid_tree = smith_get_pid_tree(PID_TREE_LIMIT_LOW); // get stdin file = fget_raw(0); if (file) { stdin_buf = kzalloc(256, GFP_ATOMIC); tmp_stdin = smith_d_path(&(file->f_path), stdin_buf, 256); fput(file); } //get stdout file = fget_raw(1); if (file) { stdout_buf = kzalloc(256, GFP_ATOMIC); tmp_stdout = smith_d_path(&(file->f_path), stdout_buf, 256); fput(file); } pname_buf = kzalloc(PATH_MAX, GFP_ATOMIC); pname = smith_d_path(¤t->fs->pwd, pname_buf, PATH_MAX); /*打印收集到的各种数据*/ if (sa_family == AF_INET) { execve_print(pname, exe_path, pgid_exe_path, data->argv, tmp_stdin, tmp_stdout, dip4, dport, sip4, sport, pid_tree, tty_name, socket_pid, socket_pname, data->ssh_connection, data->ld_preload, regs_return_value(regs)); } #if IS_ENABLED(CONFIG_IPV6) else if (sa_family == AF_INET6) { execve6_print(pname, exe_path, pgid_exe_path, data->argv, tmp_stdin, tmp_stdout, &dip6, dport, &sip6, sport, pid_tree, tty_name, socket_pid, socket_pname, data->ssh_connection, data->ld_preload, regs_return_value(regs)); } #endif else { execve_nosocket_print(pname, exe_path, pgid_exe_path, data->argv, tmp_stdin, tmp_stdout, pid_tree, tty_name, data->ssh_connection, data->ld_preload, regs_return_value(regs)); } out: if (pname_buf) kfree(pname_buf); if (stdin_buf) kfree(stdin_buf); if (stdout_buf) kfree(stdout_buf); if (buffer) kfree(buffer); if (data->free_argv) kfree(data->argv); if (pid_tree) kfree(pid_tree); if (data->free_ld_preload) kfree(data->ld_preload); if (data->free_ssh_connection) kfree(data->ssh_connection); if(tty) tty_kref_put(tty); return 0; }
(2)mprotect_pre_handler:hook了security_file_mprotect的回调函数,这个函数可能不出名,她是在do_mprotect_pkey中被调用的,整个mprotect的调用链如下:
SYSCALL_DEFINE3(mprotect, .., start, .., len, .., prot) -> do_mprotect_pkey(start, len, prot, pkey=-1) -> mprotect_fixup(vma, .., start, end, newflags) -> change_protection(vma, start, end, newprot, cp_flags) -> change_protection_range(vma, addr, end, newprot, cp_flags) -> change_p4d_range(vma, pgd, add, end, newprot, cp_flags) -> change_pmd_range(vma, pud, addr, end, newprot, cp_flags) -> change_pte_range(vma, pmd, addr, end, newprot, cp_flags)
从底层的change_pte_rang、change_pmd_range、change_p4d_range可以看出,mprotect函数最终改变的是页属性,这个也可以用来做反调试的:把关键的代码地址改为可读可执行,但是不可写,就没法下断点调试了;正常情况下,代码也只会被读,然后执行。如果被改写,肯定不是正常情况,所以可以hook这个链条上的方法来监控关键代码是否被调试了。如果页面不可写,强行更改数据会报SIGSEGV错,用ida调试x音的时候没少遇到这种弹窗吧?不过从公开的资料看,Elkeid貌似并没用在x音客户端的防护,只是在字节内部的生产环境,监控的是服务器操作系统的运行;回调函数代码如下:
int mprotect_pre_handler(struct kprobe *p, struct pt_regs *regs) { int target_pid = -1; unsigned long prot; char *file_path = "-1"; char *file_buf = NULL; char *vm_file_path = "-1"; char *vm_file_buff = NULL; char *exe_path = "-1"; char *abs_buf = NULL; char *pid_tree = NULL; struct vm_area_struct *vma; //only get PROT_EXEC mprotect info //The memory can be used to store instructions which can then be executed. On most architectures, //this flag implies that the memory can be read (as if PROT_READ had been specified). prot = (unsigned long)p_regs_get_arg2(regs); if (prot & PROT_EXEC) { abs_buf = kzalloc(PATH_MAX, GFP_ATOMIC); exe_path = smith_get_exe_file(abs_buf, PATH_MAX);//当前进程对应文件的路径 vma = (struct vm_area_struct *)p_regs_get_arg1(regs); if (IS_ERR_OR_NULL(vma)) { mprotect_print(exe_path, prot, "-1", -1, "-1", "-1"); } else { rcu_read_lock(); if (!IS_ERR_OR_NULL(vma->vm_mm)) { if (!IS_ERR_OR_NULL(&vma->vm_mm->exe_file)) { if (get_file_rcu(vma->vm_mm->exe_file)) { file_buf = kzalloc(PATH_MAX, GFP_ATOMIC); file_path = smith_d_path(&vma->vm_mm->exe_file->f_path, file_buf, PATH_MAX); fput(vma->vm_mm->exe_file); } } #ifdef CONFIG_MEMCG target_pid = vma->vm_mm->owner->pid; #endif } if (!IS_ERR_OR_NULL(vma->vm_file)) { if (get_file_rcu(vma->vm_file)) { vm_file_buff = kzalloc(PATH_MAX, GFP_ATOMIC); vm_file_path = smith_d_path(&vma->vm_file->f_path, vm_file_buff, PATH_MAX); fput(vma->vm_file); } } rcu_read_unlock(); /*被修改内存属性的pid树,从这里可以看到关键进程是不是在被调试等*/ pid_tree = smith_get_pid_tree(PID_TREE_LIMIT); mprotect_print(exe_path, prot, file_path, target_pid, vm_file_path, pid_tree); } if (pid_tree) kfree(pid_tree); if (file_buf) kfree(file_buf); if (abs_buf) kfree(abs_buf); if (vm_file_buff) kfree(vm_file_buff); } return 0; }
(3)ptrace:调试全靠它了,frida和ida这俩卧龙凤雏用的都是这个。要想监控自己的进程是不是被调试了,必须监控ptrace了!回调代码如下:
int ptrace_pre_handler(struct kprobe *p, struct pt_regs *regs) { long request; request = (long)p_get_arg1(regs); //only get PTRACE_POKETEXT/PTRACE_POKEDATA ptrace //Read a word at the address addr in the tracee's memory, //returning the word as the result of the ptrace() call. Linux //does not have separate text and data address spaces, so these linux居然没区分代码和数据空间 //two requests are currently equivalent. (data is ignored; but //see NOTES.) /*PTRACE_PEEKTEXT或PTRACE_PEEKDATA:从addr参数指示的地址开始读取一个WORD的长度的数据并通过返回值传递回来*/ if (request == PTRACE_POKETEXT || request == PTRACE_POKEDATA) { long pid; void *addr; char *exe_path = DEFAULT_RET_STR; char *buffer = NULL; char *pid_tree = NULL; pid = (long)p_get_arg2(regs); addr = (void *)p_get_arg3(regs); if (IS_ERR_OR_NULL(addr)) return 0; buffer = kzalloc(PATH_MAX, GFP_ATOMIC); /*得到当前文件路径*/ exe_path = smith_get_exe_file(buffer, PATH_MAX); /*得到当前进程的进程树*/ pid_tree = smith_get_pid_tree(PID_TREE_LIMIT); /*打印结果*/ ptrace_print(request, pid, addr, "-1", exe_path, pid_tree); if(buffer) kfree(buffer); if(pid_tree) kfree(pid_tree); } return 0; }
不过和ida有点不同,frida使用ptrace attach到进程之后,往进程中注入一个frida-agent-32.so模块,此模块是frida和frida-server通信的重要模块,所以frida不会一直占用ptrace,注入模块完成后便detach,所以ptrace回调函数收集到的数据可能不会太多!
4、大家平时用frida hook native代码的时候,有onEnter和onLeave两个分支,分别是进入native函数和离开native函数时挂钩,前者一般用来查看或修改函数的参数,后者一般用来查看和修改函数返回值,其实在linux底层已经提供了相应的方式来达到同样的目的:jprobe和kretprobe,并且这两个都是基于kprobe实现的!
参考:
1、https://cloud.tencent.com/developer/article/1874723?from=15425%20%20%20L Linux内核调试技术——kprobe使用与实现
2、https://yaotingting.net/2021/05/09/mprotect-analysis/ Linux/mprotect源码分析
3、https://github.com/bytedance/Elkeid/blob/main/driver/README-zh_CN.md About Elkeid(AgentSmith-HIDS) Driver
4、https://zhuanlan.zhihu.com/p/347313289 systemtap介绍