一、Syscall User Dispatch概述

Syscall User Dispatch(简称 SUD)是 Linux 5.11 引入的一项革命性特性,它允许用户态程序选择性拦截特定的系统调用指令,而无需修改内核、无需编写内核模块、也无需使用 ptrace。这是第一个让用户态程序自主决定"哪些 syscall 由内核处理、哪些由用户态接管"的官方机制。

SUD 的核心思想极其简洁:通过 prctl(PR_SET_SYSCALL_USER_DISPATCH, ...) 在进程地址空间中标记一个"允许区域"(selector region),当 CPU 在该区域内执行 syscall 指令时,内核不会正常分发该系统调用,而是向进程投递一个 SIGSYS 信号。信号处理器可以检查被拦截的 syscall 及其参数,自行决定是否模拟、修改或放行。

这项技术的典型应用场景包括:

  • Wine / Proton:拦截 Windows x64 应用的 syscall 指令(Windows 直接在用户态执行 syscall),将其翻译为 Linux syscall
  • gVisor:替代 ptrace 方案,更高效地进行 syscall 安全拦截
  • 安全沙箱:比 seccomp 更精细地控制 syscall 来源,区分"同一进程中不同模块的 syscall"
  • 兼容性层:在 Linux 上运行为其他 Unix 系统编译的、使用不同 syscall 约定的二进制
  • 调试与分析:追踪特定内存范围的 syscall 行为

二、背景与演进:为什么需要 SUD

2.1 传统 Syscall 处理模型

在传统 Linux 中,所有 syscall 指令被视为等价——无论它来自 .text 段、共享库、还是 JIT 生成的代码。内核无法区分 syscall 的"来源"。这种设计在大多数场景下工作良好,但导致两个关键问题:

问题一:Windows x64 应用在 Linux 上运行(Wine)

Windows x64 ABI 要求用户态直接执行 syscall 指令(类似于 Linux x64),但调用号和参数约定完全不同。传统的 Wine 方案需要让所有 syscall 都进入内核再回用户态处理,或者使用复杂的二进制翻译。SUD 让 Wine 可以选择性地只拦截"来自 Windows 应用的 syscall 指令",而不影响宿主库的 syscall。

问题二:seccomp 的粒度不足

seccomp-bpf 可以过滤所有 syscall,但无法基于 syscall 发生的"地址位置"做决策。如果一个进程同时包含需要严格隔离的沙箱代码和需要完全访问的特权代码,seccomp 只能一刀切。

2.2 演化历程

年份事件
2005Linux 2.6.16 引入 vsyscall 页面(固定的用户态快速 syscall 入口地址)
2010vsyscall 由于安全顾虑被限制为 emu 模式(每次调用触发 #GP 异常)
2012KVM 的 kvm-clock 使用固定地址 vsyscall
2015Linux 4.4 引入 VDSO(vsyscall 的增强版,支持 ASLR 随机化)
2016Intel 提出 Intel PT 等追踪技术间接解决 trace 问题
2019Gabriel Starr 首次提出 SUD 概念补丁
2021Linux 5.11 合并 SUD(作者:Gabriel Starr / Google)
2023Wine 9.0+ 正式启用 SUD 作为 Windows syscall 拦截机制

三、内核实现深度解析

3.1 核心数据结构

SUD 在内核中的实现位于 kernel/entry/syscall_user_dispatch.c,核心数据结构如下:

// include/linux/syscall_user_dispatch.h
struct syscall_user_dispatch {
    char    *selector;      // selector 页面的地址(用户态)
    u64     selector_addr;  // 允许区域起始地址
    u64     selector_len;   // 允许区域长度
    bool    on_disarm;      // 是否已 disarm(信号处理器用来区分不同阶段)
};

// task_struct 中的关联
struct task_struct {
    ...
#ifdef CONFIG_SYSCALL_USER_DISPATCH
    struct syscall_user_dispatch __rcu *syscall_dispatch;
#endif
};

关键设计:selector 位于用户空间的一个页面,由用户态程序管理。该页面使用一个位图(bitmap)标记每一个字节位置是否允许触发拦截。内核在分发 syscall 时只检查 rip(指令指针)是否在 [selector_addr, selector_addr + selector_len] 范围内。

3.2 内核调度流程

当 CPU 执行 syscall 指令时,进入 entry_SYSCALL_64,SUD 检查逻辑如下:

// arch/x86/entry/common.c (简化)
static long do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
    struct syscall_user_dispatch *sd = current->syscall_dispatch;
    
    if (sd && nr != __NR_getppid) {  // getppid 是内置例外
        // 检查 rip 是否在允许区域
        if (regs->ip >= sd->selector_addr &&
            regs->ip < sd->selector_addr + sd->selector_len) {
            // 不在 selector 中的 syscall → 拦截
            if (!on_selector_page(regs->ip, sd)) {
                force_sig_fault(SIGSYS, SYS_SECCOMP, 
                               (void __user *)regs->ip);
                return -ENOSYS;
            }
        }
    }
    return syscall_trace_enter(regs, nr);
}

