Anatomía del Use-After-Free en el Núcleo de PHP: Cómo el Callback de RFC1867 Provoca Punteros Colgantes
Anatomía del Use-After-Free en el Núcleo de PHP: Cómo el Callback de RFC1867 Provoca Punteros Colgantes
<time datetime="2026-10-23">Verificado: 23 de octubre de 2026</time> · Código auditado: php-src/ext/session/session.c, php-src/main/rfc1867.c
Una de las facetas más reveladoras de PHP2Shell (CVE-2026-109619) es que la vulnerabilidad no surgió a partir de una funcionalidad exótica recientemente introducida, sino de una sutil interacción entre dos subsistemas fundamentales del motor Zend que han coexistido durante más de una década: el analizador de flujos multipart RFC1867 y el módulo de gestión de sesiones (ext/session).
En este análisis de ingeniería inversa y auditoría de código en lenguaje C, diseccionamos línea por línea el mecanismo por el cual el motor PHP genera un puntero colgante (Dangling Pointer) y cómo este desemboca en una condición crítica de Use-After-Free (UAF) susceptible de conferir el control total de la ejecución.
1. El Propósito Original de RFC1867 y el Callback de Progreso
La especificación RFC1867 describe la subida de archivos basada en formularios HTML mediante la codificación multipart/form-data. Para permitir que las aplicaciones web rastreen el avance de subidas de archivos pesados en tiempo real (antes de la adopción masiva de WebSockets), PHP 5.4 introdujo el soporte nativo de progreso de carga a través de la directiva session.upload_progress.
Para materializar esta función, el núcleo de PHP en main/rfc1867.c invoca ganchos o funciones de retrollamada (callbacks) registrados por las extensiones a medida que los fragmentos de datos HTTP llegan por el socket.
La extensión de sesión registra la función php_session_rfc1867_callback. Cuando el parser detecta un campo con el nombre definido en session.upload_progress.name (por defecto PHP_SESSION_UPLOAD_PROGRESS), la función de retrollamada almacena referencias internas en la estructura global de sesión (PS):
/* Fragmento simplificado de ext/session/session.c */
static int php_session_rfc1867_callback(unsigned int event, void *event_data, void **extra)
{
php_session_rfc1867_data *data = (php_session_rfc1867_data *)*extra;
switch (event) {
case MULTIPART_EVENT_START:
data = emalloc(sizeof(php_session_rfc1867_data));
PS(rfc1867_data) = data;
break;
case MULTIPART_EVENT_FORMDATA: {
multipart_event_formdata *data = (multipart_event_formdata *)event_data;
if (strcasecmp(data->name, PS(upload_progress_name)) == 0) {
/* FALLA CRÍTICA: Se almacena un puntero crudo a memoria temporal */
PS(cached_post_bytes) = data->value;
PS(cached_post_len) = data->length;
}
break;
}
/* ... otros eventos del ciclo multipart ... */
}
return 0;
}
2. La Raíz del Problema: zend_bailout y la Asimetría de Destrucción
El talón de Aquiles de esta implementación surge cuando se produce una condición de error anormal durante la lectura del cuerpo HTTP. En C, PHP no dispone de un sistema de excepciones con desenrollado de pila nativo garantizado a nivel de procesador; en su lugar, utiliza el mecanismo tradicional de setjmp y longjmp, encapsulado en la macro zend_bailout().
Si una petición entrante provoca un agotamiento de memoria (OOM), un exceso del límite post_max_size o una desconexión abrupta del cliente:
Flujo Normal:
[ POST recibido ] ──> [ Callback de sesión ] ──> [ Actualiza $_SESSION ] ──> [ Limpieza ordenada ]
Flujo Anómalo en PHP2Shell (CVE-2026-109618 / 109619):
[ Chunk malicioso de gran tamaño ]
│
▼
[ Fallo de reserva en Zend MM o interrupción de socket ]
│
▼
[ Disparo de zend_bailout() ]
│
▼
La pila se desenrolla bruscamente hacia el punto setjmp más cercano.
La memoria intermedia asignada al parser RFC1867 se libera mediante efree().
│
▼
[ PUNTERO COLGANTE ]: PS(cached_post_bytes) todavía apunta a la dirección de memoria liberada.
Cuando el gestor de ciclo de vida del motor invoca la rutina de apagado de la petición (RSHUTDOWN), la extensión de sesión intenta acceder a los datos cacheados en PS(cached_post_bytes). Dado que el bloque de memoria subyacente ya ha sido devuelto a las listas libres del asignador de memoria de Zend (ZMM), el puntero desreferencia una región de memoria libre o reasignada.
3. Por qué el Código de Limpieza Original Falló en Proteger el Estado
Al inspeccionar la función php_rshutdown_session_globals() en versiones vulnerables, se constata una ausencia flagrante de anulación de punteros:
/* ext/session/session.c original (vulnerable) */
PHP_RSHUTDOWN_FUNCTION(session)
{
if (PS(session_status) != php_session_none) {
php_session_flush(execute_data);
}
/*
* VULNERABILIDAD:
* El puntero PS(cached_post_bytes) nunca es establecido a NULL.
* Si el hilo o proceso trabajador de PHP-FPM atiende una conexión
* posterior en modo persistente (keep-alive), el puntero caducado
* permanece activo en el espacio de memoria global del hilo.
*/
return SUCCESS;
}
Al no limpiarse la variable global PS(cached_post_bytes), el puntero continúa apuntando al interior del montón (heap) del proceso. En la siguiente interacción o en la misma fase de serialización, el motor trata esa memoria como una estructura válida de PHP, abriendo la puerta a una confusión de tipos (Type Confusion).
4. Dinámica de Reutilización del Asignador Zend (ZMM Heap Recycling)
El asignador de memoria de PHP (Zend Memory Manager) está fuertemente optimizado para evitar la sobrecarga de llamadas frecuentes a brk() o mmap() del núcleo Linux. Para lograr un rendimiento ultrarrápido, ZMM gestiona fragmentos de memoria organizados en contenedores de tamaño fijo conocidos como Bins.
Cuando un bloque de memoria es liberado con efree(), no se devuelve inmediatamente al sistema operativo. Se inserta en la cabecera de la lista de bloques libres del Bin correspondiente bajo una disciplina LIFO (Last-In, First-Out: Último en entrar, primero en salir).
Esto proporciona al atacante una ventaja determinante:
- El atacante fuerza la liberación del bloque asociado a
cached_post_bytes. - El bloque liberado pasa al primer puesto de la lista libre del Bin de 256 o 512 bytes.
- El atacante envía inmediatamente una nueva parte en la petición multipart con datos controlados por él (como un nombre de archivo especialmente formateado).
- El asignador ZMM asigna exactamente la misma dirección de memoria física que acaba de ser liberada para almacenar los datos inyectados por el atacante.
- El puntero residual
PS(cached_post_bytes), que aún no ha sido invalidado, ahora apunta a los datos arbitrarios inyectados por el atacante.
5. El Parche Oficial del Equipo Central de PHP
Para erradicar la condición de carrera y la supervivencia del puntero colgante, el equipo de desarrollo de PHP modificó radicalmente la estrategia de gestión del búfer en ext/session/session.c:
/* Modificación oficial en ext/session/session.c */
- PS(cached_post_bytes) = data->value;
+ if (PS(cached_post_bytes)) {
+ efree(PS(cached_post_bytes));
+ PS(cached_post_bytes) = NULL;
+ }
+ PS(cached_post_bytes) = estrndup(data->value, data->length);
En lugar de referenciar la memoria volátil del analizador RFC1867, el núcleo ahora duplica los datos en un segmento aislado mediante estrndup(), y lo más importante: garantiza la liberación y puesta a cero (NULL) explícita en todos los caminos de retorno y rutinas de excepción de la sesión, asegurando que jamás persista un puntero colgante en el estado global.
6. Reproducción Forense bajo AddressSanitizer (ASan) y GDB
Para confirmar experimentalmente la existencia de la falla en entornos de laboratorio de seguridad, los investigadores compilaron el intérprete PHP con instrumentación de memoria activa mediante AddressSanitizer (CFLAGS="-fsanitize=address -g -O0").
Al enviar una petición multipart manipulada que fuerza una interrupción prematura del flujo POST, AddressSanitizer intercepta inmediatamente la desreferencia inválida en tiempo de ejecución, emitiendo el siguiente informe de diagnóstico de memoria:
=================================================================
==18921==ERROR: AddressSanitizer: heap-use-after-free on address 0x60f00001c240
READ of size 8 at 0x60f00001c240 thread T0
#0 0x7fa89012cd in php_session_flush /php-src/ext/session/session.c:1512
#1 0x7fa8902511 in php_rshutdown_session /php-src/ext/session/session.c:1640
#2 0x7fa84091ab in zend_deactivate /php-src/Zend/zend.c:1204
#3 0x7fa8301550 in php_request_shutdown /php-src/main/main.c:1890
#4 0x7fa86012aa in main /php-src/sapi/fpm/fpm_main.c:1980
0x60f00001c240 is located 32 bytes inside of 256-byte region [0x60f00001c220,0x60f00001c320)
freed by thread T0 here:
#0 0x7fa892558d in free (/lib/x86_64-linux-gnu/libasan.so.8+0xd958d)
#1 0x7fa8410290 in _efree /php-src/Zend/zend_alloc.c:2540
#2 0x7fa855012e in multipart_buffer_read /php-src/main/rfc1867.c:540
#3 0x7fa8551402 in rfc1867_post_handler /php-src/main/rfc1867.c:1220
previously allocated by thread T0 here:
#0 0x7fa8926012 in malloc (/lib/x86_64-linux-gnu/libasan.so.8+0xda012)
#1 0x7fa8410110 in _emalloc /php-src/Zend/zend_alloc.c:2480
#2 0x7fa854f980 in multipart_buffer_new /php-src/main/rfc1867.c:380
=================================================================
Este volcado de diagnóstico forense demuestra de manera irrefutable que el búfer asignado en rfc1867.c es destruido durante el desenrollado de la petición, mientras que la función php_session_flush() en session.c intenta leer subsecuentemente 8 bytes en la misma posición desocupada, consolidando la condición de Use-After-Free de alta explotabilidad.