# Informe de campo — CVE-2026-111497

Resultados de ejercitar el conjunto de herramientas en tres hosts Linux de producción, y los
defectos que ese ejercicio puso al descubierto. Los hosts están anonimizados: no se publica ninguna
dirección IP, nombre de host ni nombre de cliente.

## 1. Resumen

| Host | SO | Función | Instalado (vulnerable) | Corregido | Resultado |
|---|---|---|---|---|---|
| A | Ubuntu 26.04 LTS | panel de alojamiento, 10 sitios | `libbrotli1 1.2.0-3build1` | `1.2.0-3ubuntu0.1` (`resolute-security`) | remediado y verificado |
| B | Debian 12 (bookworm) | servidor de aplicaciones, con sesión de escritorio presente | `libbrotli1 1.0.9-2+b6` | `1.0.9-2+deb12u1` (`bookworm-security`) | remediado y verificado |
| C | Ubuntu 24.04 LTS | servidor proxy y de aplicaciones, con cliente VPN presente | `libbrotli1 1.1.0-2build2` | `1.1.0-2ubuntu0.1` (`noble-security`) | `VERIFY: PASS`, ciclo completo |

Los tres son hosts híbridos: una carga de servidor más una sesión gráfica. Los tres tenían un
backport oficial en `-security`; la puerta de autenticidad (changelog del candidato que menciona el
CVE) pasó antes de cada instalación.

## 2. Qué encontró realmente la auditoría

### 2.1 La ruta de decodificación era libcurl, no el servidor web

En el host A el nivel web resultó ser de solo compresión: nginx usaba `ngx_brotli` para comprimir
respuestas, Apache tenía `mod_brotli` disponible pero deshabilitado, y ningún componente
descomprimía cuerpos de petición. El principal vector de RCE mediante `Content-Encoding: br` no era,
por tanto, alcanzable.

La ruta alcanzable era el HTTP saliente desde PHP:

- `wp-includes/Requests/src/Transport/Curl.php` fija `CURLOPT_ENCODING` en una cadena vacía, lo que
  hace que libcurl acepte todas las codificaciones con las que fue compilado, incluida `br`;
- `wp-includes/SimplePie/src/File.php` hace lo mismo para la obtención de fuentes;
- las factorías de Guzzle incluidas dentro de plugins hacen lo mismo para las API de proveedores.

El inventario contó **164 archivos PHP** con `CURLOPT_ENCODING` o `CURLOPT_ACCEPT_ENCODING`. libcurl
informaba de `brotli/1.2.0` entre sus características, así que cada `wp_remote_*`, cada lectura de
RSS y cada llamada a proveedores descomprimía `br` a través del decodificador vulnerable.

### 2.2 Candidatos que parecen correctos y no lo son

Antes de refrescar el bolsillo de seguridad, el único candidato de `libbrotli1` era el `1.2.0-3build1`
instalado: la misma versión upstream, recompilada. Tras `apt-get update` apareció un candidato
`1.2.0-3ubuntu0.1`. El servicio de changelogs de la distribución respondía 404 en ese momento,
porque el paquete se había publicado horas antes, así que el sufijo de versión por sí solo no era
prueba.

La prueba vino del propio paquete: descargar el `.deb` de seguridad, extraerlo y leer
`usr/share/doc/libbrotli1/changelog.Debian.gz` mostró la entrada que menciona CVE-2026-111497 y
describe el cambio del decodificador. Esa comprobación se convirtió en la puerta de autenticidad de
`remediate.sh`: una cadena de versión nunca decide por sí sola.

### 2.3 Consumidores que no deben reiniciarse

El mapeo de `libbrotlidec` no es exclusivo de los servidores. En los hosts híbridos la misma
biblioteca la mapea la pila de escritorio a través de freetype y fontconfig: GNOME Shell, Xorg,
NetworkManager, `fwupd`, `packagekitd`, los ayudantes `gsd-*` y el cliente VPN. Reiniciarlos
cortaría la sesión y el túnel, sin aportar nada a la superficie de ataque del servidor.

Por eso se clasifican como `deferred`: el informe los nombra, no se intenta ningún reinicio y la
biblioteca parcheada se carga en el siguiente inicio de sesión o reinicio.

### 2.4 Los demonios de panel son un mecanismo de reinicio aparte

En el host A, dos demonios `core` de ISPmanager conservaban el inodo antiguo tras cada reinicio con
`systemctl`. Los inicia el propio lanzador del panel mediante ámbitos transitorios de `systemd-run`,
así que no son hijos del servidor web y no tienen unidad propia. Matarlos por PID arriesga la
corrupción del estado del panel; la ruta soportada es la herramienta de control del panel:

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

Ambos demonios se relanzaron sobre la biblioteca parcheada y el escaneo de inodos obsoletos devolvió
cero.

### 2.5 Un fallo preexistente que no debe atribuirse al cambio

