Docker 基础体系 · 第 70/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 安全事件响应:隔离容器、保全证据、镜像追溯和恢复

Docker 安全事件响应的目标不是“尽快删掉可疑容器”,而是在不扩大影响的前提下,依次完成四件事:

  1. 隔离(containment):阻止攻击者继续通过容器访问外部系统或横向移动。
  2. 保全证据(preservation):保存能够回答“发生了什么、何时发生、影响了什么”的运行态和持久态信息。
  3. 追溯(attribution and provenance):确定容器实际使用的镜像、构建来源、配置来源和发布链路。
  4. 恢复(recovery):从可信镜像和可信配置重新部署,并验证旧的持久化数据和凭据没有继续携带风险。

这四个目标之间存在冲突。例如,立即删除容器可以快速止损,却会丢失进程、环境变量、容器文件系统和网络配置;立即重启宿主机可以清理内存中的恶意进程,却会破坏内存和运行态证据。因此,响应流程必须先判断影响范围和紧迫性,再选择“隔离但保留运行态”还是“立即停止并转入主机级取证”。

本文讨论的是 Linux 容器边界内的 Docker Engine 事件响应。容器共享宿主机的 Linux 内核,容器隔离依赖 namespaces、cgroups、Capabilities、Seccomp、LSM 和文件系统隔离。容器并不是虚拟机;如果事件涉及宿主机内核、Docker daemon、/var/run/docker.sock、特权容器或宿主机凭据,应把事件升级为宿主机安全事件,而不能只按“某个容器被入侵”处理。


一、先确定事件边界:容器、daemon 还是宿主机

1. Docker 容器的安全边界是什么

Linux 容器通常由以下机制共同构成:

  • namespace:隔离进程、网络、挂载点、用户和 IPC 等视图;
  • cgroup:限制和统计 CPU、内存、进程数等资源;
  • Capabilities:把传统 root 权限拆分为较小的权限集合;
  • Seccomp:限制进程能够调用的系统调用;
  • Linux Security Module:例如 AppArmor 或 SELinux;
  • overlay 文件系统:把只读镜像层和容器可写层组合成容器看到的根文件系统;
  • volume、bind mount、tmpfs:把容器外部的数据或内存文件系统挂载到容器内。

这些机制的作用是降低权限和缩小可见范围,但它们并不消除共享内核这一事实。容器内的 root 通常只是容器 namespace 中的 root,但以下配置会显著扩大风险:

services:
  app:
    image: example/app@sha256:...
    privileged: true
    pid: host
    network_mode: host
    volumes:
      - /:/host
      - /var/run/docker.sock:/var/run/docker.sock

其中:

  • privileged: true 会大幅放宽设备和 Capability 限制;
  • pid: host 允许容器看到宿主机的进程命名空间;
  • network_mode: host 使容器直接使用宿主机网络命名空间;
  • 挂载 / 使容器可以访问宿主机文件;
  • 挂载 Docker socket 通常意味着可以通过 Docker API 创建具有宿主机级影响的容器。

因此,事件响应的第一个问题不是“哪个进程异常”,而是:

可疑容器是否仍处在预期的隔离边界内?

2. 三种影响范围

可以把影响范围分成三个层次。

容器级事件

例如:

  • 容器内出现异常进程;
  • 应用账号被盗用;
  • 容器内文件被修改;
  • 容器向外发起异常连接。

此时 Docker 的容器、网络、日志和镜像元数据是主要证据来源。

Docker 平面事件

例如:

  • 未授权用户访问 Docker socket;
  • daemon API 暴露在不受信任的网络;
  • 有人创建了未知容器、网络、volume 或 secret;
  • 使用 Docker API 修改了运行配置。

此时需要审查 daemon 日志、API 访问入口、宿主机用户和 socket 权限。仅查看可疑容器不足以解释事件。

宿主机级事件

例如:

  • 容器能够访问宿主机根文件系统;
  • 出现宿主机 namespace 中的异常进程;
  • 内核漏洞或运行时漏洞可能被利用;
  • 宿主机上的 SSH、云凭据或其他服务受到影响。

此时 Docker 命令输出只能作为一部分证据。需要按照宿主机入侵响应流程保全磁盘、内存、系统日志、审计日志和云平台日志。


二、响应前的原则:先记录,再改变状态

Docker CLI 通过 Docker daemon 查询和修改对象。命令本身不是“旁观者”:

  • docker exec 会创建新的进程,改变进程列表和审计记录;
  • docker cp 读取文件,但可能触发文件访问时间变化,具体取决于文件系统和挂载方式;
  • docker inspect 通常是只读查询,但 daemon 和日志系统仍可能产生访问记录;
  • docker stop 会发送终止信号并导致进程退出;
  • docker pause 会冻结进程,改变运行态;
  • docker network disconnect 会改变网络状态;
  • docker rmdocker system prune 等操作会破坏证据。

