Dal log al vettore d’attacco: leggere gli access log di Apache, NGINX e LiteSpeed

Formato, percorsi, fusi orari e rotazione dei log di Apache, NGINX e LiteSpeed, e ricette grep e awk per risalire da un file infetto alla richiesta d’attacco.

Una finestra di terminale con l’elenco delle richieste POST di un access log e una riga verso admin-ajax.php evidenziata in rosso, collegata a un file PHP con la data di cambio stato, accanto ai nomi di Apache, NGINX e LiteSpeed

Ogni richiesta che arriva a un sito lascia una riga nel log di accesso del server web: chi l’ha fatta, quando, verso quale indirizzo, con quale esito. Quando un sito WordPress viene compromesso, da qualche parte in quei file c’è la richiesta con cui l’attacco è entrato. Trovarla è il modo più affidabile per sapere quale falla chiudere, invece di limitarsi a cancellare i sintomi.

In un articolo precedente abbiamo raccontato come usiamo modelli di intelligenza artificiale locali per smistare le richieste che nessuna regola riconosce. Qui facciamo un passo indietro, alla parte manuale: come è fatta una riga di log su Apache, NGINX e LiteSpeed, dove trovarla, quali trappole nascondono fusi orari e rotazione, e alcune ricette con grep e awk per i casi più comuni.

Com’è fatta una riga di log

I tre server web più diffusi con WordPress usano, salvo configurazioni particolari, lo stesso formato di base: il cosiddetto combined log format, nato con Apache e adottato come predefinito anche da NGINX e LiteSpeed. Una riga tipica ha questo aspetto:

203.0.113.45 - - [27/Aug/2026:22:41:07 +0200] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 312 "https://www.esempio.it/contatti/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"

Da sinistra: l’indirizzo IP del client, due campi per l’identità (quasi sempre vuoti, indicati con un trattino), data e ora con il fuso orario, la riga di richiesta con metodo, percorso e protocollo, il codice di risposta, i byte inviati, il referer e lo user agent. In Apache corrisponde alla direttiva LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" combined; in NGINX al formato combined predefinito, costruito con le variabili $remote_addr, $time_local, $request, $status e così via.

Anatomia di una riga di access log in formato combined, divisa in campi etichettati: indirizzo IP del client, campi di identità vuoti, data e ora con fuso orario, metodo, percorso e protocollo della richiesta, codice di risposta, byte inviati, referer e user agent; sotto ogni campo è indicata la posizione da usare con awk, da $1 a $10, e una nota ricorda che il corpo delle richieste POST non viene registrato
I campi di una riga in formato combined e la loro posizione per awk. Quello che manca, il corpo delle POST, è spesso la parte più interessante.

Due cose non ci sono. La prima è il corpo delle richieste POST: si vede che qualcuno ha inviato dati a admin-ajax.php, non quali dati. La seconda è il nome del sito, se più domini condividono lo stesso file. Per questo, sui server che gestiamo, estendiamo il formato con qualche campo utile all’analisi, sapendo che ogni campo in più sposta la numerazione usata negli script:

# NGINX: data in ISO 8601, tempo di risposta, host e IP originale dietro proxy
log_format forense '$remote_addr - $remote_user [$time_iso8601] "$request" '
                   '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                   '$request_time $host "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log forense;

# Apache: stessi concetti (%D è il tempo di risposta in microsecondi, %v il virtual host)
LogFormat "%a %l %u %{%Y-%m-%dT%H:%M:%S%z}t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\" %D %v" forense
CustomLog ${APACHE_LOG_DIR}/access.log forense

In LiteSpeed il formato si imposta dal pannello di amministrazione, a livello di server o di singolo virtual host, con una sintassi derivata da quella di Apache. Nelle installazioni con cPanel, dove LiteSpeed legge la configurazione di Apache, valgono le direttive di Apache.

Dove trovare i log

La posizione dipende dal server web, dalla distribuzione e, soprattutto, dall’eventuale pannello di hosting. I percorsi predefiniti più comuni sono:

  • Apache: /var/log/apache2/ su Debian e Ubuntu, /var/log/httpd/ su RHEL, AlmaLinux e Rocky Linux, spesso con un file per ogni virtual host;
  • NGINX: /var/log/nginx/, con file separati se la direttiva access_log è definita nei singoli blocchi server;
  • LiteSpeed e OpenLiteSpeed: /usr/local/lsws/logs/ per il server, e per i virtual host la cartella indicata nella loro configurazione, spesso logs/ dentro la radice del virtual host;
  • cPanel: /usr/local/apache/domlogs/, un file per dominio e uno con suffisso -ssl_log per il traffico HTTPS; l’utente vede gli archivi compressi in ~/logs/, se l’archiviazione è attiva;
  • Plesk: /var/www/vhosts/system/<dominio>/logs/, con access_log, access_ssl_log e, se NGINX fa da proxy davanti ad Apache, anche i file proxy_access_log e proxy_access_ssl_log.