3.3 Selector 位图机制

SUD 支持两种工作模式,由 selector 页面在 prctl 调用中的使用方式决定:

模式一:Range 模式(推荐)

selector 指向一个页面,页面的内容被忽略,仅指定的地址范围 [selector_addr, selector_addr + selector_len] 被标记为"信号区域"。此范围内触发的 syscall 被拦截(发送 SIGSYS),范围外的 syscall 正常执行。

模式二:Bitmap 模式

selector 指向一个页面,该页面的每一个 bit 对应指定范围内每一字节的位置。bit=1 表示拦截该位置的 syscall,bit=0 允许通过。此模式更精细但性能开销更大。

实际使用中绝大多数场景使用 Range 模式,因为它简单高效,且 Wine/gVisor 所需的就是范围标记。

3.4 关键实现细节

  • SID 位反转(Selector ID Bit Flip):为了安全地传递"从内核回到用户态信号处理器时的状态",内核在投递 SIGSYS 前会翻转 selector 页面的 PAGE_SIZE-1 处的 byte 的特定 bit。信号处理器通过检查此 bit 知道 syscall 是"被拦截的"还是"随机的 SIGSYS"。
  • disarm 机制:信号处理过程中如果需要放行被拦截的 syscall,或者需要嵌套调用,必须通过 PR_SET_SYSCALL_USER_DISPATCH 的 disarm 操作来临时禁用 SUD。
  • IPC 特殊性:某些内核 IPC(如 getppid)即使在 SUD 启用时也不能被拦截,因为内核内部机制依赖它们。
  • 快速路径:SUD 的范围检查只需两条比较指令,对正常 syscall 路径的性能影响几乎为零(< 0.1%)。

四、用户态编程接口

4.1 prctl 系统调用

SUD 的唯一配置接口是 Linux 扩展的 prctl:

#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <linux/syscall_user_dispatch.h>

#define PR_SET_SYSCALL_USER_DISPATCH  60
#define PR_SYS_DISPATCH_OFF           0
#define PR_SYS_DISPATCH_ON            1
#define SYSCALL_DISPATCH_FILTER_ALLOW 0
#define SYSCALL_DISPATCH_FILTER_BLOCK 1

int prctl(int option, unsigned long arg1, unsigned long arg2,
          unsigned long arg3, unsigned long arg4);

// 启用 SUD
prctl(PR_SET_SYSCALL_USER_DISPATCH, PR_SYS_DISPATCH_ON,
      (unsigned long)selector_page,    // selector 页面地址
      PAGE_SIZE + selector_offset,    // 允许区域: [selector_page, selector_page + len]
      (unsigned long)&selector_byte); // 用于同步的 selector 字节

// 禁用 SUD
prctl(PR_SET_SYSCALL_USER_DISPATCH, PR_SYS_DISPATCH_OFF,
      0, 0, 0);

4.2 SIGSYS 信号处理器

#include <signal.h>
#include <sys/ucontext.h>
#include <linux/audit.h>

