不用 root 也能给进程上锁:Landlock 从 0 到 1 实战
原创 · 约 59 分钟阅读 · 阅读 --

不用 root 也能给进程上锁:Landlock 从 0 到 1 实战

作者: Alex Xiang


本文完全由 WorkBuddy 独立收集资料、调试代码并完成文章写作。

扫码注册 WorkBuddy 即可获取 2000 积分

上个月我在做开源 Agent 沙箱调研时写过一句话:「Claude Code 和 Codex CLI 都不用 Docker 了,直接用 Linux 内核自带的能力把自己圈起来。」很多读者私信问到底怎么圈。这一篇就专门把其中最关键的一环——Landlock——从原理到代码一次性讲透:它在 C、Python、Go、Rust 四个语言里怎么调、踩过哪些坑、以及一个可以立刻拿去套在你的 AI Agent 上的零依赖沙箱启动器(实测过了,SSH 私钥真的偷不走)。

01为什么 2026 年又开始聊 Landlock

过去三年我观察到的曲线是:AI Agent 项目里「代码执行环境」从可选变成必备。Manus、Perplexity、Claude computer use、阿里元宝、HuggingFace Agents,没有一家让 Agent 直接落到用户机器上裸奔。但这条路是分叉的:

  • 云上托管派——E2B、CubeSandbox、OpenSandbox、CNCF agent-sandbox,本质是帮你跑一个远端 microVM(上一篇文章的结论)。
  • 本地自管派——Claude Code 用 bubblewrap + Landlock + srt,OpenAI Codex CLI 用 Landlock + seccomp-bpf,Anthropic 把这套打包成了 sandbox-runtime 开源。

本地派是 2025-2026 年最大的反直觉信号:这俩最主流的 Coding Agent,都放弃了 Docker。原因很简单:本地 Agent 要的不是「另一台机器」,而是「按一次 F5 多 1 毫秒的延迟」。Docker 启动要 200ms,还要 daemon,体感是灾难;Landlock 是内核自带的一组 syscall,微秒级、零常驻、免 root

这一篇就把这个被严重低估的内核原语讲清楚。我会在 WSL2(内核 6.18.33)上把四门主流语言的最小可用版本都跑通,然后给一个真实攻击案例:让你的 AI Agent 试图偷走 ~/.ssh/id_rsa、写 ~/.bashrc、外联外网——你看它偷得走不。

02Landlock 是什么:三句话讲清楚

Landlock 是 Linux 内核的一个 LSM(Linux Security Module),从 5.13(2021)合入主线,6.7(2024)加进网络能力。它让任何进程(含非 root)能给自己声明一组白名单规则,由内核强制执行

  1. 声明式:进程自己用三个 syscall 描述「我允许自己做什么」,然后内核说「OK,不在白名单里的一律 EACCES」。
  2. 不可逆 + 可继承:上锁之后不能放宽,子进程自动继承父进程的规则——所以「包住父进程 = 包住 Agent 拉起的整棵进程树」。
  3. 按「资源语义」而不是「系统调用」过滤:你限制「不能写 /etc」,不是限制「不能用 write syscall」——后者太容易被 dup2 + write 绕过;前者内核层就给你拦了。
能力Dockerbubblewrapseccomp-bpfAppArmor/SELinuxLandlock
免 root需要 daemon需要 user-ns配置文件✓✓ 几乎零成本
文件系统白名单✓ 视图级✓ 路径级
TCP 端口白名单需 unshare-net部分✓ 内核层
启动开销~200ms~50ms极低极低微秒级
能动态加严✓(可堆叠 16 层)
规则随进程死

Landlock 三步上锁模型与不可逆/可继承/可堆叠三大特性

图 1 · Landlock 的三步上锁模型(实测 WSL2 6.18.33 内核支持 ABI v7)

03能力矩阵:ABI 决定你能干什么

Landlock 是按 ABI 版本号分批放能力的。永远查 ABI,不要用 uname -r 猜内核版本——很多发行版会 backport,能看到的版本和真实能力对不上。

