Skip to main content

Casos reales

El malware que resucitaba en 300 milisegundos: anatomía de una infección en WordPress

Publicado el 12 de agosto de 2026

Todo empezó con una alerta de malware en Plesk, en una web que administro para un cliente.

A primera vista parecía una incidencia relativamente convencional: una instalación de WordPress antigua, varios archivos detectados como maliciosos y la necesidad de limpiar la web, actualizarla y devolverla a producción.

Un trabajo desagradable, pero conocido.

El problema apareció cuando borré uno de los archivos infectados.

Volvió a aparecer.

Lo borré (una y) otra vez.

Volvió.

Así que decidí medir cuánto tardaba en regenerarse.

296 milisegundos.

En ese intervalo apareció además una pista bastante peculiar: un proceso PHP estaba escribiendo datos mientras el archivo se reconstruía.

REAPARECIDO EN 296 ms
PID=2310272
write_bytes=+24576
syscw=+3
CMD=php -f /tmp/httpd.conf

Un proceso ejecutando mediante PHP algo llamado /tmp/httpd.conf no era precisamente lo que esperaba encontrar.

A partir de ese momento, aquello dejó de ser simplemente una limpieza de WordPress.

Había que averiguar qué estaba manteniendo viva la infección.

El escenario

La web afectada utilizaba WordPress con el tema TheStory y varios plugins acumulados durante años.

Estaba alojada en un hosting compartido administrado mediante Plesk, sin acceso SSH convencional al servidor. Por tanto, la investigación tuvo que realizarse utilizando las herramientas disponibles:

  • Plesk y WP Toolkit;
  • WP-CLI desde WP Toolkit;
  • pequeños scripts PHP temporales;
  • acceso limitado a información de /proc;
  • ImunifyAV;
  • Atlant Security;
  • copias de seguridad del proveedor.

Esta limitación resultó importante durante toda la investigación.

En un servidor administrado directamente habría podido inspeccionar procesos, usuarios, árboles de procesos, archivos temporales, logs del sistema y otros elementos con mucha más libertad.

Aquí tenía que trabajar desde dentro de una cuenta de hosting compartido; no era el laboratorio perfecto para realizar análisis forense, sino el entorno real que tenía delante.

La primera detección conocida

Posteriormente, el proveedor pudo consultar el histórico de ImunifyAV y situar la primera alerta conocida el 4 de agosto de 2026.

El escáner había identificado ocho archivos:

/wp-content/plugins/background-image-cropper/image/index.php
/wp-content/plugins/background-image-cropper/image/plugins.php
/wp-content/plugins/background-image-cropper/accesson.php
/wp-content/plugins/background-image-cropper/background-image-cropper.php
/accesson.php
/aa347c715020.php
/c49fb/index.php
/79565/index.php

Uno de ellos llamaba inmediatamente la atención:

/wp-content/plugins/background-image-cropper/

Yo (el administrador del web) nunca había instalado ningún plugin llamado background-image-cropper. Y esto es importante.

No estaba viendo simplemente un plugin legítimo que hubiera sido modificado después de explotar una vulnerabilidad. Al menos en este caso, el directorio completo formaba parte del compromiso y se presentaba dentro de la estructura de WordPress como si fuera un plugin más.

Era malware disfrazado de componente legítimo.

Pero encontrar malware responde solamente a una pregunta:

¿Qué está infectado?

No responde necesariamente a otra mucho más difícil:

¿Cómo consiguió entrar?

Confundir ambas preguntas sería uno de los peligros durante toda la investigación.

Un index.php que no quería morir

Entre los archivos encontrados había un index.php malicioso en la raíz de la instalación.

Tenía unas características bastante poco tranquilizadoras:

  • tamaño de 21.073 bytes;
  • permisos 0444;
  • código PHP fuertemente ofuscado;
  • utilización de goto;
  • llamadas a eval;
  • cadenas codificadas mediante base64.

Calculé también su SHA-256:

4182b92a2e9cb3b43cb4bb7e7f1ff1bfd3a6e7eb6d3c8a648a377154b302cb32

Decidí borrarlo manualmente y observar qué ocurría, y el archivo desapareció correctamente… pero casi inmediatamente volvió a estar allí!!!

No era WordPress restaurándolo, tampoco era simplemente un problema de permisos que hubiera impedido eliminarlo.

El archivo desaparecía y algo lo creaba de nuevo.

Así que repetí la prueba intentando observar qué procesos escribían durante ese intervalo.

En una de las ejecuciones con fichero de prueba *.php metidos con intención en la raíz de /httpdocs conseguí capturar esto:

REAPARECIDO EN 296 ms
PID=2310272
write_bytes=+24576
syscw=+3
CMD=php -f /tmp/httpd.conf

Durante la ventana exacta en la que reaparecía index.php, un proceso estaba ejecutando:

php -f /tmp/httpd.conf

El nombre resultaba especialmente extraño.

httpd.conf suele asociarse con un archivo de configuración de servidor web. Sin embargo, aquí PHP estaba recibiendo ese archivo mediante -f, es decir, tratándolo como un script que debía ejecutar.

Era una pista excelente, pero seguía siendo solamente una pista.

No podía afirmar todavía qué contenía ese archivo, quién había iniciado el proceso, desde cuándo estaba ejecutándose ni si aquel mecanismo había participado en la intrusión inicial o había aparecido posteriormente como mecanismo de persistencia.

Desde la cuenta de hosting tampoco disponía de acceso suficiente para reconstruir cómodamente UID, PPID, entorno y toda la cadena de ejecución. Había conseguido observar qué estaba ocurriendo durante la regeneración, pero todavía no sabía por qué estaba ocurriendo.

Primer intento: restaurar una copia anterior

Cuando una web está comprometida y existe una copia de seguridad anterior, restaurarla parece la solución evidente, y en principio fue lo que hice: cogí una copia anterior y la restauré sobre la instalación existente.

El resultado me enseñó una diferencia que parece trivial hasta que tienes malware delante: restaurar archivos no significa necesariamente limpiar el destino antes de restaurarlos.

El sistema de backup del proveedor recuperaba los archivos incluidos en la copia, pero no eliminaba previamente todo lo que ya estuviera en el servidor.

Lo descubrí de una forma bastante sencilla: algunos scripts temporales que yo mismo había creado durante la investigación seguían allí después de restaurar.

Si mis archivos de prueba sobrevivían a la restauración, un archivo malicioso que no estuviera presente en el backup también podía sobrevivir. Por tanto, restaurar una copia limpia encima de una instalación infectada no me garantizaba una instalación limpia.

Había que cambiar de estrategia.

Vaciar WordPress

Decidí retirar prácticamente todo el contenido de httpdocs.

La idea era sencilla, aunque un poco «dinamitarda»: si eliminaba WordPress y sus archivos, cualquier mecanismo de persistencia alojado dentro de la instalación debería desaparecer con ellos.

Así que saqué la dinamita y el lanzallama y decidí borrar todo el contenido por debajo de la raíz y no solo /httpdocs…

Pero ocurrió algo bastante más interesante.

Con httpdocs vacío comenzó a reaparecer automáticamente el contenido nuevamente malicioso:

index.php
/images/

Esto cambió de nuevo el alcance de la investigación.

La persistencia que estaba observando no dependía simplemente del árbol WordPress que acababa de retirar. Eso tampoco significaba automáticamente que todo el servidor estuviera comprometido.

En seguridad informática resulta demasiado fácil convertir una observación parcial en una conclusión espectacular. Podía demostrar que el malware reaparecía después de retirar WordPress. No podía demostrar todavía dónde estaba alojado el mecanismo responsable.

Seguí vaciando (dinamitando y arrasando, mejor dicho…) prácticamente toda la estructura accesible de la cuenta: copias, directorios temporales, logs y otros contenidos. Durante parte de esa operación la «pareja maliciosa» continuó reapareciendo.