static void sigsys_handler(int sig, siginfo_t *info, void *ucontext)
{
    ucontext_t *ctx = (ucontext_t *)ucontext;
    struct sigcontext *sc = (struct sigcontext *)&ctx->uc_mcontext;
    
    // 检查是否为有效的 SUD 拦截
    if (info->si_syscall != ctx->uc_mcontext.gregs[REG_RAX]) {
        // 不是来自 syscall 指令,是真 SIGSYS
        return;
    }
    
    // 解析 syscall 信息
    long syscall_nr = sc->gregs[REG_RAX];
    long args[6] = {
        sc->gregs[REG_RDI], sc->gregs[REG_RSI],
        sc->gregs[REG_RDX], sc->gregs[REG_R10],
        sc->gregs[REG_R8],  sc->gregs[REG_R9]
    };
    long rip = sc->gregs[REG_RIP];
    
    // 根据策略决定:模拟、修改参数、或者放行
    long result = handle_syscall(syscall_nr, args);
    sc->gregs[REG_RAX] = result;  // 设置返回值
    sc->gregs[REG_RIP] += 2;      // 跳过 syscall 指令
}

4.3 完整的最小可行示例

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <sys/prctl.h>
#include <unistd.h>
#include <stdint.h>

#define PAGE_SIZE 4096
static char selector_page[PAGE_SIZE] __attribute__((aligned(PAGE_SIZE)));

static void sigsys_handler(int sig, siginfo_t *info, void *ucontext)
{
    ucontext_t *ctx = (ucontext_t *)ucontext;
    long nr = ctx->uc_mcontext.gregs[REG_RAX];
    
    // 安全检查 selector 翻转
    if (!(selector_page[PAGE_SIZE-1] & 1)) {
        // 不是有效的 SUD 拦截
        return;
    }
    
    printf("[SUD] 拦截 syscall #%ld at rip=%llx\n",
           nr, ctx->uc_mcontext.gregs[REG_RIP]);
    
    if (nr == __NR_getpid) {
        // 模拟 getpid,返回固定值 12345
        ctx->uc_mcontext.gregs[REG_RAX] = 12345;
    } else if (nr == __NR_time) {
        // 模拟 time,返回固定时间
        long *arg0 = (long *)ctx->uc_mcontext.gregs[REG_RDI];
        if (arg0) *arg0 = 1700000000;
        ctx->uc_mcontext.gregs[REG_RAX] = 1700000000;
    } else {
        // 其他 syscall 返回 EINVAL
        ctx->uc_mcontext.gregs[REG_RAX] = -22; // -EINVAL
    }
    
    // 跳过 syscall 指令(2 bytes)
    ctx->uc_mcontext.gregs[REG_RIP] += 2;
}

int main()
{
    // 设置 SIGSYS 处理器
    struct sigaction sa = {
        .sa_sigaction = sigsys_handler,
        .sa_flags = SA_SIGINFO | SA_NODEFER | SA_RESTART,
    };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSYS, &sa, NULL);
    
    // 启用 SUD(拦截 selector_page 地址范围内的所有 syscall)
    char selector_byte = 0;
    prctl(PR_SET_SYSCALL_USER_DISPATCH, PR_SYS_DISPATCH_ON,
          (unsigned long)selector_page, 1, (unsigned long)&selector_byte);
    
    // 以下代码在 selector_page 范围内才会被拦截
    // 实际上程序代码不会直接在 selector_page 执行
    // 这是演示用途
    
    printf("getpid() = %d (模拟)\n", getpid());
    printf("SUD 演示完成\n");
    
    prctl(PR_SET_SYSCALL_USER_DISPATCH, PR_SYS_DISPATCH_OFF, 0, 0, 0);
    return 0;
}

五、Wine 实战:Windows 应用 syscall 拦截

5.1 Wine 的 Windows-on-Linux 挑战

Windows x64 应用直接在用户态执行 syscall 指令调用 NT 系统服务,而 Linux 也要求应用通过 syscall 进入内核。问题在于:

  • Windows 的 syscall 编号与 Linux 完全不同
  • Windows 的参数约定不同(rcx = 第4参数,而非 r10)
  • Windows ntdll.dll 直接包含 syscall 指令

