Linux Namespace、网络基础与 Capabilities 深度指南
容器隔离的底层原理,从内核机制到逃逸攻防
摘要|面向容器运行时开发者(以 Rust 为例),覆盖 8 种 namespace 的原理与实践、Linux 网络接口基础、capabilities 权限模型,以及容器逃逸的攻击面与防御思路。文章附带大量可运行的 Rust 代码示例和排查命令。📑 目录
01. 前置知识:NIS 域名与 Linux 网络接口
02. Capabilities——拆解 root 的超级权力
03. 8 种 Namespace 逐一拆解
04. Namespace 创建顺序与 youki 的设计
05. 容器逃逸——攻击路径与现实案例
06. Namespace + Capabilities 能否杜绝逃逸
07. 防御纵深——生产环境的容器安全体系
08. 完整示例:用 Rust 从零搭一个简易容器
进入 namespace 正题之前,有几个概念会反复出现,先交代清楚。
1.1 NIS 域名
UTS namespace 的隔离对象里有一个 "NIS domain name",这个东西现在已经很少有人用了,但搞清楚它有助于理解 UTS 的设计。
NIS(Network Information Service)是 Sun 在 1980 年代搞的集中式配置管理系统,最早叫 Yellow Pages(黄页),所以相关命令全部以yp开头——ypbind、ypserv、ypcat等。
它解决的问题:假设公司有 200 台 Linux 机器,每台都要维护/etc/passwd、/etc/group、/etc/hosts。没有 NIS 的时候,来了个新员工,管理员得登录 200 台机器挨个加账号。有了 NIS,所有配置集中存放在一台 NIS Master 上,客户端自动拉取。
// treeNIS Master ├── passwd map(用户账号) ├── group map(用户组) ├── hosts map(主机名映射) └── shadow map(密码) Client 1 ──┐ Client 2 ──┼── 自动同步 Client 3 ──┘
"NIS domain name" 就是用来标识"这批机器属于同一个 NIS 域"的一个字符串,通过domainname命令设置和查看。它跟 DNS 域名是两码事。
如今这套方案早已被 LDAP / FreeIPA / Active Directory 取代。UTS namespace 之所以还隔离它,纯粹是因为 NIS domain name 和 hostname 存储在内核同一个结构体struct utsname里,隔离 hostname 时顺手就一块隔离了。
1.2 lo 与 eth0——Linux 网络接口
Linux 中每个网络接口(网卡)都有一个名字。最常见的两个:
(1)lo(Loopback,回环接口)
纯软件虚拟网卡,IP 固定为127.0.0.1(IPv6::1)。数据包不会离开本机,在内核里发出去后直接"绕一圈"回来。
进程 A(客户端) 进程 B(服务端) │ ▲ │ 连接 127.0.0.1:8080 │ ▼ │ ┌──────────────────────────────────┐ │ lo (loopback) │ │ 数据包在内核中直接回送 │ └──────────────────────────────────┘(数据包永远不会离开这台机器)
本地开发时localhost:3000访问自己的服务,走的就是 lo。
(2)eth0(Ethernet,以太网接口)
第一块物理网卡的传统命名。数据包通过它从网线或 WiFi 发出去,到达真实的物理网络。
本机 互联网 ┌──────────────┐ ┌────────┐ │ eth0 │─── 网线/WiFi ─────────│ 路由器 │─── 外网 │ 192.168.1.5 │ └────────┘ └──────────────┘
现代发行版使用systemd-networkd后,命名规则换成了基于硬件拓扑的格式:enp0s3、ens33、wlp2s0等(防止多网卡时命名顺序不确定)。但在容器和虚拟化环境中,eth0仍然非常常见。
(3)容器网络中它们的关系
宿主机 容器(独立的 network namespace) ┌─────────────────────┐ ┌─────────────────────┐ │ eth0(物理网卡) │ │ eth0(veth 的容器端) │ │ docker0(虚拟网桥) │ │ lo │ │ veth-abc123 ─────────┼── pair ─────┼→(就是容器的 eth0) │ │ lo │ └─────────────────────┘ └─────────────────────┘
新建的 network namespace 里只有一个 lo,而且默认处于 down 状态。容器运行时需要手动创建veth pair(一对虚拟网线),把容器"接入"宿主机网络。
02 Capabilities——拆解 root 的超级权力2.1 传统模型的缺陷
Linux 传统权限模型只分两档:UID 0(root)无所不能,非 root 处处受限。这导致很多程序仅仅需要某一个特权操作,就不得不以 root 身份运行。典型的例子是ping——它需要创建 raw socket,传统方案是给它加 setuid root 位,但这意味着ping进程拿到了 root 的全部权限,远超它的实际需求。
2.2 Capabilities 的做法
从 Linux 2.2 开始,内核将 root 的"万能钥匙"拆分成了大约 40 把独立的小钥匙,每把开不同的门。程序只需要拿到它真正需要的那几把。
跟容器关系最密切的一些 capabilities:
CAP_SYS_ADMIN最强的能力,控制 mount / unshare / pivot_root /sethostname / 设置 cgroup 等。被称为"新的 root"。CAP_NET_ADMIN网络管理:配置网卡、修改路由表、设防火墙规则。CAP_NET_RAW使用 raw socket。ping 需要的就是这个。CAP_NET_BIND_SERVICE绑定 1024 以下端口(nginx 监听 80 端口需要)。CAP_CHOWN修改文件 owner。CAP_DAC_OVERRIDE绕过文件读写执行权限检查。CAP_SETUID / SETGID切换 UID / GID。CAP_KILL向任意进程发信号。CAP_MKNOD创建设备文件(/dev 下面的东西)。CAP_SYS_PTRACE调试其他进程(strace、gdb 依赖它)。CAP_SYS_TIME修改系统时钟。CAP_SYS_MODULE加载 / 卸载内核模块。
2.3 五个集合——Capabilities 的运作机制
Capabilities 的设计不是"进程要么有、要么没有"这么简单。它更像一套多层过滤器,控制"当前能用哪些能力"、"最多能拥有哪些能力"、"exec 新程序后还能保留哪些能力"。
一个场景建立直觉:你写了一个 Web 服务器,需要绑定 80 端口(需要CAP_NET_BIND_SERVICE),但运行过程中绝大部分时间不需要这个权限,只有启动时绑端口那一瞬间需要。理想的安全模型是——启动时激活这个能力,绑完端口后立刻主动丢掉。这就是为什么需要多个集合,它们配合实现"按需激活、用完即弃"。
(1)Effective (E) —— "此刻手里拿着的钥匙"
内核做权限检查时,看的就是且仅是这个集合。能力在 E 里,操作就允许;不在,就拒绝。
进程想绑定 80 端口 → 内核检查: CAP_NET_BIND_SERVICE ∈ Effective ? → 在: 允许 → 不在: EPERM (Operation not permitted)
(2)Permitted (P) —— "兜里揣着的钥匙"
进程"允许持有"的能力上限。E 必须是 P 的子集——只能把兜里有的钥匙拿出来用,不能凭空变出来。
进程可以随时把 P 中的能力激活到 E(拿出来用),也可以把 E 中的能力放回 P(暂时不用)。但一旦从 P 中移除某个能力,就再也加不回来了——单向操作。
Permitted = { CAP_NET_BIND_SERVICE, CAP_CHOWN, CAP_KILL } Effective = { } ← 当前什么都没激活 进程启动,需要绑端口: → 把 CAP_NET_BIND_SERVICE 从 P 激活到 E Effective = { CAP_NET_BIND_SERVICE } 绑定完成,主动放弃: → 从 E 和 P 中都移除 CAP_NET_BIND_SERVICE Permitted = { CAP_CHOWN, CAP_KILL } ← 永久少了一个 Effective = { }后续即使进程被攻破,攻击者也无法重新获得 CAP_NET_BIND_SERVICE
这就是"权限降级"(privilege dropping),安全编程的核心手法。
(3)Inheritable (I) —— "能传给下一代的钥匙"
控制execve()执行新程序时,哪些能力有资格被继承。I 单独不起作用,需要和新程序文件上的 capabilities 配合:
exec 后新进程的 Permitted 计算公式(简化版):P'(new) = (P(old) & Bounding) | (I(old) & I(file)) | Ambient翻译成人话:1. 老进程 P 中的能力,受 Bounding Set 过滤2. 老进程 I 和文件 I 的交集,也能进入新 P3. Ambient 集合无条件进入
(4)Bounding Set (B) —— "天花板"
单调递减的集合——只能移除能力,不能添加。它设定一个绝对上限:无论发生什么,进程及其所有后代都不可能获得 Bounding Set 之外的能力。
假设容器运行时在启动容器时: Bounding = { CAP_CHOWN, CAP_KILL, CAP_NET_BIND_SERVICE } 那么容器内的任何进程,哪怕 exec 了一个 setuid root 程序, 也不可能获得 CAP_SYS_ADMIN,因为 CAP_SYS_ADMIN ∉ Bounding
容器运行时(如 youki)做的最重要的安全操作之一,就是在容器启动时把 Bounding Set 削减到最小白名单。
(5)Ambient (A) —— "自动继承的钥匙"
Linux 4.3 引入,解决一个实际痛点:普通程序(没有文件 capabilities 的程序)在 exec 后会丢失所有能力。Ambient 中的能力在 exec 时自动添加到新进程的 Effective 和 Permitted 中,不需要文件上有特殊标记。
约束:Ambient 必须同时是 Permitted 和 Inheritable 的子集。A ⊆ P ∩ I。
(6)exec 时五个集合的流转全景
exec() 发生时 │ ┌──────────────────────┼──────────────────────┐ │ 旧进程 │ 新进程 │ │ │ │ │ Permitted ──────────┼──→ & Bounding ──┐ │ │ │ ├──→ Permitted' │ Inheritable ────────┼──→ & File_I ───┘ │ │ │ │ │ Ambient ────────────┼──→ 直接加入 ────────→ Effective' │ │ │ │ Bounding ───────────┼──→ 原样继承 ────────→ Bounding' │ │ │ └──────────────────────┼──────────────────────┘ │天花板: Bounding 只减不增地板: Ambient 保底继承
2.4 对容器的意义
容器安全的核心就是最小权限。以 Docker 为例:
完整 root(~40 个 capabilities) │ 容器运行时削减 ▼Docker 容器默认保留(约 14 个):CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER,CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID,CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE,CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE被移除的高危 capabilities: ✗ CAP_SYS_ADMIN 禁止 mount、修改 namespace ✗ CAP_NET_ADMIN 禁止改网络配置 ✗ CAP_SYS_MODULE 禁止加载内核模块 ✗ CAP_SYS_PTRACE 禁止调试宿主机进程 ✗ CAP_SYS_TIME 禁止改系统时钟 ...
与 User Namespace 的关键联系
User namespace 中的进程"拥有全部 capabilities",但这些 capabilities 仅在该 namespace 的范围内有效:
宿主机 user namespace 容器 user namespace ┌─────────────────────┐ ┌─────────────────────┐ │ UID 1000(普通用户) │ 映射后→ │ UID 0(root) │ │ Capabilities: 无 │ │ Capabilities: 全部 │ │ │ │ │ │ 这些 capabilities ──────────────→│ 仅在容器内部生效 │ │ 对宿主机资源无效 │ │ 对宿主机资源无效 │ └─────────────────────┘ └─────────────────────┘
这就是rootless 容器的安全基石:容器内是 root、可以做任何 namespace 内部的操作,但对宿主机没有任何影响。
2.5 Rust 中操作 capabilities
权限降级的完整示例:
// rust// Cargo.toml: caps = "0.5"use caps::{CapSet, Capability, CapsHashSet};fnprivilege_drop() -> Result<(), caps::errors::CapsError> {// === 阶段 1: 启动时,拥有多个能力 ===// Permitted = { NET_BIND_SERVICE, CHOWN, KILL }// Effective = { NET_BIND_SERVICE, CHOWN, KILL }// 绑定 80 端口(需要 CAP_NET_BIND_SERVICE)// bind_port_80();// === 阶段 2: 端口绑定完成,开始削减权限 ===// 从 Bounding Set 中移除不再需要的能力(影响所有后代进程) caps::drop(None, CapSet::Bounding, Capability::CAP_NET_BIND_SERVICE)?; caps::drop(None, CapSet::Bounding, Capability::CAP_CHOWN)?;// 从 Permitted 中移除(当前进程也永久丢失)let minimal: CapsHashSet = [Capability::CAP_KILL].iter().copied().collect(); caps::set(None, CapSet::Permitted, &minimal)?; caps::set(None, CapSet::Effective, &minimal)?;// === 阶段 3: 现在进程只剩 CAP_KILL ===// 即使被攻击者利用,也无法:// - 绑定特权端口(没有 NET_BIND_SERVICE)// - 修改文件属主(没有 CHOWN)// - 加载内核模块(没有 SYS_MODULE,且 Bounding 中也没有)Ok(()) }
容器运行时中的典型用法——削减到白名单:
// rustuse caps::{CapSet, Capability, CapsHashSet};fndrop_to_whitelist() -> Result<(), caps::errors::CapsError> {let allowed: CapsHashSet = [ Capability::CAP_CHOWN, Capability::CAP_SETUID, Capability::CAP_SETGID, Capability::CAP_NET_BIND_SERVICE, Capability::CAP_KILL, ].iter().copied().collect();// 将 bounding set 中不在白名单的能力逐个移除for cap_value in0..=40 {if letOk(cap) = Capability::try_from(cap_value) {if !allowed.contains(&cap) { caps::drop(None, CapSet::Bounding, cap)?; } } } caps::set(None, CapSet::Effective, &allowed)?; caps::set(None, CapSet::Permitted, &allowed)?;Ok(()) }
2.6 常用排查命令
// bash# 查看当前 shell 的 capabilities 位图grep Cap /proc/self/status# 解码位图为可读格式capsh--decode=00000000a80425fb# 查看指定进程的 capabilitiesgetpcaps<PID># 查看文件上绑定的 capabilitiesgetcap/bin/ping# 输出示例: /bin/ping cap_net_raw=ep
3.1 Mount Namespace
Mount namespace 是 Linux 最早引入的 namespace。它让不同 namespace 中的进程看到完全不同的文件系统视图——各自独立的挂载点列表,互不影响。
flag 叫CLONE_NEWNS(new namespace)而不是CLONE_NEWMNT,是因为当时这是唯一的 namespace 类型,开发者没想到后面还会有六七种,就直接用了最通用的名字。历史包袱。
容器运行时的典型操作流程:
unshare(CLONE_NEWNS)- 将根挂载点设为
MS_PRIVATE,切断与宿主机的挂载事件传播 - 将容器 rootfs bind mount 到临时目录
pivot_root()umount
宿主机看到的文件系统: 容器看到的文件系统:// (容器 rootfs,如 alpine) ├── home/ ├── bin/ ├── var/ ├── etc/ ├── proc/(宿主机的 proc) ├── proc/(容器独立的 proc) └── ... └── ...
Rust 示例:
// rustuse nix::sched::{unshare, CloneFlags};use nix::mount::{mount, MsFlags};fnsetup_mount_ns() -> nix::Result<()> { unshare(CloneFlags::CLONE_NEWNS)?;// 切断挂载传播,防止影响宿主机 mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE,None::<&str>, )?;// 挂载容器自己的 /proc mount(Some("proc"), "/proc", Some("proc"), MsFlags::empty(), None::<&str>, )?;Ok(()) }
3.2 UTS Namespace
最简单的 namespace。每个容器可以有自己的 hostname,Docker 的--hostname参数就是靠它。NIS domain name 的隔离属于附带效果。
// bash# 宿主机 $ hostnameprod-server-01# 容器内 $ hostnamemy-app-container
3.3 IPC Namespace
隔离进程间通信资源:共享内存段(shmget)、消息队列(msgget)、信号量(semget),以及 POSIX 消息队列。不同 IPC namespace 中的进程,即使使用相同的 key,也访问不到对方的 IPC 对象。
3.4 PID Namespace
PID namespace 隔离进程 ID。新 namespace 中第一个进程的 PID 为 1,承担 init 角色;容器内的进程看不到容器外的进程。PID namespace 是层级结构的:父 namespace 能看到子 namespace 的进程(但 PID 值不同),反过来不行。
宿主机视角: 容器内视角: PID 1 (systemd) PID 1234 (containerd) PID 5678 (容器进程) → PID 1 (容器 init) PID 5679 (app) → PID 2 (app)
几个关键细节:容器的 PID 1 必须正确处理SIGCHLD,负责回收僵尸进程;PID 1 退出时,整个 namespace 内的所有进程都会被内核杀死;unshare(CLONE_NEWPID)的行为比较特殊——调用者本身不会进入新 namespace,只有后续 fork 出的子进程才会。
3.5 Network Namespace
隔离范围非常广:网络设备、IPv4/IPv6 协议栈、IP 路由表、防火墙规则、/proc/net与/sys/class/net、端口号空间(不同 namespace 可以同时监听相同端口)、Unix domain socket 的抽象命名空间。
宿主机 namespace 容器 namespace ┌──────────────────────┐ ┌────────────────────┐ │ eth0(物理网卡) │ │ eth0(veth 对端) │ │ docker0(网桥) │ │ lo │ │ veth-abc ────────────┼── pair ──┼→ │ │ lo │ └────────────────────┘ └──────────────────────┘
3.6 User Namespace
| CLONE_NEWUSER |
| |
| UID / GID 映射、capabilities |
User namespace 是唯一一个不需要特权就能创建的 namespace,也是 rootless 容器的基石。核心机制是 UID/GID 映射——容器内的 UID 0 可以映射到宿主机上的某个非特权用户。
宿主机 容器 UID 100000 (nobody) → UID 0 (root) UID 100001 → UID 1 UID 100002 → UID 2
映射规则写入/proc/<pid>/uid_map和/proc/<pid>/gid_map,格式为<容器内起始ID> <宿主机起始ID> <范围长度>。
📌 为什么 youki 中 User namespace 排在第一位?创建其他 namespace 都需要CAP_SYS_ADMIN。如果以非 root 用户运行容器,必须先进入 user namespace 拿到(namespace 内的)全部 capabilities,之后才有资格创建 PID / Mount / Network 等 namespace。3.7 Cgroup Namespace
Cgroup namespace 隔离的是进程看到的 cgroup 路径。不隔离时,容器内读/proc/self/cgroup会暴露宿主机的完整 cgroup 路径(如0::/system.slice/docker-abc123.scope);隔离后,容器以为自己就在 cgroup 根(0::/)。
3.8 Time Namespace
| CLONE_NEWTIME |
| |
| CLOCK_MONOTONIC |
最新加入的 namespace。它可以让容器看到不同的"系统运行时长"(uptime),但不能改变CLOCK_REALTIME(墙上时钟)。使用场景主要是容器迁移(live migration)和检查点恢复(CRIU):把容器从一台运行了 100 天的机器迁移到运行了 1 天的机器,容器内的 uptime 应该保持连续。
04 Namespace 创建顺序与 youki 的设计youki 定义的顺序不是随意的,每一步都有依赖关系:
// ruststatic ORDERED_NAMESPACES: &[CloneFlags] = &[ CloneFlags::CLONE_NEWUSER, // 1. 先拿 capabilities CloneFlags::CLONE_NEWPID, // 2. 隔离 PID CloneFlags::CLONE_NEWUTS, // 3. 设置 hostname CloneFlags::CLONE_NEWIPC, // 4. 隔离 IPC CloneFlags::CLONE_NEWNET, // 5. 隔离网络 CloneFlags::CLONE_NEWCGROUP, // 6. 隔离 cgroup 视图 CloneFlags::CLONE_NEWNS, // 7. 最后处理挂载点 ];
关键约束:User 必须在最前——rootless 场景下,没有它就没有CAP_SYS_ADMIN,后面的 namespace 全都创建不了。Mount 必须在最后——因为 mount namespace 内需要挂载/proc(依赖 PID namespace)、挂载网络相关的文件系统(依赖 network namespace)等,所有其他 namespace 都就绪之后再处理挂载最安全。
容器逃逸指容器内的进程突破隔离边界,获得对宿主机或其他容器的访问能力。
正常状态: ┌─────────────────────────────────────────┐ │ 宿主机 (Host) │ │ ┌───────────┐ ┌───────────┐ │ │ │ 容器 A │ │ 容器 B │ │ │ │ 只能看到 │ │ 只能看到 │ │ │ │ 自己的 │ │ 自己的 │ │ │ │ 文件/进程 │ │ 文件/进程 │ │ │ └───────────┘ └───────────┘ │ │ 容器之间互相不可见,也看不到宿主机 │ └─────────────────────────────────────────┘逃逸后: ┌─────────────────────────────────────────┐ │ 宿主机 (Host) │ │ ▲ │ │ │ 攻击者突破了边界 │ │ ┌────────────┼──┐ ┌───────────┐ │ │ │ 容器 A │ │ │ 容器 B │ │ │ │ 攻击者 ──┘ │ │ 可能也 │ │ │ │ 可以: │ │ 被波及 │ │ │ │ 读宿主机文件 │ │ │ │ │ │ 执行宿主机命令│ │ │ │ │ │ 访问其他容器 │ │ │ │ │ └──────────────┘ └───────────┘ │ └─────────────────────────────────────────┘
5.1 逃逸后攻击者能做什么
| | |
|---|
| | |
| | |
| 获得 root + 全部 capabilities | |
5.2 逃逸的四大类路径
路径 1:内核漏洞利用
最"硬核"的逃逸方式。容器和宿主机共享同一个内核,容器内的进程发起的每一个 syscall 都直接由宿主机内核处理。如果内核代码有漏洞,攻击者可以从容器内触发它,在内核态获得代码执行能力——此时 namespace 和 capabilities 的隔离毫无意义。
真实案例——CVE-2020-14386:漏洞位于 AF_PACKET 套接字的内存处理,容器内只需要CAP_NET_RAW(Docker 默认授予)即可触发。攻击者创建 raw socket,发送精心构造的数据包触发内核内存越界写入,最终将自身 uid 改为 0 并跳出容器。事后 Docker 将CAP_NET_RAW从默认白名单中移除。
路径 2:危险配置
现实中最常见的逃逸原因。不需要任何漏洞,纯粹是人为把门打开了。
❌ --privileged 模式
这个 flag 的效果:授予全部 ~40 个 capabilities、关闭 seccomp 过滤、关闭 AppArmor 限制、可访问宿主机所有设备。逃逸极其简单:
// bash $ fdisk-l# 找到宿主机磁盘 $ mount/dev/sda1/mnt# 挂载宿主机根目录 $ chroot/mnt# 进入宿主机文件系统 $ # 现在你就是宿主机的 root
❌ 挂载 Docker Socket
容器内通过 Docker API 创建一个新的特权容器并挂载宿主机根目录,就能完全控制宿主机。这是很多 CI/CD 系统的经典配置错误——为了让容器内能构建 Docker 镜像,把 docker.sock 挂了进去。
路径 3:容器运行时自身的漏洞
CVE-2019-5736(runc 逃逸)的巧妙之处在于:攻击者不是直接突破 namespace,而是利用了 runc "跨越 namespace 边界"的过程——runc 一只脚在容器里、一只脚在宿主机上,攻击者在切换的瞬间下手。恶意程序通过/proc/self/exe拿到 runc 二进制的文件描述符,覆写宿主机上的 runc,下次任何 docker run / docker exec 都会执行被篡改的 runc。
路径 4:云 metadata service 横向移动
AWS / GCP / Azure 的 VM 都提供 metadata service(http://169.254.169.254/),默认情况下容器内可以直接访问它,拿到宿主机 EC2 实例的 IAM 角色临时凭证,用这个凭证可以访问 S3、DynamoDB 等云资源,甚至进一步拿到整个云账户的控制权。
5.3 各路径的现实发生概率
⚠️ 重要提醒:现实中绝大多数容器安全事故的根因是配置错误,不是高深的内核 exploit。--privileged、挂载 docker.sock、以 root 身份运行容器内应用——这些才是日常最大的威胁。06 Namespace + Capabilities 能否杜绝逃逸假设内核没有 bug,并且 capabilities 配置完全正确,是不是就不会存在容器逃逸了?理论上接近成立,但还有几条路径是 namespace 和 capabilities 管不到的。
6.1 共享资源的侧信道
侧信道攻击跟 namespace 和 capabilities 完全无关。它们工作在逻辑隔离层面,而侧信道工作在物理层面。典型攻击:Spectre、Meltdown、L1TF。利用的是 CPU 微架构的行为,不走任何 syscall,namespace 和 capabilities 对它们完全透明。
6.2 没有被 namespace 化的内核接口
Linux 内核并非所有子系统都做了 namespace 隔离。部分/proc/sys/kernel/*参数、系统级计时器(CLOCK_REALTIME)、内核全局资源上限(PID 上限、inotify 上限)等都是所有容器共享的。
📌 关键认知:namespace 隔离的是可见性,不是资源配额。限制资源用量是 cgroup 的职责。6.3 seccomp 缺位时不受 capabilities 管控的 syscall
capabilities 控制的是"你有没有权限做某个操作",但 Linux 有 300 多个系统调用,其中很多的权限检查不经过 capabilities。比如personality()(可用于绕过 ASLR)、userfaultfd()、io_uring(历史漏洞极多,Google 在生产环境中直接禁用了它)、bpf()(如果 unprivileged_bpf 开启,不需要任何 capability 就能加载 BPF 程序)。这些 syscall 不需要特殊 capability,capabilities 配置再完美也拦不住。
6.4 修正思维模型
| |
|---|
| |
| |
| |
| + cgroup + AppArmor/SELinux | |
| |
安全工程的思路不是追求"证明不可能逃逸",而是层层叠加防御,让逃逸的成本高到不值得。
7.1 多层防御
第一层: Namespace 隔离可见性(文件系统、网络、进程等)第二层: Capabilities 限制特权操作(最小权限集)第三层: Seccomp-BPF 限制可用的系统调用第四层: AppArmor/SELinux 强制访问控制(MAC)第五层: cgroup 资源限制 防止 DoS(CPU、内存、IO)第六层: 只读根文件系统 防止持久化恶意文件第七层: 内核加固 grsecurity、LKRG 等
其中Seccomp-BPF非常关键。它直接限制容器能调用哪些系统调用。Docker 默认的 seccomp profile 大概屏蔽了 44 个系统调用,包括mount、reboot、kexec_load、unshare、ptrace、bpf等。
配合逻辑:namespace 和 capabilities 限制了"你能做什么",seccomp 更进一步限制了"你能调用哪些内核接口"。每多一层,攻击者需要同时突破的条件就多一个,构造出可用攻击链的概率指数级下降。
7.2 最安全的方案:额外隔离层
如果对隔离性要求极高(比如运行不可信代码),共享内核的方案从根本上就不够:
传统容器(共享内核): ┌──────────────────────────────────┐ │ 宿主机 Linux 内核 │ ← 容器直接调用,攻击面大 │ ┌────────┐ ┌────────┐ │ │ │ 容器 A │ │ 容器 B │ │ │ └────────┘ └────────┘ │ └──────────────────────────────────┘gVisor(用户态内核): ┌──────────────────────────────────┐ │ 宿主机 Linux 内核 │ │ ┌──────────────────────┐ │ │ │ Sentry(Go 实现) │ │ ← 拦截 syscall,在用户态处理 │ │ ┌────────────────┐ │ │ 大幅缩小内核攻击面 │ │ │ 容器 │ │ │ │ │ └────────────────┘ │ │ │ └──────────────────────┘ │ └──────────────────────────────────┘Kata Containers(轻量虚拟机): ┌──────────────────────────────────┐ │ 宿主机 Linux 内核 │ │ ┌──────────────────────┐ │ │ │ 轻量 VM(独立内核) │ │ ← 硬件级隔离 │ │ ┌────────────────┐ │ │ 即使 guest 内核被攻破 │ │ │ 容器 │ │ │ 也出不了 VM │ │ └────────────────┘ │ │ │ └──────────────────────┘ │ └──────────────────────────────────┘
7.3 小结
namespace + capabilities 能阻止绝大多数常规攻击、隔离容器间的互相影响、防止容器内非 root 用户做特权操作、限制攻击者即使拿到容器内 root 后的能力。但不能防御内核 0-day、配置错误、未 namespace 化的内核子系统、以及硬件级侧信道攻击。单独任何一层都不是万无一失的,但叠在一起后,构成了业界所说的"防御纵深"(Defense in Depth)。把上面的 namespace 知识串起来:
// rustuse nix::mount::{mount, MsFlags};use nix::sched::{clone, CloneFlags};use nix::sys::signal::Signal;use nix::sys::wait::waitpid;use nix::unistd::{execvp, getpid, sethostname};use std::ffi::CString;fncontainer_entry() -> isize { println!("[container] PID = {}", getpid());// 设置 hostname sethostname("sandbox").expect("sethostname failed");// 隔离挂载传播 mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE,None::<&str>, ).ok();// 挂载独立的 /proc mount(Some("proc"), "/proc", Some("proc"), MsFlags::empty(), None::<&str>, ).ok();// 启动 shelllet sh = CString::new("/bin/sh").unwrap(); execvp(&sh, &[&sh]).expect("exec failed");0 }fnmain() {const STACK_SIZE: usize = 1024 * 1024;let mut stack = vec![0u8; STACK_SIZE];let flags = CloneFlags::CLONE_NEWNS | CloneFlags::CLONE_NEWPID | CloneFlags::CLONE_NEWUTS | CloneFlags::CLONE_NEWIPC | CloneFlags::CLONE_NEWNET | CloneFlags::CLONE_NEWCGROUP;let child_pid = unsafe { clone(Box::new(|| container_entry()), &mut stack, flags,Some(Signal::SIGCHLD asi32), ).expect("clone failed") }; println!("[host] child PID = {}", child_pid); waitpid(child_pid, None).expect("waitpid failed"); println!("[host] container exited"); }
依赖配置:
// toml [dependencies]nix = { version = "0.29", features = ["sched", "mount", "signal", "process"] }caps = "0.5"
附录:8 种 Namespace 总览表
| | | |
|---|
| CLONE_NEWNS | | |
| CLONE_NEWUTS | | |
| CLONE_NEWIPC | | |
| CLONE_NEWPID | | |
| CLONE_NEWNET | | |
| CLONE_NEWUSER | | |
| CLONE_NEWCGROUP | | |
| CLONE_NEWTIME | | |
— EOF —