Volume 与绑定挂载取证

Docker 取证工件地图 反复强调了一件事:容器内的改动默认不落盘,容器一删就没。

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

关键词:named volume、bind mount、Mounts 字段、docker diff、C/A/D 变更、未 prune 的卷、docker cp 难度:进阶 前置知识:Docker 卷与挂载概念、ext4 目录项与时间戳、常规文件系统取证流程

一、概述

Docker 取证工件地图 反复强调了一件事:容器内的改动默认不落盘,容器一删就没。

这条规则有一个例外。而这个例外恰恰是容器取证里最重要的发现来源——挂载。

容器可以把宿主机的目录"插"进自己文件系统的任意位置,写进去的东西实际存放在宿主机上,与容器本身无关。

于是形成一条清晰的分界:容器内的改动会随容器消失,跨过挂载边界的改动会长期留存。

这直接决定了取证的重点该往哪放。

攻击者很清楚容器易失这个性质,所以真正有价值的载荷——挖矿程序、WebShell、导出到本地的数据、植入的凭据——几乎都会落在挂载点上,而不是留在容器里。

只查 overlay2/<id>/diff/,会完整漏掉攻击者真正想保留的部分。这不是"漏了一部分",而是漏掉了取证价值最高的那部分。

还有一个被大量人漏掉的事实:docker rm 只删容器,不删卷。

攻击者用完即弃的容器(docker run --rm 或跑完就 rm)留下的数据,只要没执行过 docker volume prune,就一直躺在 /var/lib/docker/volumes/ 下。

这就是本篇标题里"金矿"的由来——用完即弃恰恰留下了东西。

在取证流程中的位置:本篇属于采集与连接的中间层。

它不做内容解析,做的是把容器的挂载关系翻译成宿主机路径,让分析工作可以转入常规文件系统流程。

上游依赖 Docker 取证工件地图 的路径全景与容器元数据固定;下游是三条并行的取证工作:bind mount 走标准文件系统流程(见 ext4 结构与 inode)、named volume 作为被删容器的残留数据源、以及** docker diff 与删除文件的取回**。

卷里的数据最终要交给业务侧判读——数据库 binlog 证明批量导出、WebShell 检测,都不在本篇范围内。

两类挂载的取证差异是本篇的主结构:

named volume(命名卷) bind mount(绑定挂载)
创建方式 docker volume create / -v name:/path -v /host/path:/path 或 --mount type=bind
宿主落地位置 /var/lib/docker/volumes/<name>/_data 就是那个宿主机路径本身
管理归属 Docker 自己的目录,Docker 会"管"它 宿主机自己的目录,Docker 只是挂上去
谁在用 通常是数据库、上传目录等应用数据 通常是配置、日志、代码目录
取证要点 删了容器数据还在;可能被多个容器共享 直接进常规文件系统流程;可能被业务方改动过,需区分

涉及的证据形态:

形态 具体位置 说明
挂载关系 containers/<id>/config.v2.json 的 MountPoints docker inspect 的 .Mounts 就是它,照着 Source 走
挂载五字段 Type / Source / Destination / Mode / RW tmpfs = 数据只在内存,宿主磁盘上没有;Mode 含 ro 即该路径未被写入
卷数据 /var/lib/docker/volumes/<name>/_data/ 持久化边界,被删容器的残留常在此
卷元数据 /var/lib/docker/volumes/<name>/ 下的配置文件 含挂载点、创建时间、标签
bind mount 目标 宿主机上的真实路径 走标准文件系统取证流程
差异记录 docker diff 的 C/A/D 三类 在线 CLI 的等价物在磁盘上是 diff 目录的结构痕迹

看到 Mode 含 ro 则是重要的否定性证据——那条路径确定没被写入过。

能回答什么:哪些数据会长期留存、容器的哪些路径写进去的东西在宿主机哪个位置、某个挂载点是可写还是只读、被删容器的数据留在哪里。

不能回答什么:卷里的文件是谁写的(可能来自正常业务,也可能来自攻击者,需要内容与时间判读);容器运行时是否访问过某文件(卷本身不记录访问行为);tmpfs 挂载里的内容(停止即失,只能在线取或从内存侧补)。

**卷里有 WebShell 不代表业务被攻陷,**也可能是正常运维上传的工具——两者需要靠内容与时间区分,见 4.3 内容判读与归因。

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

检测到 WebShell 痕迹时,写的是如何识别与如何排除误报(见 网站目录与 WebShell 检测),不涉及载荷构造。

读者前提:需要 Docker 取证工件地图 的容器元数据知识、镜像层与 overlay2 解析 的层与写层概念,以及标准的 Linux 文件系统取证流程。