Wine 的经典方案需要在每次 Windows syscall 时进入内核(通过一个假的"kernel trap"),再返回用户态由 Wine 翻译。SUD 提供了一片新道路:让 Windows 应用的 syscall 直接不上内核,被拦截到 Wine 的处理器。

5.2 Wine 的 SUD 集成架构


// Wine ntldr.c (简化)
void init_sud(void)
{
    // 1. 分配 selector 页面
    selector = mmap(NULL, 0x10000, PROT_READ | PROT_WRITE,
                    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    
    // 2. 设置 SIGSYS 处理器
    setup_sigsys_handler();
    
    // 3. 启用 SUD:拦截 [selector, selector + wine_addr_range)
    //    的范围——即 ntdll.dll 所在的内存区域
    prctl(PR_SET_SYSCALL_USER_DISPATCH, PR_SYS_DISPATCH_ON,
          (unsigned long)selector, 
          wine_total_mapping_size,
          (unsigned long)&selector_byte);
    
    // 4. ntdll.dll 中的 syscall 指令地址被标记在 selector 范围内
    //    而当 glibc 调用 syscall 时(文件IO等),rip 在 libc.so 范围,
    //    所以不会被拦截,正常进入内核
}

static void nt_syscall_dispatcher(int sig, siginfo_t *info, void *ucontext)
{
    // 获取 NT syscall 编号
    long nt_number = ctx->uc_mcontext.gregs[REG_RAX];
    
    // 转换并调用 Linux 等效接口
    long result = wine_ntsyscall_to_linux(nt_number, args);
    
    // 设置返回值并跳过 syscall 指令
    ctx->uc_mcontext.gregs[REG_RAX] = result;
    ctx->uc_mcontext.gregs[REG_RIP] += 2;
}

5.3 实际效果与性能提升

在 Wine 9.0+ 中启用 SUD 后的性能表现:

场景SUD 关闭SUD 启用提升
ntdll.NtQuerySystemInformation~1200ns~350ns3.4x
ntdll.NtCreateFile~850ns~200ns4.2x
ntdll.NtDelayExecution~6000ns~900ns6.7x
CPU 渲染通用场景100%~95%+5%
游戏帧率(部分场景)60 FPS~57 FPS~5%

关键收益来自避免了内核的 syscall 分发开销——不再需要从内核返回到用户态再返回到内核的往返。

六、gVisor 实战:安全的容器 syscall 拦截

6.1 方案对比:ptrace vs SUD

作为容器安全领域的明星项目,gVisor 早期依赖 ptrace 拦截所有容器内 syscall。ptrace 的缺点显而易见:每次 syscall 两次上下文切换、SIGTRAP 处理开销、性能监控缺失。

维度ptrace 方案SUD 方案
每次拦截开销+6000~12000 ns+200~500 ns
上下文切换4次/拦截(进程→内核→ptrace→内核→进程)1次信号投递
性能隔离PTRACE_SYSCALL 影响所有 syscall只拦截容器内代码的 syscall
兼容性与 seccomp、SIGMASK 等常有冲突与现有机制正交
安全性依赖 ptrace_scope 内核参数依赖 PR_SET_SYSCALL_USER_DISPATCH(进程级)
Linux 版本要求任何版本≥ 5.11(含 backport)

6.2 gVisor 的 SUD 配置示例


// sentry/loader.c (简化)
static int setup_sud(struct thread *t)
{
    // 标记容器映射空间的范围作为拦截区域
    struct syscall_user_dispatch_config conf = {
        .mode = PR_SYS_DISPATCH_ON,
        .selector_addr = t->container_mapping_start,
        .selector_len = t->container_mapping_size,
    };
    
    // 安装 SIGSYS,仅拦截容器代码的 syscall
    // sentry 自身(在 host 映射范围内)的 syscall 不被拦截
    return prctl(PR_SET_SYSCALL_USER_DISPATCH, conf.mode,
                 conf.selector_addr, conf.selector_len,
                 t->selector_byte);
}

// SIGSYS 处理器
static void sentry_sigsys_handler(struct thread *t, 
                                   struct sigcontext *ctx)
{
    // 查找对应的 sentry syscall 处理器
    int nr = (int)ctx->rax;
    
    // 检查安全策略
    if (!syscall_allowed_in_container(nr)) {
        ctx->rax = -EPERM;
        ctx->rip += 2;
        return;
    }
    
    // 路由到 sentry 内部实现
    long result = sentry_syscall_handler(nr, ctx->rdi, ctx->rsi,
                                          ctx->rdx, ctx->r10,
                                          ctx->r8, ctx->r9);
    ctx->rax = result;
    ctx->rip += 2;  // 跳过 syscall
}

七、与 seccomp 的对比与互补

7.1 seccomp 的能力与局限

seccomp-bpf 是 Linux 最强大的沙箱机制,但它在某些场景力不从心:

  • 无法按来源区分 syscall:所有代码的 syscall 都经过同一个 BPF 过滤器
  • 参数检查受限:BPF 只能读取参数值,无法读取指针指向的内存(SECCOMP_RET_TRACE 除外)
  • Return-only 修改:SECOMP_RET_ERRNO / SECCOMP_RET_TRACE 无法修改参数
  • 性能顾虑:每次 syscall 都执行 BPF 程序(虽然影响通常很小)

7.2 SUD + seccomp 组合方案

SUD 和 seccomp 可以完美互补:seccomp 做"粗粒度"的系统调用过滤(阻止危险 syscall),SUD 做"细粒度"的用户态拦截(调试或模拟)。


// 组合使用示例
void setup_sandbox(void)
{
    // 1. 先用 seccomp 设置基础沙箱(阻止 ptrace、perf_event_open 等)
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
    seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(ptrace), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(perf_event_open), 0);
    seccomp_load(ctx);
    
    // 2. 再用 SUD 拦截来自沙箱内模块的特定 syscall
    prctl(PR_SET_SYSCALL_USER_DISPATCH, PR_SYS_DISPATCH_ON,
          sandbox_code_start, sandbox_code_size,
          &selector_byte);
    
    // 结果:
    // - 沙箱内模块的 syscall → 先被 SUD 拦截到自定义处理器
    // - 如果自定义处理器决定放行 → 进入内核 → BPF 检查
    // - 如果 BPF 拒绝 → 返回 EPERM
}