Inmediatamente después de la remediación, `systemctl --failed` en el host A mostraba
`logrotate.service`. La marca de tiempo indicaba que había fallado desde su temporizador diario por
una configuración de sitio sin relación (entradas de log duplicadas más un error de permisos). Se
registró y se dejó como estaba: mezclar una reparación ajena en un cambio de seguridad hace que el
cambio no sea verificable.

## 3. Defectos que puso al descubierto el campo y sus correcciones

Nueve defectos se encontraron ejecutando el agente, no leyéndolo. Cada fila es reproducible a partir
de la secuencia de la sección 4.

| # | Defecto | Síntoma en el campo | Corrección |
|---:|---|---|---|
| 1 | `set -o pipefail` con canalizaciones que terminan antes | código de salida 141 (SIGPIPE) aunque la auditoría había tenido éxito | se elimina `pipefail`; la corrección descansa en comprobaciones explícitas |
| 2 | `set -euo` con un `-o` colgando | el shell volcó su lista de opciones a stdout | `set -eu` |
| 3 | archivo de estado cargado como shell | error de sintaxis: el nombre legible del SO contiene paréntesis | leer los valores por clave con `state_get` |
| 4 | `state.after` que redefinía `installed` | `FAIL` falso en la comparación antes/después | variables `OLD_` y `AFTER_` separadas |
| 5 | `printf '%s'` sin salto de línea en el bucle de cgroup | los nombres de unidad se concatenaron en una unidad inexistente, así que el reinicio no hizo nada en silencio | `printf '%s\n'` |
| 6 | inodo capturado desde un glob | se registró el inodo del enlace simbólico, así que la comprobación de obsolescencia pasó en falso | `find -type f -printf '%i'` |
| 7 | bucle de PID sin `\|\| true` | `set -e` terminó con código 1 después de que el paquete ya estuviera instalado | bucle protegido con `\|\| true` |
| 8 | expresión regular de cgroup ciega a los slices anidados y a las instancias de plantilla | `revoap-worker@1` siguió sobre la biblioteca antigua | tomar el segmento `.service` más interno con `awk` |
| 9 | consumidores de escritorio e infraestructura marcados como vulnerables | ruido en el informe que ocultaba el hallazgo real | `DESKTOP_INFRA_PAT` más un estado `deferred` |

Dos de ellos merecen explicarse, porque ambos produjeron un *falso verde*:

- El defecto 5 hizo que la sección de reinicio se ejecutara, no imprimiera nada alarmante y dejara
  todos los servicios sobre la biblioteca vulnerable. La lista de unidades se había construido
  correctamente y luego se colapsó en una sola cadena, de modo que `systemctl restart` se llamó con
  un nombre de unidad inexistente y su fallo no fue fatal.
- El defecto 6 hizo que la comprobación «ningún proceso mapea el inodo antiguo» se comparara con el
  inodo del enlace simbólico, que por construcción ningún proceso mapea. La comprobación informó de
  éxito en un host que no se había reiniciado.

Ninguno era visible desde el código de salida. Ambos se encontraron contrastando la afirmación con
`/proc/*/maps` en el host después de la ejecución.

## 4. Secuencia tal como se ejecutó

```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. Evidencia de verificación

| Host | Paquete final | Mapeos obsoletos de servidor | Unidades fallidas | Comprobación funcional |
|---|---|---|---|---|
| A | `1.2.0-3ubuntu0.1` | 0 (el panel se relanzó sobre el inodo parcheado) | vacío | nginx devolvió `content-encoding: br`; `curl --compressed` produjo HTML válido; 10 de 10 sitios con HTTP 200 |
| B | `1.0.9-2+deb12u1` | 0 en los consumidores de servidor; escritorio diferido | vacío | round-trip de decodificación mediante servicio local |
| C | `1.1.0-2ubuntu0.1` | 0 en los consumidores de servidor; escritorio y VPN diferidos | vacío | el ciclo completo terminó con `VERIFY: PASS` |

En los hosts B y C el hook del paquete `needrestart` reinició por sí mismo parte de los servicios de
servidor durante la instalación; el informe muestra esos reinicios como pertenecientes al hook, y el
paso de reinicio explícito ya no tenía nada pendiente para esas unidades.

## 6. Lecciones que dieron forma a la herramienta

1. **Una cadena de versión no es evidencia.** La puerta lee el changelog empaquetado, porque una
   recompilación y una corrección real son idénticas en el campo de versión hasta que se examina la
   revisión.
2. **Un archivo parcheado no es un proceso parcheado.** Toda afirmación sobre el efecto se comprueba
   en `/proc/*/maps`, nunca se infiere de `dpkg`.
3. **Una omisión silenciosa es peor que un error.** Dos defectos produjeron «éxito» sin hacer nada;
   ahora la herramienta imprime lo que reinició y vuelve a comprobar el inodo después.
4. **Los hosts híbridos necesitan una lista de exclusión, no un reinicio masivo.** La superficie de
   servidor y la de escritorio deben separarse explícitamente, o la remediación tumba la sesión.
5. **El software de panel tiene su propio ciclo de vida.** Los demonios iniciados como ámbitos
   transitorios se reinician con la herramienta de control del panel, nunca con una señal.
