授权渗透测试里,最容易被写错的结论只有一句话:扫描到了带漏洞的组件,应用就“中招”了。面对 HTTP2Shell(CVE-2026-111497,Brotli 解压解码器内存破坏,CVSS v4.0 评分 9.2)这类影响面横跨 Node.js、Python、Bun、Envoy 以及各类 HTTP 中间件的漏洞,真正要回答的不是“有没有这个库”,而是“攻击者可控的数据有没有走到受影响的解码路径上”。本文给出一套在授权范围内可执行、可复核、可回滚的核验流程:先盘点资产与依赖,再建立可达性假设,用低影响请求做受控验证,固化证据,最后给出风险排序和补丁回滚清单。全程坚持一个边界:扫描命中只是线索,不是可利用性的证明。
测试目标、范围与前置控制
开工前把三件事写进测试计划并让授权方签字,否则后续所有技术动作都缺少合法性和可解释性。
目标:确认授权资产中是否存在受 CVE-2026-111497 影响的 Brotli 组件,并判断解码路径是否会被攻击者可控数据触达;不是复现完整命令执行,也不是“打穿”业务。
范围:应用源码与锁文件、容器镜像与 SBOM、运行中的进程与动态库、对外暴露的 HTTP 入口(含反向代理)。范围外资产一律不主动探测,包括同网段邻居和第三方 SaaS。
安全控制:
- 使用隔离的测试环境或生产只读镜像,不在生产实例上构造畸形 Brotli 载荷;
- 所有探测请求标记固定 UA 和自定义 Header,方便防守方在日志里识别;
- 提前约定停止条件:出现 5xx 比例异常、进程重启、CPU 抖动或业务告警,立即停止并通知值班;
- 准备好快照与回滚窗口,补丁和配置变更可以一键还原。
需要强调的一点:CVE-2026-111497 自 Brotli 1.0.3(2018 年 3 月)起就存在于代码中,修复提交在 2026 年 2 月进入上游代码库,之后才有正式发布和公告。这意味着“版本看起来很久没动”并不代表安全,恰恰相反,长期未升级的镜像很可能携带未修复的解码器。
为什么组件命中不等于可利用
Aqua 在公开分析中给过一个很有代表性的例子:受影响的 Brotli 库确实存在于某些产品镜像里,但其分析结论是该配置下不存在可利用的解码路径,同时仍发布了修复版本。这个案例说明两件事同时成立——组件层面必须处理,可利用性层面必须单独判断。
判断可达性要看数据流,而不是看依赖树。核心问题有三个:
- 谁会解压 Brotli:请求方向(服务端解压
Content-Encoding: br的请求体)和响应方向(客户端解压对端返回的 Brotli 响应)都算解码路径。 - 输入是否可控:攻击者能否直接构造压缩体,或者能否控制上游返回的压缩体(例如被接管的第三方接口)。
- 解码器版本与运行状态:磁盘上的包版本、内存中实际加载的库、以及进程是否已经重启加载新库,三者可能不一致。
搜索资料中有团队记录到,一个自称 node2shell-check/1.0 的公开扫描器会分两批发送约 20 个 Brotli 请求体,目的就是先识别“哪些主机真的会解压 Brotli”,随后才出现携带公开利用代码的流量。这个顺序值得防守方借鉴:先确认解码行为,再谈漏洞利用。

