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": "*".

DataEmail inviateDestinatariBounce
2 settembrecirca 24.500100% caselle Google1.270
4 settembrecirca 25.00099,3% caselle BT Internet437
Nei giorni seguentiAWS 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.

PuntoNel pianoNella realtà
Credenziale condivisacancellatarigenerata: utente ancora presente, chiave attiva creata la mattina stessa, policy su "Resource": "*"
Domini da gestirecirca 4214 identità verificate
Identità cancellatenon indicate50 negli ultimi 90 giorni, 21 lo stesso giorno dall’utente root (pulizia voluta dal cliente), tra cui il dominio usato come mittente dello spam
DKIMnon indicatoin stato FAILED su un dominio
Tracciamentonon indicatonessun configuration set, nessun allarme, nessun topic di audit
Profilo della CLIfunzionantenon valido, InvalidClientTokenId
Altre chiavi IAMnon indicateattive 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.

Il percorso di un’email dopo l’interventoIl sito WordPress invia con la chiave del proprio dominio ad Amazon SES, che controlla identità, mittente e configuration set. Il configuration set audit-all, applicato di default a ogni identità, manda i conteggi per dominio a CloudWatch, dove 15 allarmi avvisano via email, e manda bounce, complaint e reject a un topic SNS letto da una coda SQS che li conserva 14 giorni.Sito WordPresschiave valida solo per il suo dominioAmazon SEScontrolla identità, mittente e setConfiguration set audit-alldefault su tutte le 14 identitàCloudWatchinvii contati per ses:from-domainSNS ses-auditbounce, complaint e reject15 allarmi, via email100/h per dominio, 300/h sull’accountCoda SQSeventi conservati 14 giorni
Ogni sito spedisce con la propria chiave. Il configuration set, applicato di default a ogni identità, è il punto da cui partono sia i conteggi per gli allarmi sia lo storico degli eventi.

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.

Soglie di allarme a confronto con il ritmo dell’attaccoL’attacco del 2 settembre viaggiava a circa 8.000 email all’ora. L’allarme sull’account scatta a 300 email all’ora, quello per singolo dominio a 100. Un sito normale ne manda circa 10 all’ora.Attacco del 2 settembre, per circa tre ore8.000/hAllarme sull’intero account300/hAllarme per singolo dominio100/hTraffico normale di un sitocirca 10/h02.0004.0006.0008.000email all’ora, scala lineare
Le soglie quasi non si vedono accanto all’attacco, ed è il punto: stanno molto sopra il traffico normale e molto sotto quello di uno spammer.

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-allarmi riceve pochi eventi l’anno e li manda per email. Deve restare zitto finché non serve.
  • ses-audit riceve 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_COMPLETE o UPDATE_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

AspettoPrimaDopo
Credenziali di invio1, condivisa da tutti i domini, "Resource": "*"14, una per dominio, vincolate a *@dominio
Attribuzione degli inviinessunaogni invio attribuito al dominio mittente
Allarminessuno15, uno sull’account e uno per dominio, entro un’ora
Storico eventinessuno, incidente non ricostruibile14 giorni di bounce, complaint e reject in coda SQS
Contenimentospegnere tutto l’accountdisattivare la sola chiave del dominio coinvolto
Infrastrutturaconfigurata a manotemplate 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 paused ha 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

  1. Amazon SES: FAQ sul processo di revisione degli invii, sospensione e riattivazione
  2. Amazon SES: credenziali SMTP e algoritmo di conversione dalla secret key
  3. Amazon SES: policy IAM e condizione ses:FromAddress
  4. Amazon SES: configuration set di default sulle identità verificate
  5. Amazon SNS: fanout delle notifiche verso code SQS
  6. Amazon CloudWatch: prezzi delle metriche custom

Un progetto da chiarire?

Parto dal problema, non dal preventivo.

Il percorso guidato richiede meno di un minuto. Le risposte mi aiutano a capire il contesto, le priorità e i vincoli prima di sentirci.

Sfidami