Sécurité

Subscription bombing : autopsie d'un formulaire newsletter détourné en relais de spam

Publié le · 16 min de lecture · par Cédric Santiago · Sanfytech

Un navigateur robotisé alimente un formulaire d'inscription, d'où jaillissent des enveloppes vers des boîtes aux lettres débordantes ; un sas retient l'une d'elles

Un mardi matin, en relisant les journaux d'un site que nous hébergeons, un chiffre ne collait pas : treize inscriptions à la newsletter dans la nuit, pour un site qui en reçoit deux ou trois par semaine. Aucune n'avait été confirmée. Toutes portaient des adresses qui n'avaient jamais mis les pieds sur le site — des boîtes Gmail, Yahoo, Hotmail, mail.ru, un domaine d'entreprise américaine. Le site n'était pas attaqué : il était utilisé. Voici ce que nous avons trouvé en remontant les logs, ce que nous avons compris du robot en face, et pourquoi la défense qui a fini par tenir n'est pas celle qu'on pose en premier.

L'attaque : votre formulaire est l'arme, pas la cible

Le mécanisme porte un nom — subscription bombing ou list bombing — et une fiche à l'OWASP (OAT-017, Spamming). L'attaquant ne veut rien de votre site. Ce qu'il veut, c'est le mail de confirmation que votre site envoie quand quelqu'un s'inscrit. Il prend l'adresse de sa victime — quelqu'un dont il a piraté le compte bancaire, ou qu'il cherche à noyer — et l'inscrit sur des centaines de sites à double opt-in. Chaque site envoie un seul mail poli, se croit irréprochable, et n'a techniquement rien fait de mal. C'est la somme qui submerge : plusieurs centaines de « Confirmez votre inscription » en une heure, au milieu desquels l'alerte de la banque passe inaperçue.

Les conséquences pour le site relais sont réelles et invisibles : chaque mail non sollicité part de votre domaine, signé par votre DKIM. Les destinataires cliquent sur « signaler comme spam ». Votre réputation d'expéditeur baisse chez Gmail et Microsoft, et un jour vos vrais mails — factures, confirmations de commande — atterrissent dans les indésirables sans que vous compreniez pourquoi. Le seuil de plainte au-delà duquel Gmail commence à vous pénaliser est de 0,3 %. Quatorze mails non sollicités sur une liste de quelques centaines d'adresses, c'est déjà dedans.

Sans access log, il n'y a pas d'enquête

Premier constat, un peu honteux : quand le premier signal est apparu — deux jours plus tôt, sur le formulaire de contact — nous n'avions aucun journal HTTP sur cette machine. Traefik ne l'active pas par défaut. Nous avions la table métier (qui a soumis quoi, quand), rien de plus : impossible de savoir combien de fois le robot avait chargé la page, s'il avait demandé le challenge anti-spam, s'il chargeait le JavaScript. La première action a donc été d'activer l'access log au format JSON, avec rotation — et de découvrir au passage que ni cron ni logrotate n'existaient sur ce VPS, un piège que nous avions déjà rencontré sur nos hyperviseurs.

# traefik.yml — access log JSON, en-têtes masqués sauf le User-Agent
accessLog:
  filePath: /var/log/traefik/access.log
  format: json
  fields:
    headers:
      defaultMode: drop
      names:
        User-Agent: keep

Tout ce qui suit vient de ce fichier. Quarante-huit heures de journal, ~220 Mo par jour (les scanners de vulnérabilités pèsent lourd), 45 % de requêtes en erreur — le bruit de fond normal d'une adresse IP publique. Et, au milieu, deux robots bien distincts.

Chronologie d'une campagne

  1. J-2, 11 h → minuit. Six soumissions sur le formulaire de contact, depuis des sorties Tor. Message en charabia, adresses Gmail avec des points insérés au hasard. Ce n'est pas le message qui l'intéresse : c'est l'accusé de réception que le formulaire renvoyait à l'adresse saisie. Le formulaire de contact était un relais d'envoi ouvert.
  2. J-1, journée. Accusé de réception supprimé (le formulaire n'écrit plus qu'au bureau), filtre de contenu, plafond de mails par destinataire, journal HTTP. Le robot contact revient onze fois dans la soirée : onze fois classé spam, zéro mail. Ce vecteur-là est fermé.
  3. J-1, 17 h 34. Une adresse « résidentielle » — nous y reviendrons — charge le site, demande un challenge anti-spam sur le bandeau newsletter… et ne soumet rien. Reconnaissance. Elle repère le formulaire, le type de captcha, la réponse attendue.
  4. J-1, 20 h 17 → J, 9 h 18. Vingt soumissions sur la newsletter, une toutes les dix à trente minutes, depuis huit adresses IP différentes réparties sur deux blocs /16 annoncés par un grand opérateur télécom européen, mais sous-alloués par /22 à un tiers avec un pays de registre exotique : l'empreinte d'un pool de proxies résidentiels loué. En élargissant la recherche, quarante-cinq adresses de ces deux blocs ont visité le site en quarante-trois heures, à raison de mille requêtes par heure — un tiers du trafic total. Quatorze mails de confirmation partent vers des inconnus. Cinq sont bloqués par le plafond — parce que le robot a réutilisé une adresse.
  5. J, 9 h 30. Déploiement du sas (on y vient). Le robot continue de frapper. Il n'obtient plus rien.