八、性能基准测试

8.1 微基准:纯 syscall 拦截性能

使用 syscall(SYS_getpid) 作为测试对象,在启用和不启用 SUD 的情况下的表现:

场景延迟上下文切换吞吐量
基线(无 SUD)~80 ns1次 (kernel entry/exit)~12M/s
SUD 拦截 + 模拟返回~180 ns0次~5.5M/s
SUD 拦截 + 放行到内核~350 ns2次~2.8M/s
ptrace 拦截(对比)~6000 ns4次~170K/s

8.2 应用场景性能总结

  • Wine 游戏场景:提升 5-15% 帧率(取决于游戏 syscall 密集程度)
  • gVisor 容器启动:启动延迟降低约 20%
  • 调试追踪场景:仅追踪目标的 syscall,开销远小于 strace
  • 正常应用(不启用 SUD):零开销

九、内核配置与系统要求

9.1 编译配置


CONFIG_SYSCALL_USER_DISPATCH=y          # 基础支持(x86/x86_64)
CONFIG_HAVE_SYSCALL_USER_DISPATCH=y      # 架构能力声明
CONFIG_SYSCALL_USER_DISPATCH_TEST=y      # 内核自测(调试用)

主流发行版支持情况:

  • Ubuntu 22.04+(内核 5.15+):默认启用
  • CentOS Stream 9 / RHEL 9(内核 5.14):默认启用
  • Fedora 36+:默认启用
  • Debian 12(内核 6.1):默认启用
  • Alpine 3.18+:默认启用

9.2 运行时检测

// 检测 SUD 是否可用
#include <sys/utsname.h>

bool sud_is_available(void)
{
    struct utsname uts;
    uname(&uts);
    
    // 需要 Linux >= 5.11
    int major, minor;
    sscanf(uts.release, "%d.%d", &major, &minor);
    if (major > 5 || (major == 5 && minor >= 11))
        return true;
    
    // 某些发行版 backport 到更旧内核
    // 实际可用性需要实际尝试 prctl
    return false;
}

