1. Linux 系统调用概述

系统调用(System Call)是用户态程序请求内核服务的唯一合法入口。理解系统调用的完整路径——从用户态 printf 到内核 sys_write——是后续实现系统调用表 Hook 的理论基础。本章从概念、切换机制、系统调用号与 sys_call_table 三个维度建立知识框架。

1.1 系统调用的概念

操作系统将 CPU 指令集划分为两个特权级:用户态(Ring 3) 和 内核态(Ring 0)。用户态程序不能直接访问硬件、修改页表或操作关键数据结构;必须通过系统调用陷入内核,由内核代表用户程序完成特权操作。

对比维度 用户态(Ring 3) 内核态(Ring 0)
权限范围 受限指令集,仅访问用户空间 全部指令,直接访问内核空间
内存访问 用户虚拟地址空间 内核虚拟地址空间 + 用户空间(通过 copy_from/to_user)
典型操作 计算、逻辑判断、内存拷贝 文件 I/O、进程调度、网络协议栈
崩溃影响 仅终止当前进程 系统 panic 或 oops
关键认知:系统调用是用户态与内核态之间的唯一受控通道。任何绕过系统调用的内核访问(如直接写 /dev/mem)都是非标准操作,在安全审计中会被标记为异常行为。

1.2 用户态与内核态切换

当用户程序调用 read()、write() 等库函数时,glibc 封装函数会执行以下切换流程:

  1. 参数准备:将系统调用号放入 rax 寄存器,参数依次放入 rdi、rsi、rdx 等寄存器。
  2. 陷入指令:执行 syscall(x86_64)或 int 0x80(x86),CPU 自动切换到 Ring 0。
  3. 入口处理:内核入口函数 entry_SYSCALL_64 保存寄存器现场,根据 rax 中的系统调用号索引 sys_call_table。
  4. 执行服务例程:调用对应的内核函数(如 ksys_read)。
  5. 返回用户态:执行 sysret 指令,恢复寄存器,切换回 Ring 3。
性能数据:一次完整的系统调用切换(用户态→内核态→用户态)在 x86_64 上大约需要 50–200 ns,其中上下文保存/恢复占主要开销。这是为什么高性能场景会使用 io_uring 等批量提交机制来减少切换次数。
用户态与内核态系统调用切换流程 用户态(Ring 3) 用户程序调用 read(fd, buf, len) glibc 封装:参数 → 寄存器 执行 syscall 指令 CPU 切换 Ring 内核态(Ring 0) entry_SYSCALL_64 保存现场 sys_call_table[rax] 查表 执行 ksys_read 服务例程 sysret 返回用户态

1.3 系统调用号与 sys_call_table

每个系统调用在 Linux 内核中都有一个唯一的整数编号,称为系统调用号。x86_64 架构下,系统调用号定义在 /usr/include/asm/unistd_64.h 中:

// 常见系统调用号(x86_64)
#define __NR_read      0
#define __NR_write     1
#define __NR_open      2
#define __NR_close     3
#define __NR_stat      4
#define __NR_fstat     5
#define __NR_lstat     6
#define __NR_poll      7
#define __NR_lseek     8
#define __NR_mmap      9
#define __NR_mprotect  10
// ... 共约 450+ 个系统调用

sys_call_table 是内核中一个函数指针数组,数组下标就是系统调用号,数组元素是对应的内核服务函数地址。其定义位于内核源码 arch/x86/kernel/syscall_64.c:

// 内核中的 sys_call_table 定义(简化)
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    [0 ... __NR_syscall_max] = &sys_ni_syscall,
    [0] = sys_read,
    [1] = sys_write,
    [2] = sys_open,
    [3] = sys_close,
    // ...
};
重要安全约束:在标准内核中,sys_call_table 被标记为 const 且所在内存页被设置为只读(通过 mark_rodata_ro)。内核模块要修改系统调用表,必须先修改页表属性(如 set_memory_rw),这本身就是安全敏感操作——这也是为什么系统调用表 Hook 技术通常需要关闭内核 CONFIG_STRICT_MEMORY_RWX 或使用 kprobes 等替代方案。
sys_call_table 结构:系统调用号到函数指针的映射 sys_call_table 结构示意图 sys_call_table[ ] 函数指针数组 [0] [1] [2] [3] [4] [5] [6] [7] [8] … 内核服务函数 sys_read sys_write sys_open sys_close sys_stat sys_fstat sys_lstat sys_poll sys_lseek … 系统调用号作为数组下标,O(1) 时间复杂度定位服务函数

1.4 系统调用表 Hook 的切入点

理解上述机制后,系统调用表 Hook 的核心思路就非常清晰了:

  • 定位:获取 sys_call_table 在内核中的虚拟地址(通过 kallsyms_lookup_name 或直接读取 /proc/kallsyms)。
  • 修改权限:将 sys_call_table 所在内存页从只读改为可写(调用 set_memory_rw)。
  • 替换指针:将目标系统调用号对应的函数指针替换为自定义的 Hook 函数地址。
  • 恢复权限(可选):修改完成后将页属性恢复为只读,防止被篡改检测。
实战提示:在 5.7+ 内核中,kallsyms_lookup_name 不再导出给模块使用,需要借助 kprobes 或直接解析 /proc/kallsyms 来获取 sys_call_table 地址。后续章节会详细演示这些绕过方法。

下表总结了系统调用表 Hook 涉及的关键内核符号及其用途:

符号/函数 用途 内核版本依赖
sys_call_table 系统调用函数指针数组 所有版本
kallsyms_lookup_name 按名称查找内核符号地址 5.7 前导出,之后需 kprobes
set_memory_rw 修改内存页为可读写 所有版本
__NR_xxx 系统调用号宏定义 架构相关,x86_64 稳定