因此,响应人员应先建立一个不可变的响应记录,例如:

export IR_ROOT="/var/ir/$(date -u +%Y%m%dT%H%M%SZ)"
sudo install -d -m 0700 "$IR_ROOT"
sudo date -u +"%Y-%m-%dT%H:%M:%SZ" | sudo tee "$IR_ROOT/started-at.txt"
sudo docker version | sudo tee "$IR_ROOT/docker-version.txt"
sudo docker info | sudo tee "$IR_ROOT/docker-info.txt"
sudo docker ps -a --no-trunc | sudo tee "$IR_ROOT/containers.txt"
sudo docker images --digests --no-trunc | sudo tee "$IR_ROOT/images.txt"
sudo docker network ls | sudo tee "$IR_ROOT/networks.txt"
sudo docker volume ls | sudo tee "$IR_ROOT/volumes.txt"

这里使用 --no-trunc 很重要。默认输出可能截断容器 ID、镜像 ID 或命令,无法作为可靠的关联键。

这些命令的前提是当前账号有权限访问 Docker daemon。对 rootful Docker,常见的 socket 是 /var/run/docker.sock;能够访问该 socket 的用户通常具有接近宿主机 root 的控制能力。因此,响应记录必须保护好,不能把结果写入受可疑容器控制的路径。

如果怀疑 Docker daemon 或宿主机已经被控制,应尽量从受信任的管理通道执行采集,并把输出写入远程或只读证据存储。攻击者可能伪造容器内日志、删除 Docker 对象,甚至修改宿主机上的 Docker 数据目录。


三、隔离容器:暂停、断网和停止分别解决什么问题

“隔离容器”不是一个单一操作。至少需要区分进程隔离和网络隔离。

flowchart LR
    A[发现可疑容器] --> B{宿主机是否可能被突破}
    B -->|否| C[记录状态与证据]
    B -->|是或不确定| D[升级为宿主机事件]
    C --> E{是否需要保留运行态}
    E -->|需要| F[暂停进程]
    E -->|不需要或风险紧迫| G[停止容器]
    F --> H[断开 Docker 网络]
    G --> H
    H --> I[采集容器和镜像证据]
    I --> J[轮换凭据并重建]
    J --> K[验证恢复]

1. docker pause:保留进程状态,但不等于隔离

sudo docker pause CONTAINER_ID
sudo docker inspect --format \
  '{{.Id}} status={{.State.Status}} paused={{.State.Paused}} pid={{.State.Pid}}' \
  CONTAINER_ID

Linux 下,Docker 通常通过 freezer 机制冻结容器中的进程。暂停的价值是:

  • 保留进程列表、内存映射和容器可写层的当前状态;
  • 暂时阻止用户态进程继续执行;
  • 为后续采集争取时间。

但暂停不是完整的网络隔离:

  • 已建立的 TCP 连接可能仍然存在;
  • 内核不会因为用户态进程暂停而自动撤销外部防火墙规则;
  • 容器外的宿主机进程、其他容器和网络设备仍可访问相关服务;
  • 某些内核级活动不应简单地假设会被容器 pause 完全阻止。

所以,pause 适合“先冻结状态”,不适合单独作为 containment 完成标志。

2. docker network disconnect:阻止通过 Docker 网络继续通信

先查看容器连接了哪些网络:

sudo docker inspect --format '{{json .NetworkSettings.Networks}}' CONTAINER_ID
sudo docker network ls

然后对每个非必要网络执行:

sudo docker network disconnect -f NETWORK_NAME CONTAINER_ID

-f 表示强制断开。断开后,可用以下命令验证:

sudo docker inspect --format '{{json .NetworkSettings.Networks}}' CONTAINER_ID

注意几个边界:

  • 容器使用 network_mode: host 时,不存在普通的容器网络连接可供这样断开;必须在宿主机防火墙、云安全组或上游网络设备层面阻断。
  • 容器使用 network_mode: none 时,本来就没有普通网络连接。
  • 容器可能通过 bind mount、Docker socket、共享 PID namespace 或宿主机上的其他进程间接影响系统;断网不能解决这些路径。
  • 断开网络会改变运行态,可能导致应用写入错误日志、连接重试或数据不一致,所以应先记录网络配置。

如果攻击者正在进行数据外传,网络隔离的优先级可能高于完整采集;这属于“先止损、后保全”的情况。此时必须记录隔离发生的准确时间和执行人。

3. docker stop:终止进程,风险最高但止损最直接

sudo docker stop --time 10 CONTAINER_ID

