La mayoría de los WordPress comprometidos no caen por un fallo del núcleo, sino por plugins sin actualizar, contraseñas débiles, ataques de fuerza bruta contra wp-login.php o archivos PHP subidos a la carpeta de medios. En este tutorial endurecerás un WordPress que corre en Ubuntu 24.04 con Nginx y PHP-FPM: ajustarás propietarios y permisos para que el servidor web no pueda modificar el código, bloquearás en Nginx las rutas que se usan en los ataques, limitarás los intentos de acceso con Nginx y Fail2ban, añadirás autenticación en dos pasos y comprobarás la integridad de los archivos.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath.
  • Un usuario no root con privilegios sudo. En los ejemplos se llama your_user.
  • WordPress funcionando en /var/www/wordpress con Nginx, PHP 8.3-FPM y MySQL o MariaDB, con HTTPS ya configurado. El bloque de servidor está en /etc/nginx/sites-available/wordpress y el dominio es example.com.
  • WP-CLI instalado en /usr/local/bin/wp.
  • UFW activo permitiendo solo SSH y web (sudo ufw allow OpenSSH y sudo ufw allow 'Nginx Full').

Haz una copia de seguridad de la base de datos y de /var/www/wordpress antes de empezar.

Paso 1: Actualizar y reducir la superficie de ataque

Los plugins y temas desactualizados son la primera vía de entrada. Revisa qué hay pendiente de actualizar:

sudo -u www-data wp --path=/var/www/wordpress core check-update
sudo -u www-data wp --path=/var/www/wordpress plugin list --update=available
sudo -u www-data wp --path=/var/www/wordpress theme list --update=available

Actualízalo todo:

sudo -u www-data wp --path=/var/www/wordpress core update
sudo -u www-data wp --path=/var/www/wordpress plugin update --all
sudo -u www-data wp --path=/var/www/wordpress theme update --all

Un plugin desactivado sigue teniendo sus archivos PHP accesibles desde la web, así que si tiene una vulnerabilidad sigue siendo explotable. Lista los plugins y temas inactivos:

sudo -u www-data wp --path=/var/www/wordpress plugin list --status=inactive --field=name
sudo -u www-data wp --path=/var/www/wordpress theme list --status=inactive --field=name

Borra los que no uses. Conserva un tema por defecto (por ejemplo twentytwentyfive) como respaldo si tu tema activo falla:

sudo -u www-data wp --path=/var/www/wordpress plugin delete hello akismet

Revisa también la lista de usuarios administradores y elimina o degrada a los que ya no lo necesiten:

sudo -u www-data wp --path=/var/www/wordpress user list --role=administrator --fields=ID,user_login,user_email

Paso 2: Ajustar propietarios y permisos

Si todos los archivos pertenecen a www-data, cualquier fallo en un plugin permite a un atacante reescribir el código de WordPress. Un modelo más seguro es:

  • El código (núcleo, plugins, temas) pertenece a tu usuario y el grupo www-data solo puede leerlo.
  • Solo wp-content/uploads es escribible por el servidor web.
  • wp-config.php no es legible por otros usuarios del sistema.

Aplica propietario y permisos:

sudo chown -R your_user:www-data /var/www/wordpress
sudo find /var/www/wordpress -type d -exec chmod 755 {} +
sudo find /var/www/wordpress -type f -exec chmod 644 {} +
sudo chmod 640 /var/www/wordpress/wp-config.php
sudo chown -R www-data:www-data /var/www/wordpress/wp-content/uploads

Comprueba que no queda nada escribible por cualquier usuario:

sudo find /var/www/wordpress -perm -o+w

El comando no debe mostrar nada.

Con este modelo WordPress ya no puede instalar ni actualizar plugins desde el panel, porque www-data no puede escribir en wp-content/plugins. Las actualizaciones se harán con WP-CLI como your_user, como verás en el paso 3. A partir de ahora ejecuta WP-CLI como tu usuario, sin sudo -u www-data:

