LabHub
学习 学习路径 课程

容器安全

用最小权限把容器收紧

在 LabHub 中继续学习

本实验在真正的 VM 中运行

这个环境不是 Pod,而是由 KubeVirt 启动的虚拟机。Linux 内核独立运行,systemd 真正管理服务,docker 也不是模拟工具,而是真正的 Docker 引擎。通过 docker run 启动的容器会成为实际进程,docker execdocker logs 也都能照常工作。

过去,这个实验在 Pod 中运行。由于该环境放弃了所有内核权限,启动容器的步骤无法进行,因此只能学习直接解开镜像归档的变通方法。现在不再需要绕路了。

有两点需要了解。

目标

分别实践以非特权用户运行容器的两种方法(运行时标志和镜像内置设置),逐一应用只读根文件系统、移除全部 capability 和禁止权限提升。最后亲手完成满足这些条件的镜像。

为什么重要

“容器已经隔离,所以内部使用 root 也没关系”之所以错误,原因很简单。不使用用户命名空间时,容器的 UID 0 与主机 UID 0 是同一个值,挂载卷所暴露的路径会直接受到该权限影响。因此,最小权限不应是可选项,而应成为默认值。尤其要养成使用数字编写 USER 的习惯。Kubernetes 的 runAsNonRoot 无法判断名称形式的用户不是 root,会拒绝 Pod;第一次看到这个错误时往往很难定位原因。capability 也是如此。只移除认为不需要的项,迟早会遗漏;先全部移除,再只添加需要的一项,就不会漏掉。

步骤

  1. 创建 /root/sec1 目录,将不指定任何用户运行 alpine:3.20 时的纯数字 UID保存到 /root/sec1/default-uid.txt。文件中只能有一个 0。 无需启动容器也能确定答案。运行用户记录在镜像 config 的 config.User 字段中;若为空,运行时会使用 uid 0——请用 skopeo inspect --config oci-archive:/opt/images/alpine_3.20.tar | jq -r '.config.User' 确认。
  2. 使用 alpine:3.20 在后台运行 sec-u 容器,设置 --user 1000:1000,并使其一直运行到评分时。
  3. 编写 /root/sec1/Dockerfile 并构建 labhub/sec:v1 镜像。使用 USER 指令指定数字 UID 10001,并使 docker run --rm labhub/sec:v1 id -u 输出 10001
  4. 以非特权用户尝试在 /etc 下创建文件,将产生的 Permission denied 错误连同标准错误一起保存到 /root/sec1/denied.txt
  5. 在后台运行 sec-ro 容器并设置 --read-only,在其中尝试写入文件,并将产生的 Read-only file system 错误保存到 /root/sec1/ro.txt
  6. 在后台运行 sec-caps 容器并设置 --cap-drop=ALL --cap-add=NET_BIND_SERVICE
  7. 在后台运行 sec-nnp 容器并设置 --security-opt no-new-privileges:true
  8. 编写 /root/sec1/hardened.Dockerfile 并构建 labhub/sec:v2 镜像。必须同时满足以下四项要求。
    • FROM 固定为具体标签,且不是 latest(例如 alpine:3.20
    • 最后一个 USER数字 UID,且值不小于 10000
    • 包含 org.opencontainers.image.source 标签
    • 实际运行时,id -u 输出该 UID

参考

默认镜像以 root 启动

创建 /root/sec1 目录,将不指定任何用户运行 alpine:3.20 时的纯数字 UID保存到 /root/sec1/default-uid.txt。文件中只能有一个 0。 无需启动容器也能确定答案。运行用户记录在镜像 config 的 config.User 字段中;若为空,运行时会使用 uid 0——请用 skopeo inspect --config oci-archive:/opt/images/alpine_3.20.tar | jq -r '.config.User' 确认。

不要保存 id 的完整输出,只能保存一个数字 UID。若原样写入 uid=0(root) gid=0...,会混入多个数字并导致失败。有一个选项只输出数字。

使用运行时标志更改 UID

使用 alpine:3.20 在后台运行 sec-u 容器,设置 --user 1000:1000,并使其一直运行到评分时。

无需重新构建镜像,也可以在运行时用标志更改用户。请使用 UID:GID 格式。评分会在容器内执行命令,因此容器必须持续运行。

让镜像本身以非 root 用户运行

编写 /root/sec1/Dockerfile 并构建 labhub/sec:v1 镜像。使用 USER 指令指定数字 UID 10001,并使 docker run --rm labhub/sec:v1 id -u 输出 10001

即使运行者忘记设置标志,镜像中也应内置安全默认值。先创建用户,再用 USER 切换;请指定数字而不是名称。alpine 不使用 useradd,而是使用 adduser

没有权限时会发生什么

以非特权用户尝试在 /etc 下创建文件,将产生的 Permission denied 错误连同标准错误一起保存到 /root/sec1/denied.txt

错误消息会写到标准错误,而不是标准输出。重定向时若不同时捕获标准错误,文件会为空并导致失败。

只读根文件系统

在后台运行 sec-ro 容器并设置 --read-only,在其中尝试写入文件,并将产生的 Read-only file system 错误保存到 /root/sec1/ro.txt

添加将根文件系统设为只读的标志来启动容器,在其中尝试写入并保存错误。这里也必须捕获标准错误。实际服务会为需要写入的路径单独挂载 tmpfs。

移除全部 capability 后只添加一项

在后台运行 sec-caps 容器并设置 --cap-drop=ALL --cap-add=NET_BIND_SERVICE

顺序很重要。不能只移除认为不需要的项,而要全部移除后只重新添加需要的一项。应添加的只有用于绑定低端口的那一项 capability。

阻断权限提升路径

在后台运行 sec-nnp 容器并设置 --security-opt no-new-privileges:true

有一个安全选项可在内核层面阻止通过执行带 setuid 位的二进制文件提升权限。使用 --security-opt 指定,并在值后添加 :true

完成加固镜像

编写 /root/sec1/hardened.Dockerfile 并构建 labhub/sec:v2 镜像。必须同时满足以下四项要求。

必须同时满足四项:固定标签(禁用 latest)、数字 UID 不小于 10000、来源标签,以及实际运行时使用该 UID。标签键是 OCI 标准名称,拼写必须准确。