Docker 通常先向容器的 PID 1 发送 SIGTERM,等待指定时间,再发送 SIGKILL。实际行为还受到容器自身的 signal handling、STOPSIGNAL 和 PID 1 实现影响。

停止容器会丢失或改变:

  • 进程运行态;
  • 尚未落盘的内存数据;
  • 临时文件;
  • 当前连接状态;
  • 进程树和部分网络状态。

当容器正在访问宿主机、删除数据、读取敏感凭据或持续外传时,应优先停止或在更外层断网。反之,如果风险暂时可控且需要内存、进程和连接信息,应先采集或暂停。

停止后的验证:

sudo docker inspect --format \
  '{{.Id}} status={{.State.Status}} exit={{.State.ExitCode}} finished={{.State.FinishedAt}}' \
  CONTAINER_ID

Exited 只能说明容器主进程已退出,不代表事件结束。挂载的 volume、宿主机上的恶意进程、已复制出去的凭据和攻击者创建的其他对象仍然存在。


四、保全容器证据:配置、运行态、文件系统和日志

1. 保存完整容器元数据

sudo docker inspect CONTAINER_ID > "$IR_ROOT/container-inspect.json"
sudo docker top CONTAINER_ID > "$IR_ROOT/container-top.txt" 2>&1 || true
sudo docker port CONTAINER_ID > "$IR_ROOT/container-ports.txt" 2>&1 || true
sudo docker diff CONTAINER_ID > "$IR_ROOT/container-diff.txt" 2>&1 || true

这些输出分别回答不同问题:

  • docker inspect:容器 ID、创建时间、状态、入口命令、环境变量、挂载、Capability、网络和使用的镜像 ID;
  • docker top:容器当前可见的进程;
  • docker port:端口映射;
  • docker diff:相对于镜像层,容器可写层中新增、修改和删除了哪些路径。

docker diff 的状态通常包括:

  • A:新增;
  • C:修改;
  • D:删除。

它不是完整的文件取证工具。它不提供文件内容的时间线,也不一定揭示挂载 volume 中的变化,因为 volume 不属于容器可写层。

2. 保存容器日志,但不要把日志当作唯一证据

sudo docker logs --timestamps CONTAINER_ID > "$IR_ROOT/container-stdout-stderr.log" 2>&1

docker logs 主要读取容器日志驱动收集的标准输出和标准错误。它的可用性依赖日志驱动:

sudo docker inspect --format \
  '{{json .HostConfig.LogConfig}}' CONTAINER_ID

如果应用把日志写入容器内部文件,docker logs 看不到;如果使用 syslogjournald、云日志或第三方驱动,日志可能需要从对应后端采集。还要检查日志轮换和保留策略,因为历史记录可能已经被覆盖。

Docker events 可用于观察 daemon 记录的对象事件:

sudo timeout 60 docker events \
  --since '2025-01-01T00:00:00Z' \
  --until '2025-01-02T00:00:00Z' \
  > "$IR_ROOT/docker-events.txt"

docker events 不是持久化审计日志。事件可能因 daemon 重启、保存周期和日志策略而不可获得,且它不能证明是谁通过哪个身份执行了 API 操作。生产环境应结合:

  • systemd journal 或 daemon 日志;
  • Linux audit;
  • 云平台审计日志;
  • Docker socket 访问记录;
  • CI/CD 和镜像仓库审计记录。

3. 保存文件系统证据:区分可写层、volume 和 bind mount

先提取挂载信息:

sudo docker inspect --format '{{range .Mounts}}
type={{.Type}} source={{.Source}} destination={{.Destination}} rw={{.RW}}
{{end}}' CONTAINER_ID

常见类型的证据位置不同:

挂载类型 数据位置 取证注意事项
volume Docker 管理的 volume 数据目录 不包含在 docker export
bind 宿主机指定路径 可能直接影响宿主机或其他服务
tmpfs 内存 停止容器后通常消失
无挂载的容器可写层 overlay 存储驱动管理的目录 需要谨慎处理存储驱动内部结构

可以使用 docker cp 复制容器内路径:

sudo docker cp CONTAINER_ID:/etc "$IR_ROOT/container-etc"

但它适合快速获取少量文件,不等价于位级取证:

  • 复制过程中容器文件可能继续变化;
  • 容器路径可能是 volume 或 bind mount;
  • 文件元数据、稀疏文件、扩展属性和删除文件的保留情况不能简单假设;
  • 从可疑容器内执行 tar 会创建新的进程,且容器内工具可能被攻击者替换。

对于需要可靠证据的场景,应在暂停或停止后,对宿主机上的对应 volume 或文件系统进行只读快照、块级镜像或经过验证的归档。不要直接修改 Docker 的 overlay2 内部目录,也不要把“进入容器后打包”当成可信的原始证据。

4. docker exportdocker commit 的边界

