关键词: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
容器删除即消失
三条由此推出的推论:
-
mounts 列表就是取证路线图。 docker inspect 的 .Mounts(磁盘上即 config.v2.json 的 MountPoints)给出了每个挂载点的 Source(宿主机路径)与 Destination(容器内路径),照着 Source 走就是普通文件系统取证。
-
未挂载路径的证据强度弱。 diff/ 里看到的东西,攻击者可以"用完即删"而不留痕迹;卷里看到的东西,攻击者是主动要它活着的。
-
业务数据卷不等于无害。 攻击者写进业务数据卷的 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 ★ 删了业务页面
三条使用要点:
docker diff 不含挂载点内容。 挂载点的路径会被显示为一条 A/C 记录指向挂载点本身,其内容不参与比较。想看挂载点内容必须去宿主机的 Source 路径。
D(删除)的文件能取回,但不能用 docker cp。 关键点:overlay 用 whiteout 特性文件(.wh.<文件名>)在 upperdir 里"遮住"lower 的同名文件,被删文件本体从未从镜像层消失。所以 docker cp 会失败(容器里已经没有这个路径了),正确做法是手工遍历 link 里的 lowerdir 链定位原文件(见 3.4 的命令)。
- 它是"相对镜像"的差异,不是"时间序"。 输出不含时间戳,时间线要靠
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\