Cloud AWS e sicurezza
Account Amazon SES sospeso: 50.000 email di spam partite da un solo WordPress
Un WordPress compromesso, una credenziale SMTP condivisa da quattordici siti e 50.000 email di spam in due giorni. AWS ha fermato l’invio dell’intero account. Racconto come l’ho rimesso in sicurezza e cosa ho trovato controllando prima di agire.
Un’agenzia che gestisce i siti WordPress dei suoi clienti apre la posta e trova il messaggio che nessuno vuole leggere: Amazon SES ha messo in pausa la capacità di invio dell’account. Da quel momento nessuno dei suoi siti spedisce più niente. Niente conferme d’ordine, niente moduli di contatto, niente reset delle password.
Nei due giorni precedenti da quell’account erano partite circa 50.000 email di spam. Non le aveva mandate l’agenzia. Le aveva mandate qualcuno che aveva trovato la credenziale SMTP dentro un WordPress vecchio e mai aggiornato, e la usava dal proprio server.
Qui racconto come ho rimesso in sicurezza l’account, cosa ho scoperto controllando lo stato reale prima di toccare qualcosa, i quattro errori che ho intercettato nel piano da cui partivo e il guasto che è venuto fuori comunque il giorno dopo. Se sei qui perché il tuo account SES è appena stato sospeso, la prima sezione è per te. Il cliente resta anonimo, i servizi AWS no: senza nomi non potresti verificare niente di quello che scrivo.
Account SES sospeso: cosa fare nelle prime ore
Prima del caso, la parte pratica. Sono i passi che rifarei identici.
- Capisci in che stato sei. AWS distingue due situazioni. Con l’account under review puoi ancora inviare e hai un periodo di tempo per correggere il problema. Con il sending pause l’invio è bloccato per tutto l’account nella regione, per tutti i mittenti. Lo stato si vede nella dashboard dell’account SES; il motivo è scritto nell’email che AWS manda all’indirizzo dell’account AWS, che spesso non è quello che leggi tutti i giorni.
- Guarda i numeri che guarda AWS. Secondo la documentazione un account finisce sotto revisione con un bounce rate dal 5% o un complaint rate dallo 0,1%, e rischia la pausa dal 10% di bounce o dallo 0,5% di complaint. Anche pochi indirizzi trappola, le spamtrap, possono bastare.
- Cerca la credenziale, non il sito. Se vedi invii che non riconosci, quasi sempre qualcuno sta usando una credenziale SMTP rubata. Per ogni access key IAM puoi sapere quando è stata usata l’ultima volta e per quale servizio. Non contare su CloudTrail per ricostruire gli invii: quelli fatti via SMTP lì non compaiono.
- Spegni la chiave, poi indaga. La password SMTP deriva dalla secret key dell’utente IAM, quindi cambiarla vuol dire disattivare quella chiave e crearne una nuova. Finché la vecchia è attiva, chi l’ha rubata può continuare a usarla.
- Rispondi al caso solo con quello che hai già fatto. La riattivazione si chiede rispondendo al caso che AWS apre nel Support Center. AWS vuole tre cose: la causa, le modifiche fatte e il motivo per cui impediscono che si ripeta. E lo scrive chiaro: vanno elencate solo le misure già implementate, non quelle in programma.
L’ultimo punto è quello su cui questo caso ha rischiato di scivolare. Ci arrivo tra poco.
Cosa era successo
L’account usava Amazon SES nella regione di Francoforte come relay SMTP per tutti i siti dei clienti dell’agenzia. Tutti con la stessa credenziale, legata a una policy IAM che permetteva ses:SendRawEmail su qualsiasi risorsa, cioè "Resource": "*".
| Data | Email inviate | Destinatari | Bounce |
|---|---|---|---|
| 2 settembre | circa 24.500 | 100% caselle Google | 1.270 |
| 4 settembre | circa 25.000 | 99,3% caselle BT Internet | 437 |
| Nei giorni seguenti | AWS sospende l’invio sull’intero account | ||
Il profilo era quello dello spam professionale: circa 8.000 messaggi all’ora per tre ore, invii separati per provider di destinazione, liste di indirizzi risalenti agli anni tra il 2005 e il 2010 che non avevano niente a che fare con i contatti dei clienti. L’attaccante aveva letto la credenziale dalla configurazione di un WordPress datato e compromesso, e la usava dalla propria macchina.
Il sito bucato non era il vero problema
Un WordPress non aggiornato che viene compromesso, capita. Il problema era che un solo sito compromesso potesse fermare tutti gli altri. Con una credenziale unica per tutti i domini era impossibile:
- capire dal traffico quale sito fosse stato bucato;
- fermare il danno senza fermare anche i clienti che non c’entravano niente;
- ricostruire dopo cosa era stato spedito: SES non conserva né i messaggi né gli header, e gli invii SMTP non lasciano traccia in CloudTrail.
Nessun configuration set, nessun allarme, nessuno storico degli eventi. L’agenzia ha scoperto l’attacco dalla sospensione, cioè quando era già finito.
Prima di toccare qualcosa, ho guardato
Sono partito da un runbook già scritto, con i passi da eseguire. Ho scelto di non eseguirlo alla lettera: prima ho fatto un giro in sola lettura su SES, IAM, SNS, CloudWatch e CloudTrail per confrontare il piano con lo stato reale dell’account. Su parecchi punti non coincidevano.
| Punto | Nel piano | Nella realtà |
|---|---|---|
| Credenziale condivisa | cancellata | rigenerata: utente ancora presente, chiave attiva creata la mattina stessa, policy su "Resource": "*" |
| Domini da gestire | circa 42 | 14 identità verificate |
| Identità cancellate | non indicate | 50 negli ultimi 90 giorni, 21 lo stesso giorno dall’utente root (pulizia voluta dal cliente), tra cui il dominio usato come mittente dello spam |
| DKIM | non indicato | in stato FAILED su un dominio |
| Tracciamento | non indicato | nessun configuration set, nessun allarme, nessun topic di audit |
| Profilo della CLI | funzionante | non valido, InvalidClientTokenId |
| Altre chiavi IAM | non indicate | attive dal 2020 e mai ruotate |
La prima riga è quella che conta. La credenziale che tutti davano per cancellata era stata ricreata, era attiva e poteva ancora spedire da qualsiasi dominio. Senza quel controllo la risposta ad AWS avrebbe elencato come fatta una misura che non lo era: l’opposto di quello che AWS chiede.
La cronologia delle identità cancellate l’ho ricostruita da CloudTrail, che registra le chiamate di gestione anche quando non registra gli invii. È da lì che è saltato fuori il dominio usato come mittente dello spam.
Una chiave per dominio, legata al proprio mittente
L’obiettivo era passare da una chiave per tutti a una chiave per dominio, con cinque requisiti:
- ogni dominio ha credenziali proprie, utilizzabili solo con il proprio indirizzo mittente;
- ogni invio è attribuibile in tempo reale al dominio che l’ha generato;
- un volume anomalo fa scattare un allarme entro un’ora;
- un dominio si spegne senza toccare gli altri;
- ad AWS si scrivono solo misure già attive.
Ho descritto tutto come codice, con un template CloudFormation generato da uno script a partire dalla lista dei domini, invece di una sequenza di comandi a mano che nessuno avrebbe potuto ripetere. È la scelta che il giorno dopo mi ha salvato, come racconto più avanti.
La policy: identità e mittente, insieme
Per ogni dominio lo stack crea un utente IAM con una policy come questa, qui con dominio e account di esempio:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "ses:SendRawEmail",
"Resource": [
"arn:aws:ses:eu-central-1:123456789012:identity/esempio.it",
"arn:aws:ses:eu-central-1:123456789012:configuration-set/audit-all"
],
"Condition": {
"StringLike": { "ses:FromAddress": "*@esempio.it" }
}
}]
}
I vincoli sono due e servono entrambi. La Resource limita l’identità SES su cui la chiave è autorizzata. La condizione su ses:FromAddress lega anche l’indirizzo mittente al dominio, così la chiave non può spedire a nome di un altro dominio verificato sullo stesso account. La seconda risorsa, il configuration set, nel piano originale mancava: ci torno quando racconto il guasto.
Il configuration set, applicato a ogni identità
WordPress non imposta mai l’header X-SES-CONFIGURATION-SET. Se il configuration set non è associato come default a ogni identità, SES non produce nessun evento e tutto il tracciamento resta muto. È il pezzo meno vistoso ed è quello che rende utile tutto il resto.
Le chiavi restano fuori da CloudFormation
Le access key non le crea lo stack. Con AWS::IAM::AccessKey la secret finirebbe negli output dello stack, leggibile da chiunque abbia il permesso di descriverlo. Le genero con uno script, derivo le password SMTP in locale e le scrivo in un solo file con permessi 600, fuori dal repository.
Allarmi tarati sull’attacco
SES pubblica su CloudWatch gli invii con la dimensione ses:from-domain, quindi ogni dominio ha il suo contatore senza scrivere una riga di codice. Ho messo un allarme per dominio a 100 messaggi all’ora e uno sull’intero account a 300.
Un sito transazionale manda unità o decine di email all’ora. Le soglie stanno abbastanza sopra da non suonare per un picco di ordini, e due ordini di grandezza sotto il ritmo dell’attacco: il prossimo lo vedo entro un’ora, non dalla sospensione.
Due canali di notifica, perché hanno frequenze diverse
ses-allarmiriceve pochi eventi l’anno e li manda per email. Deve restare zitto finché non serve.ses-auditriceve un messaggio per ogni bounce, complaint e reject e non ha iscrizioni email: lo legge una coda SQS che conserva gli eventi per 14 giorni.
La coda risolve un problema sottile. SNS non conserva niente: un messaggio pubblicato su un topic senza iscritti va perso. Senza la coda, dopo un allarme vedrei solo il traffico successivo e non quello che l’ha fatto scattare. È esattamente il motivo per cui gli invii di settembre oggi non si possono ricostruire.
La credenziale condivisa, eliminata subito
Il piano la toglieva a fine migrazione, come paracadute. Ma get-access-key-last-used diceva che la chiave rigenerata non era mai stata usata da nessun sito: non era un paracadute, era solo esposizione. L’ho eliminata subito, insieme a utente e policy.
Da qui in avanti, se un dominio fa rumore, si disattiva la sua chiave e gli altri tredici siti continuano a spedire.
Quattro errori nel piano di partenza
Sono la parte meno visibile del lavoro. Nessuno li nota finché non esplodono, e ognuno avrebbe prodotto un guasto, un costo o un problema operativo.
La password SMTP calcolata male
Il runbook derivava la password SMTP dalla secret key con una catena HMAC-SHA256 incompleta: mancava il primo passaggio, quello con la data fissa 11111111 che la documentazione AWS indica come costante.
runbook: AWS4+secret → regione → ses → aws4_request → SendRawEmail
corretto: AWS4+secret → 11111111 → regione → ses → aws4_request → SendRawEmail
Ogni password generata così riceve 535 Authentication Credentials Invalid. L’ho intercettato con il test su un solo dominio che faccio sempre prima del rollout. Senza, avrei distribuito quattordici credenziali inutilizzabili e il problema sarebbe venuto fuori sito per sito, durante la riconfigurazione.
Se ti trovi davanti quell’errore dopo aver convertito una chiave IAM in password SMTP, controlla prima tre cose: la catena completa, la regione giusta e il byte di versione 0x04 davanti alla firma.
Una email per ogni email
Il runbook mandava l’evento SEND a un topic SNS iscritto via email. Tradotto: una notifica nella casella per ogni messaggio spedito. Ai ritmi dell’attacco sarebbero state circa 24.500 email in tre ore, e il canale degli allarmi sarebbe diventato illeggibile proprio quando serviva. Da qui la separazione dei due topic.
Metriche che costano e non servono
Il piano aggiungeva la dimensione ses:caller-identity e l’evento DELIVERY alle metriche CloudWatch. Le metriche custom costano 0,30 $ al mese l’una: circa 21 $ e 4,20 $ al mese per dati che nessun allarme usava e che erano già disponibili altrove. Tolte.
Il rate limit che lo script ingoiava
Associando il configuration set alle 14 identità in sequenza, una chiamata veniva rifiutata per rate limit e lo script ignorava l’errore. Ho aggiunto retry con backoff e la stampa dell’errore vero, così un’identità non resta senza tracciamento in silenzio.
Il guasto del giorno dopo
Il giorno dopo il deploy AWS ha riattivato l’account, e un cliente mi ha segnalato che il suo sito non riusciva a mandare email. L’errore era questo:
User '...:user/ses-<dominio>' is not authorized to perform
'ses:SendRawEmail' on resource '...:configuration-set/audit-all'
SES controlla il permesso di invio non solo sull’identità mittente ma anche sul configuration set applicato al messaggio, compreso quello di default dell’identità. La policy del runbook, che avevo ripreso nel template, autorizzava solo identity/<dominio>. Risultato: nessuno dei quattordici siti poteva inviare.
Perché i test non l’avevano visto? Con l’account sospeso SES rifiuta il messaggio con account paused prima di controllare i permessi. Il mio test SMTP si fermava a un errore che mi aspettavo, e l’ho preso per buono. E il policy simulator l’avevo interrogato solo sulla risorsa identità.
La correzione è stata una riga per policy: la risorsa configuration-set/audit-all, applicata con un change set CloudFormation. Quattordici policy modificate, nessuna risorsa sostituita, chiavi e password invariate: nessun sito da riconfigurare. La condizione su ses:FromAddress vale anche per la nuova risorsa, e l’ho verificato sul policy simulator con le policy live: mittente del proprio dominio allowed, mittente estraneo implicitDeny su entrambe le risorse.
Se ti trovi questo errore dopo aver ristretto una policy SES a una sola identità, la causa è quasi certamente questa: manca il configuration set tra le risorse.
La lezione me la tengo stretta. Un test che si ferma a un errore atteso non verifica niente di quello che viene dopo. Con un servizio sospeso i permessi si controllano sul policy simulator, per ogni risorsa che la chiamata tocca, non solo per quella principale.
Come ho verificato
Ho scritto uno script in sola lettura che controlla tutto lo stato dell’intervento e si può rilanciare quando si vuole:
- account corretto e stato di invio SES;
- stack CloudFormation in
CREATE_COMPLETEoUPDATE_COMPLETE; - 14 identità su 14 associate al configuration set, con lo stato DKIM di ciascuna;
- un utente IAM per dominio, ognuno con la sua chiave;
- nessuna chiave residua sulla credenziale condivisa;
- 15 allarmi presenti;
- iscrizioni SNS confermate su entrambi i topic;
- il file dei segreti mai entrato nella storia di git.
Risultato: 14 credenziali su 14 si autenticano sull’endpoint SMTP, e con simulate-principal-policy la chiave di ogni dominio risulta allowed sui propri indirizzi e implicitDeny sugli altri domini, sui sottodomini e sul dominio usato per lo spam.
Prima e dopo
| Aspetto | Prima | Dopo |
|---|---|---|
| Credenziali di invio | 1, condivisa da tutti i domini, "Resource": "*" | 14, una per dominio, vincolate a *@dominio |
| Attribuzione degli invii | nessuna | ogni invio attribuito al dominio mittente |
| Allarmi | nessuno | 15, uno sull’account e uno per dominio, entro un’ora |
| Storico eventi | nessuno, incidente non ricostruibile | 14 giorni di bounce, complaint e reject in coda SQS |
| Contenimento | spegnere tutto l’account | disattivare la sola chiave del dominio coinvolto |
| Infrastruttura | configurata a mano | template CloudFormation rigenerabile, versionato in git |
Analisi, progettazione, deploy e verifica hanno richiesto 1 ora e 48 minuti di lavoro effettivo, più la correzione del giorno dopo. Restano la riconfigurazione SMTP dei quattordici siti, il DKIM del dominio in errore e la taratura delle soglie dopo una settimana di traffico reale.
E resta la parte più importante, che è un lavoro a sé: la revisione di core, temi, plugin e utenze amministratore delle quattordici installazioni WordPress. Questo intervento limita il danno della prossima compromissione. A prevenirla è la manutenzione dei siti.
Cosa SES non può fare
Sono le quattro domande che mi ha fatto il cliente, e che mi fanno quasi tutti. Meglio chiarirle prima di progettare.
Si può mettere un limite di invio per dominio?
No. In SES quota giornaliera e velocità di invio esistono solo a livello di account. L’equivalente pratico è quello che ho costruito: un allarme per dominio e un interruttore sulla sua chiave.
Un limite di volume avrebbe evitato la sospensione?
No. Il blocco è dipeso dalla qualità dei destinatari, liste esterne piene di trappole antispam, non dal volume. Con un limite più basso le stesse trappole sarebbero state colpite comunque, solo più lentamente.
Serve un IP dedicato?
Non per siti transazionali a basso volume. La reputazione di un IP dedicato si costruisce con traffico costante: con poche email al giorno peggiora invece di migliorare.
Si possono recuperare le email inviate prima?
No. SES non tiene copia dei messaggi, e gli invii precedenti all’attivazione del tracciamento non si possono ricostruire. È il motivo per cui il tracciamento va acceso prima di averne bisogno.
Come impostare la risposta ad AWS
La richiesta di riattivazione è una risposta scritta al caso aperto da AWS, e va costruita sulle tre domande che AWS stessa pone. In questo caso:
- La causa. Una credenziale SMTP unica per tutti i siti, sottratta da un WordPress compromesso e usata da un server esterno per spedire a liste acquistate o rubate.
- Le modifiche già fatte. Credenziale condivisa eliminata, una chiave per dominio vincolata al proprio mittente, configuration set di default su ogni identità, allarmi per dominio, storico degli eventi in coda.
- Perché non si ripete. Una credenziale rubata oggi vale per un solo dominio e un solo mittente, un volume anomalo si vede entro un’ora e si ferma spegnendo una chiave sola.
Niente promesse e niente «stiamo valutando»: quello che non è ancora attivo non va nella risposta. Qui l’account è stato riattivato il giorno dopo il deploy.
Cosa mi porto dietro
- Una credenziale condivisa trasforma un incidente locale in uno globale. Il sito bucato capita. Che un sito bucato possa fermare tutti gli altri è una scelta di progetto.
- Verificare lo stato reale prima di agire. La credenziale «cancellata» era stata rigenerata, il perimetro di 42 domini era di 14. Un runbook descrive un’intenzione, non la realtà.
- Testare su un campione prima del rollout. Un dominio di prova mi ha evitato quattordici credenziali inutilizzabili.
- Diffidare dei test che si fermano a un errore atteso. L’
account pausedha nascosto un difetto di permessi fino al primo invio reale. - Progettare le notifiche sulla frequenza. Un canale di allarme che riceve rumore smette di essere letto.
- Conservare gli eventi prima di averne bisogno. Senza storico, dopo un allarme vedi il futuro, non la causa.
- Infrastruttura come codice anche nelle urgenze. La correzione del giorno dopo è stata una riga, applicata con un change set e leggibile in un diff.
Se il tuo account SES è fermo, o se tutti i tuoi siti spediscono con la stessa chiave e leggendo ti è venuto un dubbio, guarda come lavoro su AWS oppure raccontami la situazione: Sfidami →
Fonti
- Amazon SES: FAQ sul processo di revisione degli invii, sospensione e riattivazione
- Amazon SES: credenziali SMTP e algoritmo di conversione dalla secret key
- Amazon SES: policy IAM e condizione ses:FromAddress
- Amazon SES: configuration set di default sulle identità verificate
- Amazon SNS: fanout delle notifiche verso code SQS
- Amazon CloudWatch: prezzi delle metriche custom