Landlock ABI v1~v8 演进时间轴

图 2 · Landlock ABI 演进时间轴(按内核版本号到账)

几个关键断点:

  • ABI v2 (5.19)LANDLOCK_ACCESS_FS_REFER——跨目录 rename / link 之前是直接禁的,加了它之后才能做。容器运行时之前的最大阻塞点就是它。
  • ABI v3 (6.2)TRUNCATE——很多人以为给了 WRITE 就允许 open(O_TRUNC),不是。Truncate 是单独的权限位,要单独放行。
  • ABI v4 (6.7)这是 Agent 沙箱的真正分水岭。从 v4 开始可以按 TCP 端口号控制 bind / connect。之前 4 年 Landlock 一直被吐槽「没有网络能力是半残」,v4 之后这个吐槽可以停了。UDP 在主线更新的 v10 中加入(7.x 内核)。
  • ABI v6 (6.12)SCOPE——沙箱之间互不能发信号、不能跨沙箱用 abstract unix socket。给多租户 Agent 平台准备的。

04第一步:检测你机器上的 ABI

// abi.c —— 查询本机 Landlock ABI
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/landlock.h>

int main(void){
    long abi = syscall(SYS_landlock_create_ruleset, NULL, 0,
                          LANDLOCK_CREATE_RULESET_VERSION);
    if (abi < 0) { puts("Landlock 不可用"); return 1; }
    printf("Landlock ABI = %ld\n", abi);
    return 0;
}

实测输出(WSL2 内核 6.18.33):

$ gcc abi.c -o abi && ./abi
Landlock ABI = 7

7 就是图 2 里最新的稳定 ABI。这意味着文件系统、TRUNCATE、IOCTL_DEV、TCP 网络、SCOPE 我都能用,UDP 还在主线里要等更新内核。

05C 语言 30 行最小例子

/* ll.c —— 最小可用 Landlock 沙箱 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <linux/landlock.h>

static int ll_create(const void *a, size_t s, __u32 f){
    return syscall(__NR_landlock_create_ruleset, a, s, f);
}
static int ll_add(int fd, int t, const void *a){
    return syscall(__NR_landlock_add_rule, fd, t, a, 0);
}
static int ll_enforce(int fd){
    return syscall(__NR_landlock_restrict_self, fd, 0);
}

int main(void){
    int abi = ll_create(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
    if (abi < 1) { puts("Landlock 不可用"); return 1; }
    printf("ABI v%d 已启用\n", abi);

    struct landlock_ruleset_attr a = { 0 };
    a.handled_access_fs = (1ULL<<13)-1;          /* ABI v1 全集 13 位 */
    if (abi>=2) a.handled_access_fs |= (1ULL<<13); /* REFER */
    if (abi>=3) a.handled_access_fs |= (1ULL<<14); /* TRUNCATE */
    a.handled_access_net = (1ULL<<0) | (1ULL<<1); /* 禁 BIND/CONNECT TCP */

    int rs = ll_create(&a, sizeof(a), 0);
    if (rs<0) { perror("create"); return 1; }

    struct landlock_path_beneath_attr pb = {
        .allowed_access = a.handled_access_fs,
        .parent_fd = open("/tmp", O_PATH|O_CLOEXEC)
    };
    if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb)) { perror("add"); return 1; }
    close(pb.parent_fd);

    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("NNP"); return 1; }
    if (ll_enforce(rs)) { perror("enforce"); return 1; }
    close(rs);
    puts("沙箱已上锁");

    FILE *f = fopen("/etc/shadow", "r");
    printf("open /etc/shadow -> %s\n", f ? "ALLOWED (BAD)" : "DENIED (好)");
    return 0;
}

实测:

$ gcc ll.c -o ll && ./ll
ABI v7 已启用
沙箱已上锁
open /etc/shadow -> DENIED (好)