Anatomie du robot : un vrai navigateur qui ment sur son nom

La question qu'on se pose tout de suite : est-ce un script qui appelle l'API, ou un navigateur ? La réponse est dans le volume et la nature des requêtes. Un appel direct au point d'entrée du formulaire tiendrait en trois requêtes : obtenir un challenge, le résoudre, poster. Chaque session du robot en fait soixante à soixante-dix, et ce sont des requêtes qu'un script n'a aucune raison — ni aucun moyen — de produire.

20:17:16  GET  /                                     page d'accueil
20:17:16  GET  /_next/static/chunks/…js   ×18          bundles JS et CSS
20:17:17  GET  /api/cours-statut                     appelé par un useEffect de la page
20:17:17  GET  /tarifs?_rsc=5CB68i4…      ┐
20:17:17  GET  /planning?_rsc=5CB68i4…    │  41 préchargements en < 1 s,
20:17:17  GET  /contact?_rsc=5CB68i4…     │  avec DEUX jetons _rsc différents
   …      GET  /…?_rsc=tXyZJ52U…          ┘
20:17:19  GET  /api/altcha/challenge                 émis par le widget anti-spam
20:17:21  POST /api/newsletter                       preuve de travail résolue, valide

Quatre indices, par force décroissante. Les préchargements ?_rsc= d'abord : le routeur client de Next.js précharge les pages des liens visibles en demandant leur payload React Server Components, avec un jeton que le runtime génère lui-même à partir de l'arbre de composants. On en voit quarante et un, avec deux jetons (deux arbres de layout), dans l'ordre exact où Next les émet. Un script ne connaît ni ce mécanisme, ni ces jetons. Le challenge anti-spam ensuite : il est demandé par l'élément <altcha-widget> une fois monté — sur ce bandeau, uniquement au focus du champ e-mail — et la solution soumise est cryptographiquement valide. Les bundles /_next/static/, inutiles à qui ne rend pas la page. Et /api/cours-statut, appelé par un useEffect : le robot du formulaire de contact, qui crawle vingt pages après avoir posté, le déclenche sur chacune, au bon moment, seulement là où le code de la page l'appelle.

Deux lignes de temps : le robot charge la page, le JavaScript, quarante et un préchargements, le challenge et soumet en cinq secondes sans aucune image ; l'humain charge aussi vingt images et le favicon, puis soumet après trois minutes
Ce que le journal HTTP montre d'une session robot et d'une session humaine sur la même page. Ce qui manque au robot est aussi parlant que ce qu'il fait.

Puis on compare avec les deux seuls humains de la période : une visiteuse sur iPhone, l'administrateur du site sur Firefox. Le tableau est sans appel.

  • Zéro image sur les cinq sessions robot, seize à vingt sur les deux humaines. Attention au raccourci : un navigateur headless charge les images par défaut — les captures d'écran Playwright fonctionnent grâce à ça. Zéro image, c'est le script qui les bloque volontairement (page.route('**/*.{png,jpg,webp}', r => r.abort())), l'optimisation n° 1 de tout scraper. C'est un choix de l'attaquant, donc un trait révélateur.
  • Zéro favicon. L'ancien mode headless de Chromium — et le chromium-headless-shell que Playwright utilise par défaut depuis sa version 1.49 — ne demande jamais le favicon ; le nouveau mode headless (le seul restant depuis Chrome 132, celui de Puppeteer) le demande, au point que des utilisateurs s'en plaignent comme d'un bug. Ça fait pencher vers Playwright — sans le prouver : les deux outils laissent la même empreinte HTTP.
  • Aucun cache d'une session à l'autre. La même adresse IP est revenue huit fois en quinze heures et a retéléchargé les dix-huit fichiers JavaScript à chaque fois, sans un seul 304. C'est la signature d'un browser.newContext() par tour : contexte vierge, cookies vides, cache vide.
  • HTTP/2 et TLS 1.3 partout. La pile réseau d'un vrai Chromium. Un python-requests parlerait HTTP/1.1.
  • Un User-Agent qui ment. « MSIE 6.0 », « AOL 9.5 / Windows 98 », « PPC Mac OS X », « Arora/0.4 sur WebKit 523 » — tiré au sort dans une liste, différent à chaque session. Un navigateur de 2005 ne peut pas exécuter Next.js ni résoudre une preuve de travail dans un Web Worker. C'est un vrai Chromium qui ment sur son nom, pas un script qui imite un Chromium.

