seccomp 是个好东西

Posted on March 20, 2022

最近在实现 git 商城的时候, 遇到了收款方面的问题.

不沾钱就不用为钱相关的破事费心. 不想碰钱, 钱总得收了才能发货. 不然岂不是做慈善.

所以, 我决定, 让卖家在自己的 git 仓库里放一个 js 脚本来收钱.

具体的来说, 就是每当用户要支付订单的时候, js 脚本就被运行, 要求吐出一行链接. 这个链接可以让用户进行支付. 他可以是一个收款的网页, 也可以是一个能唤醒支付宝或者微信的 scheme 地址.

这就要求嵌入一个 js 引擎. 但是, 试问现今写 js , 谁不是写 nodejs 呢? 但是, 嵌入一个 node 非我所愿. 我更希望的做法, 是以子进程的方式运行node, 而不是将 libnode.so 嵌入主程序.

运行独立的 node 进程, 还必须让 node 可控. node 不能做一些危险操作. 这个只要不用 root 账号运行 node, node 自然是无法做危害系统安全的事情.

但是, 不危害系统安全, 不等于没有危害. 即便没有 root 权限, 脚本还能挖矿, 还能偷数据, 还能当肉鸡 ddos 别人, 还能拿我们的服务器当代理.

这一个脚本能干的事情太多了. 只是不让用 root, 只是保护了操作系统本身不被破坏有何意义?

所以我需要能禁锢 node 进程的新法子. 早早换 systemd 做系统 init 的好处就是, systemd 教会了我有一种高级的系统调用保护法, seccomp. seccomp 可以过滤系统调用. 编写一个 BPF 小代码, 执行在内核里. 这个小代码可以决定, 是允许执行系统调用,还是返回错误; 返回错误的话, 用什么错误代码返回.

于是我用 seccomp 封锁了绝大多数的系统调用. 只留下了寥寥数十个. seccomp 的使用方法是, 在 fork 后, 使用 seccomp_init, seccomp_rule_add_exact, seccomp_load 设定并安装好一个过滤器. 然后再执行 execve 运行 node 进程.

但是, 可恨的是, openat 系统调用无法封锁. 封锁了 openat, 则 ELF loader 无法加载 so. 但是不封锁 openat, node 进程就可以肆意打开文件盗取系统秘密. 不得不防.

经研究, 发现 seccomp 除了同意/不同意执行, 还有一个办法, 就是把这个决定通过一个 fd 消息发送到父进程去决定允许还是不允许.

就是在 seccomp_rule_add_exact() 调用的时候, action 参数使用 SCMP_ACT_NOTIFY , 然后 seccomp_load, 安装成功后, 即可通过 seccomp_notify_fd 获取一个 fd, 这个 fd 通过 file description passing 机制, 到父进程. 就绪后, 父进程就可以用 seccomp_notify_receive 获取到子进程要调用的系统调用通知了. 子进程但凡想调用被标记为 SCMP_ACT_NOTIFY 的系统调用, 进程就会被挂起, 等待父进程的英明决策. 父进程可以拒绝掉, 也可以同意继续执行. 还可以 “代为执行”.

while (true)
{
    seccomp_notif* req		 = nullptr;
    seccomp_notif_resp* resp = nullptr;
    seccomp_notify_alloc(&req, &resp);
    scoped_exit cleanup([=]() { ::seccomp_notify_free(req, resp); });
    auto ret = seccomp_notify_receive(seccomp_notify_fd, req);
    if (ret != 0)
    {
        int e = errno;
        LOG_DBG << "[seccomp] seccomp_notify_receive failed with e=" << e;
        co_return;
    }

    resp->id	= req->id;
    resp->error = -EPERM;
    resp->val	= 0;

    switch (req->data.nr)
    {
        case SCMP_SYS(openat):
        {
            // req->data.args[1] 是待打开的文件名. 但是, 这个指针是在待打开的进程里的, 所以
            // 要使用跨进程 memcpy
            struct iovec this_readbuf = { openat_param1, sizeof (openat_param1) - 1 };
            struct iovec traced_readbuf = { reinterpret_cast<void*>(req->data.args[1]), 4096 };
            memset(openat_param1, 0 , sizeof openat_param1);
            process_vm_readv(req->pid, &this_readbuf, 1, &traced_readbuf, 1, 0);
            std::string node_want_open = openat_param1;

            // node_want_open 字符串就是 node 本次 open 要打开的文件名了
            // 可以在这里允许或拒绝

        }break;
        default:
            break;
    }

    ret = seccomp_notify_respond(seccomp_notify_fd, resp);
    if (ret != 0)
        co_return;
}

父进程里获取到的是子进程空间的参数指针, 因此需要调用 process_vm_readv 直接读取对方的内存。

在这个父进程里,我只允许 node 打开 /usr/ 下的系统库,/etc 下的个别配置文件. 总之, 只允许node打开必要的文件,其他文件统统不允许打开。

这样, node 运行起来就在一个安全的沙箱环境, 再也不能肆意妄为了。

Comments