# Field report — CVE-2026-111497

Results of exercising the toolset on three production Linux hosts, and the defects that the
exercise exposed. Hosts are anonymised: no IP address, hostname or customer name is published.

## 1. Summary

| Host | OS | Role | Installed (vulnerable) | Fixed | Result |
|---|---|---|---|---|---|
| A | Ubuntu 26.04 LTS | hosting panel, 10 sites | `libbrotli1 1.2.0-3build1` | `1.2.0-3ubuntu0.1` (`resolute-security`) | remediated, verified |
| B | Debian 12 (bookworm) | application server, desktop session present | `libbrotli1 1.0.9-2+b6` | `1.0.9-2+deb12u1` (`bookworm-security`) | remediated, verified |
| C | Ubuntu 24.04 LTS | proxy and app server, VPN client present | `libbrotli1 1.1.0-2build2` | `1.1.0-2ubuntu0.1` (`noble-security`) | `VERIFY: PASS`, full cycle |

All three are hybrid hosts: a server workload plus a graphical session. All three had an official
backport in `-security`; the authenticity gate (candidate changelog naming the CVE) passed before
each installation.

## 2. What the audit actually found

### 2.1 The decode path was libcurl, not the web server

On host A the web tier turned out to be compression-only: nginx used `ngx_brotli` for response
compression, Apache had `mod_brotli` available but disabled, and no component decoded request
bodies. The main RCE vector through `Content-Encoding: br` was therefore not reachable.

The reachable path was outbound HTTP from PHP:

- `wp-includes/Requests/src/Transport/Curl.php` sets `CURLOPT_ENCODING` to an empty string, which
  makes libcurl accept every encoding it was built with — including `br`;
- `wp-includes/SimplePie/src/File.php` does the same for feed fetching;
- Guzzle factories shipped inside plugins do the same for vendor APIs.

The inventory counted **164 PHP files** with `CURLOPT_ENCODING` or `CURLOPT_ACCEPT_ENCODING`.
libcurl reported `brotli/1.2.0` among its features, so every `wp_remote_*`, RSS fetch and vendor
call decompressed `br` through the vulnerable decoder.

### 2.2 Candidates that look right and are not

Before the security pocket was refreshed, the only `libbrotli1` candidate was the installed
`1.2.0-3build1` — the same upstream release, rebuilt. After `apt-get update` a candidate
`1.2.0-3ubuntu0.1` appeared. The distribution changelog endpoint answered 404 at that moment,
because the package had been published hours earlier, so the version suffix alone was not proof.

The proof came from the package itself: downloading the security `.deb`, extracting it and reading
`usr/share/doc/libbrotli1/changelog.Debian.gz` showed the entry naming CVE-2026-111497 and
describing the decoder change. That check became the authenticity gate of `remediate.sh` — a
version string never decides on its own.

### 2.3 Consumers that must not be restarted

The `libbrotlidec` mapping is not exclusive to servers. On the hybrid hosts the same library is
mapped through freetype and fontconfig by the desktop stack: GNOME Shell, Xorg, NetworkManager,
`fwupd`, `packagekitd`, `gsd-*` helpers, and the VPN client. Restarting them would drop the
session and the tunnel, while contributing nothing to the server attack surface.

They are therefore classified as `deferred`: the report names them, no restart is attempted, and
the patched library is loaded at the next login or reboot.

### 2.4 Panel daemons are a separate restart mechanism

On host A, two ISPmanager `core` daemons kept the old inode after every `systemctl` restart. They
are started by the panel's own launcher through `systemd-run` transient scopes, so they are not
children of the web server and have no unit of their own. Killing them by PID risks panel state
corruption; the supported path is the panel control tool:

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

Both daemons respawned on the patched library, and the stale-inode scan then returned zero.

### 2.5 A pre-existing failure that must not be attributed to the change

