2. Linux 系统调用基础:系统调用概念、系统调用号与表、用户态到内核态的切换流程

2.1 系统调用概念

系统调用(System Call)是用户空间程序请求内核服务的唯一合法入口。它定义了用户态与内核态之间的安全边界,确保任何对硬件资源、进程管理、文件系统、网络通信等核心功能的访问都必须经过内核的授权与校验。

在 Linux 中,系统调用具有以下关键特征:

  • 特权级切换:从 Ring 3(用户态)切换到 Ring 0(内核态),获得执行特权指令的能力。
  • 固定接口:每个系统调用由唯一的编号标识,参数传递遵循特定约定(寄存器传递)。
  • 安全屏障:内核在系统调用入口处进行参数校验、权限检查、地址空间合法性验证。
  • 可抢占性:系统调用执行过程中可能被更高优先级的任务或中断抢占。
安全视角: 系统调用是攻击者从用户态尝试提权或破坏内核的关键入口。内核模块开发者必须理解系统调用的完整路径,才能正确地在钩子函数或自定义系统调用中实施参数校验。

2.2 系统调用号与系统调用表

每个系统调用在 Linux 内核中都有一个唯一的 系统调用号(syscall number)。该编号是用户态程序与内核态处理函数之间的索引映射。

2.2.1 系统调用号分配

系统调用号定义在架构相关的头文件中,例如 x86_64 架构的定义位于 arch/x86/include/asm/unistd_64.h。以下为部分常见系统调用号示例:

系统调用号 系统调用名称 内核处理函数 功能简述
0 read sys_read 从文件描述符读取数据
1 write sys_write 向文件描述符写入数据
2 open sys_open 打开或创建文件
39 getpid sys_getpid 获取当前进程 ID
59 execve sys_execve 执行新程序
62 kill sys_kill 向进程发送信号

2.2.2 系统调用表结构

内核维护一个全局的系统调用表 sys_call_table,该表是一个函数指针数组,数组下标即为系统调用号,数组元素指向对应的内核处理函数。

在 x86_64 架构中,系统调用表的声明类似于:

// arch/x86/kernel/syscall_64.c
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,
    // ... 其他系统调用
};

系统调用表的关键安全属性:

  • 只读保护:现代内核将 sys_call_table 放置在只读内存区域,防止恶意模块篡改。
  • 索引边界:内核在分发系统调用前会校验系统调用号是否在有效范围内(nr < NR_syscalls)。
  • 空指针防护:未实现的系统调用号指向 sys_ni_syscall,该函数直接返回 -ENOSYS。
内核模块安全提示: 如果内核模块试图通过修改 sys_call_table 来钩取系统调用,必须注意:
1. 内核可能已启用 CONFIG_HAVE_ARCH_SECCOMP_FILTER 或 CONFIG_ARCH_HAS_SYSCALL_WRAPPER,导致直接修改失效。
2. 即使成功修改,也必须在钩子函数中执行严格的参数校验,否则会引入安全漏洞。

2.3 用户态到内核态的切换流程

当用户态程序发起系统调用时,CPU 需要完成从用户态到内核态的上下文切换。x86_64 架构下,主要使用 syscall 指令(快速系统调用)实现切换。

2.3.1 切换流程步骤

  1. 用户态准备:应用程序将系统调用号放入 rax 寄存器,参数依次放入 rdi、rsi、rdx、r10、r8、r9 寄存器。
  2. 执行 syscall 指令:CPU 将 RIP 保存到 RCX,将 RFLAGS 保存到 R11,然后跳转到 IA32_LSTAR MSR 指定的内核入口点(entry_SYSCALL_64)。
  3. 内核态入口处理:保存用户态寄存器上下文,切换到内核栈,设置 FS/GS 段基址。
  4. 系统调用分发:调用 do_syscall_64() 函数,该函数根据 rax 中的系统调用号索引 sys_call_table,调用对应的处理函数。
  5. 内核处理与返回:系统调用处理函数执行完毕后,将返回值放入 rax,执行 sysretq 指令返回用户态,恢复 RIP 和 RFLAGS。

2.3.2 切换流程 SVG 架构图

用户态到内核态系统调用切换流程 用户态 → 内核态系统调用切换流程 用户态 (Ring 3) 应用程序调用 read(fd, buf, count) 设置寄存器:rax=0, rdi=fd, rsi=buf, rdx=count 执行 syscall 指令 CPU 切换 Ring 3 → Ring 0 内核态 (Ring 0) entry_SYSCALL_64 入口 保存寄存器,切换内核栈 do_syscall_64() 分发 系统调用表 sys_call_table [0] → sys_read [1] → sys_write [2] → sys_open [3] → sys_close 根据 rax=0 索引到 sys_read 处理函数 系统调用返回:sysretq → 恢复用户态执行 参数校验点 入口处检查

2.4 系统调用参数传递与校验时机

理解系统调用的切换流程后,内核模块开发者需要重点关注 参数校验的时机:

校验阶段 位置 校验内容 安全意义
入口校验 do_syscall_64() 之前 系统调用号范围、seccomp 过滤 防止非法系统调用号、沙箱逃逸
参数预检 系统调用处理函数开头 用户态指针有效性、长度范围、权限检查 防止空指针解引用、缓冲区溢出
数据拷贝 copy_from_user/copy_to_user 地址空间合法性、页边界检查 防止内核内存泄露、任意地址读写
逻辑校验 系统调用执行过程中 参数语义合法性、状态一致性 防止逻辑漏洞、条件竞争
内核模块开发要点: 当编写自定义系统调用或钩取现有系统调用时,必须在 参数预检 阶段使用 access_ok() 宏验证用户态指针,使用 copy_from_user() 安全拷贝数据,切勿直接解引用用户态指针。

2.5 系统调用与内核模块安全增强的关系

系统调用是内核模块安全增强的核心战场。内核模块可以通过以下方式介入系统调用路径:

  • 系统调用钩子:替换 sys_call_table 中的函数指针,在原始调用前后插入参数校验逻辑。
  • 自定义系统调用:通过 register_syscall 或修改内核源码添加新的系统调用,实现专用安全接口。
  • seccomp 过滤器:利用内核的 seccomp 机制限制进程可用的系统调用,减少攻击面。
  • LSM 钩子:通过 Linux Security Module 框架在系统调用关键路径上实施访问控制。