# Полевой отчёт — 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`, полный цикл |

Все три хоста гибридные: серверная нагрузка плюс графический сеанс. У всех трёх официальный
backport присутствовал в `-security`; проверка подлинности (changelog кандидата называет CVE)
проходила до каждой установки.

## 2. Что аудит нашёл на самом деле

### 2.1 Путём декодирования был libcurl, а не веб-сервер

На хосте A веб-уровень оказался предназначенным только для сжатия: nginx использовал
`ngx_brotli` для сжатия ответов, в Apache `mod_brotli` был доступен, но отключён, и ни один
компонент не декодировал тела запросов. Основной вектор RCE через `Content-Encoding: br` тем
самым был недостижим.

Достижимым путём был исходящий HTTP из PHP:

- `wp-includes/Requests/src/Transport/Curl.php` устанавливает `CURLOPT_ENCODING` в пустую
  строку, из-за чего libcurl принимает все кодировки, с которыми был собран, — включая `br`;
- `wp-includes/SimplePie/src/File.php` делает то же самое при загрузке лент;
- фабрики Guzzle, поставляемые внутри плагинов, делают то же самое для API сторонних
  поставщиков.

Инвентаризация насчитала **164 PHP-файла** с `CURLOPT_ENCODING` или `CURLOPT_ACCEPT_ENCODING`.
libcurl сообщал `brotli/1.2.0` среди своих возможностей, поэтому каждый `wp_remote_*`, загрузка
RSS и вызов стороннего API распаковывали `br` через уязвимый декодер.

### 2.2 Кандидаты, которые выглядят правильными и таковыми не являются

До обновления security-кармана единственным кандидатом `libbrotli1` была установленная
`1.2.0-3build1` — тот же выпуск upstream, пересобранный. После `apt-get update` появился
кандидат `1.2.0-3ubuntu0.1`. Конечная точка с changelog дистрибутива в тот момент отвечала 404,
поскольку пакет был опубликован несколькими часами ранее, поэтому один лишь суффикс версии
доказательством не был.

Доказательство дал сам пакет: загрузка security-`.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` сохраняли старый inode после каждого перезапуска через
`systemctl`. Их запускает собственный загрузчик панели через временные scope `systemd-run`,
поэтому они не являются дочерними процессами веб-сервера и не имеют собственного юнита. Убийство
их по PID грозит повреждением состояния панели; поддерживаемый путь — инструмент управления
панелью:

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

Оба демона перезапустились на пропатченной библиотеке, и последующее сканирование устаревших
inode вернуло ноль.

### 2.5 Существовавший ранее сбой, который нельзя приписывать изменению

Сразу после устранения `systemctl --failed` на хосте A показал `logrotate.service`. Отметка
времени говорила о том, что служба упала по своему ежедневному таймеру из-за посторонней
конфигурации сайта (дублирующиеся записи журнала плюс ошибка прав). Сбой был зафиксирован
и оставлен без вмешательства: смешивание постороннего ремонта с изменением безопасности делает
изменение непроверяемым.

## 3. Дефекты, выявленные в поле, и их исправления

Девять дефектов найдены запуском агента, а не чтением его кода. Каждую строку можно воспроизвести
по последовательности из раздела 4.

| # | Дефект | Проявление в поле | Исправление |
|---:|---|---|---|
| 1 | `set -o pipefail` при конвейерах, завершающихся досрочно | код 141 (SIGPIPE), хотя аудит завершился успешно | `pipefail` удалён; корректность опирается на явные проверки |
| 2 | `set -euo` с висячим `-o` | оболочка вывела список своих опций в stdout | `set -eu` |
| 3 | файл состояния, подключённый как shell | синтаксическая ошибка: в pretty-name ОС есть скобки | чтение значений по ключу через `state_get` |
| 4 | `state.after` переопределял `installed` | ложный `FAIL` при сравнении «до/после» | отдельные переменные `OLD_` и `AFTER_` |
| 5 | `printf '%s'` без перевода строки в цикле по cgroup | имена юнитов склеились в один несуществующий юнит, поэтому перезапуск молча ничего не сделал | `printf '%s\n'` |
| 6 | inode, снятый по glob | записывался inode символьной ссылки, поэтому проверка устаревания ложно проходила | `find -type f -printf '%i'` |
| 7 | цикл по PID без `\|\| true` | `set -e` завершил работу с кодом 1, когда пакет уже был установлен | цикл защищён `\|\| true` |
| 8 | regex по cgroup не видит вложенные slice и шаблонные экземпляры юнитов | экземпляр `revoap-worker@1` остался на старой библиотеке | выбор самого внутреннего сегмента `.service` через `awk` |
| 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. **Строка версии не является доказательством.** Проверка читает changelog внутри пакета,
   потому что пересборка и настоящее исправление выглядят одинаково в поле версии, пока не
   рассмотрена ревизия.
2. **Пропатченный файл — не пропатченный процесс.** Каждое утверждение об эффекте проверяется
   в `/proc/*/maps` и никогда не выводится из `dpkg`.
3. **Молчаливый холостой ход хуже ошибки.** Два дефекта выдавали «успех», ничего не делая;
   теперь инструмент печатает, что именно он перезапустил, и после этого повторно проверяет inode.
4. **Гибридным хостам нужен список исключений, а не повальный перезапуск.** Серверную
   и настольную поверхности нужно разделять явно, иначе устранение уронит сеанс.
5. **У панельного программного обеспечения свой жизненный цикл.** Демоны, запущенные как
   временные scope, перезапускаются инструментом управления панели и никогда — сигналом.