建议先按 3.1 第 1 步 拿到挂载关系再动手——挂载关系没拿到就进目录遍历,等于在几百 GB 里盲找。

二、核心原理

2.1 容器文件系统的分层:哪里会持久,哪里不会

理解挂载的关键,是先把容器内一个路径的"归属"想清楚:

容器内看到的:
/app/data/orders.db  ← 容器内 /app 是镜像层,/app/data 是挂载点
     │                      ↑ 写这里不会进容器层
     │
     └──→ 实际落在宿主机的某个真实目录
           named volume : /var/lib/docker/volumes/appdata/_data/orders.db
           bind mount   : /srv/digiforensics/data/orders.db

容器内看到的:
/usr/local/bin/ksync  ← 纯容器层路径,★不挂载
     │
     └──→ 实际落在 /var/lib/docker/overlay2/<id>/diff/usr/local/bin/ksync
           容器删除即消失

三条由此推出的推论:

  1. mounts 列表就是取证路线图。 docker inspect 的 .Mounts(磁盘上即 config.v2.json 的 MountPoints)给出了每个挂载点的 Source(宿主机路径)与 Destination(容器内路径),照着 Source 走就是普通文件系统取证。

  2. 未挂载路径的证据强度弱。 diff/ 里看到的东西,攻击者可以"用完即删"而不留痕迹;卷里看到的东西,攻击者是主动要它活着的。

  3. 业务数据卷不等于无害。 攻击者写进业务数据卷的 WebShell 会被业务进程加载执行,其危害等同于站点目录被植入(见《网站目录与 WebShell 检测》)。

2.2 Mounts 字段逐项解读

docker inspect 的 .Mounts 数组每一项代表一个挂载。这五个字段必须逐个看:

字段 含义 取证判据
Type volume(命名卷)/ bind(绑定挂载)/ tmpfs(内存文件系统)/ npipe 等 出现** tmpfs** = 数据只在内存,宿主磁盘上没有,容器停止即失(当证据处理时要单列)
Source 宿主机上的实际路径 ★★★★★ 取证的核心。看到 /、/etc、/var/run/docker.sock 等即高危
Destination 容器内的挂载点路径 与镜像的 Config.Volumes 对比可发现运行时多挂的目录
Mode 挂载选项字符串,含 ro/rw、z(SELinux)、Z、nocopy 等 ** ro(只读)= 该路径未被写入**,是重要的否定性证据;rw = 可写,攻击者可能写过
RW 布尔值,是否可写 与 Mode 里的 ro 对应,false 即只读

Mounts 与磁盘上的字段对应关系,离线读 config.v2.json 时注意结构略有不同:

// docker inspect 的 Mounts(在线)
"Mounts": [
  {"Type":"volume","Name":"appdata","Source":"/var/lib/docker/volumes/appdata/_data",
   "Destination":"/data","Driver":"local","Mode":"","RW":true,"Propagation":""},
  {"Type":"bind","Source":"/srv/digiforensics/config","Destination":"/etc/app",
   "Mode":"ro","RW":false,"Propagation":"rprivate"},
  {"Type":"tmpfs","Source":"","Destination":"/run","Mode":"","RW":true}
]
// config.v2.json 的 MountPoints(离线,key 是容器内路径)
"MountPoints": {
  "/data": {"Source":"/var/lib/docker/volumes/appdata/_data","Destination":"/data","RW":true,"Type":"volume"},
  "/etc/app": {"Source":"/srv/digiforensics/config","Destination":"/etc/app","RW":false,"Type":"bind"},
  "/run": {"Source":"","Destination":"/run","RW":true,"Type":"tmpfs"}
}

⚠️ HostConfig.Binds 与 Mounts 都要看:前者是用户请求的原始字符串(["/:/host", "/srv/data:/data:ro"]),后者是 Docker 解析后的结果。原始串能看出用户写的挂载模式与选项,解析结果更完整。两者不一致时以 Mounts 为准,但 Binds 里可能有解析后丢失的信息。

2.3 named volume 的落盘位置与生命周期

/var/lib/docker/volumes/
├── appdata/
│   ├── _data/          ★★★ 实际数据在这里,与普通目录无差别
│   └── metadata.db     ★★  卷的元数据(名称、创建时间、driver、labels、挂载点)
├── pgdata/
│   └── _data/
└── <name>/
位置 内容 取证价值
volumes/<name>/_data/ 真实数据,普通 ext4 目录 ★★★★★
volumes/<name>/metadata.db 卷的 Name/CreatedAt/Labels/Mountpoint ★★★★ 卷的创建时间是重要时间锚点
volumes/metadata.db 全局卷元数据(可能在新版 Docker 中存在) ★★★