sudo docker export CONTAINER_ID -o "$IR_ROOT/container-rootfs.tar"
sha256sum "$IR_ROOT/container-rootfs.tar" \
  | sudo tee "$IR_ROOT/container-rootfs.tar.sha256"

docker export 导出的是容器文件系统视图,但有两个关键限制:

  1. 不会导出挂载的 volume 内容
  2. 它不是镜像的原始层结构,也不保留完整镜像历史。

因此,导出的 tar 可以帮助分析容器当前看到的根文件系统,却不能单独证明镜像来源,也不能替代 volume 取证。

docker commit 可以把容器当前状态保存成新镜像,但不应把它当成修复或证据保全的主要方式:

sudo docker commit --pause=false CONTAINER_ID incident-snapshot:case-123

它存在几个问题:

  • 容器可写层可能包含攻击者修改;
  • 挂载的 volume 不会因此自动变成镜像内容;
  • --pause=false 会提高并发修改风险;默认暂停行为也会改变运行态;
  • 生成的镜像不是可信重建产物;
  • 容器中的环境变量、密钥和临时文件可能被一并保存。

如果确实需要保存一个分析快照,应同时保存原容器 inspect、原镜像摘要、创建时间、命令执行者和快照镜像 ID,并将快照推送到隔离的取证仓库,而不是重新用于生产。

5. 对证据计算哈希,但理解哈希的意义

sudo sha256sum \
  "$IR_ROOT/container-inspect.json" \
  "$IR_ROOT/container-diff.txt" \
  "$IR_ROOT/container-stdout-stderr.log" \
  "$IR_ROOT/container-rootfs.tar" \
  | sudo tee "$IR_ROOT/SHA256SUMS"

SHA-256 只能证明“之后拿到的文件与记录的字节相同”,不能证明文件内容本身真实,也不能证明采集时宿主机没有被攻击者修改。更完整的证据链还需要:

  • 采集时间和时区;
  • 采集命令;
  • 执行人和主机标识;
  • 原始文件权限;
  • 证据存储的访问控制;
  • 传输校验;
  • 必要时使用签名或 WORM/不可变存储。

五、容器运行态取证:进程、环境和连接

1. 优先使用 daemon 提供的只读信息

sudo docker inspect --format \
  'id={{.Id}}
name={{.Name}}
image={{.Image}}
created={{.Created}}
status={{.State.Status}}
pid={{.State.Pid}}
entrypoint={{json .Config.Entrypoint}}
cmd={{json .Config.Cmd}}' \
  CONTAINER_ID

这里的 .Image 是容器创建时关联的镜像 ID。它比容器配置中的 Image 字符串更接近实际使用对象,但仍应和镜像本地元数据、仓库摘要以及发布记录交叉验证。

环境变量可能包含明文凭据:

sudo docker inspect --format '{{json .Config.Env}}' CONTAINER_ID \
  > "$IR_ROOT/container-env.json"

该文件必须按秘密材料保护。环境变量泄露是事件响应中的二次风险:把它上传到普通工单、聊天系统或公开日志会扩大泄露范围。

2. docker exec 会改变证据

以下命令很常用:

sudo docker exec CONTAINER_ID ps aux
sudo docker exec CONTAINER_ID cat /proc/1/status

但它们会在容器内新建进程,可能触发恶意程序、改变进程树或被攻击者拦截。若容器已经暂停,docker exec 通常无法执行;这正是暂停保留运行态和执行交互式检查之间的冲突。

生产响应中应优先使用:

  • docker inspect
  • docker top
  • 宿主机侧 /proc 和 namespace 工具;
  • 宿主机审计日志;
  • 受信任的取证工具。

如果必须 exec,应记录完整命令、时间和原因,并先确认这不会触发更严重的损害。

3. 宿主机侧检查容器进程

docker inspect 中的 .State.Pid 是容器 init 进程在宿主机 PID namespace 中的 PID。示例:

PID="$(sudo docker inspect --format '{{.State.Pid}}' CONTAINER_ID)"
sudo readlink "/proc/$PID/ns/mnt"
sudo readlink "/proc/$PID/ns/net"
sudo tr '\0' '\n' < "/proc/$PID/environ" \
  > "$IR_ROOT/host-observed-container-environ.txt"
sudo cat "/proc/$PID/status" \
  > "$IR_ROOT/host-observed-container-status.txt"

这类信息只能在宿主机仍然可信时使用。若攻击者已经获得宿主机 root,/proc 内容和 Docker 元数据都可能被伪造或删除。

还应采集网络连接,但命令取决于宿主机工具:

sudo nsenter -t "$PID" -n ss -tunap \
  > "$IR_ROOT/container-network.txt" 2>&1 || true