Un dernier détail, plus révélateur que tout le reste : le robot tâtonne. Ses trois premières soumissions arrivent 1,5 seconde après le challenge. Puis, en cours de nuit, il passe à quatorze, dix-huit secondes — un délai « humanisé » ajouté à la volée. Le lendemain matin, il retombe à 0,9 seconde. Il n'a reçu aucun retour lui disant ce qui marche, alors il essaie. Retenez ce point : on ne lui a jamais rien dit, et c'est exactement ce qui fait qu'il continue à brûler ses adresses IP pour rien.

Pourquoi les défenses classiques n'ont rien vu

Le site n'était pas nu. Il avait un captcha à preuve de travail (ALTCHA, dont nous avons expliqué le choix ici), un plafond de mails par destinataire, une limitation de débit par adresse IP, et CrowdSec en observation. Chacune de ces couches a été contournée — pas par ruse, mais parce qu'aucune n'était conçue pour cette forme d'attaque.

  • La preuve de travail est une barrière de volume : elle rend coûteuses dix mille soumissions par minute. Elle ne fait rien contre une soumission toutes les vingt minutes, et un navigateur piloté résout le puzzle exactement comme le vôtre. Monter le coût de calcul ne punirait que vos visiteurs — le robot, lui, a du temps.
  • Le plafond par destinataire (un mail de confirmation par adresse et par 24 h) protège une victime donnée contre la répétition. Le robot utilise une adresse différente à chaque coup. Il n'a bloqué que ses cinq redites.
  • Le rate-limit par IP (cinq requêtes par quart d'heure) suppose que l'attaquant vient d'un endroit. Il vient de huit, à raison d'une requête chacun.
  • CrowdSec cherche des scans, des tentatives d'exploitation, du brute-force. Il a vu 115 alertes en dix-sept heures — toutes de vrais scanners — et zéro sur les huit adresses du robot. Un POST propre qui répond 200, une fois par IP, ne ressemble à rien de ce qu'il connaît. Sa liste communautaire de 17 000 adresses ne contenait aucune des huit : du résidentiel jamais signalé.

La parade : retirer l'arme

Nous avons choisi la troisième. Depuis ce matin-là, une inscription depuis le formulaire public n'envoie plus aucun mail. Elle est enregistrée dans un état intermédiaire — « à valider » — avec tout ce que la soumission a laissé comme traces, et c'est une personne du bureau qui, depuis le back-office, décide de libérer le mail de confirmation. Deux règles non négociables dans cette conception :

  • La personne du bureau ne confirme jamais à la place du visiteur. Elle ne fait que déclencher l'envoi. C'est toujours le clic du destinataire sur le lien qui vaut consentement — la preuve RGPD reste intacte. Un jeton de confirmation ne vaut rien tant que le mail n'est pas parti : s'il se présente avant, il a fuité.
  • La réponse HTTP ne change pas. Le robot reçoit le même 202 Accepted qu'avant. Rien ne lui dit qu'il est dans un sas. Voir plus haut : un attaquant sans retour d'information n'adapte rien.
// Handler d'inscription (extrait). Le mode est un réglage, pas une réécriture :
// Newsletter__ValidationManuelle=false rétablit l'envoi immédiat sous plafond.
sub.RenseignerSignaux(request.Ip, request.UserAgent,
                      request.DelaiSoumissionMs, request.PiegeRempli);

if (parametres.ValidationManuelle)
{
    sub.MettreEnAttenteValidation();   // statut AValider, aucun mail
    await uow.SaveChangesAsync(ct);
    return Unit.Value;                 // même 202 que d'habitude
}
// … chemin historique : plafond par destinataire puis envoi

Trois détails d'implémentation qui comptent plus qu'ils n'en ont l'air. Le réglage est actif par défaut, même sans la variable d'environnement : une image déployée par oubli ne redevient pas un relais. Le mail part avant le changement d'état, et l'état ne change que si le mail est parti — un échec du fournisseur d'envoi laisse la demande dans le sas avec l'erreur affichée, au lieu d'une ligne qui prétend qu'un mail est parti. Et une re-soumission de la même adresse ne régénère pas les jetons ni n'envoie rien : elle rafraîchit seulement les traces. Un test d'architecture recense par réflexion les vingt classes capables d'envoyer un e-mail et exige que chacune soit déclarée « publique » (donc plafonnée) ou « interne » — ajouter un envoi sans trancher fait échouer la CI.

Instrumenter plutôt qu'interpréter

Un sas n'est utile que si la personne qui le tient peut décider en deux secondes. Sinon elle ne voit que des adresses Gmail, et elle finit par libérer le mail du robot elle-même. Il fallait donc capturer, à chaque soumission, des signaux — et pas n'importe lesquels : des signaux que le serveur peut vérifier, pas des déclarations du navigateur.

Le délai de saisie, mesuré avec une horloge de confiance

Le widget ALTCHA renvoie un champ took (temps de résolution). Il est déclaré par le client : sans valeur. Mais le challenge lui-même est daté et signé par le serveurexpiresAt = émission + 30 min, sous HMAC. En vérifiant la solution, on retrouve l'heure d'émission, et on obtient un délai « challenge → soumission » que le client ne peut pas antidater. Un humain met plusieurs secondes à taper une adresse après que le widget a demandé son challenge ; le robot a soumis en 0,9 à 1,7 seconde sur quatre de ses tentatives.

// Vérification ALTCHA côté serveur (Next.js route handler, extrait)
const { error, payload } = await server.verify(altcha, deriveKey, secret, keySecret, usedChallenges);
const expiresAt = payload?.challenge?.parameters?.expiresAt;      // secondes Unix, signé
const emisA = Number.isFinite(expiresAt) ? expiresAt * 1000 - CHALLENGE_TTL_MS : null;
const delaiSoumissionMs = emisA ? Math.max(0, Date.now() - emisA) : null;

Le champ leurre

Un champ site_web, hors écran, hors tabulation, invisible aux lecteurs d'écran. Une personne ne le voit jamais ; un robot qui « remplit tous les champs » y met quelque chose. Vieux comme le web, gratuit, et toujours efficace contre les outils génériques — inefficace contre un script qui cible le champ e-mail par son identifiant, ce qui est probablement le cas ici. On le pose quand même : il ne coûte rien et trie une partie du bruit.

Le navigateur incohérent, lu par marqueurs d'époque

Première version : une liste de noms (MSIE, AOL, BonEcho). Elle a laissé passer deux lignes du robot en « Arora/0.4 sur WebKit 523 ». Seconde version : on ne cherche plus des noms mais des marqueurs d'époque — un WebKit inférieur à 536 (2011 ; Chrome moderne est en 537.36, Safari en 605), un Gecko 1.x, le jeton ; U; abandonné en 2011. Vérifié sans faux positif sur Firefox 154, Chrome 140, Safari iOS 18, Android et macOS. Le principe : un navigateur qui exécute votre application ne peut pas être celui qu'il prétend être si ce dernier est antérieur aux API qu'il vient d'utiliser.

const UA_ARCHAIQUE =
  /MSIE [5-9]\.|AOL \d|Windows 98|Windows NT [45]\.|PPC Mac OS X|BonEcho|Acoo Browser|Arora\/|FunWebProducts
   |AppleWebKit\/(?:[1-4]\d\d|5[0-2]\d|53[0-5])\b|rv:1\.\d|; U;/i;

Le témoin image : constater une présence plutôt qu'une absence

Le robot ne charge aucune image. On serait tenté d'en faire un critère de blocage — ce serait une erreur : un visiteur qui revient a les images en cache et n'en demande aucune non plus, comme le robot ; sans parler du mode économie de données, des lecteurs texte, des aperçus de liens. L'absence d'une requête ne prouve rien. La bonne approche retourne le problème : on donne au navigateur une image à charger dont on connaît l'identité. Une image d'un pixel, invisible, dont l'adresse porte l'identifiant du challenge anti-spam — unique, donc jamais en cache. Un vrai navigateur la charge sans y penser ; un robot qui bloque les images ne la demandera jamais. À la soumission, le serveur vérifie que le témoin de ce challenge a été chargé. Si non : signal « images non chargées », à côté des autres. Vérifiable côté serveur, lié au formulaire précis, invisible au robot, quarante-trois octets. Et si le robot s'adapte en chargeant les images, il paie vingt fichiers de plus par session — exactement ce qu'il cherchait à éviter.

Ce que nous n'avons pas fait, et pourquoi

  • Bannir sur le favicon. Les navigateurs le mettent en cache très longtemps ; Firefox ne le redemande pas d'une visite à l'autre. L'administrateur du site, ce matin-là : zéro favicon. Il aurait été le premier banni.
  • Monter le coût de la preuve de travail. Ça multiplie le temps de calcul de chaque visiteur légitime pour un robot qui, lui, a la nuit devant lui.
  • Remplacer ALTCHA par Turnstile. Soyons honnêtes : Turnstile aurait probablement arrêté ce robot-là, tel qu'il tourne — un Chromium qui se dit MSIE 6 est le cas d'école de ce qu'il détecte. Mais pas ce type d'attaque : un robot qui ajuste déjà ses délais en cours de nuit ajustera son User-Agent en une ligne, playwright-stealth masque le reste, et les fermes de résolution vendent du Turnstile résolu à un dollar les mille. Il aurait perdu une nuit, pas la campagne — et nous aurions réintroduit Cloudflare comme sous-traitant sur toutes les pages, ce qu'on avait retiré exprès.
  • Un plafond global à l'edge. Avant le sas, c'était la bonne mesure : au-delà de trois inscriptions par heure toutes sources confondues, refuser. Avec le sas, plus aucun mail ne part, donc borner les soumissions ne protège plus rien — et c'est le seul levier qui peut refuser de vrais visiteurs le soir d'une annonce.
  • Activer le bouncer CrowdSec tel quel. Dix-sept heures d'observation ont montré 115 alertes justes… et un faux positif majeur : le préchargement ?_rsc= des listes du back-office, vu comme un crawl, aurait banni l'administrateur deux fois dans la matinée. Un bouncer se met en place après avoir lu ce qu'il aurait fait, jamais avant.

Ce qui vient ensuite

Le sas neutralise les dégâts. Il reste à faire en sorte que les tentatives n'arrivent plus jusqu'à lui. Deux leviers immédiats : le rejet silencieux des signaux forts (leurre rempli ou navigateur incohérent : même 202, rien enregistré, une ligne de log — zéro faux positif possible, zéro retour pour le robot), et le blocage à l'edge des deux blocs /16 sur les seules routes de formulaire — cinq minutes de YAML, efficace tant qu'il ne change pas de pool de proxies. Puis, en chantier séparé, le bouncer CrowdSec limité aux POST des formulaires, après avoir réglé le scénario de crawl et allowlisté ce qui doit l'être. Et à moyen terme, un score de risque local qui combinera ces signaux pour trier seul les demandes propres — le jour où le sas débordera.

La checklist si vous avez un formulaire à double opt-in

  • Un access log HTTP, avant tout. Sans lui, vous ne saurez jamais ce qui s'est passé. JSON, rotation, et vérifiez que quelque chose exécute la rotation.
  • Traitez chaque envoi de mail vers une adresse saisie par un inconnu comme un relais potentiel. Accusé de réception de contact, confirmation newsletter, lien magique, relance de panier : listez-les, plafonnez-les, et faites échouer la CI si quelqu'un en ajoute un sans le déclarer.
  • Un plafond global sur les inscriptions, toutes sources confondues — ou un sas, si le volume le permet.
  • Des signaux instrumentés : délai depuis un challenge signé, leurre, témoin image. Affichés à un humain, pas transformés en bannissement automatique.
  • Ne dites jamais au robot qu'il a échoué. Même code de réponse, même corps, même délai. Le silence est votre meilleur allié : un attaquant sans retour continue à dépenser ses ressources dans le vide.
  • Surveillez votre taux de plaintes chez Google Postmaster et Microsoft SNDS. C'est là que le subscription bombing vous coûte, des semaines après.

Le mot de la fin

Personne n'a « piraté » ce site. Un formulaire ordinaire, conçu selon les bonnes pratiques, a fait exactement ce pour quoi il était conçu — envoyer un mail de confirmation — quatorze fois, pour le compte de quelqu'un d'autre. C'est le cas le plus fréquent de la sécurité web, et le moins spectaculaire : une fonction légitime, utilisée par la mauvaise personne, à la mauvaise échelle. La défense n'a pas été un produit. Elle a été de lire les logs jusqu'au bout, de comprendre ce que l'attaquant était venu chercher, et de le lui retirer — sans le lui dire.

C'est ce genre de nuit qui définit ce que nous entendons par infogérance sérieuse : pas un tableau de bord vert, mais quelqu'un qui remarque treize inscriptions là où il devrait y en avoir deux. Un formulaire qui vous inquiète, un domaine dont la délivrabilité se dégrade sans raison ? Parlons-en.

À lire ensuite

Articles liés

Un projet, une migration, un doute ?

Parlons-en. Premier échange gratuit, réponse sous 24h ouvrées.