Immediately after remediation, `systemctl --failed` on host A listed `logrotate.service`. The
timestamp showed it had failed from its daily timer on an unrelated site configuration (duplicate
log entries plus a permission error). It was recorded and left alone: mixing an unrelated repair
into a security change makes the change unverifiable.

## 3. Defects the field exposed, and their fixes

Nine defects were found by running the agent, not by reading it. Each row is reproducible from the
sequence in section 4.

| # | Defect | Symptom in the field | Fix |
|---:|---|---|---|
| 1 | `set -o pipefail` with pipelines that exit early | exit 141 (SIGPIPE) although the audit had succeeded | `pipefail` removed; correctness rests on explicit checks |
| 2 | `set -euo` with a dangling `-o` | the shell dumped its option list to stdout | `set -eu` |
| 3 | state file sourced as shell | syntax error: the OS pretty-name contains parentheses | read values by key with `state_get` |
| 4 | `state.after` redefining `installed` | false `FAIL` in the before/after comparison | separate `OLD_` and `AFTER_` variables |
| 5 | `printf '%s'` without a newline in the cgroup loop | unit names concatenated into one bogus unit, so the restart silently did nothing | `printf '%s\n'` |
| 6 | inode captured from a glob | the symlink inode was recorded, so the stale check passed falsely | `find -type f -printf '%i'` |
| 7 | PID loop without `\|\| true` | `set -e` exited 1 after the package was already installed | loop guarded with `\|\| true` |
| 8 | cgroup regex blind to nested slices and template instances | `revoap-worker@1` stayed on the old library | take the innermost `.service` segment with `awk` |
| 9 | desktop and infrastructure consumers marked vulnerable | report noise that hid the real finding | `DESKTOP_INFRA_PAT` plus a `deferred` status |

Two of these are worth spelling out, because both produced a *false green*:

- Defect 5 meant the restart section ran, printed nothing alarming, and left every service on the
  vulnerable library. The unit list had been built correctly and then collapsed into a single
  string, so `systemctl restart` was called with one nonexistent unit name and its failure was not
  fatal.
- Defect 6 meant the "no process maps the old inode" check compared against the symlink inode,
  which by construction no process maps. The check reported success on a host that had not been
  restarted.

Neither was visible from the exit code. Both were found by cross-checking the claim against
`/proc/*/maps` on the host after the run.

## 4. Sequence as executed

```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. Verification evidence

| Host | Package after | Stale server mappings | Failed units | Functional check |
|---|---|---|---|---|
| A | `1.2.0-3ubuntu0.1` | 0 (panel respawned on the patched inode) | empty | nginx returned `content-encoding: br`; `curl --compressed` produced valid HTML; 10 of 10 sites HTTP 200 |
| B | `1.0.9-2+deb12u1` | 0 for server consumers; desktop deferred | empty | decode round-trip via local endpoint |
| C | `1.1.0-2ubuntu0.1` | 0 for server consumers; desktop and VPN deferred | empty | full cycle ended with `VERIFY: PASS` |

On host B and host C the `needrestart` package hook restarted part of the server services by
itself during installation; the report shows those restarts as belonging to the hook, and the
explicit restart step then had nothing left to do for those units.

## 6. Lessons that shaped the tool

1. **A version string is not evidence.** The gate reads the packaged changelog, because a rebuild
   and a real fix look identical in the version field until the revision is examined.
2. **A patched file is not a patched process.** Every claim about the effect is checked in
   `/proc/*/maps`, never inferred from `dpkg`.
3. **A silent no-op is worse than an error.** Two defects produced "success" while doing nothing;
   the tool now prints what it restarted and re-checks the inode afterwards.
4. **Hybrid hosts need a denylist, not a blanket restart.** The server surface and the desktop
   surface must be separated explicitly, or the remediation takes the session down.
5. **Panel software has its own lifecycle.** Daemons started as transient scopes are restarted
   through the panel's control tool, never with a signal.