El problema era ya evidente: no sabía dónde terminaba la infección.

Una intervención que cambió el comportamiento

Durante la investigación había apartado parte del contenido comprometido dentro de una carpeta denominada infected-web-124856621-etc…

El responsable del hosting terminó aplicando permisos 000 sobre esa carpeta. Después de esta intervención dejé de observar la regeneración automática.

La coincidencia es interesante, pero nuevamente hay que resistirse a convertirla en una explicación definitiva.

No pude establecer con suficiente evidencia:

  • qué mecanismo ejecutaba exactamente /tmp/httpd.conf;
  • cómo había sido iniciado;
  • si estaba relacionado directamente con el contenido de infected;
  • si existía otro elemento intermedio;
  • qué papel desempeñaba en el compromiso inicial.

Lo único que pude documentar es la cronología: la regeneración existía, observé un proceso escribiendo durante ella, se modificaron permisos sobre contenido comprometido y posteriormente dejé de reproducir ese comportamiento.

Todo lo demás habría sido especulación.

Recuperar la web sin arrastrar la infección

Llegado a este punto había que recuperar el servicio.

El histórico de ImunifyAV situaba la primera detección conocida el 04/08/2026; mientras que el proveedor disponía de una copia del 02/08/2026

Era anterior a la primera infección conocida, y esta vez no repetí la restauración sobre la instalación existente. Primero dejé limpia la estructura sobre la que se iba a recuperar la web y después restauré la copia. A continuación actualicé WordPress, plugins y todos los componentes que podían actualizarse.

El index.php legítimo de WordPress quedó reducido a su función habitual como front controller:

define( 'WP_USE_THEMES', true );
require __DIR__ . '/wp-blog-header.php';

Calculé su SHA-256:

eea9347b1e266ca5407b92633958c148dbfebea307e511a3a226ea61828e2eba

Los controles posteriores mantuvieron exactamente ese mismo hash.

La web podía volver a producción, pero recuperar la web no significaba que hubiera descubierto cómo había sido comprometida: son dos problemas diferentes.

🕵️ Interrogando a los sospechosos

Una vez estabilizada la instalación, quedaba intentar reconstruir el posible vector de entrada.

Había varios candidatos razonables.

MainWP Child y UpdraftPlus

Ambos plugins tienen suficiente historial como para justificar una revisión. Sin embargo, el contexto concreto reducía considerablemente algunas hipótesis.

MainWP Child se mantenía actualizado precisamente porque la instalación se gestionaba mediante MainWP.

UpdraftPlus también recibía actualizaciones mediante ese sistema y se utilizaba en su versión gratuita, sin conexión con UpdraftCentral.

Eso no constituye una demostración matemática de inocencia, simplemente hacía que determinadas vulnerabilidades históricas no encajaran bien con el entorno real que encontré.

Export Media Library

MainWP marcó Export Media Library 4.0.2 como plugin abandonado, con más de 1.200 días desde su última actualización: eso era motivo suficiente para eliminarlo, pero antes quise inspeccionarlo.

Busqué especialmente mecanismos que pudieran permitir operaciones desde usuarios no autenticados, subida de archivos o escritura arbitraria.

La exportación requería:

current_user_can('upload_files')

y además utilizaba:

check_admin_referer(...)

No encontré un endpoint público de subida ni un mecanismo compatible con la escritura y ejecución arbitraria de PHP que había observado.

La conclusión fue deliberadamente limitada: era un plugin abandonado que no tenía sentido conservar, pero no encontré evidencia para atribuirle el compromiso.

Los plugins auxiliares de TheStory

También revisé los pequeños plugins asociados al tema:

story-portfolio-items
story-recent-posts

Busqué patrones relacionados con AJAX público, subida de archivos, escritura, inclusiones dinámicas, eval, base64_decode, unserialize y otras superficies potencialmente interesantes. No apareció ningún mecanismo evidente compatible con el ataque. No fue una auditoría completa de seguridad, sino una búsqueda dirigida por las características del incidente… y no produjo un culpable.