生命周期矩阵(决定证据是否还在):

操作 卷内数据 结论
docker rm <container> 保留 ★ 删容器不删卷,数据仍在
docker rm --volumes <container> 删除 匿名卷随容器删除而消失
docker volume rm <name> 删除 显式删卷,数据不可恢复
docker volume prune 删除未使用的卷 ★ 最高危的误删操作,会连带删掉"已不用但有证据"的卷
docker run --rm 匿名卷随 --rm 删除 需及时固定
宿主机重启 保留 卷是磁盘数据

取证推论:docker volume ls 列出的是当前仍存在的卷。"已删除但未 prune 的卷"这个说法本身不成立——volume rm 是直接删目录,不进回收站。所以准确表述是:"docker rm 删了容器但卷还在,卷数据仍可取证",而不是"已删除的卷还能恢复"。真正会丢数据的是 volume rm 与 volume prune,报告里要区分清楚。

2.4 docker diff 的用法与 C/A/D 语义

docker diff <container> 把容器当前文件系统与它所基于的镜像逐项比较,输出三类变更:

标记 含义 实际含义 取证价值
A Added 容器内新增的文件或目录 ★★★★★ 最可疑:容器启动后新增的东西必然是运行期行为
C Changed 内容被修改 ★★★★★ 配置文件被改(如 .bashrc、crontab、应用配置)
D Deleted 被删除 ★★★★** D 项有独立价值**:被删文件的原始内容仍在 lowerdir(镜像层)里,可取回

docker diff digiforensics-app

A /opt/app/.cache/miner ★ 挖矿程序落地

A /tmp/.X11-unix

C /etc/crontab

C /root/.bashrc

D /usr/share/nginx/html/index.html ★ 删了业务页面

三条使用要点:

  1. docker diff 不含挂载点内容。 挂载点的路径会被显示为一条 A/C 记录指向挂载点本身,其内容不参与比较。想看挂载点内容必须去宿主机的 Source 路径。
  2. D(删除)的文件能取回,但不能用 docker cp。 关键点:overlay 用 whiteout 特性文件(.wh.<文件名>)在 upperdir 里"遮住"lower 的同名文件,被删文件本体从未从镜像层消失。所以 docker cp 会失败(容器里已经没有这个路径了),正确做法是手工遍历 link 里的 lowerdir 链定位原文件(见 3.4 的命令)。
  3. 它是"相对镜像"的差异,不是"时间序"。 输出不含时间戳,时间线要靠 State.StartedAt、文件 mtime、日志三处交叉。

⚠️ 不产生输出的三种情况:容器已被 rm;容器与镜像的差异为空(干净容器);log driver 或存储驱动为特殊实现时行为可能不同。"没有 diff 输出"不等于"没有活动"——攻击者完全可以在 volume 里动手,那部分 docker diff 根本看不到。

2.5 容器内 /tmp 为什么不落盘

这是本篇最容易被误解的一点。

容器内任何路径的写入,默认都落在 overlay2/<id>/diff/(upperdir)里,包括 /tmp。

但有两个例外让"默认"不成立:

情况 是否落宿主磁盘 说明
容器内 /tmp 写入 ✅ 落在 overlay2/<id>/diff/tmp 落盘,但随容器删除而消失
容器内 /tmp 若是 tmpfs 挂载 ❌ 只在内存 需看 Mounts 里有没有 Type: tmpfs 指向 /tmp
docker run 显式 -v /host/tmp:/tmp ✅ 落在宿主机指定目录 此时容器内 /tmp 反而是持久的

准确表述:"容器内 /tmp 里的数据默认不落盘"这个说法不准确。应写成:"容器内 /tmp 的数据落在一个随容器删除即消失的写层里,容器仍在时可以从 overlay2/<id>/diff/tmp 读到;只有当 /tmp 被显式挂载为 tmpfs 或挂到宿主目录时,数据才有不同的生命周期。"

这个区分在报告里很关键:在 diff/tmp 里找到的挖矿程序,是"容器仍在运行"的证据;若容器已删,则该目录已不存在,必须去 docker diff 记录、卷、或内存里另找。

卷里的业务数据往往就是库文件。拿到数据文件之后的引擎识别、时区归一、完整性校验与报告表述,是与容器无关的通用流程,见数据库取证通用方法。


三、操作步骤

3.1 第 1 步:先拿挂载关系(在线与离线两条路)

