# 现场报告 — CVE-2026-111497

在三台生产 Linux 主机上实际运行该工具集的结果，以及此次演练暴露出的缺陷。主机已匿名化：
不公布任何 IP 地址、主机名或客户名称。

## 1. 概述

| 主机 | 操作系统 | 角色 | 已安装（存有漏洞） | 修复版本 | 结果 |
|---|---|---|---|---|---|
| A | Ubuntu 26.04 LTS | 托管面板，10 个站点 | `libbrotli1 1.2.0-3build1` | `1.2.0-3ubuntu0.1`（`resolute-security`） | 已修复，已验证 |
| B | Debian 12（bookworm） | 应用服务器，存在桌面会话 | `libbrotli1 1.0.9-2+b6` | `1.0.9-2+deb12u1`（`bookworm-security`） | 已修复，已验证 |
| C | Ubuntu 24.04 LTS | 代理与应用服务器，存在 VPN 客户端 | `libbrotli1 1.1.0-2build2` | `1.1.0-2ubuntu0.1`（`noble-security`） | `VERIFY: PASS`，完整周期 |

三台都是混合主机：既有服务器工作负载，也有图形会话。三台在 `-security` 中都有官方回溯移植；
每次安装之前，真实性闸门（候选版本变更日志提及该 CVE）均已通过。

## 2. 审计实际发现了什么

### 2.1 解码路径是 libcurl，而不是 Web 服务器

在主机 A 上，Web 层实际上只做压缩：nginx 用 `ngx_brotli` 做响应压缩，Apache 虽提供
`mod_brotli` 但已禁用，没有任何组件解码请求体。因此经 `Content-Encoding: br` 的主要 RCE
向量并不可达。

可达的路径是来自 PHP 的出站 HTTP：

- `wp-includes/Requests/src/Transport/Curl.php` 把 `CURLOPT_ENCODING` 设为空字符串，
  使 libcurl 接受其编译时支持的所有编码 —— 包括 `br`；
- `wp-includes/SimplePie/src/File.php` 在抓取 feed 时同样如此；
- 插件内附带的 Guzzle 工厂在调用第三方 API 时同样如此。

清单统计到 **164 个 PHP 文件**含 `CURLOPT_ENCODING` 或 `CURLOPT_ACCEPT_ENCODING`。
libcurl 在其特性中报告了 `brotli/1.2.0`，因此每次 `wp_remote_*`、RSS 抓取与第三方调用都会
经存有漏洞的解码器解压 `br`。

### 2.2 看起来正确、实则不然的候选版本

在刷新安全源之前，唯一的 `libbrotli1` 候选版本就是已安装的 `1.2.0-3build1` —— 同一上游
版本，重新构建而已。执行 `apt-get update` 之后出现了候选版本 `1.2.0-3ubuntu0.1`。当时
发行版的变更日志端点返回 404，因为该软件包是数小时前才发布的，所以仅凭版本后缀不足以作为
证据。

证据来自软件包本身：下载安全源的 `.deb`，解开它并读取
`usr/share/doc/libbrotli1/changelog.Debian.gz`，可以看到点名 CVE-2026-111497 并描述解码器
改动的条目。这项检查后来成为 `remediate.sh` 的真实性闸门 —— 版本字符串从不单独作出决定。

### 2.3 不得重启的消费者

`libbrotlidec` 的映射并不专属于服务器。在这些混合主机上，桌面栈通过 freetype 与 fontconfig
映射同一个库：GNOME Shell、Xorg、NetworkManager、`fwupd`、`packagekitd`、`gsd-*`
辅助进程，以及 VPN 客户端。重启它们会中断会话与隧道，而对服务器攻击面毫无贡献。

因此它们被归类为 `deferred`：报告点名它们，不尝试重启，已打补丁的库在下次登录或重启时加载。

### 2.4 面板守护进程使用另一套重启机制

在主机 A 上，两个 ISPmanager `core` 守护进程在每次 `systemctl` 重启之后仍持有旧 inode。
它们由面板自身的启动器通过 `systemd-run` 瞬时 scope 启动，因此不是 Web 服务器的子进程，
也没有自己的单元。按 PID 杀掉它们有损坏面板状态的风险；受支持的路径是面板控制工具：

```bash
mgrctl -m core --restart srvparam
mgrctl -m ispmgr --restart srvparam
```

两个守护进程都在已打补丁的库上重新拉起，随后陈旧 inode 扫描返回零。

### 2.5 不得归因于本次变更的既有故障

修复之后立即检查时，主机 A 上的 `systemctl --failed` 列出 `logrotate.service`。时间戳显示
它是由每日定时器在一个无关的站点配置上失败的（重复的日志条目加上一个权限错误）。该故障被
记录在案并保持不动：把无关的修理混进一次安全变更，会使该变更无法验证。

## 3. 现场暴露的缺陷及其修复

九个缺陷是通过运行该代理发现的，而不是通过阅读它。表中每一行都可以从第 4 节的执行顺序复现。

