Dopo la bonifica: checklist di hardening per WordPress, PHP e web server

Aggiornamenti, credenziali e 2FA, un pool PHP-FPM per sito, limiti al login, backup esterni e monitoraggio: la checklist di hardening dopo una bonifica.

Uno scudo con un segno di spunta accanto a una checklist con voci spuntate in verde, su quattro livelli: WordPress, PHP, web server e sistema

Una bonifica ben fatta restituisce un sito pulito, ma non un sito più robusto di prima. Se il punto d’ingresso era un plugin vulnerabile, una password riutilizzata o un permesso troppo largo sui file, lo stesso varco resta aperto finché qualcuno non lo chiude. L’hardening è proprio questo: ridurre le possibilità di un nuovo attacco e, se arriva comunque, limitarne i danni e renderlo visibile in fretta.

Questa checklist raccoglie le misure che applichiamo dopo una pulizia, divise per livello: WordPress, PHP, web server e sistema. Non ripete i dettagli già trattati nell’articolo su wp-config.php, salt e permessi: qui l’obiettivo è il quadro completo, con i comandi per metterlo in pratica.

Nessuna misura da sola rende un sito invulnerabile. Molte misure insieme fanno sì che un attacco debba superare più ostacoli, e che ogni ostacolo lasci una traccia.

Perché l’hardening viene dopo la bonifica

L’ordine conta. Irrigidire un sito ancora infetto serve a poco: una backdoor già presente continua a funzionare anche con i permessi corretti, e una regola nel file .htaccess può essere cancellata da chi ha già accesso in scrittura. Prima si rimuovono il malware e tutte le forme di persistenza, comprese quelle che non stanno nei file di WordPress, come i cron job, i processi rimasti in memoria e i trigger nel database. Poi si chiude il varco da cui l’attacco è entrato e si alzano le difese intorno.

Conviene anche pensare per livelli. Un sito WordPress non è solo un’applicazione: gira dentro un interprete PHP, dietro un web server, su un sistema operativo con i suoi utenti e le sue attività pianificate. Ogni livello ha le sue misure.

Schema a quattro livelli sovrapposti, dall’esterno verso l’interno: web server con blocco di xmlrpc, limiti al login e intestazioni di sicurezza; PHP con pool dedicato, open_basedir e disable_functions; WordPress con aggiornamenti, 2FA e DISALLOW_FILE_EDIT; sistema con permessi, backup esterni e monitoraggio dell’integrità. Una freccia rossa indica un attacco che deve attraversarli tutti
Difesa a livelli: ogni strato riduce ciò che un attacco può fare se quello precedente cede.

WordPress: aggiornamenti e superficie ridotta

La maggior parte delle infezioni che vediamo parte da un componente aggiuntivo, non dal core. È coerente con i dati pubblici: secondo il rapporto State of WordPress Security in 2025 di Patchstack, delle 7.966 nuove vulnerabilità dell’ecosistema WordPress registrate nel 2024 il 96% riguardava plugin. Il primo punto della checklist è quindi sapere cosa è installato e se è aggiornato.

# Core, plugin e temi con aggiornamenti disponibili
wp core check-update
wp plugin list --update=available --fields=name,version,update_version
wp theme list --update=available

# Aggiornare tutto (dopo un backup)
wp core update && wp core update-db
wp plugin update --all
wp theme update --all

# Aggiornamenti automatici per i plugin (WP-CLI 2.5 o successivo)
wp plugin auto-updates enable --all

Il secondo punto è togliere ciò che non serve. Un plugin disattivato non esegue codice attraverso WordPress, ma i suoi file restano sul disco e restano raggiungibili dal browser: se contengono uno script vulnerabile che si può chiamare direttamente, la disattivazione non protegge nulla. Lo stesso vale per i temi di default mai usati e per le copie di prova dimenticate in sottocartelle.

# Plugin e temi inattivi: candidati alla cancellazione
wp plugin list --status=inactive --field=name
wp theme list --status=inactive --field=name

# Dopo averli verificati uno per uno
wp plugin delete nome-plugin
wp theme delete twentytwentyone

Se un plugin è stato abbandonato dal suo autore, cioè non riceve aggiornamenti da molto tempo o è stato rimosso dalla directory ufficiale, va sostituito anche se al momento non ha vulnerabilità note: quando ne avrà una, nessuno la correggerà.

Infine, due costanti di wp-config.php: DISALLOW_FILE_EDIT, che toglie l’editor di temi e plugin dal pannello, va sempre attivata; DISALLOW_FILE_MODS, che blocca anche installazioni e aggiornamenti dal pannello, ha senso solo se gli aggiornamenti passano da WP-CLI o da un processo di deploy. Il perché è spiegato nell’articolo su wp-config.php.