Atlant Security

Atlant merecía especial atención por una razón sencilla: ya estaba instalado antes de la infección, y por eso pertenecía al entorno existente cuando ocurrió el compromiso.

Inspeccioné especialmente sus endpoints AJAX disponibles sin autenticación.

Encontré:

wp_ajax_nopriv_aswp_2fa_validate
wp_ajax_nopriv_aswp_2fa_resend

Revisé el flujo: básicamente era una cadena de piezas de código que se pasaban el testigo unas a otras, y cada una hacía una pequeña parte del proceso de autenticación

No apareció un bypass de autenticación evidente.

Otra vez, la conclusión correcta era que no había encontrado evidencia que permitiera señalar Atlant como vector.

Cuando 200 alertas no significan 200 infecciones

Después de restaurar y actualizar la web ejecuté un escaneo completo con Atlant, y el resultado fue espectacular: alrededor de 200 alertas clasificadas como críticas.

PHP in Uploads Directory

Revisé los archivos.

La inmensa mayoría eran cachés PHP legítimas generadas por WPML y Twig. Otros eran pequeños index.php defensivos destinados precisamente a impedir el listado o acceso directo a determinados directorios. No había 200 infecciones. Había un escáner aplicando una regla heurística razonable: Encontrar PHP dentro de uploads es sospechoso.

Y tenía razón en considerarlo sospechoso. Lo que no podía hacer era conocer automáticamente el contexto de cada archivo. Este episodio dejó otra lección importante: un escáner proporciona evidencias que deben interpretarse; no sustituye el análisis.

Severidad, confianza y confirmación no son sinónimos. Un resultado marcado como critical puede merecer atención inmediata y seguir siendo un falso positivo.

Dos imágenes y una coincidencia demasiado tentadora

Poco antes de conocerse la infección había corregido dos recursos de la web que todavía apuntaban al antiguo dominio .es.

Uno era la firma o logotipo:

/wp-content/uploads/2022/06/Luciano-firma-1.png

El otro era el icono de Instagram utilizado por TheStory:
/wp-content/themes/thestory/images/icons_white/instagram.png

Las modificaciones las había realizado desde el panel administrativo del propio tema y la proximidad temporal resultaba tentadora: había realizado aquellas modificaciones y poco después había descubierto la infección.

Era muy fácil construir una historia, cambié estas rutas y algo vulnerable dentro de TheStory permitió ejecutar código. El problema es que una historia convincente no constituye una prueba. Inspeccioné los dos PNG y parecían archivos PNG legítimos.

Después hice algo mucho más útil: reproduje exactamente las modificaciones, y Antes de empezar establecí un baseline.

El index.php tenía:

SHA-256
eea9347b1e266ca5407b92633958c148dbfebea307e511a3a226ea61828e2eba

Y no existían:

/wp-content/plugins/background-image-cropper
/accesson.php
/images

Primero corregí únicamente la firma, volví a comprobar: nada.

El hash permaneció idéntico y ninguno de los indicadores conocidos apareció, después corregí también el icono de Instagram.

Nueva comprobación, exactamente el mismo resultado: nada, no conseguí reproducir el compromiso.

Eso no demuestra absolutamente que aquellas acciones fueran irrelevantes: el entorno ya había sido actualizado y endurecido, por lo que no era idéntico al existente antes de la infección.

Pero la hipótesis perdió muchísimo peso, la correlación temporal había resultado más atractiva que la evidencia.

Una debilidad real: PHP podía ejecutarse en uploads

Durante las conversaciones con el proveedor apareció otro dato importante: antes del incidente, wp-content/uploads no estaba protegido contra la ejecución de scripts PHP.

Después de la recuperación se aplicó protección mediante las herramientas de seguridad de Plesk.

Comprobé el directorio, no existían:

uploads/.htaccess
uploads/web.config

El index.php existente únicamente contenía:

<?php
header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden' );
die( '403 Forbidden' );