| # | 缺陷 | 现场症状 | 修复 |
|---:|---|---|---|
| 1 | `set -o pipefail` 配合会提前退出的管道 | 审计其实成功了，退出码却是 141（SIGPIPE） | 移除 `pipefail`；正确性改由显式检查保证 |
| 2 | `set -euo` 带一个悬空的 `-o` | shell 把自己的选项列表倾泻到 stdout | 改为 `set -eu` |
| 3 | 状态文件被当作 shell 脚本 source | 语法错误：操作系统易读名称中含有括号 | 用 `state_get` 按键读取取值 |
| 4 | `state.after` 重新定义了 `installed` | 变更前后比对中出现虚假的 `FAIL` | 拆分为 `OLD_` 与 `AFTER_` 变量 |
| 5 | 在 cgroup 循环中用 `printf '%s'` 而不带换行 | 单元名被拼成一个不存在的单元，重启静默地什么都没做 | 用 `printf '%s\n'` |
| 6 | 从通配符结果中捕获 inode | 记录到的是符号链接的 inode，陈旧检查虚假通过 | 用 `find -type f -printf '%i'` |
| 7 | PID 循环没有 `\|\| true` | 软件包其实已安装完成，`set -e` 却以退出码 1 结束 | 循环以 `\|\| true` 加以保护 |
| 8 | cgroup 正则对嵌套 slice 与模板实例失明 | `revoap-worker@1` 仍留在旧库上 | 用 `awk` 取最内层的 `.service` 段 |
| 9 | 桌面与基础设施消费者被标记为存有漏洞 | 报告噪音掩盖了真正的发现 | 用 `DESKTOP_INFRA_PAT` 并增加 `deferred` 状态 |

其中两个值得展开说明，因为二者都产生了*虚假的通过*：

- 缺陷 5 意味着重启环节确实执行了、没有打印任何可疑内容，却让每个服务都留在存有漏洞的库上。
  单元列表本已正确构建，随后被拼成单个字符串，于是 `systemctl restart` 是带着一个并不存在的
  单元名被调用的，而它的失败并不致命。
- 缺陷 6 意味着「没有进程映射旧 inode」的检查是拿符号链接的 inode 作比对，而按构造没有任何
  进程会映射它。该检查在一台根本没有被重启的主机上报告了成功。

两者都无法从退出码看出来。它们都是在运行结束后，用主机上的 `/proc/*/maps` 交叉核对结论时
发现的。

## 4. 实际执行的顺序

```text
host A  detect -> install -> restart nginx, php-fpm pools, pdns, zabbix-agent, ihttpd
        -> mgrctl --restart core/ispmgr -> stale scan 0 -> 10/10 sites HTTP 200 -> decode round-trip
host B  detect -> remediate (defects 1..5 surfaced) -> restart apache2, php-fpm x2, zabbix x2,
        fastpanel apps, revoap-worker instances -> stale scan: server consumers 0
host C  detect -> remediate (defects 6..9 surfaced) -> verify -> VERIFY: PASS
```

## 5. 验证证据

| 主机 | 修复后的软件包 | 服务器侧陈旧映射 | 失败的单元 | 功能检查 |
|---|---|---|---|---|
| A | `1.2.0-3ubuntu0.1` | 0（面板已在打过补丁的 inode 上重新拉起） | 空 | nginx 返回 `content-encoding: br`；`curl --compressed` 得到有效 HTML；10 个站点中 10 个返回 HTTP 200 |
| B | `1.0.9-2+deb12u1` | 服务器侧消费者为 0；桌面已延迟 | 空 | 通过本地端点完成解码往返 |
| C | `1.1.0-2ubuntu0.1` | 服务器侧消费者为 0；桌面与 VPN 已延迟 | 空 | 完整周期以 `VERIFY: PASS` 结束 |

在主机 B 与主机 C 上，`needrestart` 软件包钩子在安装期间自行重启了部分服务器服务；报告将
这些重启记为钩子所为，因此显式的重启步骤对相应单元已无剩余工作。

## 6. 塑造该工具的经验

1. **版本字符串不是证据。** 闸门读取打包的变更日志，因为在检查修订号之前，一次重建与一次
   真正的修复在版本字段上看起来完全相同。
2. **打过补丁的文件不等于打过补丁的进程。** 每一项关于效果的主张都在 `/proc/*/maps` 中核实，
   绝不从 `dpkg` 推断。
3. **静默的空操作比报错更糟。** 有两个缺陷在什么都没做的情况下「成功」；该工具现在会打印它
   重启了什么，并在之后重新检查 inode。
4. **混合主机需要拒绝名单，而不是一律重启。** 服务器面与桌面面必须显式分开，否则修复会把
   会话弄断。
5. **面板软件有自己的生命周期。** 以瞬时 scope 启动的守护进程要通过面板的控制工具重启，
   绝不使用信号。
