Guides1 chasse jouable incluse

Le backtracking catastrophique : pourquoi une regex peut se figer

Un motif de 7 caractères peut prendre des secondes sur une saisie de 30 caractères, et des heures sur une de 40. Comment fonctionne le backtracking, pourquoi les quantificateurs imbriqués comme (a+)+ explosent, les pannes que cela a causées chez Stack Overflow et Cloudflare, et les réécritures qui rendent un motif sûr.

Logo de Regex Hunter

· 7 min de lecture

/^key=[^=]*$/

à attraper

  • key=value
  • key=
  • key=42
  • key=on

à épargner

  • key=a=b
  • key
  • value
  • k=v

La plupart des regex s'exécutent en quelques microsecondes. Quelques-unes, sur une saisie défavorable, tournent pendant des secondes, des minutes ou plus, en monopolisant un cœur de processeur tout du long. On appelle cela le backtracking catastrophique (retour arrière catastrophique), et il a déjà mis hors ligne de vrais sites web.

La bonne nouvelle, c'est que les motifs dangereux ont une forme reconnaissable, et que les correctifs sont généralement petits. Cet article montre la forme, les chiffres et les réécritures.

01Comment fonctionne le backtracking

JavaScript, Python, Java, PHP et la plupart des autres langages utilisent un moteur à backtracking. Quand un motif offre des choix (combien de caractères prend un +, quel côté d'un | essayer), le moteur en choisit un et continue. Si la correspondance échoue plus loin, il revient en arrière et essaie le choix suivant.

Pour un motif comme ^a+$ sur aaaa!, il n'y a pas grand-chose à essayer : a+ prend les quatre a, $ échoue sur le !, le moteur rend un a à la fois, et chaque tentative échoue vite. Une poignée d'étapes, puis « aucune correspondance ».

02La forme qui explose

Placez maintenant un quantificateur à l'intérieur d'un autre :

Correspondances2/2 comme prévu
/^(a+)+$/

Doit correspondre1/1

  • aaaa

Ne doit pas1/1

  • aaaa!
Essayez-le dans le bac à sable
Il trouve les mêmes chaînes que ^a+$. La différence, c'est la quantité de travail quand la correspondance échoue.

(a+)+ veut dire « un ou plusieurs groupes, chacun d'un ou plusieurs a ». Quatre a peuvent se découper en groupes de huit façons : aaaa, aaa|a, a|aaa, aa|aa, aa|a|a, etc. Quand le ! fait échouer la correspondance, le moteur essaie chaque découpage avant d'abandonner. Chaque a supplémentaire double le nombre de découpages.

Voici ce que cela donne avec Node 23 sur un ordinateur portable ordinaire, sur une chaîne de a suivie de ! :

Longueur ^(a+)+$ ^a+$
24 a 0,12 s moins de 0,01 ms
26 a 0,5 s moins de 0,01 ms
28 a 2 s moins de 0,01 ms
40 a plus de deux heures (estimation) moins de 0,01 ms

Mêmes chaînes trouvées, coût radicalement différent. Le danger vient toujours d'une correspondance qui échoue : quand la saisie correspond, le premier chemin fonctionne généralement.

La forme générale à surveiller est un groupe répété dont l'intérieur peut correspondre au même texte de plusieurs façons :

  • Des quantificateurs imbriqués : (a+)+, (\w+)*, (\d*)+
  • Des alternatives qui se chevauchent, répétées : (a|a)+, (\w|\d)+, (.*,)*
  • Plusieurs .* à la suite sur le même texte : .*=.*=.*

03C'est arrivé en production

Stack Overflow, juillet 2016. Un message contenait environ 20 000 caractères d'espacement consécutifs au milieu d'une ligne. Une regex censée supprimer les espaces en fin de ligne, en gros \s+$, a tenté une correspondance à partir de chacune de ces espaces. Chaque tentative allait jusqu'au bout des espaces, trouvait encore du texte au lieu de la fin de ligne, et échouait. C'est un travail quadratique, environ 200 millions d'étapes, et le site est resté hors ligne 34 minutes.

Cloudflare, juillet 2019. Une nouvelle règle de pare-feu contenait un motif avec .*(?:.*=.*). Chaque serveur qui exécutait la règle est monté à 100 % de CPU, et le réseau de Cloudflare a perdu du trafic dans le monde entier pendant environ 27 minutes.

Aucun des deux motifs n'avait l'air dangereux au premier coup d'œil. Les deux se comportaient bien sur des saisies normales.

04Correctif n° 1 : rendre les choix exclusifs

Le correctif le plus propre est un motif où chaque caractère ne peut correspondre que d'une seule façon. Supprimez l'imbrication :

Correspondances2/2 comme prévu
/^a+$/

Doit correspondre1/1

  • aaaa

Ne doit pas1/1

  • aaaa!
Essayez-le dans le bac à sable
Un seul quantificateur, une seule façon de correspondre. Une saisie qui échoue échoue en quelques étapes.

Un exemple réel : des nombres séparés par des virgules. La version risquée imbrique un groupe répété autour d'un autre quantificateur et rend la virgule facultative, si bien qu'une suite de chiffres peut se découper de nombreuses façons :

Motif10 car.
/^(\d+,?)+$/
Essayez-le dans le bac à sable
Risqué : « 123 » peut se lire 1|2|3, 12|3, 1|23 ou 123. Une longue suite de chiffres suivie d'une lettre va se traîner.

La version sûre rend la virgule obligatoire entre les éléments, si bien que chaque chiffre n'a qu'une seule place possible :

Correspondances5/5 comme prévu
/^\d+(,\d+)*$/

Doit correspondre2/2

  • 1
  • 10,200,3000

Ne doit pas3/3

  • 1,
  • 1,,2
  • 1, 2
Essayez-le dans le bac à sable
Sûr : un nombre, puis zéro ou plusieurs paires « virgule, nombre ». Il n'existe qu'une façon de lire n'importe quelle saisie.

05Correctif n° 2 : dire ce qui ne peut pas apparaître

.* est souvent le coupable, car il peut correspondre à n'importe quoi, y compris au texte dont la partie suivante a besoin. Une classe négative est exclusive par construction :

Correspondances3/3 comme prévu
/^key=[^=]*$/

Doit correspondre2/2

  • key=value
  • key=

Ne doit pas1/1

  • key=a=b
Essayez-le dans le bac à sable
[^=]* ne peut pas avaler un =, donc il n'y a qu'une façon de découper la ligne.

06Correctif n° 3 : groupes atomiques et quantificateurs possessifs

Un groupe atomique (?>…) correspond une fois et ne rend jamais rien. Un quantificateur possessif (a++, \d*+) fait de même pour un seul quantificateur. L'un comme l'autre supprime les alternatives que le moteur réessaierait sinon.

Python prend en charge les deux depuis la 3.11. Java, PCRE et PHP les ont aussi. JavaScript n'a ni l'un ni l'autre, mais un lookahead avec une référence arrière se comporte comme un groupe atomique : (?=(a+))\1 prend les a une fois pour toutes, sans découpage possible.

07Correctif n° 4 : limiter la saisie, ou le moteur

  • Plafonnez la longueur de la saisie avant la recherche. Beaucoup de motifs explosifs sont inoffensifs sur 200 caractères.
  • Utilisez un moteur en temps linéaire pour les saisies non fiables. RE2 de Google (avec des bindings pour Node, Python, Go et d'autres) et la crate regex de Rust ne font jamais de backtracking : ils ne peuvent donc pas exploser. Ils ne prennent en charge ni les références arrière ni les lookarounds : c'est le prix à payer.
  • Fixez un délai d'expiration quand la plateforme le permet. En .NET, Regex accepte un matchTimeout.

08À vous de jouer

Écrivez la version sûre d'une liste de nombres séparés par des virgules :

Chasse jouablepar 12

Attrapez les nombres séparés par des virgules comme 1,22,333 (sans espace, sans élément vide).

Cibles · 0/5 attrapées

  • 1cible
  • 42cible
  • 1,2cible
  • 10,200,3000cible
  • 7,7,7cible

Innocents · erreurs : 0

  • ,1innocent
  • 1,innocent
  • 1,,2innocent
  • a,binnocent
  • 1, 2innocent
  • ,innocent

Tapez une regex — le plateau se met à jour en direct.

Essayez-le dans le bac à sable
Ça vous plaît ? Une nouvelle chasse chaque jour.Jouer la chasse du jour

09Récapitulatif

  • Signe de danger : un groupe quantifié dont l'intérieur peut correspondre au même texte de plusieurs façons : (a+)+, (\w|\d)+, (.*,)*.
  • Le coût apparaît sur les correspondances qui échouent, et il peut doubler à chaque caractère.
  • Faites en sorte que chaque caractère ne puisse correspondre que d'une façon : séparateurs obligatoires, pas d'imbrication.
  • Préférez [^x]* à .* quand la partie suivante commence par x.
  • Utilisez les groupes atomiques ou les quantificateurs possessifs quand c'est possible (Python 3.11+, Java, PCRE).
  • Pour les saisies non fiables, plafonnez leur longueur ou utilisez RE2.

10Continuez à vous entraîner