用最小权限把容器收紧
本实验在真正的 VM 中运行
这个环境不是 Pod,而是由 KubeVirt 启动的虚拟机。Linux 内核独立运行,systemd 真正管理服务,docker 也不是模拟工具,而是真正的 Docker 引擎。通过 docker run 启动的容器会成为实际进程,docker exec 和 docker logs 也都能照常工作。
过去,这个实验在 Pod 中运行。由于该环境放弃了所有内核权限,启动容器的步骤无法进行,因此只能学习直接解开镜像归档的变通方法。现在不再需要绕路了。
有两点需要了解。
- **首次启动大约需要 1 分钟。**因为 VM 需要启动并安装 Docker,比 Pod 实验(通常 40 秒)更慢。
- **没有浏览器预览。**进入 VM 的连接只开放评分端口。如果启动了 Web 服务器,请在 VM 内使用
curl检查。
目标
分别实践以非特权用户运行容器的两种方法(运行时标志和镜像内置设置),逐一应用只读根文件系统、移除全部 capability 和禁止权限提升。最后亲手完成满足这些条件的镜像。
为什么重要
“容器已经隔离,所以内部使用 root 也没关系”之所以错误,原因很简单。不使用用户命名空间时,容器的 UID 0 与主机 UID 0 是同一个值,挂载卷所暴露的路径会直接受到该权限影响。因此,最小权限不应是可选项,而应成为默认值。尤其要养成使用数字编写 USER 的习惯。Kubernetes 的 runAsNonRoot 无法判断名称形式的用户不是 root,会拒绝 Pod;第一次看到这个错误时往往很难定位原因。capability 也是如此。只移除认为不需要的项,迟早会遗漏;先全部移除,再只添加需要的一项,就不会漏掉。
步骤
- 创建
/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'确认。 - 使用
alpine:3.20在后台运行sec-u容器,设置--user 1000:1000,并使其一直运行到评分时。 - 编写
/root/sec1/Dockerfile并构建labhub/sec:v1镜像。使用USER指令指定数字 UID 10001,并使docker run --rm labhub/sec:v1 id -u输出10001。 - 以非特权用户尝试在
/etc下创建文件,将产生的Permission denied错误连同标准错误一起保存到/root/sec1/denied.txt。 - 在后台运行
sec-ro容器并设置--read-only,在其中尝试写入文件,并将产生的Read-only file system错误保存到/root/sec1/ro.txt。 - 在后台运行
sec-caps容器并设置--cap-drop=ALL --cap-add=NET_BIND_SERVICE。 - 在后台运行
sec-nnp容器并设置--security-opt no-new-privileges:true。 - 编写
/root/sec1/hardened.Dockerfile并构建labhub/sec:v2镜像。必须同时满足以下四项要求。FROM固定为具体标签,且不是latest(例如alpine:3.20)- 最后一个
USER是数字 UID,且值不小于 10000 - 包含
org.opencontainers.image.source标签 - 实际运行时,
id -u输出该 UID
参考
- 只获取数字 UID 时使用
id -u。只输入id会混入多个数字。 - 要将标准错误也写入文件,请使用
명령 > 파일 2>&1形式。 - 第 4 步也可以不使用容器。
setpriv --reuid=1000 --regid=1000 --clear-groups <명령>会降低 uid/gid 后执行命令——与容器运行时通过--user所做的事情相同。 - 在 alpine 中创建用户时,使用
adduser -D -u 10001 app形式。 - 标签写作
LABEL org.opencontainers.image.source="https://example.com/repo"。 - 常见错误 1:如果第 1 步保存
id的完整输出,uid=0(root) gid=0(root)中的所有数字都会被匹配,导致失败。 - 常见错误 2:如果第 2、5、6、7 步的容器使用
--rm或很快结束的命令启动,评分时它们会消失。 - 常见错误 3:第 8 步若使用
USER app这样的名称会失败,原因与 Kubernetes 拒绝它相同。
默认镜像以 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 镜像。必须同时满足以下四项要求。
FROM固定为具体标签,且不是latest(例如alpine:3.20)- 最后一个
USER是数字 UID,且值不小于 10000 - 包含
org.opencontainers.image.source标签 - 实际运行时,
id -u输出该 UID
必须同时满足四项:固定标签(禁用 latest)、数字 UID 不小于 10000、来源标签,以及实际运行时使用该 UID。标签键是 OCI 标准名称,拼写必须准确。