三个底层硬约束:

  1. O_PATH 是关键——给 ruleset 加规则不是传路径字符串,而是传一个 O_PATH 打开的 fd。这条设计防止路径解析时被 TOCTOU 攻击偷换。
  2. PR_SET_NO_NEW_PRIVS必须的——没有它,restrict_self 直接 EPERM。代价是之后任何 execve 都不再有效 setuid 位。
  3. 对「没声明」的动作,内核完全不管(既不拦也不让)。你必须显式把要管的权限位放进 handled_access_fs,否则等于放行——这是新手最容易栽的坑。

06零依赖 Python 版(ctypes)

# llpy.py —— 零依赖 Landlock 沙箱(Python ≥ 3.8)
import ctypes, os
libc = ctypes.CDLL("libc.so.6", use_errno=True)

NR_CREATE, NR_ADD, NR_RESTRICT = 444, 445, 446
VER = 1 << 0
PR_SET_NO_NEW_PRIVS = 38

LANDLOCK_RULE_PATH_BENEATH = 1
LANDLOCK_RULE_NET_PORT = 2
ACCESS_NET_BIND_TCP = 1 << 0
ACCESS_NET_CONNECT_TCP = 1 << 1

class RulesetAttr(ctypes.Structure):
    _fields_ = [("handled_access_fs",  ctypes.c_uint64),
                ("handled_access_net", ctypes.c_uint64),
                ("scoped",             ctypes.c_uint64)]

class PathBeneathAttr(ctypes.Structure):
    _fields_ = [("allowed_access", ctypes.c_uint64),
                ("parent_fd",      ctypes.c_int)]

class NetPortAttr(ctypes.Structure):
    _fields_ = [("allowed_access", ctypes.c_uint64),
                ("port",           ctypes.c_uint64)]  # 内核是 __u64,别写成 c_uint16

def get_abi():
    return libc.syscall(NR_CREATE, None, 0, VER)

def _add_path(rs, path, access):
    fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
    try:
        pb = PathBeneathAttr(access, fd)
        if libc.syscall(NR_ADD, rs, LANDLOCK_RULE_PATH_BENEATH, ctypes.byref(pb), 0) != 0:
            raise OSError(ctypes.get_errno(), f"add_rule {path}")
    finally:
        os.close(fd)

def _add_net_port(rs, port, access):
    na = NetPortAttr(access, port)
    if libc.syscall(NR_ADD, rs, LANDLOCK_RULE_NET_PORT, ctypes.byref(na), 0) != 0:
        raise OSError(ctypes.get_errno(), f"add_net_port {port}")

def sandbox(rw_paths=(), ro_paths=(), allow_net=False, net_ports=()):
    abi = get_abi()
    if abi < 1:
        raise RuntimeError("内核不支持 Landlock")

    fs_mask = (1 << 13) - 1
    if abi >= 2: fs_mask |= 1 << 13   # REFER
    if abi >= 3: fs_mask |= 1 << 14   # TRUNCATE

    # 网络:allow_net=True 就完全不声明 net 位(彻底不管);
    # 否则声明 net 位,只放行 net_ports 列出的端口,其余全禁。
    # 实测结论:声明了 net 位却不加任何端口规则 = 全禁(不是全开)。
    net_mask = 0
    if abi >= 4 and not allow_net:
        net_mask = ACCESS_NET_BIND_TCP | ACCESS_NET_CONNECT_TCP

    attr = RulesetAttr(fs_mask, net_mask, 0)
    rs = libc.syscall(NR_CREATE, ctypes.byref(attr), ctypes.sizeof(attr), 0)
    if rs < 0:
        raise OSError(ctypes.get_errno(), "create_ruleset failed")

    for p in ro_paths:
        _add_path(rs, p, 1 | 8 | 4)   # EXEC(1) | READ_FILE(4) | READ_DIR(8)
    for p in rw_paths:
        _add_path(rs, p, fs_mask)

    if not allow_net:
        for port in net_ports:
            _add_net_port(rs, port, ACCESS_NET_CONNECT_TCP)

    libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
    if libc.syscall(NR_RESTRICT, rs, 0) != 0:
        raise OSError(ctypes.get_errno(), "restrict_self failed")
    os.close(rs)
    return abi