Su un hosting condiviso senza accesso alla riga di comando i log si scaricano di solito dal pannello, in sezioni con nomi come «Raw Access», «Log» o «Statistiche». Se il pannello mostra solo statistiche aggregate, conviene chiedere i file grezzi al supporto del fornitore, specificando il periodo che interessa: molti li conservano più a lungo di quanto rendano disponibile al cliente, ma non per sempre, quindi la richiesta va fatta subito.

Mappa dei percorsi dei log di accesso: Apache in /var/log/apache2 su Debian e Ubuntu o /var/log/httpd su RHEL; NGINX in /var/log/nginx; LiteSpeed e OpenLiteSpeed in /usr/local/lsws/logs o nella cartella logs del virtual host; cPanel in /usr/local/apache/domlogs con file separati per HTTPS; Plesk in /var/www/vhosts/system/dominio/logs; in basso un avviso che dietro una CDN o un proxy l’indirizzo registrato è quello del proxy, a meno di configurare mod_remoteip o real_ip
Dove cercare i log di accesso a seconda del server web e del pannello. Dietro una CDN, prima di tutto va capito quale indirizzo IP viene registrato.

Un’avvertenza importante riguarda i siti dietro una CDN o un reverse proxy, come Cloudflare. In quel caso il server web vede come client il proxy, e senza una configurazione apposita registra sempre gli stessi pochi indirizzi della CDN: l’analisi per IP diventa inutile. Il vero indirizzo arriva in un’intestazione, come X-Forwarded-For o CF-Connecting-IP, che va fatta usare al server web con il modulo mod_remoteip di Apache o real_ip di NGINX, fidandosi solo delle intestazioni provenienti dagli indirizzi del proxy. In caso contrario, chiunque potrebbe inserire un indirizzo falso.

Fusi orari e rotazione: le due trappole

Il campo della data riporta l’ora locale del server con lo scostamento dal tempo universale: +0200 in Italia d’estate, +0100 d’inverno. Il problema nasce quando si incrociano fonti diverse. Il log di un proxy o di una CDN può essere in UTC, WordPress salva alcune date nel fuso orario impostato nel sito e altre in UTC, il comando find mostra le date dei file nel fuso orario della sessione. Un’ora di scarto è sufficiente per cercare l’attacco nella finestra sbagliata. Prima di correlare, conviene portare tutto sullo stesso fuso, per esempio lanciando i comandi con TZ=UTC davanti.

La seconda trappola è la rotazione. Quasi tutti i sistemi usano logrotate, che rinomina il file ogni giorno o ogni settimana, lo comprime e cancella i più vecchi dopo un certo numero di cicli. Il file access.log contiene quindi solo le ultime ore o gli ultimi giorni; il resto è in access.log.1, access.log.2.gz e così via, finché esiste. Su molti hosting condivisi la conservazione è di pochi giorni: se l’infezione è stata scoperta dopo un mese, i log del momento dell’attacco potrebbero non esserci più.

# Quanto indietro arrivano i log disponibili
ls -ltr /var/log/nginx/
zcat -f $(ls -tr /var/log/nginx/access.log*) | head -1

# Cercare in tutti i file, compressi e non, in ordine cronologico
zcat -f $(ls -tr /var/log/nginx/access.log*) | grep '"POST /wp-login.php'

# Conservazione configurata
grep -A8 '/var/log/nginx' /etc/logrotate.d/nginx

Dai file ai log: correlare le date

Il punto di partenza più solido è quasi sempre un file malevolo già individuato. La sua data di modifica si può falsificare con un semplice touch, mentre il ctime, cioè il momento dell’ultimo cambio di stato dell’inode, viene aggiornato dal sistema e non si può impostare a piacere senza privilegi elevati. Si parte quindi dall’elenco dei file PHP con ctime recente:

# File PHP con cambio di stato dopo il 1° agosto, in ordine cronologico
TZ=Europe/Rome find /var/www/esempio.it/public_html -name '*.php' \
  -newerct '2026-08-01' -printf '%C+ %T+ %p\n' | sort