Ese archivo impide acceder directamente al propio index.php, pero no bloquea por sí mismo la ejecución de cualquier otro PHP almacenado en el directorio; por tanto la protección tenía que estar aplicada a nivel del servidor web o de la configuración gestionada por Plesk. En vez de asumir que funcionaba, decidí comprobarlo.

Una prueba bastante sencilla

Creé temporalmente:

wp-content/uploads/t42-php-test.php

El archivo no hacía nada peligroso, únicamente intentaba imprimir:

T42_PHP_EXECUTED

Lo solicité mediante HTTP, el servidor respondió haciendome muescas:

Forbidden
You do not have permission to access this document.

Eliminé el fichero; la prueba me permitía afirmar algo concreto: actualmente PHP almacenado dentro de uploads no puede ejecutarse mediante una petición web.

Pero había que evitar nuevamente una conclusión demasiado cómoda. Sabía, según la información del proveedor, que antes del incidente esa protección no existía, y eso representaba una debilidad real. Sin embargo, para convertirla en el vector del ataque necesitaba responder dos preguntas diferentes.

¿Cómo consiguió el atacante escribir el primer archivo?

No lo sé.

Si conseguía escribir un PHP dentro de uploads, ¿podía ejecutarlo?

Antes del endurecimiento, según el proveedor, sí; ahora, experimentalmente, no. Bloquear PHP dentro de uploads elimina una condición peligrosa. No reconstruye retrospectivamente cómo ocurrió la intrusión.

Medidas posteriores

Además de reconstruir la instalación desde una copia anterior y actualizar los componentes, tomé varias medidas preventivas. Solo existían dos cuentas administrativas legítimas y no apareció ningún administrador desconocido, así que activé autenticación de dos factores en la cuenta técnica y solicité un cambio de contraseña para la cuenta del propietario. También eliminé componentes abandonados o innecesarios y mantuve la protección contra ejecución de PHP dentro de uploads.

Son medidas razonables después de un compromiso, pero tampoco deben utilizarse para fabricar retrospectivamente una explicación.

Cambiar contraseñas no demuestra que las anteriores hubieran sido robadas, activar 2FA no demuestra que el atacante hubiera utilizado credenciales, eliminar un plugin abandonado no demuestra que ese plugin fuera vulnerable, endurecer uploads no demuestra que el ataque comenzara allí.

Son medidas destinadas a reducir superficie y riesgo futuro: no son pruebas sobre el pasado.

Recuperar el servicio tenía un coste: perder parte de la evidencia

Llegado a determinado punto de la investigación tenía dos objetivos que empezaban a entrar en conflicto: por un lado, conservar intacta una instalación comprometida para continuar estudiándola. Por otro, garantizar que ningún componente desconocido permaneciera en la cuenta antes de devolver la web a producción.

El problema era que no sabía dónde terminaba la infección: había retirado WordPress de httpdocs y el malware seguía regenerándose; conservar selectivamente archivos suponía asumir que sabía cuáles eran seguros precisamente cuando todavía no podía garantizarlo. Por eso opté por vaciar la estructura accesible y reconstruir desde una copia anterior a la primera detección conocida. La decisión permitió recuperar la web sobre un destino limpio. Pero tuvo una consecuencia…

Parte de la evidencia que habría resultado útil posteriormente para reconstruir el vector inicial dejó de estar disponible. También restauré la base de datos del 2 de agosto, por lo que desaparecieron registros de Atlant generados después de esa fecha. Algunos logs del entorno tampoco pude conservarlos durante la operación. Con la experiencia obtenida durante este incidente, hoy intentaría preservar previamente una copia completa del entorno comprometido y todos los registros disponibles antes de iniciar la reconstrucción.

En aquel momento, sin embargo, la prioridad era eliminar una persistencia cuyo alcance todavía desconocía y devolver la web a producción sin arrastrar restos de la infección: recuperación y análisis forense persiguen objetivos distintos, a veces pueden avanzar juntos. Otras veces hay que decidir cuál tiene prioridad.

