PHP2SHELL: RCE sin autenticación en PHP a través de session.upload_progress
El 21 de octubre de 2026 el equipo de seguridad de PHP publicó un aviso de seguridad que no deja lugar a medias: dos vulnerabilidades críticas con CVSS v4 9.2 que permiten ejecución remota de código sin autenticación, en prácticamente cualquier instalación de PHP que sirva peticiones web.
Lo que hace especial a PHP2SHELL no es solo su impacto, sino lo que su mecanismo revela sobre la arquitectura de PHP: un estado global que sobrevive a la petición que lo creó y que la siguiente petición consume antes de que el ciclo de vida normal del request pueda limpiarlo.
En este post analizamos el fallo, lo reproducimos de forma diferencial en un laboratorio aislado con Docker y AddressSanitizer, y —lo más importante— separamos lo que está verificado de la narrativa que circuló antes del parche.
TL;DR: actualiza a 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12. No hay mitigación completa fuera del parche. Y ojo: el parche no cierra por sí solo la inyección en el archivo de sesión.
Las dos vulnerabilidades
| CVE-2026-109618 | CVE-2026-109619 | |
|---|---|---|
| Nombre oficial | RCE vía corrupción por OOM | RCE vía estado global obsoleto |
| Advisory | GHSA-w8hw-w9fx-j79w | GHSA-6wmf-4rv5-p3rp |
| CVSS v4 | 9.2 | 9.2 |
| Versiones afectadas | < 8.2.35, < 8.3.36, < 8.4.27, < 8.5.12 |
igual |
| Riesgo máximo | mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed | igual |
En términos simples:
- CVE-2026-109618: el manejo de out of memory puede interrumpir PHP mientras los datos internos de la petición están solo parcialmente inicializados. La limpieza posterior puede tratar datos inválidos como si fueran válidos.
- CVE-2026-109619: el estado interno propiedad de una petición puede sobrevivir después de que su memoria de soporte haya sido liberada. Una petición posterior, atendida por el mismo worker, puede usar ese estado obsoleto antes de que la inicialización normal lo reinicie.
El propio advisory de PHP es explícito sobre el riesgo: se considera más alto en mod_php, con un exploit demostrado en el que una petición no autenticada dispara el ataque antes de que se ejecute cualquier código de la aplicación.
En otras palabras: no necesitas una sesión válida, ni credenciales, ni una vulnerabilidad de negocio. Solo una petición HTTP bien formada contra un PHP vulnerable.
Superficie de ataque
Todo parte de una funcionalidad que mucha gente ni siquiera sabe que tiene activa: session.upload_progress, el mecanismo que permite a JavaScript mostrar el progreso de un upload sin polling.
Cuando está habilitado (upload_progress.enabled=On, valor por defecto en muchas instalaciones) y se recibe un POST multipart/form-data con el campo PHP_SESSION_UPLOAD_PROGRESS, PHP:
- Lee y decodifica la sesión asociada,
- Guarda un volcado del progreso en esa misma sesión,
- Lo hace antes de
RINIT, es decir, antes de que corra una sola línea de código de la aplicación.
Ese punto —”antes de RINIT”— es la clave de todo el fallo. En esa fase el motor no ha inicializado el estado de la petición, y la limpieza de la petición todavía no puede ejecutarse.
El mecanismo: un UAF entre dos peticiones
El flujo real (verificado, no inferido) es el siguiente:
Mecanismo cross-request: el estado global obsoleto sobrevive en el worker y lo consume una petición posterior
Request 1 — el productor
El atacante envía un multipart/form-data con PHP_SESSION_UPLOAD_PROGRESS y una sesión deliberadamente enorme. Durante el arranque, la deserialización de esa sesión supera memory_limit y provoca un OOM.
El detalle técnico es lo que lo cambia todo: zend_bailout() aborta la ejecución saltándose el PHP_VAR_UNSERIALIZE_DESTROY. El global del proceso BG(unserialize) sigue apuntando a un heap que ya fue liberado.
Request 2 — el consumidor
Una petición distinta, atendida por el mismo worker, llega a ese estado antes de que el RINIT normal pueda reiniciarlo. El deserializador recorre un puntero a memoria liberada: use-after-free.
Con un layout de heap controlado, ese UAF deja de ser un crash y se convierte en ejecución remota de código.
El detalle que hace la diferencia
Productor y consumidor deben caer en el mismo worker. En FPM real eso depende del pool, pero es completamente alcanzable: basta con que el pool tenga pocos workers o que el atacante envíe peticiones en paralelo para que dos de ellas colisionen en el mismo proceso.
Y hay un matiz que explica muchos “casi reproduces” fallidos: la sesión pequeña del consumidor debe escribirse fuera de banda (directamente en el almacenamiento de sesiones). Si se planta con una petición HTTP, esa misma petición es servida por el worker y su RINIT normal reinicia el estado obsoleto antes de que llegue el consumidor.
El parche: qué cambió en el código
El fix de 8.4.27 en ext/session/session.c documenta el problema con una honestidad notable:
1
2
3
4
5
6
7
8
9
10
11
12
/* The upload progress handler (php_session_rfc1867_update()) decodes and
* re-encodes the session during request startup, before RINIT, when no user
* code may run. Objects are then kept as __PHP_Incomplete_Class: no
* autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),
* and they are written back unchanged. */
static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
{
PHP_VAR_UNSERIALIZE_INIT(*var_hash);
if (PS(in_rfc1867_update)) {
php_var_unserialize_set_incomplete_classes_only(*var_hash, true);
}
}
Y el ciclo de vida documentado en el advisory es el que describimos arriba:
- Productor: el hook de upload progress decodifica la sesión durante el arranque, antes de
RINIT. Ese estado (sesión y decodificador) pertenece a la petición. - Un OOM (u otro error fatal) posterior a su publicación impide el reset correspondiente → PHP libera el heap de la petición mientras el global persistente del worker sigue apuntando a él.
- Consumidor: una petición posterior servida por el mismo worker alcanza ese estado y lo trata como válido → UAF → con layout de heap controlado: RCE.
CVE-2026-109618 es la otra cara de la misma moneda: el manejo del OOM puede interrumpir PHP con datos de petición a medio inicializar, y la limpieza trata lo inválido como válido. Por eso 8.4.27 no es un parche puntual sino una campaña global de endurecimiento del OOM (ZEND_HAS_OOM(), delayed_oom, memory_hard_limit_percent, zend_mm_oom_info_dtor y cientos de tests oom_*.phpt nuevos).
La narrativa que NO se sostiene
Antes del parche circuló una narrativa muy concreta sobre el mecanismo: un “stale bucket pointer” en php_session_rfc1867_progress, un spray de 149 campos t[] y un zend_fcall_info forjado. El diagrama original de la investigación describe esa hipótesis:
La descripción original “2a/2b/2c” de la mecánica: precisa sobre el código, incorrecta sobre la premisa
En nuestro laboratorio verificamos esa descripción contra el código publicado y la conclusión es clara:
- La mecánica de los punteros cacheados en
php_session_rfc1867_progresses una descripción exacta del código (verificada en 8.4.4, 8.4.26, 8.5.4, 8.5.11 y en el paquete de Ubuntu). - Pero la premisa de que el árbol decodificado libera los arrays cacheados no se sostiene en el PHP publicado. La línea que lo impide es:
1
2
3
progress->cancel_upload |= php_check_cancel_upload(progress);
Z_TRY_ADDREF(progress->data); /* <-- el interruptor */
zend_hash_update(Z_ARRVAL_P(sess_var), progress->key.s, &progress->data);
zend_hash_update() mueve el zval sin addref (ZVAL_COPY_VALUE). Con esa línea, el array de progreso obtiene su propia referencia: track_init() libera la copia deserializada y no el array vivo, y los punteros cacheados nunca quedan colgando.
Lo comprobamos con un experimento de ablación: compilando PHP sin Z_TRY_ADDREF(progress->data), la misma petición de 258 bytes produce el UAF de inmediato, con una traza que coincide con la publicada. Sin la ablación, no hay UAF.
Moraleja: el PoC público de ~62 KB no logra ejecución de código en el PHP publicado. Reproduciéndolo con un handler envenenado: HTTP 200, sin ASAN, y el handler forjado nunca llega a interpretarse.
Este tipo de verificación es exactamente el valor que aporta un laboratorio reproducible frente a la amplificación sin control de una cadena de retweets.
Verificación diferencial en laboratorio
Montamos un laboratorio aislado en Docker que compila PHP desde código fuente con AddressSanitizer, con dos perfiles: uno para detección de UAF intra-petición (ASAN=1, USE_ZEND_ALLOC=0) y otro para provocar el OOM real con Zend MM activo (ASAN=0, USE_ZEND_ALLOC=1).
El laboratorio completo está publicado en github.com/julio-romo-muninn/PHP2SHELL (MIT), con el informe técnico, los PoCs y las recipes de build.
| Escenario | Versión | Resultado |
|---|---|---|
| Cross-request real (2 requests, sesión enorme + OOM) | 8.4.26 | worker muerto: SEGV en tmp_var con traza ASAN |
| Igual | 8.4.27 | HTTP 200, el worker sobrevive — sin ASAN |
| Trigger canónico de 258 B (single request) | 8.4.4, 8.5.4 | HTTP 200, sin ASAN |
| Forma publicada (62.929 B, handler envenenado) | 8.4.4 | HTTP 200, sin ASAN; el handler nunca se interpretó |
Igual, build ablad (sin Z_TRY_ADDREF) |
8.4.4 | ASAN heap-use-after-free (zend_array_count ← serialize ← php_session_flush ← update ← callback) |
| OOM vía campo gigante (variante) | 8.4.26 / 8.4.27 | OOM en main/rfc1867.c:632 en ambos; sin UAF en el consumidor |
La traza del caso vulnerable:
1
2
3
4
5
6
7
8
9
10
==1==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000134 (READ)
#0 tmp_var ext/standard/var_unserializer.c:168
#1 var_tmp_var ext/standard/var_unserializer.c:190
#2 ps_srlzr_decode_php ext/session/session.c:1134
#3 php_session_decode ext/session/session.c:279
#4 php_session_initialize ext/session/session.c:502
#5 php_session_rfc1867_update ext/session/session.c:3196
#6 php_session_rfc1867_callback ext/session/session.c:3381
#7 rfc1867_post_handler main/rfc1867.c:1273
#10 php_auto_globals_create_post main/php_variables.c:818
Esto establece un UAF cross-request con un diferencial limpio vulnerable/parcheado: no es un “creo que funciona”, es un SEGV reproducible en 8.4.26 y un 200 limpio en 8.4.27.
La segunda cadena: actualizar no basta
El hallazgo más incómodo del laboratorio no es la vulnerabilidad: es lo que sobrevive al parche.
El fix PR #22113 no forma parte de 8.4.27. La siguiente cadena sigue viva:
POST multipart/form-dataconPHPSESSIDen el body (session fixation: el handlerMULTIPART_EVENT_FORMDATAno validause_only_cookies) y un campo de archivo cuyo nombre lleva código PHP.- El handler rfc1867 guarda el nombre sin sanear en el archivo de sesión.
- Un
include()de ese archivo (sink LFI en la aplicación) ejecuta el PHP inyectado.
| Escenario | Resultado |
|---|---|
| 8.4.26 (vulnerable) | RCE: uid=33(www-data) |
| 8.4.27 (parcheado) | RCE: uid=33(www-data) — la cadena sigue viva |
8.4.27 + session.upload_progress.enabled=Off |
bloqueado |
Es decir: el parche corrige la corrupción de memoria, pero no cierra la inyección en el archivo de sesión ni la fijación de sesión desde el body. El riesgo residual que queda en el PHP parcheado es:
- Session fixation: el atacante elige el SID (vía campo
PHPSESSIDen el body, no en cookie). - Inyección de contenido en el archivo de sesión (la clave de progreso y el nombre del archivo se escriben literalmente).
- RCE solo si existe un sink en la aplicación que haga
include()del archivo de sesión.
Parchear corrige el UAF. No corrige un LFI en tu aplicación.
Detección
No hay un exploit limpio con firma conocida, así que la detección se apoya en señales de comportamiento:
log_errors=Oncon unerror_logfuncional yerror_reporting=E_ALL.- Buscar warnings y deprecations repetidos antes del script, errores fatales y
Maximum execution time exceeded. - Peticiones
multipart/form-datacon el campoPHP_SESSION_UPLOAD_PROGRESSdesde origen no confiable. - Peticiones con SID en el body en lugar de en la cookie.
- Desapariciones de workers: si un worker muere durante una petición y el siguiente request tarda de más, es una señal.
- Reglas de detección públicas:
break-the-build/php-uploadprogress-wafpara Suricata / ModSecurity / nginx-njs.
Mitigación
Lo único que funciona de verdad
Actualizar a 8.2.35, 8.3.36, 8.4.27 o 8.5.12. El equipo de PHP lo dice literalmente: “There are no complete mitigations, updating is the only safe choice”. Los vendors de distribuciones LTS recibieron los parches por adelantado: revisa tu proveedor.
PHP 8.1 y anteriores ya no reciben actualizaciones del proyecto PHP. Si estás en esa versión, migra a una soportada.
Si no puedes actualizar hoy
Esto reduce la exposición, pero no te hace seguro:
Para CVE-2026-109618 (corrupción por OOM):
memory_limit=-1, de preferencia en un lugar que la aplicación no pueda sobrescribir (php_admin_valueen el pool FPM o en la configuración de Apache), y verifica que ningún pool, directorio o ajuste de la aplicación vuelva a poner un límite finito.post_max_sizebajo: 8M si la app no necesita subidas, 32M si sí. Manténupload_max_filesizepor debajo depost_max_size.- Impón límites en el servidor web o proxy: tamaño del body decodificado, número de campos, número de partes multipart, número de cabeceras y profundidad de anidamiento. Deben cubrir también las peticiones sin
Content-Lengthusable. - Usa un límite de contenedor o cgroup como frontera de disponibilidad, con una política que mate solo al worker afectado. No sustituyas por
RLIMIT_ASoRLIMIT_DATA.
Para CVE-2026-109619 (estado global obsoleto):
session.upload_progress.enabled=0y reinicia todos los workers. Usaphp.ini, un pool FPM o configuración de servidor que se aplique antes del parseo de la petición. No confíes enini_set()ni en.user.inide FPM. Esto corta las rutas de sesión confirmadas, pero no las demás variantes.- En PHP 8.5:
fatal_error_backtraces=0y reinicio de workers. - Elimina los ajustes inválidos o deprecados aplicados por petición que emiten diagnósticos antes de la inicialización. Ocultarlos con
error_reporting,display_errorso@no ayuda. - Corrige los errores fatales y los timeouts alcanzables por el atacante (callbacks, procesamiento de subidas o importaciones, y la propia limpieza).
Lado aplicación:
- Elimina cualquier
include()/require()sobre el archivo de sesión. - Rechaza un
PHPSESSIDque venga en el body. - Considera
pm.max_requests=1en FPM.
Conclusión
PHP2SHELL es un recordatorio de hasta dónde llega un fallo de gestión de ciclo de vida. No hace falta una vulnerabilidad de lógica de negocio, ni un deserializador roto, ni una inyección: basta con que un global de proceso sobreviva a la petición que lo creó y que la siguiente lo lea antes de que el motor tenga oportunidad de limpiarlo.
Tres ideas para llevarse:
- El estado que cruza peticiones es estado privilegiado. En PHP, un worker es un proceso de larga vida. Cualquier puntero global que apunte a memoria de petición es un UAF esperando su turno.
- Verificar es tan importante como explotar. La narrativa que circuló antes del parche describía el código con precisión pero partía de una premisa falsa; el PoC público de 62 KB no lograba ejecución de código. Un laboratorio reproducible con ASAN y un build con ablación convierten opiniones en hechos.
- Parchear no es cerrar. La cadena de inyección en el archivo de sesión + LFI siguió funcionando en la versión parcheada. El parche corrige la corrupción de memoria; el resto depende de tu configuración y de tu código.
Actualiza hoy. Y auditá tus include() sobre sesiones mientras lo haces.
1
2
$ echo "¡Gracias por leernos!"
¡Gracias por leernos!
Referencias
- Aviso oficial de PHP (21 Oct 2026): php.net/archive/2026.php#2026-10-21-5
- Laboratorio de verificación (MIT): github.com/julio-romo-muninn/PHP2SHELL
- GHSA-6wmf-4rv5-p3rp — CVE-2026-109619
- GHSA-w8hw-w9fx-j79w — CVE-2026-109618
- Contexto externo: GMO Flatt Security, watchTowr FAQ, CERT-EU 2026-018