# 2026-08-27+22:41:13.4817260440 2023-11-02+10:14:55.0000000000 ./wp-content/plugins/form-x/tmp/sess.php
#   ^ ctime: scrittura reale        ^ mtime: falsificato

Con l’orario in mano si guardano due finestre nel log. Prima della scrittura: quale richiesta, pochi secondi o minuti prima, può aver creato il file? Di solito una POST verso un componente vulnerabile, un caricamento, un accesso all’amministrazione. Dopo la scrittura: chi ha richiesto direttamente il nuovo file, e da quale indirizzo? Quelle richieste confermano l’uso della backdoor e danno gli indirizzi da cercare nel resto dei log.

# Tutte le richieste tra le 22:36 e le 22:41 del 27 agosto
grep -E '27/Aug/2026:22:(3[6-9]|4[01]):' access.log

# Chi ha usato il nuovo file, e quando
zgrep -h 'form-x/tmp/sess.php' access.log* | awk '{print $1, $4, $6, $9}'

# Tutto ciò che hanno fatto quegli indirizzi, dall'inizio dei log disponibili
zcat -f $(ls -tr access.log*) | grep -E '^(203\.0\.113\.45|198\.51\.100\.7) '
Linea del tempo che correla log e file system: alle 22:38 una serie di richieste di scansione ai percorsi di un plugin; alle 22:41:07 una POST verso admin-ajax.php con risposta 200; alle 22:41:13 il ctime del file sess.php creato nella cartella del plugin; alle 22:43 e nei giorni successivi richieste GET e POST dirette a sess.php da un secondo indirizzo IP; in basso si evidenzia che la data di modifica del file, del 2023, è falsificata mentre il ctime indica il momento reale della scrittura
La ricostruzione tipica: ricognizione, sfruttamento, scrittura del file, uso della backdoor. Il ctime del file fa da ancora tra il file system e il log.

Una volta trovato l’ingresso, vale la pena distinguere il rumore dall’attacco mirato. Ogni sito esposto riceve di continuo richieste automatiche a percorsi che non esistono, come copie di backup di wp-config.php, file .env o pannelli di altri software, e la maggior parte finisce con un 404. Sono scanner che provano di tutto su tutti; interessano poco, a meno che una di quelle richieste abbia ricevuto un 200. Le richieste mirate, al contrario, colpiscono percorsi che esistono davvero sul sito, cioè componenti installati, e ottengono risposte coerenti.

# Percorsi più richiesti con esito 404: il rumore di fondo
awk '$9 == 404 {split($7, p, "?"); print p[1]}' access.log | sort | uniq -c | sort -rn | head -20

# Stessi percorsi "da scanner", ma con esito 200: questi vanno guardati
grep -E '"GET /[^ ]*(wp-config\.php\.(bak|old|save)|\.env|\.git/config)[^ ]* HTTP/[0-9.]+" 200 ' access.log

Brute force su wp-login.php e xmlrpc.php

I tentativi di indovinare le password sono il rumore di fondo di qualunque sito WordPress, e quasi sempre falliscono. Il punto non è contarli, ma capire se uno è andato a segno. Qui aiuta un dettaglio del comportamento di WordPress: una POST a wp-login.php con credenziali sbagliate restituisce di nuovo la pagina di login con codice 200, mentre un accesso riuscito risponde con un reindirizzamento 302 verso la bacheca.

# Tentativi di login per indirizzo
grep '"POST /wp-login.php' access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head

# Login con esito 302: probabili accessi riusciti, da verificare uno per uno
awk '$6 == "\"POST" && $7 == "/wp-login.php" && $9 == 302 {print $1, $4}' access.log

# xmlrpc.php: richieste per indirizzo
awk '$7 ~ /^\/xmlrpc\.php/ {print $1}' access.log | sort | uniq -c | sort -rn | head

Un 302 non è una prova: anche plugin di sicurezza o di login personalizzato possono reindirizzare. Ma un 302 da un indirizzo che fino a un minuto prima collezionava 200 è un segnale forte, da confrontare con le sessioni e gli utenti del sito. Per xmlrpc.php vale una cautela in più: con il metodo system.multicall una sola richiesta può contenere centinaia di tentativi di login, e il log ne mostra una riga sola. Contare le righe sottostima l’attacco; conviene guardare anche la dimensione delle risposte, che cresce con il numero di tentativi inclusi.

admin-ajax.php e REST API