nsenter 会进入目标进程的 namespace,ss 观察的是该网络 namespace 中的连接。它并不自动记录历史连接,因此只能说明采集时状态。历史外联需要依赖防火墙、流量镜像、代理、DNS 和云网络日志。


六、镜像追溯:从容器实例回到构建输入

1. 运行时实际使用的镜像

先查看容器关联的镜像 ID:

IMAGE_ID="$(sudo docker inspect --format '{{.Image}}' CONTAINER_ID)"
printf '%s\n' "$IMAGE_ID"
sudo docker image inspect "$IMAGE_ID" > "$IR_ROOT/image-inspect.json"
sudo docker image history --no-trunc "$IMAGE_ID" \
  > "$IR_ROOT/image-history.txt"

如果容器最初由标签创建,例如:

docker run example/app:latest

latest 只是创建时的解析入口。之后仓库可以把 latest 指向另一个 manifest,不能仅凭当前标签判断旧容器运行的是哪个版本。应记录:

  • 容器 .Image 的本地 ID;
  • 镜像的 RepoDigests
  • 镜像配置摘要;
  • 创建时间;
  • 入口命令;
  • 镜像标签;
  • 所有层的 digest;
  • 容器创建时的 Compose、部署系统或 CI 记录。

查询仓库摘要:

sudo docker image inspect "$IMAGE_ID" \
  --format '{{json .RepoTags}}
{{json .RepoDigests}}
{{.Id}}'

digest 是内容寻址标识。对于 OCI/Docker 镜像,manifest 或 index 的摘要通常形如 sha256:...。使用摘要可以把“想部署哪个对象”从可变标签转换为不可变内容引用:

services:
  app:
    image: registry.example.com/team/app@sha256:0123456789abcdef...

这解决的是引用漂移问题,但不自动证明镜像内容安全。一个被恶意构建出来的镜像同样可以拥有稳定 digest。

2. docker image history 能说明什么,不能说明什么

镜像历史可以展示构建层和部分命令:

sudo docker image history --no-trunc "$IMAGE_ID"

它可以帮助发现:

  • 异常的下载命令;
  • 不应存在的脚本;
  • 以 root 执行的安装步骤;
  • 可疑的基础镜像层。

但它不是完整的构建证明:

  • BuildKit 可能合并、重排或优化层;
  • shell 命令可能被脚本隐藏;
  • 多阶段构建会丢弃中间阶段;
  • 构建上下文、构建参数、秘密挂载和外部网络响应不一定完整出现在 history;
  • history 中出现的命令不等于命令输出没有被篡改。

因此,追溯必须继续回到镜像仓库、CI/CD、Git 提交、构建日志、依赖锁文件和发布审批记录。

3. 区分四种“镜像来源”

事件响应时经常把以下概念混为一谈:

  1. 基础镜像来源:例如某个发行版或运行时镜像;
  2. 构建上下文:Dockerfile、源代码、依赖文件和复制进去的资源;
  3. 构建器:BuildKit、构建节点、构建权限和网络环境;
  4. 发布引用:仓库、tag、digest、签名和部署配置。

完整追溯链可以表示为:

Git commit
   ↓
Dockerfile + build context + lock files
   ↓
BuildKit builder + build parameters
   ↓
image manifest digest
   ↓
registry repository/tag/signature/SBOM
   ↓
Compose or deployment manifest
   ↓
container ID + runtime configuration

其中任一环节缺少记录,都可能只能得到“高度可能”,而不能得到确定结论。

4. SBOM、签名和扫描结果如何参与响应

SBOM 是软件物料清单,描述镜像中包含哪些软件包及其版本。它适合回答“哪些组件可能受某个漏洞影响”,但不等于运行时完整文件清单,也不证明镜像没有恶意代码。

Docker 环境中的 SBOM 和扫描能力依赖具体 Docker 版本、Docker Scout、仓库或第三方工具。执行前应确认插件和后端是否已安装,例如:

docker sbom "$IMAGE_ID"

如果命令不可用,不能因此推断“镜像没有 SBOM”;只说明当前客户端没有可用的 SBOM 能力。也可以从镜像仓库或 CI 产物获取 SBOM,并记录其生成对象的 digest。

签名用于证明某个身份签署了某个内容摘要。响应时应验证:

  • 签名覆盖的是哪个 digest;
  • 签名主体和信任根是否符合组织策略;
  • 签名是在构建前、构建后还是发布后生成;
  • 签名验证使用的策略是否与生产门禁一致。

签名验证通过只说明“该身份签署了该内容”,不说明签名者本身未被攻破,也不说明构建输入可信。

扫描结果具有时间和数据库版本。相同镜像在不同漏洞数据库时间点可能得到不同结果。应保存扫描工具版本、数据库时间、镜像 digest 和原始报告,而不是只保存一个“通过/失败”结论。