第一阶段:资产与依赖识别
这一步只做盘点,不做任何请求。产出应是一份可追溯的清单,每个条目至少包含:资产标识、Brotli 组件来源(系统包、语言包、静态链接)、版本号、证据文件路径。
语言层的清单可以直接从锁文件里筛。
# Node.js 项目:检查 brotli 相关间接依赖
grep -nE '"(brotli|brotli-wasm|iltorb)"' package-lock.json pnpm-lock.yaml 2>/dev/null
# Python 环境:列出已安装的 brotli / brotlicffi
python -m pip list --format=freeze | grep -iE '^(brotli|brotlicffi)=='
# 系统包(Debian/Ubuntu 系)
dpkg -l | grep -i brotli容器镜像要单独看,因为镜像里的副本不会被宿主机的包管理覆盖。
# 只读方式检查镜像内的 brotli 相关文件与版本
docker run --rm --entrypoint sh <image:tag> -c \
'find / -name "libbrotli*" -o -name "*brotli*.dist-info" 2>/dev/null | head -50'运行时状态是最容易被忽略的一环。磁盘上的包已经升级、内存中的旧库仍在被进程使用,是补丁后仍然“暴露”的常见原因。搜索资料中给出了一个直观的检查方式:
# 查找仍持有已删除 Brotli 库文件句柄的进程
sudo lsof 2>/dev/null | grep -E 'libbrotlidec.*\( *deleted *\)'输出的每一行都代表一个仍在执行旧代码的进程。另外还要看容器基础镜像的构建时间、CI 产物是否重新构建、以及有没有静态编译进二进制的情况——静态链接的副本不会出现在包管理器里,只能靠二进制特征或 SBOM 比对。
第二阶段:可达性假设与数据流梳理
盘点完成后,为每个应用写一条明确的可达性假设,格式建议为:
若攻击者能向
<入口>发送Content-Encoding: br的请求体,则该请求会在<组件>处被解压,解码器版本为<版本>,因此假设存在可达的解码路径。
然后逐条验证假设是否成立,重点看以下几类常见路径:
- 服务端请求解压:Express 5 使用的 body-parser 2.x 默认对所有 parser 启用
inflate,会自动解压 gzip、br 等压缩体;选择关闭时官方说明的效果是拒收压缩数据。 - 客户端响应解压:undici 实现的
fetch会在 HTTPS 请求中发送包含br的Accept-Encoding,并用node:zlib解压对端返回的响应。也就是说,即便自身不接收压缩请求,从被接管的第三方接口拉取数据同样构成解码路径。 - 反向代理与网关:Envoy 等组件的 Brotli 支持需要单独确认版本与启用状态。
- 间接调用:部分 CMS 自身不打包 Brotli,但某些功能会间接使用服务器上的 Brotli 库,这类路径要顺着调用链确认。
梳理结果应当明确区分三种状态:已确认可达、已确认不可达、尚无法判定。第三类不能省略,它本身就是需要进一步验证的工作项。
第三阶段:受控验证与停止条件
验证的目标是“证明解码发生”,而不是“证明能执行代码”。在授权范围内,用最小影响的方式观察行为差异即可。
一个低影响的判定思路是对比响应特征,前提是所有请求都指向测试环境或影子实例:
# 占位目标,仅用于隔离测试环境
TARGET="https://test.example.internal"
# 对照组:普通未压缩请求体
curl -sS -o /dev/null -w "%{http_code}\n" \
-X POST "$TARGET/api/echo" \
-H "Content-Type: application/json" \
-H "X-Security-Test: http2shell-reachability" \
--data '{"k":"v"}'
# 实验组:声明 Brotli 编码的请求体(载荷仅为无害占位,不含畸形构造)
curl -sS -o /dev/null -w "%{http_code}\n" \
-X POST "$TARGET/api/echo" \
-H "Content-Type: application/json" \
-H "Content-Encoding: br" \
-H "X-Security-Test: http2shell-reachability" \
--data-binary @sample.br判读方式:如果实验组返回与对照组一致的业务响应,说明该入口确实执行了解压;如果返回 415 或直接拒绝,说明该路径被前置拦截。不要在验证阶段构造触发内存破坏的畸形输入,那是另一个授权层级的工作,且极易造成拒绝服务。
停止条件必须写死:
- 单入口连续异常响应超过约定阈值即停;
- 目标进程出现重启或健康检查失败即停;
- 监控中出现未预期的 CPU 或内存尖峰即停;
- 任何非测试环境的流量误入即停。
验证结束后做一次清理确认:删除临时上传的 sample.br、检查测试账号与临时配置是否残留、确认日志中只保留本次标记的请求记录。搜索资料里提到的那些公开扫描流量本身也说明,网络上已经有人在批量识别“谁会解压 Brotli”,所以测试标记和日志留存对防守方同样有价值。
证据固化与报告字段
每条结论都要能被第三方独立复核。建议在报告中固定以下字段:
| 字段 | 内容要求 |
|---|---|
| 资产标识 | 主机名或镜像 digest,不使用模糊的环境描述 |
| 组件证据 | 版本号 + 证据来源(锁文件行号、包查询输出、镜像内文件路径) |
| 运行时证据 | 进程 PID、加载的库路径、是否处于已删除状态 |
| 可达性判定 | 已确认可达 / 已确认不可达 / 尚无法判定,附判定依据 |
| 请求证据 | 方法、路径、请求头、响应码与响应体摘要 |
| 影响判断 | 说明当前配置下的实际影响,不夸大也不淡化 |
| 复现条件 | 环境、版本、前置配置三项齐全 |
| 处置状态 | 待处理 / 已升级 / 已缓解 / 已复测 |
影响判断这一栏要克制。Aqua 的案例说明,同样的组件命中在不同配置下结论完全不同,报告里应当写清楚“在本环境、本配置下”的结论,而不是照搬通用严重级别。
风险排序与补丁路径
排序不看 CVSS 单项分数,而看三个维度叠加:可达性 × 输入可控性 × 运行状态。已确认可达且输入完全可控的,排在最前;组件存在但路径被前置拦截的,可以列入补丁计划但不占用应急窗口。
公开信息中给出的修复版本线索(使用时请以官方公告为准):
- Node.js:26.12.1、24.22.1、22.23.4
- Python:
brotli升至 1.2.1,brotlicffi升至 1.2.1.0 - 各类发行版和商业产品以厂商公告为准,Brotli 的修复是按打包软件分别发布的
补丁顺序建议为:先处理已确认可达的入口,再处理组件存在但暂不可达的资产,最后处理无法判定项。CISA 已将 CVE-2026-111497 列入 KEV 目录,JPCERT/CC 也发布了注意提醒,实际利用流量已被观测到,因此已确认可达的资产不具备“观察一段时间再说”的空间。
临时缓解的边界
在补丁窗口到来之前,缓解措施可以降低暴露面,但每一种都有明确局限,不能当作修复。
在应用层关闭自动解压:如将 body-parser 的 inflate 置为 false。官方说明的效果是压缩数据会被直接拒绝。局限是 gzip 等其他压缩也不再可用,且自行调用 zlib 解压的代码不受此保护。
在反向代理层拒绝 Brotli 请求体:
# 仅示例:拒绝 Content-Encoding 为 br 的请求体
if ($http_content_encoding ~* br) {
return 415;
}局限是内部通信、其他端口、以及响应方向的解压路径都覆盖不到。上线前务必在验证环境确认不会误伤正常用户的压缩请求。
移除未使用的 brotli 与 brotlicffi 包:可以直接消除对应解码能力。局限是如果有其他依赖引用它,移除会破坏功能,需要先确认。
为 fetch 显式指定 Accept-Encoding 并去掉 br:能减少主动索取 Brotli 响应的情况,但恶意对端可以不按约定返回,而客户端仍会尝试解压。这条缓解措施的实际效果有限。
复测与回滚清单
补丁和缓解措施上线后,按下面的顺序复测,任何一项失败都触发回滚。
- 版本核对:重新执行依赖查询,确认语言包、系统包、镜像内副本三层版本均已更新。
- 运行状态核对:确认旧进程已被替换,
lsof中不再出现已删除的 Brotli 库句柄;容器环境需确认新镜像已滚动生效。 - 可达性复核:重跑第三阶段的对比请求,确认返回特征符合预期(已修复环境下仍可能正常解压,取决于修复方式,需以厂商说明为准)。
- 业务回归:压缩传输相关功能、第三方接口拉取、网关转发逐一验证。
- 日志审计:检查补丁窗口前后是否出现异常请求模式。
回滚条件写清楚:业务核心功能不可用、错误率高于约定阈值、或运行状态核对连续两次失败。回滚依赖的是事前准备的快照和旧镜像 tag,所以备份必须在变更前完成并验证可恢复性,而不是变更后才想起。
最后回到文章开头那句话。HTTP2Shell 的教训不在于“库很多”,而在于依赖、运行状态和数据流三者经常对不上:镜像里有、宿主机没有;磁盘上升级了、进程还在跑旧库;应用自己不解压、但 fetch 帮它解了。授权测试的价值就是把这三层对齐,用可复核的证据说明哪些路径真的可达,哪些只是扫描噪音,然后据此排期修复、验证和回滚。
- it2021
- it2021.com
- nginx
- shell
- tag
- test
- 渗透测试
- 漏洞利用





1F
运行时还在用旧库这个坑太真实了
2F
lsof查已删除库句柄这招实用
3F
补丁后没重启进程等于白打
4F
关inflate会不会影响正常gzip业务
5F
fetch自动带br这事之前真没注意