Empecemos por el parche que no funcionó. En GiveWP 4.16.6 alguien se sentó, entendió el fallo, escribió una comprobación recursiva que recorre el resultado deserializado buscando __PHP_Incomplete_Class y, al encontrar uno, devolvía la cadena $data en crudo. Detección: correcta. Respuesta: entregarle al que llama los bytes del atacante tal cual. Esa es la frase con la que quiero que te quedes, porque es la vulnerabilidad entera en miniatura. Una función de saneado que no se atreve a destruir datos no ha saneado nada, solo ha movido la explosión un marco de pila más abajo. La 4.16.7.2 devuelve false en su lugar, y esa diferencia de una palabra es la diferencia entre un arreglo y un comentario.
if (self::containsPhpIncompleteClass($unserializedData)) {
// 4.16.6 returned $data here, which re-armed the payload
return false;
}¿Cómo acaba ahí un equipo con experiencia? Porque la API de PHP invita a ello. safeUnserialize() en src/Helpers/Utils.php llama a unserialize() con ['allowed_classes' => false], que se lee como un interruptor de emergencia y está documentado como cualquier cosa menos eso. El manual es tajante: la opción no impide la creación del objeto. Lo que obtienes es un marcador __PHP_Incomplete_Class que conserva fielmente el nombre de la clase original y absolutamente todas sus propiedades. Lo lees y no pasa nada. Lo vuelves a serializar y PHP emite la misma secuencia de bytes con la que empezaste. El flag protege la lectura actual, y solo la lectura actual.
El recorrido de esta CVE es casi aburrido, y por eso merece la pena memorizarlo. Un atacante mete un payload de gadget serializado en el campo last_name de su propia cuenta a través de profile.php. Al enviar la donación, includes/process-donation.php monta user_info con esos datos de la cuenta, pasa cada campo por el helper y escribe el array resultante en la tabla wp_give_sessions. La siguiente petición lee esa fila y llama a unserialize() sin ninguna guarda, y el objeto despierta de verdad. Nada del cuerpo de la petición fue nunca malicioso de una forma que un validador pudiera notar. Tu propia base de datos es entrada de usuario con mejor reputación.
El contraargumento honesto es que devolver false es destructivo, y los cambios destructivos generan tickets de soporte. Ahí fuera hay un donante cuyo nombre de empresa dispara de verdad containsSerializedDataRegex, y el enfoque de la 4.16.6 era, si entrecierras los ojos, conservador: preservar el dato del usuario, no perder una donación. Aun así me quedo con el false, siempre. Pesa los dos modos de fallo con sinceridad. Por un lado, se descarta una fila de sesión y un donante vuelve a enviar un formulario. Por el otro, system() sin autenticar como usuario del servidor web en un plugin con más de 100,000 instalaciones, todas las versiones hasta la 4.16.7.1, CVSS 10.0. No es una decisión reñida, y tratarla como si lo fuera es la manera de terminar escribiendo una comprobación cuyo único efecto es demostrar que viste el problema.
Lo que de verdad admiro de la 4.16.7.2 es que no finge saber qué capa era la que importaba. El camino de escritura ahora rechaza la donación si un campo de nombre contiene datos serializados. Tres puntos de lectura distintos recibieron guardas explícitas de allowed_classes: el getter de sesión en class-give-session.php, la lectura de la tabla de sesiones en class-give-db-sessions.php y el muro de donantes en class-give-donor-wall.php, que resultó ser alcanzable por un visitante anónimo mediante el shortcode público [give_donor_wall] sin siquiera una cookie. Y ProviderForwarder::__call() ahora comprueba que el provider resuelto implementa el contrato antes de llamar a nada. Cuatro cortes independientes en una misma cadena no es paranoia, es admitir que quien arregla un fallo de deserialización no puede enumerar todos los sumideros. Bien hecho.
La mitad del gadget merece su propia pregunta incómoda. La cadena pasa por TCPDF, que el plugin empaqueta para generar PDF, y entra en clases de Give\TestData que existen para generar datos de demostración. __call() buscaba un nombre en loadedProviders, una simple propiedad de tipo array sin tipado alguno, y pasaba lo que encontrase a call_user_func_array(). Pon esa propiedad a la cadena system durante la deserialización, deja que TCPDF::__destruct() haga lo suyo y ya tienes una shell. No voy a fingir que yo audito mi propio directorio vendor buscando esto. Pero si el código de seeding y de fixtures de test viaja a producción junto a una librería de PDF con métodos mágicos en su ruta de destructor, esos dos ya son compañeros de trabajo, los hayas presentado tú o no.
Una cosa más que mucha gente va a entender mal en las próximas dos semanas: subir de versión no es la remediación. Si te plantaron un payload antes de actualizar, sigue ahí sentado en tus tablas. Por eso la 4.16.7.2 incluye una migración SanitizeSerializedObjectPayloads sobre usermeta, give_donormeta, give_donationmeta y give_sessions, que sustituye los objetos anidados por cadenas vacías. Comprueba que se ejecutó de verdad. Y ojo: give_action=user_register sigue ignorando la opción users_can_register en la 4.16.7.2, así que las cuentas siguen siendo gratis para quien las quiera en sitios con el registro desactivado. Patchstack lo cataloga ya como un problema de control de acceso aparte, ahora que la cadena de RCE está rota, lo cual es razonable, pero la regla de WAF te toca escribirla a ti.
Así que esto es lo que me gustaría discutir en los comentarios. ¿Deserializas algo, lo que sea, que haya salido de tu propia base de datos? Y si lo haces, ¿qué le pasa a una fila que no supera la comprobación? ¿Se descarta en silencio, se descarta con ruido en una tabla de dead letter, o se marca para que la mire una persona? Yo he estado en el lado del fail-closed en esa reunión y la he perdido más de una vez, porque alguien preguntó con toda la razón cuántos registros legítimos íbamos a triturar. Si has encontrado una versión de esto que la gente de operaciones acepta, quiero saber cómo lo planteaste.




Comentarios
Aún no hay comentarios — escribe el primero.
Inicia la conversación
Sin cuenta ni contraseña — introduce tu correo y te enviamos un enlace de acceso de un solo uso. ¿Primera vez? Todo se configura automáticamente.
Tu valoración se aplicará automáticamente al iniciar sesión.
Revisa tu bandeja de entrada
Hemos enviado un enlace de acceso a …. Ábrelo en este dispositivo — esta pestaña te conectará automáticamente.
¿No llega nada? Mira en la carpeta de spam — y marca el correo como «No es spam» para que la próxima vez llegue directo.