七、隔离后的调查:网络、对象和时间线

1. 不是只查一个容器

攻击者可能创建辅助对象,因此应枚举与事件时间窗口相关的:

sudo docker ps -a --no-trunc
sudo docker network ls
sudo docker volume ls
sudo docker images --digests --no-trunc
sudo docker ps -aq | xargs -r sudo docker inspect \
  > "$IR_ROOT/all-containers-inspect.json"

还要检查:

  • 是否有异常重启策略,例如 alwaysunless-stopped
  • 是否有未知的 Docker secret、config 或 volume;
  • 是否有 Compose 项目中的替代容器;
  • 是否有定时任务、systemd unit、CI runner 或云编排系统在自动重建容器;
  • 是否有其他容器挂载了同一个 volume;
  • 是否有同一个镜像 digest 被部署到其他主机。

仅删除一个容器而不关闭自动编排,可能导致它立刻被重新创建,甚至让攻击者继续保留访问。

2. 建立时间线

至少应关联以下时间:

  • 镜像构建时间;
  • 镜像推送时间;
  • 容器创建时间;
  • 容器启动、停止和重启时间;
  • Docker daemon 事件时间;
  • 应用异常日志时间;
  • Git 提交和合并时间;
  • CI/CD 发布时间;
  • 凭据创建、使用和轮换时间;
  • 外部网络访问时间。

时间必须统一为 UTC,并保留原始时区信息。容器内的时间、宿主机时间、日志后端时间和云平台时间可能存在时钟偏差,不能机械地用字符串排序替代时间校准。

3. 用证据关联对象,而不是用名称关联对象

容器名称、镜像标签和 Compose 服务名都可能重复或变化。更可靠的关联键是:

  • 容器完整 ID;
  • 镜像 manifest digest;
  • image config digest;
  • volume 名称及宿主机数据标识;
  • 网络 ID;
  • Git commit;
  • CI 构建 ID;
  • 云资源 ID。

例如,两个都叫 web 的容器可能来自不同 Compose 项目;两个都叫 latest 的部署也可能使用不同 digest。


八、凭据和持久化数据:恢复前必须单独处理

1. 容器删除不会撤销凭据

如果凭据曾出现在以下位置:

  • 环境变量;
  • 镜像层;
  • 容器可写层;
  • 日志;
  • shell history;
  • 配置文件;
  • /var/run/docker.sock 可访问路径;
  • 云实例元数据或挂载的凭据目录;

那么删除容器只会删除一个副本,不会让凭据自动失效。恢复前应轮换可能暴露的:

  • 数据库密码;
  • API token;
  • 云访问密钥;
  • SSH 密钥;
  • TLS 私钥;
  • registry 凭据;
  • webhook secret;
  • 应用 session signing key。

轮换顺序取决于依赖关系。对数据库密码,通常要先准备新凭据并验证新应用可以使用,再撤销旧凭据,避免恢复过程因凭据同时失效而中断。

2. volume 是独立的持久化对象

容器生命周期和 volume 生命周期不同:

sudo docker rm CONTAINER_ID

通常不会自动删除命名 volume。反过来,删除 volume 可能直接造成数据丢失。恢复前应先列出挂载关系,判断数据是否被多个服务共享。

对数据库 volume,不能只复制正在写入的目录作为一致备份。应优先使用:

  • 数据库原生逻辑备份;
  • 数据库支持的物理备份;
  • 存储系统快照;
  • 停机后复制;
  • 文件系统一致性快照。

例如,直接在数据库仍运行时打包 data/ 目录,可能得到内部页、WAL、索引和元数据之间不一致的快照。这个归档也许能用于取证,但不应未经验证直接作为恢复源。

3. 先判断数据是否被篡改

恢复时要区分:

  • 可恢复性:备份是否完整、可读取;
  • 可信性:备份是否早于入侵、是否可能已经被攻击者修改;
  • 一致性:数据库和相关文件是否处于一致状态;
  • 合规性:恢复是否会重新引入敏感数据或违规配置。

一个“最近的备份”可能已经包含攻击者植入的账号、后门任务或被篡改的业务数据。因此,备份时间不能单独作为可信依据。


九、从可信输入重建,而不是把被入侵容器修补回来

1. 为什么不应把 docker commit 当作恢复方式

被入侵容器的当前状态包含未知修改。即使删除了恶意进程,容器可写层仍可能存在:

  • 被替换的二进制文件;
  • 修改过的启动脚本;
  • 恶意 cron 或应用配置;
  • SSH authorized keys;
  • 后门用户;
  • 下载的工具和凭据;
  • 伪造的日志。

把该状态 commit 成新镜像,等于把未知状态固化并重新发布。事件响应可以保存它作为分析快照,但生产恢复应重新构建。

