Experiencias · Lessons Learned
El hallazgo falso que no lo era
Este informe está anonimizado. Describe un incidente real sin nombres de host, direcciones ni datos de clientes. El foco no es la vulnerabilidad, sino la cadena de prueba: ¿qué se comprueba cuando hay que decidir en horas si un sistema ha sido comprometido — y con qué facilidad se lee mal el resultado?
Punto de partida
En un solo día el proyecto PHP publicó dos vulnerabilidades críticas explotables sin autenticación (CVE-2026-109618 y CVE-2026-109619, CVSS 9,2 cada una, denominadas en conjunto «PHP2Shell»). Afectan a instalaciones PHP que atienden peticiones web. El host en cuestión ejecutaba un proxy inverso, una instancia de Nextcloud sobre PHP-FPM y varias webs estáticas. La pregunta del operador era sencilla:
¿Estamos afectados? ¿Está activo el parche? ¿Y muestran los logs que alguien haya explotado la falla?
Tres preguntas, tres problemas de prueba muy distintos. Ahí estaba la dificultad.
Paso 1 — probar la afectación en lugar de suponerla
El primer reflejo («usamos PHP, luego estamos afectados») es demasiado grueso. Lo decisivo es la SAPI: solo están expuestas las instalaciones que procesan peticiones web. La prueba fue igual de concreta:
- Inventario del entorno PHP: únicamente
fpmycliinstalados — sinmod_php, sinphp-cgi, sin Apache. - Puntos expuestos: exactamente una aplicación PHP detrás del proxy (Nextcloud) y un puerto de administración solo en LAN; junto a ello un sitio Adminer huérfano cuyo socket PHP ya no existía desde semanas atrás.
- Sin «PHP en otro lugar»: los sitios estáticos sin bloque FastCGI no son superficie de ataque PHP.
Paso 2 — prueba del parche: paquete y tiempo de ejecución
Una actualización de seguridad instalada aún no es un parche activo. Entre «el paquete está en disco» y «el proceso en ejecución lo usa» hay un reinicio del maestro FPM. Por eso se comprobó dos veces:
| Comprobación | Resultado | Qué prueba |
|---|---|---|
| Versión del paquete | 8.5.4-0ubuntu1.5 (fix de Ubuntu, USN-8948-1/-2) | El fix está instalado |
| Momento de instalación | 2026-10-22 06:12 (actualizaciones automáticas) | Cuándo llegó el fix |
| Reinicio de FPM | 2026-10-22 06:13, maestro activo desde entonces | El fix se ejecuta |
| Integridad de archivos | verificación de paquetes sin desviación | Sin binarios manipulados |
Estas cuatro líneas son la prueba real del parche. Todo lo demás es interpretación de logs.
Paso 3 — cuatro capas de log, cuatro preguntas distintas
Un solo log no puede probar un incidente. Cada capa responde a una pregunta distinta — y tiene su propio punto ciego:
| Capa | Responde | Punto ciego |
|---|---|---|
| Log de acceso del proxy | ¿Qué peticiones llegaron, con qué estado y tamaño de cuerpo? | Sin contenido; la rotación limita la retrospectiva |
| Log de la aplicación (Nextcloud) | ¿Qué hizo la aplicación internamente — errores, avisos, inicios de sesión? | Con nivel info solo eventos, sin registros de acceso |
| Log de FPM | Caídas de workers, agotamiento de memoria, errores fatales | Solo nivel PHP, sin contexto HTTP |
| Journald / kernel | OOM kills, segfaults, core dumps | Sin semántica de aplicación |
Además: Fail2ban (fuerza bruta SSH) y el gestor de paquetes (momento del parche). Solo la combinación sostiene una afirmación — y solo si se nombran los puntos ciegos.
El hallazgo falso — «la aplicación no escribe log»
La búsqueda del log de la aplicación recorrió los directorios habituales (/srv, /var, /home, profundidad limitada) y no encontró nada. La conclusión era obvia: no hay log. Era falsa.
Se habían pasado por alto dos cosas:
- El directorio de datos estaba fuera del espacio de búsqueda. La instancia no guardaba sus datos en el directorio de programa, sino en un volumen propio. La búsqueda fue minuciosa — solo que no allí.
- El logger tenía un fallback. Si el archivo de log configurado no existía y su directorio no era escribible, la aplicación escribía en un archivo sustituto dentro del directorio de datos. Es un comportamiento correcto — pero oculta una mala configuración.
El defecto real estaba una capa más abajo: el trabajo de rotación lee la ruta del log desde la configuración. Como esa ruta no existía, comparaba el tamaño de un archivo inexistente — y nunca rotaba. Resultado: un único archivo de 438 MB en lugar de rotación horaria.
logfile = dir-programa/data"] --> L{"¿Archivo existe?"} L -->|no| F["El logger usa el fallback:
dir-datos/nextcloud.log"] L -->|sí| W["El logger escribe el archivo configurado"] C --> R["El trabajo de rotación lee
la misma ruta configurada"] R --> N{"¿Archivo ahí?"} N -->|no| X["Sin rotación
(el archivo crece sin límite)"] F --> X
La lección es incómoda porque afecta al propio trabajo: «no encontrado» no es un hallazgo mientras el espacio de búsqueda no esté documentado. Un hallazgo negativo exige el mismo cuidado que uno positivo — incluida la indicación de dónde no se buscó.
El segundo hallazgo falso — el cortafuegos
Un puerto de administración era «accesible solo en la LAN». La comprobación no halló ninguna autorización específica para ese puerto y anotó: no hay autorización, por tanto cerrado desde fuera. También era demasiado corto.
En realidad existía una regla general para toda la subred LAN que permitía cualquier puerto. El puerto estaba abierto en la LAN — la protección venía de la topología de red, no de la regla de puerto. El resultado («no accesible desde internet») era correcto, la justificación no. Y una justificación errónea es el comienzo de la siguiente decisión equivocada.
Regla: un cortafuegos se lee en conjunto — política por defecto, autorizaciones generales, orden. Mirar solo «¿falta una autorización de puerto?» no basta.
Lo que los logs mostraron realmente
Tras corregir la ruta del log, la evaluación fue posible — y el cuadro resultó anodino:
- 18.244 peticiones HTTP en diez días; la aplicación PHP no respondió ni una sola con 5xx.
- Cuerpo de petición mayor: 25 KB. Ningún cuerpo ≥ 1 MB — la ruta típica de agotamiento de memoria no apareció en el tráfico observado.
- Log de la aplicación: 14.518 entradas, 0 errores fatales, un único error (app store inaccesible), sin inicios de sesión fallidos, sin instalaciones de apps.
- Búsqueda de indicadores en 789.528 líneas (funciones típicas de webshell, rutas
/tmp/): cero coincidencias en cada una. Las coincidencias con nombres conocidos de shell resultaron ser secuencias aleatorias dentro de identificadores de petición. - FPM y kernel: sin errores fatales, sin OOM kills, sin segfaults.
- Raíz web: ningún archivo PHP nuevo desde el inicio del periodo, ningún cambio inesperado.
El único hallazgo «caliente» fue un escáner que envió cuatro peticiones de aproximadamente 1 MB al sitio Adminer huérfano — exactamente el patrón que produce un intento de explotación. Las cuatro terminaron en 502 porque el socket PHP no existía. El ataque corrió contra un backend muerto. Eso también es un resultado: el hallazgo más peligroso demostró la eficacia de un endurecimiento (involuntario).
El límite de la prueba — y por qué hay que asegurar la evidencia
Dos reglas de retención limitaban la afirmación:
- Logs del proxy: rotación de 5 × 10 MB — unos diez días de retrospectiva. El periodo quedó cubierto por completo, nada más.
- Log de la aplicación: la rotación conserva exactamente un archivo anterior. En la siguiente ejecución el periodo evaluado habría sido sobrescrito.
Por eso el periodo se aseguró antes de la siguiente rotación como archivo propio: comprimido, propietario root, permisos 640 (el archivo contiene nombres de usuario y direcciones IP), con suma de verificación documentada. Una prueba que no se puede reproducir vale poco en una auditoría — una suma de verificación convierte un archivo en una afirmación verificable.
(un archivo rotado)"] --> B["Extracto del periodo
2026-10-15 … 2026-10-25"] B --> C["Archivo gzip
root:root 640"] C --> D["SHA-256 en el informe"] D --> E["Reproducción:
gunzip + wc -l"]
Lo que funcionó bien
- Prueba del parche por partida doble (versión del paquete y tiempo de ejecución) — la afirmación decisiva no dependía de los logs.
- Pruebas negativas en lugar de suposiciones: ningún archivo PHP nuevo, ningún 5xx, ningún OOM kill, ningún error fatal.
- Ataque contra backend muerto reconocido — el hallazgo 502 evitó una valoración dramatizada.
- Decisión razonada, no afirmada: se examinó una obligación de notificación y se descartó con motivos, no se ignoró en silencio.
Lo que haremos distinto la próxima vez
- Documentar el espacio de búsqueda antes de buscar. Escribir «no encontrado» solo cuando se nombren rutas y profundidades.
- Añadir una contraprueba a cada afirmación de configuración. Ruta del log: no solo leerla, sino verificar que el archivo existe, crece y rota.
- Probar la rotación activamente (superar el tamaño artificialmente, demostrar la rotación en el log) — no suponer que funciona.
- Leer el cortafuegos en conjunto (política por defecto, reglas generales de subred, orden).
- Desmontar de inmediato los sitios huérfanos. Un sitio PHP muerto no es un riesgo, pero es una fuente permanente de ruido en el log y un punto ciego en la valoración.
- Asegurar la evidencia antes de la rotación — con propietario, permisos y suma de verificación.
- Corregir los hallazgos falsos, no suavizarlos. El informe contiene la corrección con fecha y causa. Un hallazgo falso reparado en silencio destruye más confianza que el propio error.
Transferibilidad
El incidente en sí fue poco espectacular: parcheado, no explotado, sin obligación de notificación. Lo valioso fue el aparato de prueba: afectación mediante inventario, parche mediante paquete y tiempo de ejecución, explotación mediante varias capas de log, valoración con límites de prueba explícitos, evidencia con suma de verificación — y un informe con historial de cambios que también contiene la propia equivocación. Esta estructura se puede versionar en Git y verificarse años después.
Fuentes
- Proyecto PHP: avisos de seguridad sobre
CVE-2026-109618yCVE-2026-109619. - CERT-EU, Security Advisory 2026-018 (mitigaciones y estado de explotación).
- Ubuntu Security Notices USN-8948-1/-2 (fix del paquete para PHP 8.5).
- Declaración del fabricante del dispositivo perimetral: allí no se usa PHP.