C++ `seccomp`:限制进程系统调用,增强安全性

哈喽,各位好!今天咱们来聊聊一个能让你的C++程序更“乖巧”、更安全的利器:seccomp。想象一下,你的程序就像一个在房间里玩耍的小朋友,seccomp就像一道看不见的围栏,规定了小朋友只能玩哪些玩具,不能碰哪些危险的东西。这样,即使小朋友不小心“玩脱了”,也不会造成太大的破坏。

什么是 seccomp

seccomp (Secure Computing Mode) 是 Linux 内核提供的一种安全机制,它允许你限制进程可以执行的系统调用。系统调用,简单来说,就是程序跟操作系统内核“打交道”的方式,比如打开文件、读写数据、创建进程等等。通过 seccomp,你可以告诉内核:“这个程序只能用这些系统调用,其他的统统不许碰!”

seccomp 的作用

  • 增强安全性: 限制恶意代码利用漏洞执行危险的系统调用,比如修改系统文件、执行任意代码等。
  • 降低攻击面: 减少程序可能被攻击的入口点。
  • 沙箱环境: 创建一个受限的运行环境,用于运行不信任的代码。

seccomp 的几种模式

seccomp 主要有三种模式:

  1. SECCOMP_MODE_DISABLED (禁用): 这是默认模式,不进行任何限制。
  2. SECCOMP_MODE_STRICT (严格模式): 只允许 exit(), sigreturn(), read(), write(),其他系统调用直接杀死进程。这种模式非常严格,通常只用于非常简单的场景。
  3. SECCOMP_MODE_FILTER (过滤模式): 使用 BPF (Berkeley Packet Filter) 规则来定义允许或禁止的系统调用。这是最灵活、最常用的模式。

SECCOMP_MODE_STRICT 示例

先来个简单的例子,看看 SECCOMP_MODE_STRICT 模式如何“粗暴”地限制系统调用:

#include <iostream>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    // 启用 SECCOMP_MODE_STRICT 模式
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT) == -1) {
        perror("prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)");
        return 1;
    }

    // 尝试执行一个不允许的系统调用
    std::cout << "尝试打开文件..." << std::endl;
    FILE *fp = fopen("test.txt", "w"); // fopen 使用了 open 系统调用
    if (fp == NULL) {
        perror("fopen");
    } else {
        fclose(fp);
    }
    std::cout << "程序结束" << std::endl;

    return 0;
}

编译并运行这个程序,你会发现程序在尝试打开文件时直接被 killed 了。因为 fopen 函数底层调用了 open 系统调用,而 SECCOMP_MODE_STRICT 模式不允许 open 调用。

SECCOMP_MODE_FILTER 和 BPF

SECCOMP_MODE_FILTER 模式才是 seccomp 的真正强大之处。它允许你使用 BPF 规则来更精细地控制系统调用。

BPF (Berkeley Packet Filter) 简介

BPF 最初是用于网络数据包过滤的,后来被扩展到其他领域,包括 seccomp。BPF 规则实际上是一段小型的字节码程序,它会对系统调用进行检查,并根据规则决定是否允许该调用。

BPF 规则结构

BPF 规则通常包含以下几个部分:

  • Load: 从内存中加载数据 (比如系统调用号、参数等)。
  • Compare: 将加载的数据与预设的值进行比较。
  • Jump: 根据比较结果跳转到不同的指令。
  • Return: 返回一个值,指示是否允许系统调用。

返回值

BPF 规则的返回值决定了 seccomp 的行为:

返回值 含义
SECCOMP_RET_KILL 立即杀死进程。
SECCOMP_RET_TRAP 发送 SIGSYS 信号给进程,可以自定义信号处理函数。
SECCOMP_RET_ERRNO(errno) 返回一个指定的 errno 值给系统调用。
SECCOMP_RET_TRACE(errno) 通知 ptrace 追踪器,并传递一个 errno 值。
SECCOMP_RET_ALLOW 允许系统调用。

使用 libseccomp

为了更方便地编写 BPF 规则,通常会使用 libseccomp 库。这个库提供了一组 API,可以简化 BPF 规则的创建和安装过程。

libseccomp 示例

下面是一个使用 libseccomp 库的例子,它允许 readwriteexit 系统调用,禁止其他所有调用:

#include <iostream>
#include <unistd.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <seccomp.h>
#include <errno.h>