Credenziali, password applicative e 2FA

Dopo una compromissione tutte le credenziali vanno considerate lette: quelle degli utenti WordPress, la password del database, gli accessi SFTP, SSH e al pannello dell’hosting, le chiavi API salvate nelle opzioni dei plugin. Le chiavi e i salt si rigenerano con wp config shuffle-salts, che disconnette tutti gli utenti. Ma ci sono due voci che spesso si dimenticano.

La prima sono gli utenti stessi. Un attacco crea spesso un amministratore in più, a volte nascosto dall’elenco del pannello con un filtro: il controllo va fatto da WP-CLI o direttamente nel database, come spiegato nell’articolo sugli amministratori fantasma. La seconda sono le password applicative, introdotte in WordPress 5.6 per l’accesso alla REST API. Sono indipendenti dalla password dell’utente: cambiare quella non le invalida, e una password applicativa creata dall’attacco continua a funzionare.

# Amministratori presenti
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

# Password applicative di un utente, e revoca di tutte
wp user application-password list 1 --fields=uuid,name,created,last_used
wp user application-password delete 1 --all

# Sessioni attive di tutti gli utenti
wp user session destroy --all-users

Se nessuna integrazione usa le password applicative, si possono disattivare del tutto con una riga in un plugin must-use: add_filter( 'wp_is_application_passwords_available', '__return_false' );. È anche un buon momento per ricordare che la cartella mu-plugins va controllata con attenzione, perché è uno dei nascondigli preferiti per le backdoor.

Per gli amministratori e per chiunque possa pubblicare codice o installare plugin serve l’autenticazione a due fattori. WordPress non la include nel core, ma esistono plugin consolidati, tra cui Two Factor, sviluppato da contributori del progetto WordPress. Un secondo fattore basato su app (TOTP) o su chiave di sicurezza rende inutile una password rubata da un computer infetto o da un altro servizio violato.

File: proprietà, permessi e upload

I valori di riferimento per i permessi, 644 per i file e 755 per le cartelle, sono già nell’articolo su wp-config.php. Qui conta un aspetto che fa più differenza dei numeri: chi è il proprietario dei file e con quale utente gira PHP. Se coincidono, qualunque codice eseguito da PHP può riscrivere l’intera installazione, core compreso. Se sono diversi, PHP può scrivere solo dove gli viene concesso esplicitamente.

# File di proprietà di un utente di deploy, leggibili dal gruppo di PHP
chown -R deploy:esempio /var/www/esempio.it/htdocs
find /var/www/esempio.it/htdocs -type d -exec chmod 750 {} +
find /var/www/esempio.it/htdocs -type f -exec chmod 640 {} +

# Scrittura consentita solo negli upload (e nelle cache, se servono)
chmod -R g+w /var/www/esempio.it/htdocs/wp-content/uploads

In questo schema deploy è l’utente che carica il codice e lancia gli aggiornamenti con WP-CLI, mentre esempio è l’utente del pool PHP del sito, di cui parliamo più avanti. Il web server deve poter leggere i file statici, quindi va aggiunto al gruppo o servono permessi di lettura più larghi: dipende dalla configurazione. Gli aggiornamenti dal pannello non funzionano più, ma un plugin vulnerabile non può modificare wp-includes o iniettare codice in index.php.

La cartella degli upload resta scrivibile per forza, ed è per questo che al suo interno PHP non deve essere eseguibile. Le regole per Apache, LiteSpeed e NGINX sono nell’articolo sulle webshell negli upload: se non sono ancora applicate, è una delle prime cose da fare, possibilmente nella configurazione del server e non in un .htaccess che l’utente del sito può modificare.

PHP: un pool per sito, open_basedir e disable_functions

Su un server con più siti, la misura più efficace a livello PHP è dare a ogni sito il proprio pool PHP-FPM, con un utente di sistema dedicato. Con un solo pool condiviso, tutti i siti girano con lo stesso utente: basta un plugin vulnerabile in uno di essi perché l’attacco possa leggere il wp-config.php e scrivere nei file di tutti gli altri. È la ragione per cui a volte ripuliamo un sito e lo troviamo reinfettato da un vicino di cui nessuno si ricordava.

Confronto tra due configurazioni. A sinistra un unico pool PHP-FPM con l’utente www-data serve tre siti: il sito A compromesso può scrivere nei file dei siti B e C, frecce rosse. A destra tre pool separati, ciascuno con il proprio utente e il proprio open_basedir: il sito A compromesso resta confinato nella sua cartella, i tentativi verso B e C sono bloccati
Con un pool condiviso un sito compromesso raggiunge tutti gli altri; con un pool per sito l’infezione resta confinata.

