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 llamayour_user. - WordPress funcionando en
/var/www/wordpresscon Nginx, PHP 8.3-FPM y MySQL o MariaDB, con HTTPS ya configurado. El bloque de servidor está en/etc/nginx/sites-available/wordpressy el dominio esexample.com. - WP-CLI instalado en
/usr/local/bin/wp. - UFW activo permitiendo solo SSH y web (
sudo ufw allow OpenSSHysudo 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-datasolo puede leerlo. - Solo
wp-content/uploadses escribible por el servidor web. wp-config.phpno 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
Notaalgunos plugins necesitan escribir en su propio directorio dentro de
wp-content(por ejemplowp-content/cacheen plugins de caché). Da permiso de escritura awww-datasolo en esos directorios concretos, nunca en todowp-content.
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_EDITelimina el editor de temas y plugins del panel, que permite ejecutar PHP arbitrario con una sola cuenta de administrador robada.DISALLOW_FILE_MODSimpide instalar o actualizar plugins y temas desde el panel, coherente con los permisos del paso 2.FORCE_SSL_ADMINobliga a usar HTTPS en el acceso y el panel.WP_DEBUG_DISPLAYevita 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;
}
Advertenciabloquear
xmlrpc.phprompe la app móvil de WordPress y las conexiones de Jetpack. Si usas alguno de los dos, elimina ese bloque y confía en Fail2ban (paso 6) para frenar los abusos.
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.
Importantesi el sitio está detrás de un proxy o CDN, el registro de Nginx muestra la IP del proxy y Fail2ban bloquearía al proxy entero. Configura antes el módulo
real_ipde Nginx con los rangos de tu proveedor para registrar la IP real del visitante.
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_MODSel botón de actualizar desaparece; actualiza con WP-CLI comoyour_user. - No se pueden subir imágenes:
wp-content/uploadsdebe pertenecer awww-data. Repite el últimochowndel paso 2. - Fail2ban no bloquea nada: comprueba con
fail2ban-regexque el filtro encuentra líneas y quelogpathapunta al registro correcto. Revisasudo 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.