int main() {
    // 创建 seccomp 上下文
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL); // 默认行为是杀死进程
    if (!ctx) {
        perror("seccomp_init");
        return 1;
    }

    // 允许 read 系统调用
    if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) < 0) {
        perror("seccomp_rule_add(read)");
        seccomp_release(ctx);
        return 1;
    }

    // 允许 write 系统调用
    if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0) < 0) {
        perror("seccomp_rule_add(write)");
        seccomp_release(ctx);
        return 1;
    }

    // 允许 exit 系统调用
    if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0) < 0) {
        perror("seccomp_rule_add(exit)");
        seccomp_release(ctx);
        return 1;
    }

    // 加载 seccomp 规则
    if (seccomp_load(ctx) < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        return 1;
    }

    // 释放 seccomp 上下文
    seccomp_release(ctx);

    std::cout << "Seccomp 规则已加载,现在只能执行 read, write 和 exit 系统调用。" << std::endl;

    // 尝试执行一个允许的系统调用
    char buf[100];
    ssize_t n = read(0, buf, sizeof(buf));
    if (n > 0) {
        write(1, buf, n);
    }

    // 尝试执行一个不允许的系统调用
    std::cout << "尝试打开文件..." << std::endl;
    FILE *fp = fopen("test.txt", "w"); // fopen 使用了 open 系统调用
    if (fp == NULL) {
        perror("fopen"); // 这行代码不会被执行,因为进程会被直接杀死
    } else {
        fclose(fp);
    }
    std::cout << "程序结束" << std::endl; // 这行代码也不会被执行

    return 0;
}

这个程序首先创建了一个 seccomp 上下文,并设置默认行为为 SCMP_ACT_KILL (杀死进程)。然后,它分别添加了允许 readwriteexit 系统调用的规则。最后,它加载这些规则,并尝试执行一些系统调用。

编译运行这个程序,你会发现,程序可以正常执行 readwrite 调用,但在尝试打开文件时,会被 seccomp 杀死。

更复杂的规则:参数过滤

seccomp 不仅可以限制系统调用,还可以根据系统调用的参数进行过滤。比如,你可以限制 open 系统调用只能打开特定的文件。

#include <iostream>
#include <unistd.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <seccomp.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>

int main() {
    // 创建 seccomp 上下文
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
    if (!ctx) {
        perror("seccomp_init");
        return 1;
    }

    // 允许 exit 系统调用
    if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0) < 0) {
        perror("seccomp_rule_add(exit)");
        seccomp_release(ctx);
        return 1;
    }
    // 允许 open 系统调用,但只能打开 "/tmp/allowed.txt" 文件
    const char *allowed_file = "/tmp/allowed.txt";
    scmp_arg_cmp arg[] = {
        SCMP_A_ARG0, SCMP_CMP_EQ, (scmp_datum_t)allowed_file
    };
    if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 1, arg[0]) < 0) {
        perror("seccomp_rule_add(open)");
        seccomp_release(ctx);
        return 1;
    }
    // 允许 openat 系统调用,但只能打开 "/tmp/allowed.txt" 文件
    // openat(dirfd, pathname, flags, mode)
    // dirfd: 如果pathname是相对路径,则相对于dirfd指向的目录。如果pathname是绝对路径,则忽略dirfd参数。
    // 当 dirfd = AT_FDCWD 时,pathname是相对路径时,等同于 open(pathname, flags, mode)
    const char *allowed_file_openat = "/tmp/allowed.txt";
    scmp_arg_cmp arg_openat[] = {
        SCMP_A_ARG1, SCMP_CMP_EQ, (scmp_datum_t)allowed_file_openat
    };
    if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 1, arg_openat[0]) < 0) {
        perror("seccomp_rule_add(openat)");
        seccomp_release(ctx);
        return 1;
    }

    // 加载 seccomp 规则
    if (seccomp_load(ctx) < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        return 1;
    }

    // 释放 seccomp 上下文
    seccomp_release(ctx);

    std::cout << "Seccomp 规则已加载,现在只能打开 /tmp/allowed.txt 文件。" << std::endl;

    // 尝试打开允许的文件
    int fd_allowed = open("/tmp/allowed.txt", O_WRONLY | O_CREAT, 0644);
    if (fd_allowed == -1) {
        perror("open(/tmp/allowed.txt)");
    } else {
        std::cout << "成功打开 /tmp/allowed.txt" << std::endl;
        close(fd_allowed);
    }

    // 尝试打开不允许的文件
    int fd_forbidden = open("forbidden.txt", O_WRONLY | O_CREAT, 0644);
    if (fd_forbidden == -1) {
        perror("open(forbidden.txt)"); // 这行代码不会被执行,因为进程会被直接杀死
    }

    return 0;
}

在这个例子中,我们使用了 SCMP_A_ARG0 来指定要比较的参数,SCMP_CMP_EQ 来指定比较操作符 (等于),allowed_file 来指定允许的文件名。 这样,程序只能打开 /tmp/allowed.txt 文件,尝试打开其他文件会被 seccomp 阻止。

更进一步:使用 SECCOMP_RET_TRACE 进行调试

