LabHub
学习 学习路径 课程

容器安全

容器里的 root 就是宿主机的 root

在 LabHub 中继续学习

一句话总结

如果没有启用 user namespace,container 中的 root 与 host 的 root 同为 UID 0。“container 已经隔离,所以内部使用 root 也没关系”这种说法是错误的。

概念图: UID 0 权限会原样生效 · 14 个 · 约 44 个 · 一次性解除全部上述保护的开关

为什么需要了解这一点

大多数官方 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,以及 keyctlkexec_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 会阻止 unshareptrace 等数十种危险 system call。自定义 profile 虽然精确, 却要付出kernel 或 runtime 变化后可能悄然失效的维护成本。仅启用默认值就能获得大部分收益。

下个练习将做什么

确认默认 image 以 root 启动,再分别通过 runtime flag 与 Dockerfile 实现 非特权运行,逐项应用 read-only root、删除全部 capability、阻止 privilege escalation。 最后亲自创建一个满足所有这些条件的 image。