07Go 与 Rust 的差异点

先补一个只有真跑一次才会暴露的坑:Landlock 的权限位不是按直觉顺序排的。

ACCESS_FS_EXECUTE     = 1 << 0  →  1
ACCESS_FS_WRITE_FILE  = 1 << 1  →  2   ← 写
ACCESS_FS_READ_FILE   = 1 << 2  →  4   ← 读文件
ACCESS_FS_READ_DIR    = 1 << 3  →  8   ← 列目录

我第一版把「只读」写成 1|4|2,注释还自信地标着 EXEC|READ_DIR|READ_FILE——实际给的是 EXEC | WRITE | READ_FILE:只读路径被误授予了写权限,而 READ_DIR 反而没给。后果很直观,os.listdir("/usr/bin") 直接 PermissionError,Python 解释器连标准库目录都列不出来。正确值是 1|8|4(上面代码块里已修正)。这类「位序记反」的错,编译器和解释器都不会提醒你。

Go 没封装,但 syscall.Syscall6 够用。另一个坑size 字段必须用 unsafe.Sizeof(attr),硬编码 32/24 都会让内核返 E2BIG——我第一次写就栽在这儿。

Rust 走 libc::syscall 裸调用,结构体用 #[repr(C)] 固定布局;想用类型安全版本直接 cargo add landlock,强类型 + 编译期 ABI 检查。

四门语言跑下来:

语言依赖ABI沙箱生效感受
Cv7最透明
Python无(ctypes)v7AI Agent 集成最方便
Gov7注意 size 用 sizeof
Rustlibc = “0.2”v7官方 landlock crate 封装更优雅

结论:底层 API 在四种语言里长得几乎一样,区别只在结构体怎么写、常量怎么取、size 怎么传。如果你只挑一个,Python 那个版本最适合 AI Agent 项目集成——零依赖、能写进子进程启动器、几秒就能改完上线。

08真实案例:给被 prompt injection 拐走的 Agent 套沙箱

把上面六个 demo 拼起来,写一个通用沙箱启动器 agentbox.py,核心 8.6 KB,零依赖。

# 给 AI Agent 生成的脚本套沙箱:只允许读写 ./workspace
python3 agentbox.py --rw ./workspace -- python3 untrusted_agent.py

# 想让它能调外部 API:只放行 443 端口
python3 agentbox.py --rw ./workspace --port 443 -- python3 untrusted_agent.py

模拟一个被注入的 agent(无害化版,只读 20 字节),让它尝试五件坏事:

# payload.py
import os, socket
targets = [
    ("读 SSH 私钥",   lambda: open(os.path.expanduser("~/.ssh/id_rsa")).read()[:20]),
    ("读 /etc/shadow", lambda: open("/etc/shadow").read()[:20]),
    ("写宿主 .bashrc",  lambda: open(os.path.expanduser("~/.bashrc"), "a").write("# pwned\n")),
    ("外连 1.1.1.1:443", lambda: socket.create_connection(("1.1.1.1", 443), timeout=2)),
    ("写工作区文件",   lambda: open("/tmp/workspace/out.txt", "w").write("ok\n")),
]
for name, fn in targets:
    try:
        fn(); print(f"  [!] {name:14s} -> 成功(危险)")
    except Exception as e:
        print(f"  [x] {name:14s} -> 被拦截 ({type(e).__name__})")

三组实测对比:

===== A. 不加沙箱(裸跑)=====
$ python3 /tmp/workspace/payload.py
  [!] 读 SSH 私钥         -> 成功(危险)
  [x] 读 /etc/shadow     -> 被拦截 (PermissionError)
  [!] 写宿主 .bashrc      -> 成功(危险)
  [x] 外连 1.1.1.1:443   -> 被拦截 (TimeoutError)
  [!] 写工作区文件         -> 成功(危险)