wp --path=/var/www/wordpress plugin list

Paso 3: Endurecer wp-config.php

Varias constantes de WordPress reducen lo que un atacante puede hacer si consigue acceso al panel. Añádelas con WP-CLI:

wp --path=/var/www/wordpress config set DISALLOW_FILE_EDIT true --raw
wp --path=/var/www/wordpress config set DISALLOW_FILE_MODS true --raw
wp --path=/var/www/wordpress config set FORCE_SSL_ADMIN true --raw
wp --path=/var/www/wordpress config set WP_DEBUG_DISPLAY false --raw
  • DISALLOW_FILE_EDIT elimina el editor de temas y plugins del panel, que permite ejecutar PHP arbitrario con una sola cuenta de administrador robada.
  • DISALLOW_FILE_MODS impide instalar o actualizar plugins y temas desde el panel, coherente con los permisos del paso 2.
  • FORCE_SSL_ADMIN obliga a usar HTTPS en el acceso y el panel.
  • WP_DEBUG_DISPLAY evita que los errores de PHP, con rutas y detalles internos, se muestren a los visitantes.

Regenera las claves y salts de autenticación. Esto cierra todas las sesiones abiertas, incluida la de un posible intruso:

wp --path=/var/www/wordpress config shuffle-salts
Success: Shuffled the salt keys.

Como el panel ya no puede actualizar nada, programa las actualizaciones con el cron de tu usuario. Abre su crontab:

crontab -e

Añade esta línea, que aplica cada noche las actualizaciones menores del núcleo (las de seguridad) y las de plugins y temas:

0 4 * * * /usr/local/bin/wp --path=/var/www/wordpress core update --minor --quiet && /usr/local/bin/wp --path=/var/www/wordpress plugin update --all --quiet && /usr/local/bin/wp --path=/var/www/wordpress theme update --all --quiet

Las versiones mayores del núcleo (por ejemplo de 6.8 a 6.9) actualízalas a mano con wp core update después de probarlas.

Paso 4: Bloquear rutas peligrosas en Nginx

Varias rutas de WordPress no deberían ser accesibles desde internet. Agrupa las reglas en un snippet para poder reutilizarlo en otros sitios:

sudo nano /etc/nginx/snippets/wordpress-security.conf
# Nunca ejecutar PHP subido a la carpeta de medios
location ~* ^/wp-content/uploads/.*\.(?:php|phtml|phar)$ {
    deny all;
}

# Archivos que revelan la versión o configuración
location ~* ^/(?:wp-config\.php|wp-config-sample\.php|readme\.html|license\.txt)$ {
    deny all;
}

# XML-RPC: objetivo habitual de fuerza bruta y amplificación
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

# Archivos ocultos (.git, .env, .htaccess), salvo la validación de Let's Encrypt
location ~ /\.(?!well-known) {
    deny all;
}

Incluye el snippet en tu bloque de servidor HTTPS:

sudo nano /etc/nginx/sites-available/wordpress

Nginx evalúa los location con expresiones regulares en el orden en que aparecen, así que la línea include debe ir antes del bloque location ~ \.php$; si no, la regla genérica de PHP ejecutaría los archivos de uploads antes de llegar a la de bloqueo:

server {
    # ... listen, server_name, ssl_certificate, root, index ...

    include snippets/wordpress-security.conf;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}

Recarga Nginx y comprueba que las reglas funcionan. Crea un archivo PHP de prueba en uploads e intenta ejecutarlo:

sudo nginx -t && sudo systemctl reload nginx
echo '<?php echo "ejecutado";' | sudo tee /var/www/wordpress/wp-content/uploads/test.php > /dev/null
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-content/uploads/test.php
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/xmlrpc.php
sudo rm /var/www/wordpress/wp-content/uploads/test.php
403
403

Paso 5: Limitar los intentos de acceso en Nginx

