容器里的 root 就是宿主机的 root
一句话总结
如果没有启用 user namespace,container 中的 root 与 host 的 root 同为 UID 0。“container 已经隔离,所以内部使用 root 也没关系”这种说法是错误的。
为什么需要了解这一点
大多数官方 image 至今仍以 root 启动。执行 docker run --rm node:22-bookworm-slim id
会得到 uid=0(root)。到这里并不意外,问题出在下面这一行。
docker run --rm -v /etc:/host-etc alpine:3.20 sh -c 'echo "# injected" >> /host-etc/hosts'
它会直接生效,host 的 /etc/hosts 被修改。如果 container 真是一个箱子,这本应不可能;
但如前一课程所述,container 不是箱子,而是视野受限的进程。对于通过 mount 开放的路径,
该进程的 UID 0 权限会原样生效。这并非隔离被破坏,而是从一开始就如此设计。
工作原理
标准做法是让 image 本身使用非特权用户。
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app
COPY --chown=10001:10001 . /app
USER 10001:10001
使用数字 UID 而不是名称是有原因的。Kubernetes 的
runAsNonRoot 在 image 的 USER 为名称时,无法判断它是否为 root,
因此会拒绝 Pod。
Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root
非特权用户无法监听低于 1024 的端口。此时容易想到添加 NET_BIND_SERVICE
capability,但通常更简单的做法是让应用监听 8080,再由 service
层对外暴露为 80。增加权限应放到最后考虑。
capability 把 root 权限拆成约 40 个部分,container 默认集合包含其中
14 个(CapEff: 00000000a80425fb)。其中包括 cap_chown、cap_dac_override、
cap_fowner、cap_setuid、cap_setgid、cap_net_bind_service、cap_net_raw、
cap_sys_chroot、cap_mknod、cap_setfcap 等,而 SYS_ADMIN、NET_ADMIN、
SYS_PTRACE 不在其中。
推荐组合如下。
--cap-drop=ALL --cap-add=NET_BIND_SERVICE
--security-opt no-new-privileges:true
--read-only --tmpfs /tmp
--user 10001:10001
seccomp 默认 profile 会阻止约 400 个 system call 中的约 44 个,
其中包括曾实际用于 container escape 的 open_by_handle_at,以及 keyctl、kexec_load。
实际工作中的表现
--privileged 是一个一次性解除全部上述保护的开关。而且,
挂载 Docker socket 实际上也具有相同含义,因为获得该 socket 后,可以新建一个挂载 host
root filesystem 的 privileged container。CI runner 配置中经常习惯性开启这两项;从那一刻起,
其余安全设置都只是装饰。
真实事故也发生在这幅图景中。CVE-2019-5736 通过 /proc/self/exe 覆盖 host 的 runc binary;
CVE-2024-21626 则利用 runc 未关闭 file descriptor,仅通过操纵 WORKDIR 便访问 host filesystem。
两者都不是 kernel bug,而是runtime bug;但它们之所以成立,是因为 container 与 host
运行在同一个 kernel 之下。
实际能把权限削减到什么程度
“不以 root 运行”只是起点。container 中可以削减四个层级:用户、 权限(capability)、filesystem、system call。四层全部处理后,遭入侵后能做的事情会大幅减少。
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false # setuid 로 권한을 되찾는 길을 막는다
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
allowPrivilegeEscalation: false 悄然发挥着重要作用。 如果它开启,
就能通过 container 内的 setuid binary 再次提升权限。即使降低了运行用户,
却没有关闭该值,也只完成了一半。
丢弃全部权限,只恢复真正需要的部分。 必须监听低于 1024 的端口时,
只添加 NET_BIND_SERVICE。更好的答案是从一开始就监听 8080,由 service
映射到 80,这样连增加权限的理由都不存在。
read-only root 会暴露真正需要写入的位置。 启用后通常一开始会失败,
而失败位置正是程序执行写入的位置。把 emptyDir 只挂载到 /tmp 与 cache path,
其他位置就无法写入。让攻击者无处下载并保存工具,才是该设置的真正价值。
文件所有权是最常见的启动障碍。 image 由 root 所有,却以其他用户运行时,
可能无法读取。构建 image 时最好预先通过 COPY --chown=10001:10001 设置正确。
如果是 volume,fsGroup 会在 mount 时改变 group。
多数情况下,seccomp 默认 profile 已经足够。 RuntimeDefault 会阻止
unshare、ptrace 等数十种危险 system call。自定义 profile 虽然精确,
却要付出kernel 或 runtime 变化后可能悄然失效的维护成本。仅启用默认值就能获得大部分收益。
下个练习将做什么
确认默认 image 以 root 启动,再分别通过 runtime flag 与 Dockerfile 实现 非特权运行,逐项应用 read-only root、删除全部 capability、阻止 privilege escalation。 最后亲自创建一个满足所有这些条件的 image。