2. 使用可信基础镜像和锁定依赖

一个可追溯的构建例子:

# 生产中应将 digest 替换为组织批准并验证过的摘要
FROM registry.example.com/base/python:3.12@sha256:0123456789abcdef...

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes -r requirements.txt

COPY src/ /app/src/

USER 65532:65532
ENTRYPOINT ["python", "-m", "src.main"]

这里有四个不同层面的控制:

  • 基础镜像用 digest 固定内容;
  • 依赖文件用 hash 固定包内容;
  • --no-cache-dir 减少不必要的缓存材料;
  • USER 降低应用进程权限。

USER 65532:65532 只有在文件权限、监听端口和运行目录都允许非 root 用户工作时才成立。不能因为 Dockerfile 写了 USER 就假设应用实际没有能力写入敏感路径;仍需检查挂载、Capabilities 和宿主机权限映射。

3. Compose 恢复配置

services:
  app:
    image: registry.example.com/team/app@sha256:0123456789abcdef...
    read_only: true
    user: "65532:65532"
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev
    environment:
      APP_ENV: production
    volumes:
      - app-data:/var/lib/app

volumes:
  app-data:
    name: app-data-recovered

这些配置的含义是:

  • read_only: true 使容器根文件系统只读,但不影响显式可写的 volume 和 tmpfs;
  • cap_drop: ALL 删除 Linux Capabilities,应用若需要特定能力,应逐项加回;
  • no-new-privileges:true 防止进程通过 setuid/setgid 或类似机制获得新的特权;
  • tmpfs 提供明确的临时写入位置;
  • 使用新 volume 名称可以避免无意中覆盖原始证据。

但 Compose 配置必须结合实际服务验证。只读根文件系统可能导致应用无法写 PID 文件、缓存、运行时 socket 或临时文件;此时应明确添加最小范围的 tmpfs 或 volume,而不是直接取消只读保护。

4. 恢复的状态转换

恢复不是“启动成功”这么简单,可以分为:

证据保全完成
  → 凭据已轮换
  → 镜像 digest 已验证
  → 配置经过审查
  → 备份已验证
  → 在隔离环境启动
  → 健康检查与安全控制验证
  → 恢复流量
  → 持续监控

在隔离环境启动时,应验证:

docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker inspect "$(docker compose ps -q app)" \
  --format 'status={{.State.Status}} user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}}'

docker compose config 用于展开并校验 Compose 配置;它不会证明镜像安全。docker compose pull 获取配置引用的镜像;如果引用使用 digest,应核对拉取后的 RepoDigests 或镜像 ID 与审批记录一致。

验证项目至少包括:

  • 容器是否以预期 UID 运行;
  • 根文件系统是否只读;
  • 是否意外启用了 privileged、host namespace 或 Docker socket;
  • Capability 是否为预期集合;
  • 对外端口是否最小;
  • 健康检查是否通过;
  • 应用是否只能访问必要的数据库和外部服务;
  • 恢复后的数据是否经过完整性和业务一致性检查;
  • 旧 token 是否已经失效。

十、常见失败路径和诊断方法

失败一:发现异常后直接 docker rm -f

表现是容器消失,后续只能看到“服务已重启”或“容器不存在”。这会丢失运行态、容器可写层关联和部分日志。

改进方式是先执行:

docker inspect
docker top
docker diff
docker logs --timestamps
docker inspect --format '{{json .Mounts}}'

只有在外传、破坏或宿主机风险迫切时,才优先强制停止,并记录为什么跳过常规保全。

失败二:只看 docker logs

如果攻击者没有使用标准输出,或者日志驱动已轮换,日志可能完全没有异常。必须同时检查容器配置、文件系统 diff、挂载、daemon 日志、宿主机审计和网络日志。

失败三:只看镜像 tag

app:latest 不能作为取证身份。应使用容器实际 image ID、manifest digest 和仓库审计记录。如果本地镜像已被清理,仍需从部署系统、镜像仓库和构建系统恢复 digest 关联。

失败四:导出容器后认为数据已经完整保存

docker export 不包含 volume。应分别保存:

  • 容器根文件系统;
  • 每个 named volume;
  • 每个 bind mount 指向的宿主机路径;
  • 相关数据库备份;
  • 配置和 secret 的引用关系。

失败五:重建后漏洞扫描通过,就认为事件结束

扫描只能覆盖已知漏洞和工具能够识别的内容,不能证明:

  • 凭据已经失效;
  • 数据没有被篡改;
  • Docker socket 没有被滥用;
  • 宿主机没有被突破;
  • CI/CD 和 registry 没有持续被控制;
  • 其他相同镜像实例没有受到影响。