Lo que sé

Después de toda la investigación puedo afirmar con bastante seguridad:

  • La instalación de WordPress estuvo comprometida.
  • La primera detección conocida de ImunifyAV corresponde al 4 de agosto de 2026.
  • Existía un falso plugin denominado background-image-cropper.
  • Existían varias puertas traseras, entre ellas accesson.php.
  • Un index.php malicioso se regeneraba automáticamente después de eliminarlo.
  • En una prueba concreta reapareció en 296 ms.
  • Durante esa regeneración observé actividad de escritura procedente de php -f /tmp/httpd.conf.
  • La regeneración continuó después de retirar WordPress de httpdocs.
  • Restaurar un backup sobre la instalación existente no eliminaba necesariamente archivos residuales.
  • Restaurar sobre una estructura previamente vaciada permitió recuperar una instalación limpia.
  • Las modificaciones realizadas sobre las dos imágenes sospechosas no reprodujeron el compromiso.
  • Los plugins inspeccionados no proporcionaron un vector convincente.
  • Antes del incidente era posible ejecutar PHP dentro de uploads, según la información facilitada por el proveedor.
  • Después del endurecimiento comprobé experimentalmente que esa ejecución estaba bloqueada.
  • Los controles posteriores mantuvieron intacto el hash del index.php legítimo.

Lo que todavía no sé

Y hay otra lista igualmente importante.

No puedo demostrar:

  • cuál fue el vector inicial;
  • qué petición HTTP inició el compromiso;
  • desde qué dirección IP;
  • si se explotó una vulnerabilidad concreta de WordPress, un plugin o el tema;
  • si se utilizaron credenciales administrativas o una sesión comprometida;
  • si el primer archivo malicioso fue escrito dentro de uploads;
  • qué función exacta desempeñaba /tmp/httpd.conf;
  • quién inició aquel proceso;
  • cómo se mantenía;
  • si existió algún problema adicional de aislamiento dentro del hosting compartido.

Podría elegir cualquiera de esas hipótesis y construir una explicación perfectamente plausible, pero no sería investigación: sería ficción.

Qué aprendí de este caso

Este incidente dejó varias conclusiones que resultan más útiles que encontrar apresuradamente un culpable.

Detectar malware no significa haber descubierto el vector de entrada.

Un antivirus puede identificar un archivo infectado sin explicar cómo llegó hasta allí.

Restaurar no significa limpiar.

Si el procedimiento de restauración no elimina previamente el destino, archivos maliciosos ajenos al backup pueden sobrevivir.

Un archivo que reaparece no es el problema completo.

Si borrarlo provoca que vuelva a aparecer, existe otro mecanismo capaz de reconstruirlo.

Los escáneres necesitan interpretación humana.

Doscientas alertas críticas no equivalen necesariamente a doscientas infecciones.

Correlación temporal no significa causalidad.

Las modificaciones realizadas poco antes de descubrir el ataque parecían sospechosas hasta que intenté reproducirlas.

Bloquear PHP en uploads es defensa en profundidad.

Es una medida importante, pero no demuestra que el atacante utilizara ese camino.

Recuperación y análisis forense no son exactamente el mismo trabajo.

Preservar evidencia puede entrar en conflicto con la necesidad de eliminar rápidamente cualquier elemento cuyo alcance todavía se desconoce.

Y quizá la lección más importante:

«No lo sé» también puede ser una conclusión técnica correcta.

Un misterio todavía abierto

La web quedó recuperada, actualizada y endurecida, el malware conocido desapareció, el index.php legítimo mantuvo su hash, PHP dejó de poder ejecutarse desde uploads.

Las acciones que parecían temporalmente relacionadas con la infección no consiguieron reproducirla.

Pero quedó una pregunta sin responder:

¿cómo entró el atacante por primera vez?

La evidencia que conservaba no permitía contestarla con rigor y eso podría ser el final del caso.

Por ahora, el malware está muerto… cómo consiguió entrar sigue siendo un misterio.