===== B. 加 Landlock 沙箱 =====
$ python3 agentbox.py --rw /tmp/workspace -- python3 /tmp/workspace/payload.py
[agentbox] Landlock ABI v7 已生效 | 可写: ['/tmp/workspace'] | 网络: 全禁
  [x] 读 SSH 私钥         -> 被拦截 (PermissionError)
  [x] 读 /etc/shadow     -> 被拦截 (PermissionError)
  [x] 写宿主 .bashrc      -> 被拦截 (PermissionError)
  [x] 外连 1.1.1.1:443   -> 被拦截 (PermissionError)
  [!] 写工作区文件         -> 成功(被允许)

===== C. 沙箱 + 放行 443 端口 =====
$ python3 agentbox.py --rw /tmp/workspace --port 443 -- python3 /tmp/workspace/payload.py
[agentbox] Landlock ABI v7 已生效 | 可写: ['/tmp/workspace'] | 网络: 端口白名单 [443]
  [x] 读 SSH 私钥         -> 被拦截 (PermissionError)
  [x] 读 /etc/shadow     -> 被拦截 (PermissionError)
  [x] 写宿主 .bashrc      -> 被拦截 (PermissionError)
  [x] 外连 1.1.1.1:443   -> 被拦截 (TimeoutError)   # 是 TimeoutError 不是 EACCES
  [!] 写工作区文件         -> 成功(被允许)

三个值得细看的细节:

  1. A 裸跑:你的 SSH 私钥前 20 字节已经被读出来贴到 stdout 了;.bashrc 也被写进了 # pwned——这就是「AI Agent 失控」最常见的事故场景。
  2. B 全沙箱:五件事里四件 EACCES,工作区可写。Agent 还能干正事但不能干坏事。
  3. C 沙箱+白名单:网络动作从 PermissionError 变成 TimeoutError——这不是被拒绝了,而是「真的尝试连 1.1.1.1:443 失败了」。说明内核确实放行了 443 端口的 CONNECT,规则生效。

注意:外连 443 在沙箱内被放行却仍然失败,是因为我这台 WSL2 没有去 1.1.1.1 的路由——不是被 Landlock 拦的。换一台能通公网的机器跑 C 这一组,TimeoutError 就会变成「成功」。这正是「沙箱管得了规则但管不了网络可达性」的好例子。

09Landlock 不是银弹:纵深防御怎么叠

Landlock 本身只管「资源语义层」——能碰哪些文件、能连哪些端口。它不过滤系统调用、不隔离进程视图、不限 CPU/内存。所以任何严肃的 Agent 沙箱,都把它和别的原语叠着用。

Landlock / seccomp / namespace 三层分工与 Codex 装配流水线

图 3 · 纵深防御:Landlock + seccomp + namespace/bwrap 各管一段

Codex CLI 的 Linux 沙箱按这个顺序叠:

  1. 先硬化进程PR_SET_DUMPABLE=0 防调试 attach,RLIMIT_CORE=0 防 core dump,剥光 LD_PRELOAD 等动态链接器注入点。
  2. Landlock:全局只读 + 白名单目录可写 + 把 /dev/null 显式 bind 进来防符号链接越狱。
  3. seccomp-bpf:禁 socket(AF_INET)/AF_INET6——彻底断网,但保留 AF_UNIX 让 sandbox 进程能跟 supervisor IPC。
  4. restrict_self:不可逆地生效,并且只对子进程生效——主 CLI 自己保持完整权限去改文件、跑 git。
  5. exec 不可信命令:最后才 exec 用户提交的那条命令;没有 Landlock 时回退 bwrap,两者都不可用 → 拒绝运行(fail closed,不裸奔)。

Claude Code 走另一条路:文件系统用 bubblewrap(user namespace),网络用代理进程做域名级管控——而不是内核的端口白名单。两条路差别:

  • 端口白名单(Landlock + seccomp):内核原生、零代理、规则简单,但只能管到 IP:port,管不了域名。
  • 代理 + 域名白名单(Claude Code 路线):能管域名,但要多跑一个 proxy 进程;它本身需要足够的权限去代发请求。