十、安全考量

10.1 安全优势

  • 进程级隔离:SUD 是进程级设置,不影响系统中的其他进程
  • 与 seccomp 正交:两者可同时使用,形成深度防御
  • 不可绕过(在内核侧):selector 区域的 syscall 必然进入 SIGSYS,无法通过 sysenter / int 0x80 / VDSO 绕过
  • 明确的权限边界:只有进程自身能配置 SUD,不会泄漏到其他进程

10.2 安全注意事项

  • selector 字节保护:selector 页面的翻转字节必须不能被攻击者篡改,否则可能绕过 SUD 检查
  • 信号处理复杂:在 SIGSYS 处理器中需要格外注意可重入性、SIGMASK 状态
  • capability 要求:无需特殊 capability,任何进程都可启用 SUD,这增加了一些表面攻击面
  • 线程安全:SUD 是线程级的设置(每线程 PRCTRL),多线程应用需要分别配置

10.3 与 SELinux/AppArmor 的交互

SUD 与现有的 Linux安全模块(LSM)正交运作。SELinux 和 AppArmor 的规则对 SUD 的设置没有直接限制—这意味着即使启用了 SUD,进程仍需遵守 LSM 的策略。这些模块没有对 PR_SET_SYSCALL_USER_DISPATCH 的显式权限检查。

十一、调试与故障排除

11.1 常见问题及解决

问题一:SIGSYS 信号未收到

检查 selector 页面是否正确对齐(必须 4KB 对齐)、确保 prctl 返回 0、确认 rip 确实在 selector 范围内。

问题二:syscall 未被拦截

确认目标 ip 地址在 [selector_addr, selector_addr + selector_len) 范围内(左闭右开)。特别注意 JIT 编译的代码地址是否在范围内。

问题三:性能下降

SUD 本身对非拦截 syscall 没有性能影响。如果观察到性能问题,检查是否是 SIGSYS 处理中的额外逻辑导致。

11.2 使用 strace 调试

# 查看 SUD 是否生效
strace -e signal=SIGSYS ./my_sud_app

# 检查 prctl 调用返回值
strace -e=prctl ./my_sud_app

# 使用 perf 追踪所有 syscall
perf trace -e 'syscalls:sys_enter_*' ./my_sud_app

# 检查 selector 区域的内存映射
cat /proc/self/maps | grep selector_page

11.3 eBPF 追踪 SUD 事件

// 使用 eBPF 追踪 SUD 投递的 SIGSYS
SEC("tp/signal/signal_generate")
int trace_sigsys(struct trace_event_raw_signal_generate *ctx)
{
    if (ctx->sig == SIGSYS) {
        bpf_printk("SIGSYS sent to pid=%d comm=%s\n",
                   bpf_get_current_pid_tgid() >> 32,
                   bpf_get_current_comm());
    }
    return 0;
}

十二、总结与展望

Syscall User Dispatch 是 Linux 内核近年来最优雅的安全与控制机制之一。它将"谁能决定 syscall 去向"的权力部分下放给了用户态,同时保持了内核的安全边界。这种化繁为简的设计哲学值得其他内核特性学习。

随着 Wine、Proton(Steam Play)和 gVisor 的广泛采用,SUD 已经从实验性特性转变为生产环境关键能力。其在以下方面仍有发展空间:

  • ARM64 支持:当前 x86/x86_64 支持完整,ARM64 正在上游化
  • 更灵活的 filter:未来可能允许更精细的按 syscall 编号过滤
  • 内核辅助的 disarm:简化嵌套场景的信号处理模式
  • 与 io_uring 集成:结合 io_uring 的提交/完成链,提供更高性能的 syscall 模拟路径

对于系统程序员和安全工程师来说,理解 SUD 不仅是掌握一个 API,更是理解 Linux 内核设计哲学的一个窗口——如何在"全由内核控制"和"全由用户态处理"之间找到完美的平衡点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部