Un pool per sito si definisce in un file dedicato. L’esempio che segue è per Debian e Ubuntu con PHP 8.3; su altre distribuzioni cambiano i percorsi, non le direttive.

; /etc/php/8.3/fpm/pool.d/esempio.it.conf
[esempio.it]
user = esempio
group = esempio
listen = /run/php/php8.3-fpm-esempio.it.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s

php_admin_value[open_basedir] = /var/www/esempio.it/
php_admin_value[upload_tmp_dir] = /var/www/esempio.it/tmp
php_admin_value[sys_temp_dir] = /var/www/esempio.it/tmp
php_admin_value[session.save_path] = /var/www/esempio.it/sessions
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[expose_php] = off

I valori impostati con php_admin_value non possono essere cambiati dal codice del sito con ini_set(), né da un file .user.ini. open_basedir limita i percorsi che PHP può aprire alla cartella del sito: per questo anche le cartelle temporanee e delle sessioni vanno spostate al suo interno, altrimenti caricamenti e login smettono di funzionare. Dopo la modifica si verifica la configurazione con php-fpm8.3 -t e si ricarica il servizio.

Su disable_functions servono due avvertenze. La prima: alcune funzioni servono davvero, per esempio a plugin di backup o di ottimizzazione delle immagini che lanciano programmi esterni, quindi l’elenco va provato sul sito reale e non copiato alla cieca. La seconda: non è una barriera invalicabile. Nel tempo sono state pubblicate diverse tecniche per aggirarla, dalla combinazione di putenv() e mail() con LD_PRELOAD a vari bug dell’interprete. È un ostacolo per gli attacchi automatici e le webshell più comuni, non un confinamento. Il confinamento vero lo danno l’utente dedicato e i permessi sul file system.

Web server: xmlrpc, login e intestazioni di sicurezza

Il file xmlrpc.php è un’interfaccia storica di WordPress, oggi sostituita quasi ovunque dalla REST API. Resta però un bersaglio frequente per i tentativi di accesso a forza bruta, anche perché il metodo system.multicall permette di provare molte password in una sola richiesta. Se nessun servizio lo usa, e va controllato perché alcuni plugin come Jetpack e alcune app di pubblicazione ne hanno bisogno, si blocca a livello di web server:

# NGINX, nel blocco server
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

# Apache 2.4
<Files "xmlrpc.php">
    Require all denied
</Files>

La pagina di login va protetta con un limite di frequenza. Con NGINX lo si fa con il modulo limit_req: si definisce una zona che conta le richieste per indirizzo IP e la si applica solo a wp-login.php, così il resto del sito non ne risente.

# Nel blocco http
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=6r/m;

# Nel blocco server, prima della location generica per i file .php
location = /wp-login.php {
    limit_req zone=wplogin burst=5 nodelay;
    limit_req_status 429;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm-esempio.it.sock;
}

Per bloccare gli indirizzi più insistenti si aggiunge fail2ban, che legge i log e li banna a livello di firewall. Un login fallito su wp-login.php risponde con un codice 200 (la pagina viene ripresentata con l’errore), mentre uno riuscito risponde con un redirect 302: il filtro conta quindi le richieste POST terminate con 200 o con il 429 del limite appena configurato.

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

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

Il filtro presuppone il formato di log predefinito, con l’indirizzo del client all’inizio della riga; se il sito è dietro un CDN o un proxy, bisogna prima far registrare al web server l’IP reale del visitatore, altrimenti fail2ban bannerà il proxy.

Le intestazioni di sicurezza, infine, non fermano un’infezione lato server, ma riducono l’impatto di alcuni attacchi nel browser dei visitatori, come il clickjacking o l’interpretazione di un file caricato come script. Sono poche righe:

server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
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;

Attenzione a un dettaglio di NGINX: se una location definisce anche una sola direttiva add_header, non eredita quelle del blocco server. Conviene quindi verificare il risultato con curl -sI https://esempio.it/ su più tipi di pagina. Una Content Security Policy è la più efficace contro gli skimmer JavaScript, ma su WordPress va costruita con pazienza: si parte con Content-Security-Policy-Report-Only, si raccolgono le violazioni e solo dopo la si rende attiva.

Backup fuori dal server e monitoraggio

Un backup che sta sullo stesso server del sito, scritto dallo stesso utente, può essere cancellato o infettato dallo stesso attacco. I backup utili dopo un incidente hanno tre caratteristiche: stanno altrove, chi li crea non può cancellarli e ne esistono più versioni, così da poter risalire a prima dell’infezione. Il modello più robusto è quello in cui è il server di backup a prelevare i dati dal sito, e non il sito a spedirli: così le credenziali per cancellare i backup non si trovano mai sulla macchina esposta.