Los bots prueban miles de contraseñas por hora contra wp-login.php. Nginx puede limitar cuántas peticiones acepta de cada IP a esa ruta antes de que lleguen a PHP. Define la zona de límite en el contexto http:

sudo nano /etc/nginx/conf.d/wordpress-ratelimit.conf
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=10r/m;
limit_req_status 429;

Esto permite 10 peticiones por minuto a cada IP. Añade al final del snippet del paso 4 un bloque específico para la página de acceso:

sudo nano /etc/nginx/snippets/wordpress-security.conf
location = /wp-login.php {
    limit_req zone=wplogin burst=5 nodelay;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}

burst=5 absorbe los picos legítimos (cargar la página y enviar el formulario). Recarga y lanza 20 peticiones seguidas:

sudo nginx -t && sudo systemctl reload nginx
for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} ' https://example.com/wp-login.php; done; echo
200 200 200 200 200 200 429 429 429 429 429 429 429 429 429 429 429 429 429 429

Paso 6: Bloquear IP atacantes con Fail2ban

El límite de Nginx frena a un bot, pero no le impide volver. Fail2ban lee el registro de acceso y bloquea en el firewall las IP que acumulan intentos fallidos. Un acceso fallido a WordPress devuelve 200 (vuelve a mostrar el formulario), mientras que uno correcto devuelve 302 (redirige al panel); el filtro se basa en esa diferencia.

Instala Fail2ban:

sudo apt install -y fail2ban

Crea el filtro:

sudo nano /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> -.*"POST /wp-login\.php HTTP/[0-9.]+" 200
            ^<HOST> -.*"POST /xmlrpc\.php HTTP/[0-9.]+"
ignoreregex =

Crea la jail que lo usa:

sudo nano /etc/fail2ban/jail.d/wordpress.conf
[wordpress-login]
enabled  = true
port     = http,https
filter   = wordpress-login
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 10m
bantime  = 1h

Una IP que falle 5 veces en 10 minutos queda bloqueada una hora. Si tu sitio usa un registro de acceso propio, cambia logpath. Antes de activarlo, prueba el filtro contra el registro real:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf
Lines: 18342 lines, 0 ignored, 37 matched, 18305 missed

Reinicia Fail2ban y comprueba el estado de la jail:

sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-login
Status for the jail: wordpress-login
|- Filter
|  |- Currently failed: 2
|  |- Total failed:     2
|  `- File list:        /var/log/nginx/access.log
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:

Si te bloqueas a ti mismo, desbloquea tu IP con sudo fail2ban-client set wordpress-login unbanip your_ip.

Paso 7: Activar la autenticación en dos pasos

Aunque una contraseña se filtre, un segundo factor impide el acceso. El plugin Two Factor, mantenido por colaboradores del núcleo de WordPress, admite aplicaciones TOTP (Google Authenticator, Aegis, 1Password), llaves de seguridad y códigos de respaldo. Como el panel ya no puede instalar plugins, instálalo con WP-CLI:

wp --path=/var/www/wordpress plugin install two-factor --activate

Cada usuario lo activa desde Usuarios > Perfil, en la sección Two-Factor Options: escanea el código QR con su aplicación, introduce el código de verificación y genera códigos de respaldo. Empieza por todas las cuentas de administrador y comprueba que puedes cerrar sesión y volver a entrar con el segundo factor antes de aplicarlo al resto.

Paso 8: Añadir cabeceras de seguridad

Las cabeceras HTTP indican al navegador que aplique protecciones adicionales frente a clickjacking, detección de tipos MIME y fugas de la URL de origen. Añádelas al principio del snippet:

sudo nano /etc/nginx/snippets/wordpress-security.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;

Strict-Transport-Security (HSTS) obliga al navegador a usar siempre HTTPS con tu dominio durante un año; añádela solo cuando estés seguro de que todo el sitio funciona por HTTPS. Una Content-Security-Policy estricta suele romper el editor de bloques y muchos plugins, así que si quieres una, pruébala primero con Content-Security-Policy-Report-Only.

Nginx solo hereda los add_header del nivel superior en los location que no definen los suyos. Si algún location de tu sitio tiene su propio add_header (por ejemplo X-Cache-Status), repite ahí estas cabeceras. Recarga y compruébalas:

sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ | grep -iE 'x-content-type|x-frame|referrer|strict-transport'
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000

Paso 9: Verificar la integridad de los archivos

WP-CLI puede comparar cada archivo del núcleo y de los plugins del repositorio oficial con las sumas de verificación publicadas en WordPress.org. Es la forma más fiable de detectar código modificado o archivos añadidos por un atacante:

wp --path=/var/www/wordpress core verify-checksums
wp --path=/var/www/wordpress plugin verify-checksums --all
Success: WordPress installation verifies against checksums.
Success: Verified 8 of 8 plugins.

Cualquier archivo marcado como File doesn't verify against checksum o File should not exist merece una revisión inmediata. Los plugins de pago o personalizados no están en WordPress.org y aparecerán como no verificables: compáralos con una copia descargada del proveedor.

Busca además archivos PHP en uploads, donde nunca debería haberlos:

sudo find /var/www/wordpress/wp-content/uploads -type f -name '*.php'

Programa la verificación semanal en el crontab de tu usuario (crontab -e); cron te enviará la salida por correo si el servidor tiene un agente de correo configurado:

0 5 * * 1 /usr/local/bin/wp --path=/var/www/wordpress core verify-checksums --quiet && /usr/local/bin/wp --path=/var/www/wordpress plugin verify-checksums --all --quiet

Paso 10: Limitar los privilegios de la base de datos

El usuario de MySQL de WordPress solo necesita acceso a su propia base de datos, nunca privilegios globales. Comprueba sus permisos (sustituye wpuser por el usuario de DB_USER en wp-config.php):

sudo mysql -e "SHOW GRANTS FOR 'wpuser'@'localhost';"
+-------------------------------------------------------------+
| Grants for wpuser@localhost                                 |
+-------------------------------------------------------------+
| GRANT USAGE ON *.* TO `wpuser`@`localhost`                  |
| GRANT ALL PRIVILEGES ON `wordpress`.* TO `wpuser`@`localhost` |
+-------------------------------------------------------------+

Si ves GRANT ALL PRIVILEGES ON *.* o el usuario es root, crea un usuario dedicado con acceso solo a la base de datos de WordPress y actualiza wp-config.php. Comprueba también que MySQL solo escucha en local:

sudo ss -tlnp | grep -E ':3306|:33060'
LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=812,fd=23))

Solución de problemas

  • El panel pide credenciales FTP al actualizar: es el efecto esperado de los permisos del paso 2. Con DISALLOW_FILE_MODS el botón de actualizar desaparece; actualiza con WP-CLI como your_user.
  • No se pueden subir imágenes: wp-content/uploads debe pertenecer a www-data. Repite el último chown del paso 2.
  • Fail2ban no bloquea nada: comprueba con fail2ban-regex que el filtro encuentra líneas y que logpath apunta al registro correcto. Revisa sudo journalctl -u fail2ban.
  • Te has quedado sin acceso por la autenticación en dos pasos: desactiva el plugin con wp --path=/var/www/wordpress plugin deactivate two-factor, entra y vuelve a configurarlo.

Conclusión

Has reducido la superficie de ataque de WordPress eliminando código sin uso, has separado el código de los datos escribibles, has endurecido wp-config.php, has bloqueado en Nginx las rutas que se usan en los ataques, has frenado la fuerza bruta con limit_req y Fail2ban, has añadido un segundo factor y sabes verificar la integridad de los archivos. Como siguientes pasos, programa copias de seguridad automáticas de la base de datos y de wp-content fuera del servidor, revisa periódicamente los usuarios administradores y considera un WAF delante del sitio si recibes ataques frecuentes.