在线:逐个容器的 Mounts,一行一个挂载点

for c in $(docker ps -aq); do echo "== 容器 $c" docker inspect "$c" | jq -r '.[0].Mounts[]? | "(.Type)\ (.Source)\ (.Destination)\ Mode=(.Mode)\ RW=(.RW)"' docker inspect "$c" | jq -r '.[0].HostConfig.Binds[]? // empty' # 原始挂载串 done

离线:从 config.v2.json 的 MountPoints 取同一批信息

python3 -c " import json,glob for f in sorted(glob.glob('/mnt/df/var/lib/docker/containers/*/config.v2.json')): d=json.load(open(f)) print('==', d.get('Name'), d.get('Created')) for dest,m in (d.get('MountPoints') or {}).items(): print(f" {m.get('Type')}\ {m.get('Source')}\ {dest}\ RW={m.get('RW')}") "

输出按 Source 分组,得到三张清单(这三张清单就是取证路线图):

清单 来源 接下来做什么
卷清单 Type=volume 的 Source(在 /var/lib/docker/volumes/ 下) 3.3
宿主目录清单 Type=bind 的 Source 3.2
内存挂载清单 Type=tmpfs 记为"数据不可得",写进报告

3.2 第 2 步:bind mount —— 走常规文件系统取证流程

绑定挂载指向的是宿主机自己的业务目录,分析手法与《Linux 服务器取证工件地图》完全一致:

1) 目录总览与时间线

ls -la /mnt/df/srv/digiforensics/config/ stat -c '%n mtime=%y ctime=%z mode=%a owner=%U:%G' /mnt/df/srv/digiforensics/config/ find /mnt/df/srv/digiforensics/config -type f
-printf '%T@ %TY-%Tm-%Td %TH:%TM %m %u:%g %s %p
' | sort -n | tail -30

2) 异常筛查(非业务文件、可执行落地、隐藏目录)

find /mnt/df/srv/digiforensics/config -name '.*' -type f | head -20 find /mnt/df/srv/digiforensics/config -type f -perm /111 -printf '%m %TY-%Tm-%Td %p
' | head -20 find /mnt/df/srv/digiforensics/config -type f -newermt '2024-03-17 00:00' | head -40

3) 与容器内看到的文件名交叉验证(容器还在时)

docker exec 不做;改用 docker cp 取出(见 3.5),比对哈希

绑定挂载的额外注意点:

注意点 说明
业务方可能自己改过 bind mount 指向的是业务目录,改配置可能是正常运维行为,不能凭"文件变了"定性
写入者不一定来自容器 同一目录宿主机进程也能写。取 mtime 并与容器 StartedAt 对齐才可关联
Mode=ro 是重要否定证据 只读挂载意味着该路径在容器运行期间不可能被容器写入,可写进报告作为排除依据
部分挂载可能遮蔽了底层镜像内容 容器内看到的 /etc/app 是宿主目录,镜像层里原本的 /etc/app 被遮住了(可在 lowerdir 里读到被遮蔽的原内容)

3.3 第 3 步:named volume —— 被删容器留下的"金矿"

1) 卷全量清单(含未被任何容器挂载的 dangling 卷)

ls -1 /mnt/df/var/lib/docker/volumes/ docker volume ls docker volume ls -f dangling=true # ★ 未被任何容器使用的卷,最可能是"用完即弃"的 docker volume inspect appdata pgdata # 取 Mountpoint、CreatedAt、Labels

2) ★ 卷元数据(创建时间是关键锚点)

for v in /mnt/df/var/lib/docker/volumes/*/; do echo "== $v"; ls -l "$v"; cat "$v/metadata.db" 2>/dev/null done

3) 逐个卷扫时间线(★ 从"最近改动"倒查最快)

for v in /mnt/df/var/lib/docker/volumes/*/_data; do echo "===== $v" find "$v" -type f -printf '%TY-%Tm-%Td %TH:%TM %m %u:%g %s %p
' 2>/dev/null | sort | tail -15 done

4) 高危文件筛查(跨所有卷)

find /mnt/df/var/lib/docker/volumes//_data -type f
( -name 'xmrig' -o -name '
.sh' -o -name '.jsp' -o -name '.php'
-o -name 'authorized_keys' -o -name '*.py' -o -name 'id_rsa' )
-printf '%TY-%Tm-%Td %TH:%TM %m %s %p
' 2>/dev/null | sort | tail -40

5) 可执行文件(挖矿/后门落地)

find /mnt/df/var/lib/docker/volumes/*/_data -type f -perm /111
-printf '%TY-%Tm-%Td %TH:%TM %m %s %p\