# Dump coerente del database senza bloccare le tabelle InnoDB
mysqldump --single-transaction --routines --triggers \
  -u backup -p nome_database | gzip > /var/backups/esempio.it/db-$(date +%F).sql.gz

Le opzioni --routines e --triggers non sono un dettaglio: includono nel dump anche il codice che vive nel database, che è esattamente ciò che serve per accorgersi di un trigger malevolo confrontando due backup.

Il monitoraggio chiude il cerchio. L’obiettivo non è impedire tutto, ma accorgersi in ore, non in settimane, se qualcosa cambia. Il controllo più semplice è confrontare i file del core e dei plugin con le versioni ufficiali:

# File del core diversi dall'originale o aggiunti
wp core verify-checksums

# Plugin della directory ufficiale (quelli commerciali vengono saltati)
wp plugin verify-checksums --all

# File PHP modificati nelle ultime 24 ore
find /var/www/esempio.it/htdocs -name '*.php' -mtime -1 -ls

Eseguiti ogni giorno da un cron di sistema, con l’output inviato per email solo quando non è vuoto, questi comandi segnalano gran parte delle reinfezioni. A livello di sistema si aggiunge uno strumento di integrità come AIDE, che tiene un’impronta dei file e avvisa quando cambiano, e un controllo periodico di crontab, utenti amministratori e cartella mu-plugins. Anche i log vanno tenuti e letti: una richiesta insolita a un file di un plugin è spesso il primo segnale di un tentativo, e oggi si possono analizzare in modo molto più efficace anche con modelli di AI eseguiti in locale.

La checklist in sintesi

Non tutte le voci hanno la stessa urgenza. Alcune vanno fatte subito, prima di rimettere il sito in produzione; altre richiedono modifiche alla configurazione del server e vanno pianificate; altre ancora sono abitudini da mantenere nel tempo.

Checklist su tre colonne. Subito: aggiornare core, plugin e temi, cancellare quelli inutilizzati, cambiare credenziali e salt, revocare password applicative, chiudere le sessioni attive, bloccare PHP negli upload. Entro la settimana: attivare la 2FA, separare proprietario dei file e utente PHP, creare un pool PHP-FPM per sito con open_basedir, bloccare xmlrpc e limitare il login, aggiungere le intestazioni di sicurezza. Sempre: backup esterni e versionati, prove di ripristino, verifica dei checksum, controllo di cron, utenti e mu-plugins, lettura dei log
La checklist divisa per urgenza: prima di riaprire il sito, nei giorni successivi e come controllo continuo.
  1. Subito: aggiornare core, plugin e temi; cancellare quelli inutilizzati o abbandonati; cambiare tutte le credenziali, rigenerare i salt, revocare le password applicative e le sessioni; bloccare l’esecuzione di PHP negli upload.
  2. Entro la settimana: attivare la 2FA per gli amministratori; separare il proprietario dei file dall’utente PHP; creare un pool PHP-FPM per ogni sito, con open_basedir e un elenco di disable_functions provato sul sito; bloccare xmlrpc.php se non serve; limitare la frequenza dei login; aggiungere le intestazioni di sicurezza.
  3. Sempre: backup esterni, versionati e non cancellabili dal sito, con prove periodiche di ripristino; verifica quotidiana dei checksum e dei file modificati; controllo di cron, utenti e mu-plugins; conservazione e lettura dei log.

Se il sito era finito in una blacklist, l’hardening è anche il presupposto per chiedere la revisione: Google e gli altri servizi vogliono vedere che il problema è stato risolto, non solo ripulito. Come procedere è spiegato nella guida alla rimozione dalla blacklist con Search Console.

Molte misure si applicano in autonomia; quelle a livello di sistema, come i pool separati e la proprietà dei file, richiedono accesso root e qualche prova per non interrompere il sito. Quando ci affidano una bonifica, con intervento garantito in 6, 12 o 24 ore per installazione WordPress, chiudiamo sempre il varco da cui è entrato l’attacco e indichiamo quali punti di questa lista mancano; se vuoi un parere sul tuo caso, puoi scriverci.

IL PROSSIMO PASSO

Il tuo sito
è infetto?

Ogni ora conta: il malware continua a colpire visitatori e clienti, e Google, l’hosting e le blacklist non aspettano. Scegli il piano, paga online e condividi gli accessi: la bonifica parte subito, a prezzo fisso e con tempi garantiti.

Richiedi la bonifica

Ti porta ai piani: bonifica entro 6, 12 o 24 ore dalla ricezione degli accessi.

MANAGED SERVER S.R.L.info@rimozionemalware.com +39 02 5656 9681

Lunedì – venerdì · 09:30 – 19:30

Preferisci un modulo online?
Contattaci su managedserver.it ↗