有时候,你想知道程序到底尝试调用了哪些被 seccomp 阻止的系统调用,以便更好地调整规则。这时,可以使用 SECCOMP_RET_TRACE

#include <iostream>
#include <unistd.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <seccomp.h>
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/ptrace.h>
#include <sys/reg.h>

// 全局变量,用于存储子进程的 PID
pid_t child_pid;

// 信号处理函数,用于处理 SIGSYS 信号
void sigsys_handler(int sig) {
    // 信号处理逻辑
    std::cout << "接收到 SIGSYS 信号,说明有系统调用被 seccomp 阻止了。" << std::endl;

    // 使用 ptrace 获取系统调用号
    long syscall_number = ptrace(PTRACE_PEEKUSER, child_pid, sizeof(long) * ORIG_RAX, NULL); // ORIG_RAX 是 x86_64 架构上的系统调用号寄存器
    if (syscall_number == -1) {
        perror("ptrace(PTRACE_PEEKUSER)");
        exit(1);
    }

    std::cout << "被阻止的系统调用号是: " << syscall_number << std::endl;

    // 允许进程继续执行(但仍然会被 seccomp 阻止)
    ptrace(PTRACE_CONT, child_pid, NULL, NULL);
}

int main() {
    // 创建子进程
    child_pid = fork();
    if (child_pid == -1) {
        perror("fork");
        return 1;
    }

    if (child_pid == 0) {
        // 子进程

        // 注册 SIGSYS 信号处理函数
        signal(SIGSYS, sigsys_handler);

        // 启用 ptrace
        if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
            perror("ptrace(PTRACE_TRACEME)");
            return 1;
        }

        // 启用 seccomp
        scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW); // 默认允许所有系统调用
        if (!ctx) {
            perror("seccomp_init");
            return 1;
        }

        // 禁止 open 系统调用,并使用 SECCOMP_RET_TRACE
        if (seccomp_rule_add(ctx, SCMP_ACT_TRACE(1), SCMP_SYS(open), 0) < 0) { // 1 是一个自定义的 errno 值,可以用来区分不同的 TRACE 事件
            perror("seccomp_rule_add(open)");
            seccomp_release(ctx);
            return 1;
        }

        // 加载 seccomp 规则
        if (seccomp_load(ctx) < 0) {
            perror("seccomp_load");
            seccomp_release(ctx);
            return 1;
        }

        seccomp_release(ctx);

        // 尝试打开文件
        std::cout << "子进程:尝试打开文件..." << std::endl;
        FILE *fp = fopen("test.txt", "w"); // fopen 使用了 open 系统调用
        if (fp == NULL) {
            perror("fopen"); // 这行代码不会被执行,因为信号处理函数会先被调用
        } else {
            fclose(fp);
        }

        std::cout << "子进程:程序结束" << std::endl;

        return 0;
    } else {
        // 父进程

        int status;
        waitpid(child_pid, &status, 0); // 等待子进程退出

        if (WIFSTOPPED(status)) {
            // 子进程被信号停止
            int sig = WSTOPSIG(status);
            std::cout << "子进程接收到信号: " << sig << std::endl;

            // 如果是 SIGSYS 信号,表示 seccomp 触发了 TRACE 事件
            if (sig == SIGSYS) {
                std::cout << "父进程:Seccomp 触发了 TRACE 事件" << std::endl;
            }
        }

        return 0;
    }
}

这个例子中,我们首先创建了一个子进程,并在子进程中设置了 seccomp 规则,禁止 open 系统调用,并使用 SECCOMP_RET_TRACE。 当子进程尝试打开文件时,seccomp 会发送 SIGSYS 信号给子进程。

父进程使用 ptrace 跟踪子进程,并在收到 SIGSYS 信号时,使用 ptrace 获取被阻止的系统调用号。这样,你就可以知道程序到底尝试调用了哪个系统调用,从而更好地调整 seccomp 规则。

一些建议

  • 最小权限原则: 只允许程序需要的系统调用,禁止其他所有调用。
  • 逐步调整: 先允许所有系统调用,然后逐步添加限制,并使用 SECCOMP_RET_TRACE 进行调试。
  • 考虑依赖: 确保允许程序依赖的所有系统调用,包括动态链接库需要的调用。
  • 更新规则: 随着程序功能的增加或改变,及时更新 seccomp 规则。
  • 不要过度限制: 过度限制可能导致程序无法正常运行,影响用户体验。

总结

seccomp 是一个强大的安全工具,可以有效地限制进程的系统调用,增强程序的安全性。通过合理地配置 seccomp 规则,你可以创建一个更安全、更可靠的运行环境。希望今天的讲解对你有所帮助!记住,安全无小事,多一份安全措施,就少一份风险。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注