Molte vulnerabilità dei plugin passano da due punti d’ingresso comuni: wp-admin/admin-ajax.php, dove il parametro action decide quale funzione del plugin viene eseguita, e le rotte della REST API sotto /wp-json/, raggiungibili anche nella forma ?rest_route=. Le rotte REST compaiono per intero nel log; per admin-ajax.php, invece, action è visibile solo se inviato nella query string, perché nelle POST sta nel corpo.

# Azioni di admin-ajax.php visibili nella query string
grep -oE 'admin-ajax\.php\?[^" ]*action=[^&" ]+' access.log | sed 's/.*action=//' | sort | uniq -c | sort -rn

# Rotte REST più richieste, in entrambe le forme
grep -oE '"(GET|POST) /(wp-json/[^ ?"]+|[^ "]*\?rest_route=[^ &"]+)' access.log | sort | uniq -c | sort -rn | head -20

# Enumerazione degli utenti
grep -E 'wp-json/wp/v2/users|[?&]author=[0-9]+' access.log

Quello che si cerca è l’anomalia rispetto al traffico normale del sito: una rotta di un plugin che nessun visitatore usa, chiamata da un solo indirizzo pochi secondi prima della comparsa di un file; una sequenza di POST a admin-ajax.php senza alcuna pagina visitata prima, cosa che un browser vero non fa; un’azione il cui nome corrisponde a una vulnerabilità pubblicata di recente per un plugin installato. Quando il componente vulnerabile viene individuato, la correzione è aggiornarlo o rimuoverlo; in attesa della patch, la rotta si può bloccare a livello di server web o di firewall applicativo.

User agent e indirizzi: cosa dicono e cosa no

Lo user agent è scelto da chi fa la richiesta, quindi va letto come una dichiarazione, non come un fatto. È utile al contrario: gli strumenti automatici poco curati si tradiscono con valori come python-requests, Go-http-client, curl o una stringa vuota, e un attacco condotto con lo stesso script mantiene spesso lo stesso user agent anche cambiando indirizzo, il che permette di collegare richieste che arrivano da IP diversi.

# User agent più frequenti tra le richieste POST
awk -F'"' '$2 ~ /^POST / {print $6}' access.log | sort | uniq -c | sort -rn | head -20

# Una richiesta che si dichiara Googlebot viene davvero da Google?
host 66.249.66.1                          # deve risolvere in *.googlebot.com o *.google.com
host crawl-66-249-66-1.googlebot.com      # e il nome deve tornare allo stesso indirizzo

Anche l’indirizzo IP dice meno di quanto sembri. Gli attacchi arrivano da server in affitto, reti residenziali compromesse, proxy e VPN, e lo stesso operatore può cambiare indirizzo a ogni richiesta. Serve a raggruppare le richieste e a bloccare temporaneamente chi insiste, non a identificare chi c’è dietro. La verifica inversa del DNS, invece, è il metodo che Google indica per riconoscere i propri crawler, ed è preziosa per capire se un malware mostra contenuti diversi a Googlebot, tipico dello spam SEO con cloaking.

Un metodo, e i suoi limiti

Messe in fila, le ricette diventano un metodo: fissare le ancore temporali dal file system, portare tutto sullo stesso fuso orario, guardare le finestre prima e dopo ogni ancora, raccogliere gli indirizzi e gli user agent coinvolti, ripercorrere all’indietro la loro attività fino alla prima richiesta sospetta. Il risultato è una sequenza che si può spiegare riga per riga, ed è quella che permette di dire con ragionevole certezza da dove è entrato l’attacco.

I limiti vanno messi in conto. Senza il corpo delle POST molte ipotesi restano ipotesi; se i log sono stati ruotati via, la finestra dell’attacco può essere persa; se l’attacco è arrivato da SFTP, dal pannello di hosting o da un altro sito dello stesso account, nel log del server web non c’è nulla da trovare. Per questo l’analisi incrocia sempre più fonti: log degli errori, log di PHP-FPM, accessi SSH e FTP, le attività pianificate e il database.

Ogni nostra pulizia si chiude con l’indicazione del vettore d’attacco più probabile e delle misure per chiuderlo, ricostruite proprio così, con l’aiuto di modelli eseguiti in locale per la parte più ripetitiva. Il ripristino di un’installazione WordPress è garantito entro 6, 12 o 24 ore a seconda dell’urgenza. Se hai i log e vuoi capire cosa è successo al tuo sito, scrivici: la prima cosa che ti chiederemo è di non cancellarli.

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 ↗