Je tiens ce site comme un atelier : FrankenPHP en worker mode, serveur MCP, CI qui refuse de merger dès que PHPStan max tousse. À force, j'avais fini par croire que ma QA voyait tout. Eh bien, j'avais tort, et c'est une IA qui me l'a appris.
En mai 2026, Claude Mythos a lu le code source de quelques-uns des logiciels les plus critiques de la planète et y a trouvé plus de dix mille failles. Sur Symfony, le même modèle en a remonté dix-neuf — dont une exécution de code arbitraire dans Twig — et la Core Team les a toutes confirmées, sans un seul faux positif. En juin, Washington a tranché : Mythos et Fable 5, son cadet grand public, coupés pour tout le monde.
Entre les deux, j'avais lâché Fable 5 sur mon propre code.
Je voulais voir ce qu'un modèle de pointe trouverait là où ma QA ne voit plus rien. Fable 5 a passé les quelque 102 000 lignes du site au crible — lecture en parallèle, puis une passe adversariale dont le seul rôle était de réfuter chaque piste, preuve par grep à l'appui.
Et là-dedans, des bugs « high ». Pas des coquilles hein mais des features que j'avais livrées, relues, et qui ne marchaient pas, et en silence en plus. La plus belle, changer un mot de passe dans mon admin qui ne changeait rien : flash vert, « mot de passe mis à jour », ancien hash conservé, et un test que j'ai oublié d'écrire.
Ça ne m'a pas appris que je code mal, mais ça a confirmé ce que ce billet va dérouler : le pire trou de sécurité, ce n'est pas le code qui manque, c'est le code qui ressemble à de la sécurité et qui ne s'exécute pas.
via GIPHY
Le même piège, trois fois
J'ai vu ce piège prendre trois formes en quelques semaines et toujours avec la même mécanique : un garde-fou présent, qui ne tourne pas au moment où il compte.
Visage 1 — l'absence (IDOR). On l'a vu en grand avec l'ANTS : onze millions de comptes citoyens exfiltrés à la boucle for, parce qu'entre l'authentification et la base, personne n'avait écrit le contrôle d'autorisation.
Visage 2 — le contrôle qui se saute (CVE-2026-45075). La version vicieuse : le contrôle est écrit, passe la revue, et ne s'exécute pas. Mai 2026, débusquée par cette même IA dans Symfony :
#[Route('/admin/export', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN', methods: ['GET'])]
public function export(): Response { /* ... */ }L'attribut protège parfaitement les GET. Mais le routeur sert les requêtes HEAD via le handler GET, tandis que l'attribut, lui, ne voyait pas HEAD : une requête HEAD /admin/export exécute le contrôleur avec le contrôle ROLE_ADMIN silencieusement sauté. Pas de corps en réponse, mais les en-têtes fuient et les effets de bord s'exécutent. L'attribut est là. En revue, il rassure. Sur un HEAD, il ne fait rien. (Corrigé en 7.4.12 / 8.0.12 : Symfony inclut désormais HEAD dès que tu listes GET.)
Visage 3 — le mécanisme jamais branché (mon code). Et le troisième, c'est Fable 5 qui me l'a mis sous le nez :
final class UserCrudController extends AbstractCrudController
{
// Ressemble à un EventSubscriber. N'en est pas un.
public function getSubscribedEvents(): array
{
return [BeforeEntityUpdatedEvent::class => 'setPassword'];
}
public function setPassword(/* ... */): void { /* hash + set */ }
}AbstractCrudController d'EasyAdmin n'implémente pas EventSubscriberInterface. Sans cette interface, l'autoconfiguration ne taggue jamais la classe kernel.event_subscriber, et personne n'introspecte getSubscribedEvents(). Résultat : setPassword() n'est jamais appelé. Le champ valide, le flash s'affiche, l'ancien hash reste. Le cas qui fait froid dans le dos : la rotation d'un mot de passe compromis qui ne prend pas effet. Aucun test ne pouvait l'attraper — le code est là, il a l'air de tourner.
Le correctif, c'est la même méthode — getSubscribedEvents() — mais posée sur une classe qui, elle, implémente l'interface. Le code était bon ; c'est le câblage qui manquait. Comme quoi coder tard sans relire, on en oublie pas l'essentiel.
final readonly class UserPasswordSubscriber implements EventSubscriberInterface
{
// __construct(UserPasswordService, RequestStack)
public static function getSubscribedEvents(): array
{
return [
BeforeEntityPersistedEvent::class => 'hashPassword',
BeforeEntityUpdatedEvent::class => 'hashPassword',
];
}
public function hashPassword(BeforeEntityPersistedEvent|BeforeEntityUpdatedEvent $event): void
{
$user = $event->getEntityInstance();
if (!$user instanceof User) {
return; // events EasyAdmin globaux : on ne réagit qu'au form User
}
// hash + set, délégué à UserPasswordService
}
}Un seul événement à couvrir ? Un listener #[AsEventListener] à __invoke unique fait l'affaire, plus léger encore. Trois incarnations, une seule leçon. Et la pire des trois n'est pas dans mon code : elle est dans le moteur de template que des milliers de projets font tourner sans y penser.
L'autopsie : CVE-2026-46640
D'abord, qui est concerné. Si tous tes templates Twig sont écrits par toi et vivent dans templates/, lis la suite tranquille : cette faille ne te touche pas. Elle vise un cas précis mais répandu — le rendu d'un template qu'un utilisateur peut fournir : CMS qui laisse éditer un thème, éditeur d'e-mails, outil low-code, plateforme multi-tenant.
Pour ces cas-là, Twig fournit le sandbox : une SecurityPolicy qui n'autorise qu'une liste blanche de tags, filtres et fonctions. C'est censé être la cloison étanche entre le template de l'utilisateur et ton serveur.
Le mécanisme. Depuis Twig 3.15, la syntaxe objet.(expression) accède à un attribut dynamique — elle remplace l'ancienne fonction attribute(). Le piège : quand l'objet est _self et que l'expression est une chaîne littérale, le parser court-circuite vers le chemin d'appel de macro et concatène cette chaîne — contrôlée par l'attaquant — directement dans le nom d'une MacroReferenceExpression, sans vérifier que c'est un identifiant valide. À la compilation, ce nom est recraché brut dans le PHP généré.
{# Un template "sandboxé" fourni par l'utilisateur… #}
{{ _self.(<chaîne attaquant>) }}
{# …dont la chaîne atterrit telle quelle dans le PHP compilé du template. #}Et voilà tout le sujet. Le code malveillant est injecté à la compilation du template, donc exécuté avant que checkSecurity() ne soit jamais appelé. Le sandbox contrôle à l'exécution ce que le template a le droit de faire ; ici, le mal est fait à la compilation, une étape plus tôt. La cloison étanche est bien là — l'attaquant entre par la pièce d'avant. Sandbox global, liste blanche vide : aucune importance. Bypass complet.
C'est, à la lettre, le fil rouge de ce billet. IDOR oubliait d'écrire le contrôle ; le HEAD le sautait ; ici le sandbox arrive une étape trop tard. À chaque fois : un garde-fou présent, qui ne s'exécute pas au moment où il compte.
Le fix. Corrigé dans Twig 3.26.0 : le parser valide désormais que l'attribut dynamique résout vers un vrai identifiant de macro avant d'emprunter ce chemin, et le compilateur émet le nom par une voie échappée. De ton côté, ça tient en un composer update twig/twig — vérifie que tu es en ≥ 3.26.0. Si tu rends du Twig non-fiable, c'est un patch à passer sans attendre.
Le détail qui donne le vertige. Ce n'est pas un oubli de débutant ou une erreur idiote mais un écart subtil entre deux phases — compilation et exécution. Une IA lâchée en lecture adversariale sur le code source a pu repérer cette faille.
Ce que ça change vraiment
Si une IA trouve dix mille failles critiques en un mois, et dix-neuf dans Symfony, certaines avec le correctif fourni dans la foulée, alors la découverte de vulnérabilités vient de changer de coût. Ce qui demandait un chercheur sécu, des semaines et un budget devient une passe de lecture qu'on lance le soir. Pour les deux camps. L'attaquant qui boucle sur tes endpoints n'a plus besoin d'être bon : il a besoin d'un agent.
Et là, le calendrier devient cruel. Au moment précis où l'outil bascule du laboratoire à la routine, on en coupe l'accès : Fable 5 et Mythos 5, interdits à tout utilisateur non américain par décision de l'administration US. Officiellement au nom de la sécurité nationale, après qu'une société a contourné ses garde-fous, couper l'outil défensif parce que quelqu'un l'a retourné en arme.
Avant ce blocage, j'avais lâché sur mon code Fable 5 ; deux jours plus tard, il passait sous contrôle export. L'asymétrie n'est pas une vue de l'esprit : je l'ai vécue dans les deux sens, coup sur coup.
Le bras de fer géopolitique — qui a le droit d'utiliser quel modèle, et au nom de quoi — se jouera ailleurs, par des gens dont c'est le métier. Ici, on reste au ras du code, et au ras du code, on en tire la mauvaise leçon si on conclut « il faut le meilleur modèle ». La bonne : quand l'outil de pointe peut t'être retiré du jour au lendemain, le seul avantage qui te reste est structurel.
Du code qui ne peut pas mal tourner plutôt que du code qu'on surveille — un ID hors de l'URL, un sandbox qui s'exécute au bon moment, un listener réellement branché. Et les agents que tu as encore sous la main, pointés non pas sur ta vélocité mais sur tes propres failles, avant la prod. C'est ça, à mon sens, la première utilité du code par agent : pas écrire la feature plus vite, mais trouver ce qui, dedans, ne marche pas ou ne protège plus.
Le mot de la fin
J'ai corrigé bien entendu la mise à jour des mots de passe. Une interface pour qu'un mécanisme qui avait l'air de tourner se mette enfin à tourner. La même économie qu'avec IDOR : les huit lignes de test qui auraient sauvé onze millions de comptes, les huit caractères de subject: qui transforment un rôle en autorisation. La sécurité ne tient presque jamais à un gros morceau, elle tient à ces détails-là : ceux qu'on relit un vendredi à 18 h en se disant que ça ira.
Ce qui a changé en 2026, c'est qui les voit : hier, un humain attentif, parfois et aujourd'hui, une IA en lecture adversariale, systématiquement, quand on y a ccès. Le métier glisse : moins écrire la ligne, plus garantir qu'elle s'exécute, et orchestrer la machine qui le vérifie.
Une coquille, une erreur dans ce billet ? Signale-la-moi.