恢复完成后,还要搜索同一镜像 digest、同一凭据、同一外联域名、同一攻击者 IP 和同一异常文件在其他节点上的出现情况。

失败六:在不可信容器内执行大量调查命令

exec 产生新进程,也可能执行被替换的工具。调查命令越多,越可能改变现场。需要高可信证据时,应优先采用宿主机侧和外部日志系统,并对采集工具、传输链路和存储位置进行控制。


十一、一个可操作的最小响应剧本

下面的剧本适合风险尚未达到必须立即断电或隔离宿主机的场景。将 CONTAINER_IDNETWORK_NAME 替换为已经确认的对象,不要直接复制到生产环境盲目执行。

set -u

export CASE="case-123"
export IR_ROOT="/var/ir/$CASE"
export CONTAINER_ID="replace-with-full-container-id"

sudo install -d -m 0700 "$IR_ROOT"

# 1. 建立初始快照
sudo docker version > "$IR_ROOT/docker-version.txt"
sudo docker inspect "$CONTAINER_ID" > "$IR_ROOT/container-before.json"
sudo docker top "$CONTAINER_ID" > "$IR_ROOT/top-before.txt" 2>&1 || true
sudo docker diff "$CONTAINER_ID" > "$IR_ROOT/diff-before.txt" 2>&1 || true
sudo docker logs --timestamps "$CONTAINER_ID" \
  > "$IR_ROOT/logs-before.txt" 2>&1 || true

# 2. 保存镜像关联
IMAGE_ID="$(sudo docker inspect --format '{{.Image}}' "$CONTAINER_ID")"
printf '%s\n' "$IMAGE_ID" | sudo tee "$IR_ROOT/image-id.txt"
sudo docker image inspect "$IMAGE_ID" > "$IR_ROOT/image.json"
sudo docker image history --no-trunc "$IMAGE_ID" \
  > "$IR_ROOT/image-history.txt" 2>&1 || true

# 3. 保存网络和挂载
sudo docker inspect --format '{{json .NetworkSettings.Networks}}' "$CONTAINER_ID" \
  > "$IR_ROOT/networks.json"
sudo docker inspect --format '{{json .Mounts}}' "$CONTAINER_ID" \
  > "$IR_ROOT/mounts.json"

# 4. 暂停,保留运行态
sudo docker pause "$CONTAINER_ID"

# 5. 重新读取状态,确认暂停成功
sudo docker inspect --format \
  'status={{.State.Status}} paused={{.State.Paused}} pid={{.State.Pid}}' \
  "$CONTAINER_ID" | sudo tee "$IR_ROOT/state-after-pause.txt"

接下来根据 networks.json 的结果断开非必要网络:

sudo docker network disconnect -f NETWORK_NAME "$CONTAINER_ID"

如果发现容器使用 host 网络、挂载 Docker socket、挂载宿主机根目录,或者存在宿主机级异常,应停止在容器层面操作,转入宿主机隔离和取证。若需要阻止继续执行,可执行:

sudo docker stop --time 10 "$CONTAINER_ID"

完成采集后,不要直接从原容器恢复生产。应:

  1. 轮换所有可能暴露的凭据;
  2. 从可信 Git 提交和批准的基础镜像重新构建;
  3. 固定镜像 digest;
  4. 在 Compose 或编排配置中重新应用非 root、Capabilities、Seccomp、只读根文件系统和网络限制;
  5. 使用经过验证的干净数据恢复;
  6. 在隔离环境验证;
  7. 最后恢复流量并持续监控。

十二、Linux 容器边界的最终判断

Docker 的容器 ID、镜像 digest、volume、网络 ID 和 daemon 事件共同组成了事件响应的对象模型:

container ID
  ├── image config / manifest digest
  ├── writable layer
  ├── mounts → volumes / host paths
  ├── network endpoints
  ├── process namespace and cgroups
  └── logs and daemon events

其中任何一个分支都可能成为攻击路径或证据来源。删除容器只处理了对象本身,不能自动处理镜像仓库、volume、宿主机文件、网络流量、凭据和其他实例。

最可靠的恢复条件不是“容器又能启动”,而是:

恢复可信度=可信构建输入+固定内容引用+已轮换凭据+已验证数据+已验证运行边界\text{恢复可信度} = \text{可信构建输入} + \text{固定内容引用} + \text{已轮换凭据} + \text{已验证数据} + \text{已验证运行边界}

这个表达式不是数学上的安全证明,而是恢复决策的必要条件集合:任何一项缺失,都可能让同一个事件通过镜像、数据、凭据或宿主机入口再次出现。对于 Linux 容器,最终还必须回答宿主机是否可信;如果答案是否定的,正确的恢复对象就不再是某个容器,而是经过重新安装、验证和密钥重建的宿主机或节点。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。