选哪条取决于你的威胁模型:能列清 IP:port 走 Landlock;只愿给域名走代理。

10坑清单

现象正解
没设 PR_SET_NO_NEW_PRIVS非 root 调用 restrict_selfEPERM必须 prctl(38,1,0,0,0) 先行
WRITE_FILE=2 当成 READ_DIR=8只读路径被授予写权限,却 listdir 报 EACCES位序:EXEC=1, WRITE=2, READ_FILE=4, READ_DIR=8;只读用 `1
硬编码 ABI ≥ 3 的权限位跑在 5.13 内核create_rulesetEINVAL先查 ABI,把不认识的位 mask 掉
Go/Python 把 size 写错create_rulesetE2BIGsizeof(attr),不要硬编码
WRITE 没给 TRUNCATE写入器 open(O_TRUNC) 静默失败ABI ≥ 3 时 mask 加上 1ULL<<14
声明 handled_access_net 但没加任何端口规则以为「放开了网络」实际全禁——连出网全被 EACCES想放行就按端口显式加规则(--port 443);不想管网络就别声明 net 位
用 Landlock 当唯一防护子进程仍能 fork bomb、仍能 /proc/self/mem 读自己叠 seccomp + cgroup + namespace
第 17 次 restrict_selfE2BIG(栈满)上限 16 层;包到 Agent 时要预留层数
想给 /proc 加细规则很多 /proc 文件无法被显式限制/proc 默认只放只读全局,别试着精细化
规则生效后想放宽没有任何 syscall 能放宽重启进程;上锁前先准备好全部 fd/socket

11选型地图

场景是否用 Landlock理由
AI Agent 跑不可信脚本(Codex / Claude Code)强烈推荐零成本 + 文件路径级精度
CI runner 跑用户 PR推荐(叠 bwrap)但 CI 场景 nsjail 整体更合适
多租户 SaaS 跑用户代码不够用需要 microVM(Firecracker / CubeSandbox)
浏览器渲染进程可作加固层Chrome 的多层 sandbox 已经做完了
通用桌面应用沙箱不够用桌面场景 bubblewrap/Flatpak 更主流
本机 IDE 插件隔离强烈推荐VS Code / JetBrains 已经在考虑这条路
macOS / Windows用不上macOS 走 Seatbelt,Windows 走 AppContainer

一句话总结:Landlock 是给「我想在不动 docker 的前提下,给单进程画个圈」准备的。这个场景下它几乎没有替代品,缺点是只能管资源语义不管别的,需要叠层。

12结语:把沙箱从云上搬回本地

2026 年的本地 Agent 沙箱正在发生一件有趣的事:云上在内卷 microVM 启动时间(E2B → Cube 把冷启从 150ms 压到 60ms),本地则在内卷「零 daemon 免 root」。后者就是 Landlock 这条线——它不跟谁竞争,它做的事是 Docker、Firecracker、bubblewrap 都做不了也懒得做的:让任何进程用三行 syscall 给自己画一个圈

如果你的 Agent 还跑在宿主机裸环境里,今天就花 10 分钟把 agentbox.py 接到子进程启动那一行——本文所有代码块都在 WSL2(内核 6.18.33,Landlock ABI v7)上逐字跑过,包括四门语言的最小版本和三组沙箱对比。Landlock 从 5.13 就进了主线,所以只要内核 ≥ 5.13 的发行版都能直接跑(网络端口规则需要 ≥ 6.7)。下一篇文章我会继续拆 Anthropic 那个 sandbox-runtime,把 Claude Code 怎么「bwrap + Landlock + 代理」三件套的内部细节再展开——这玩意比想象的复杂,它做了一件很容易被忽略的事:在 macOS 上替已经 deprecated 的 sandbox-exec 续了命。


本文同步发布于 zicode.com · 公众号「字与码」

— 前百度/微博工程师,写技术不靠百度 —

打开原图 ↗