¿Cómo puedo reconocer una expresión regular malvada?

83

Recientemente me di cuenta de los ataques de denegación de servicio de expresión regular y decidí eliminar los llamados patrones de expresiones regulares 'malvados' dondequiera que pudiera encontrarlos en mi código base, o al menos aquellos que se usan en la entrada del usuario. Los ejemplos dados en el enlace OWASP anterior y wikipedia son útiles, pero no hacen un gran trabajo al explicar el problema en términos simples.

Una descripción de las expresiones regulares malignas, de wikipedia :

  • la expresión regular aplica repetición ("+", "*") a una subexpresión compleja;
  • para la subexpresión repetida, existe una coincidencia que también es un sufijo de otra coincidencia válida.

Con ejemplos, nuevamente de wikipedia :

  • (a+)+
  • ([a-zA-Z]+)*
  • (a|aa)+
  • (a|a?)+
  • (.*a){x} para x> 10

¿Es este un problema que simplemente no tiene una explicación más simple? Estoy buscando algo que facilite evitar este problema al escribir expresiones regulares, o encontrarlas dentro de una base de código existente.

Mike Partridge
fuente
7
Otro enlace sobre este tema es este: regular-expressions.info/catastrophic.html
Daniel Hilgarth
1
Aquí hay una herramienta para realizar análisis estáticos en expresiones regulares para descubrir posibles problemas de ReDoS
tripleee
El enlace proporcionado por @tripleee parece tener un enlace roto a la herramienta RXXR. Aquí hay un espejo de GitHub: github.com/ConradIrwin/rxxr2
Mike Hill
3
Además, para aquellos curiosos, parece que los autores de la herramienta RXXR original la reemplazaron con RXXR2. Su nueva página está alojada aquí y actualmente tiene un enlace de trabajo a la fuente de RXXR2
Mike Hill

Respuestas:

76

¿Por qué las expresiones regulares malvadas son un problema?

Porque las computadoras hacen exactamente lo que les dices que hagan, incluso si no es lo que querías decir o es totalmente irrazonable. Si le pide a un motor Regex que demuestre que, para una entrada determinada, existe o no una coincidencia para un patrón determinado, entonces el motor intentará hacerlo sin importar cuántas combinaciones diferentes deban probarse.

Aquí hay un patrón simple inspirado en el primer ejemplo en la publicación del OP:

^((ab)*)+$

Dada la entrada:

abababababababababababab

El motor de expresiones regulares intenta algo como (abababababababababababab)y se encuentra una coincidencia en el primer intento.

Pero luego tiramos la llave inglesa:

abababababababababababab a

El motor lo intentará primero, (abababababababababababab)pero eso falla debido a ese extra a. Esto provoca un seguimiento catastrófico, porque nuestro patrón (ab)*, en una muestra de buena fe, liberará una de sus capturas ("retrocederá") y dejará que el patrón externo vuelva a intentarlo. Para nuestro motor de expresiones regulares, se parece a esto:

(abababababababababababab)- No
(ababababababababababab)(ab)- No
(abababababababababab)(abab)- No
(abababababababababab)(ab)(ab)- No
(ababababababababab)(ababab)- No
(ababababababababab)(abab)(ab)- No
(ababababababababab)(ab)(abab)- No
(ababababababababab)(ab)(ab)(ab)- No
(abababababababab)(abababab)- No
(abababababababab)(ababab)(ab)- No
(abababababababab)(abab)(abab)- No
(abababababababab)(abab)(ab)(ab)- No
(abababababababab)(ab)(ababab)- No
(abababababababab)(ab)(abab)(ab)- No
(abababababababab)(ab)(ab)(abab)- No
(abababababababab)(ab)(ab)(ab)(ab)- No
(ababababababab)(ababababab)- No
(ababababababab)(abababab)(ab)- No
(ababababababab)(ababab)(abab)- No
(ababababababab)(ababab)(ab)(ab)- No
(ababababababab)(abab)(abab)(ab)- No
(ababababababab)(abab)(ab)(abab)- No
(ababababababab)(abab)(ab)(ab)(ab)- No
(ababababababab)(ab)(abababab)- No
(ababababababab)(ab)(ababab)(ab)- No - No - No
(ababababababab)(ab)(abab)(abab)- No
(ababababababab)(ab)(abab)(ab)(ab)- No
(ababababababab)(ab)(ab)(ababab)- No
(ababababababab)(ab)(ab)(abab)(ab)- No
(ababababababab)(ab)(ab)(ab)(abab)- No
(ababababababab)(ab)(ab)(ab)(ab)(ab)- No
                              ...
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(abababab) - No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ababab)(ab)- No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(abab)(abab)- No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(abab)(ab)(ab)- No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ababab)- No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(abab)(ab)- No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(abab)- No
(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)(ab)- No

El número de combinaciones posibles escala exponencialmente con la longitud de la entrada y, antes de que te des cuenta, el motor de expresiones regulares está consumiendo todos los recursos de tu sistema tratando de resolver esto hasta que, habiendo agotado todas las combinaciones posibles de términos, finalmente se da por vencido y informa "No hay coincidencia". Mientras tanto, su servidor se ha convertido en una pila ardiente de metal fundido.

Cómo detectar expresiones regulares malvadas

De hecho, es muy complicado. Yo mismo he escrito un par, aunque sé cuáles son y, en general, cómo evitarlos. Vea Regex tomando un tiempo sorprendentemente largo . Envolver todo lo que pueda en un grupo atómico puede ayudar a prevenir el problema del retroceso. Básicamente, le dice al motor de expresiones regulares que no vuelva a visitar una expresión determinada: "bloquee lo que haya coincidido en el primer intento". Sin embargo, tenga en cuenta que las expresiones atómicas no evitan el retroceso dentro de la expresión, por ^(?>((ab)*)+)$lo que sigue siendo peligroso, pero ^(?>(ab)*)+$seguro (coincidirá (abababababababababababab)y luego se negará a ceder cualquiera de sus caracteres coincidentes, evitando así un retroceso catastrófico).

Desafortunadamente, una vez que está escrito, en realidad es muy difícil encontrar inmediatamente o rápidamente una expresión regular problemática. Al final, reconocer una expresión regular incorrecta es como reconocer cualquier otro código incorrecto : se necesita mucho tiempo y experiencia y / o un solo evento catastrófico.


Curiosamente, desde que se escribió esta respuesta por primera vez, un equipo de la Universidad de Texas en Austin publicó un artículo que describe el desarrollo de una herramienta capaz de realizar análisis estáticos de expresiones regulares con el propósito expreso de encontrar estos patrones "malvados". La herramienta fue desarrollada para analizar programas Java, pero sospecho que en los próximos años veremos más herramientas desarrolladas para analizar y detectar patrones problemáticos en JavaScript y otros lenguajes, especialmente a medida que la tasa de ataques ReDoS continúa aumentando .

Detección estática de vulnerabilidades DoS en programas que utilizan expresiones regulares
Valentin Wüstholz, Oswaldo Olivo, Marijn JH Heule e Isil Dillig
La Universidad de Texas en Austin

JDB todavía recuerda a Monica
fuente
Esta es una muy buena respuesta para describir / por qué / el ejemplo de expresiones regulares lleva mucho tiempo, pero estoy buscando algunas reglas que una persona pueda internalizar para ayudar a reconocer un problema de expresiones regulares.
Mike Partridge
4
Saber "por qué" es el paso más importante para evitar escribir una expresión regular "malvada". Desafortunadamente, una vez que está escrito, en realidad es muy difícil encontrar inmediatamente o rápidamente una expresión regular problemática. Si desea una solución general, la agrupación atómica suele ser la mejor manera de hacerlo, pero eso puede tener un impacto significativo en los patrones con los que coincidirá la expresión regular. Al final, reconocer una expresión regular incorrecta es como una expresión regular de cualquier otro código incorrecto: se necesita mucha experiencia, mucho tiempo y / o un solo evento catastrófico.
JDB todavía recuerda a Monica el
Es por eso que prefiero los motores de expresiones regulares que no admiten el retroceso sin que el usuario lo fuerce. IE lex / flex.
Spencer Rathbun
@MikePartridge es el problema común de la teoría clásica de TI, decidir si algún código se repetirá infinitamente o se detendrá es un problema de tipo NP-completo. Con las expresiones regulares, probablemente pueda adivinar / capturar algunas de ellas buscando ciertos patrones / reglas, pero a menos que haga un análisis NP-completo pesado, nunca las detectará todas. Algunas opciones: 1) nunca permita que el usuario ingrese regexp a su servidor. 2) configure el motor de expresiones regulares para finalizar el cálculo lo suficientemente temprano (pero pruebe su expresión regular válida en su código aún funciona, incluso con límites estrictos). 3) ejecute el código de expresiones regulares en un hilo de baja prioridad con límites de cpu / mem.
Ped7g
1
@MikePartridge: recientemente encontré un artículo sobre algunas herramientas nuevas que se están desarrollando para detectar estas expresiones regulares problemáticas de manera estática. Cosas interesantes ... creo que valdrá la pena seguirlas.
JDB todavía recuerda a Monica el
12

Lo que usted llama una expresión regular "malvada" es una expresión regular que exhibe un retroceso catastrófico . La página vinculada (que escribí) explica el concepto en detalle. Básicamente, el retroceso catastrófico ocurre cuando una expresión regular no coincide y diferentes permutaciones de la misma expresión regular pueden encontrar una coincidencia parcial. El motor de expresiones regulares intenta todas esas permutaciones. Si desea revisar su código e inspeccionar sus expresiones regulares, estos son los 3 problemas clave a tener en cuenta:

  1. Las alternativas deben ser mutuamente excluyentes. Si varias alternativas pueden coincidir con el mismo texto, el motor probará ambas si falla el resto de la expresión regular. Si las alternativas están en un grupo que se repite, tiene un retroceso catastrófico. Un ejemplo clásico es (.|\s)*hacer coincidir cualquier cantidad de texto cuando el tipo de expresión regular no tiene un modo "el punto coincide con los saltos de línea". Si esto es parte de una expresión regular más larga, una cadena de asunto con una serie de espacios lo suficientemente larga (emparejados por ambos .y \s) romperá la expresión regular. La solución es utilizar (.|\n)*para hacer que las alternativas sean mutuamente excluyentes o incluso mejor para ser más específico sobre qué caracteres están realmente permitidos, como [\r\n\t\x20-\x7E]para imprimibles ASCII, pestañas y saltos de línea.

  2. Los tokens cuantificados que están en secuencia deben ser mutuamente excluyentes entre sí o ser mutuamente excluyentes lo que se interpone entre ellos. De lo contrario, ambos pueden coincidir con el mismo texto y se probarán todas las combinaciones de los dos cuantificadores cuando el resto de la expresión regular no coincida. Un ejemplo clásico es a.*?b.*?chacer coincidir 3 cosas con "cualquier cosa" entre ellas. Cuando cno se puede igualar, el primero .*?se expandirá carácter por carácter hasta el final de la línea o archivo. Para cada expansión, la segunda .*?se expandirá carácter por carácter para coincidir con el resto de la línea o archivo. La solución es darse cuenta de que no puede haber "nada" entre ellos. La primera carrera debe detenerse en by la segunda carrera debe detenerse en c. Con personajes únicosa[^b]*+b[^c]*+ces una solución fácil. Dado que ahora nos detenemos en el delimitador, podemos usar cuantificadores posesivos para aumentar aún más el rendimiento.

  3. Un grupo que contiene un token con un cuantificador no debe tener un cuantificador propio a menos que el token cuantificado dentro del grupo solo se pueda emparejar con otra cosa que sea mutuamente excluyente con él. Eso asegura que no hay forma de que menos iteraciones del cuantificador externo con más iteraciones del cuantificador interno puedan coincidir con el mismo texto que más iteraciones del cuantificador externo con menos iteraciones del cuantificador interno. Este es el problema ilustrado en la respuesta de JDB.

Mientras escribía mi respuesta, decidí que esto merecía un artículo completo en mi sitio web . Esto ahora también está en línea.

Jan Goyvaerts
fuente
10

Lo resumiría como "Una repetición de una repetición". El primer ejemplo que enumeró es bueno, ya que dice "la letra a, una o más veces seguidas. Esto puede volver a suceder una o más veces seguidas".

Lo que debe buscar en este caso es una combinación de cuantificadores, como * y +.

Una cosa algo más sutil a tener en cuenta es la tercera y cuarta. Esos ejemplos contienen una operación OR, en la que ambos lados pueden ser verdaderos. Esto, combinado con un cuantificador de la expresión, puede resultar en MUCHAS coincidencias potenciales dependiendo de la cadena de entrada.

En resumen, estilo TLDR:

Tenga cuidado con cómo se utilizan los cuantificadores en combinación con otros operadores.

Jarmund
fuente
3
Actualmente, esta respuesta se acerca más a lo que estoy buscando: una regla general para reconocer una expresión regular que podría causar un retroceso catastrófico.
Mike Partridge
1
Lo que dejó fuera, y lo que parece ser una parte importante del problema, es la captura de grupos.
Mike Partridge
@MikePartridge Eso también. Traté de reducirlo tanto como sea posible, por lo que hay otras cosas que pueden causar las mismas cosas, como capturar grupos.
Jarmund
7

Sorprendentemente, me he encontrado con ReDOS varias veces realizando revisiones de código fuente. Una cosa que recomendaría es usar un tiempo de espera con cualquier motor de expresión regular que esté usando.

Por ejemplo, en C # puedo crear la expresión regular con un TimeSpanatributo.

string pattern = @"^<([a-z]+)([^<]+)*(?:>(.*)<\/\1>|\s+\/>)$";
Regex regexTags = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1.0));
try
{
    string noTags = regexTags.Replace(description, "");
    System.Console.WriteLine(noTags);
} 
catch (RegexMatchTimeoutException ex)
{
    System.Console.WriteLine("RegEx match timeout");
}

Esta expresión regular es vulnerable a la denegación de servicio y, sin el tiempo de espera, girará y consumirá recursos. Con el tiempo de espera, lanzará un RegexMatchTimeoutExceptiondespués del tiempo de espera dado y no provocará el uso de recursos que conduzca a una condición de Denegación de servicio.

Querrá experimentar con el valor de tiempo de espera para asegurarse de que funcione para su uso.

Casey
fuente
7

Detectando expresiones regulares malvadas

  1. Pruebe el proyecto RegexStaticAnalysis de Nicolaas Weideman .
  2. Pruebe mi detector vuln-regex -estilo de conjunto que tiene una CLI para la herramienta de Weideman y otras.

Reglas de juego

Las expresiones regulares malvadas siempre se deben a la ambigüedad en el NFA correspondiente, que puede visualizar con herramientas como la expresión regular .

A continuación se muestran algunas formas de ambigüedad. No los use en sus expresiones regulares.

  1. Cuantificadores de anidación como (a+)+(también conocido como "altura de estrella> 1"). Esto puede provocar una explosión exponencial. Vea la safe-regexherramienta de la sub-pila .
  2. Disyunciones superpuestas cuantificadas como (a|a)+. Esto puede provocar una explosión exponencial.
  3. Evite adyacencias superpuestas cuantificadas como \d+\d+. Esto puede provocar la explosión de un polinomio.

Recursos adicionales

Escribí este artículo sobre expresiones regulares súper lineales. Incluye un montón de referencias a otras investigaciones relacionadas con las expresiones regulares.

James Davis
fuente
4

Yo diría que esto está relacionado con el motor de expresiones regulares en uso. Es posible que no siempre pueda evitar este tipo de expresiones regulares, pero si su motor de expresiones regulares está construido correctamente, entonces es un problema menor. Consulte esta serie de blogs para obtener una gran cantidad de información sobre el tema de los motores de expresiones regulares.

Tenga en cuenta la advertencia al final del artículo, en el sentido de que retroceder es un problema NP-Completo. Actualmente no hay forma de procesarlos de manera eficiente y es posible que desee rechazarlos en su entrada.

Spencer Rathbun
fuente
a*a*no utiliza referencias inversas. Ahora, el motor de expresiones regulares usa retroceso , que es, quizás, lo que quisiste decir. En cuyo caso, todos los motores modernos utilizan el retroceso. Puede desactivar fácilmente el retroceso a través de (?>...), pero eso a menudo no cambiará el significado de su expresión (y en algunos casos puede eludirse).
JDB todavía recuerda a Monica
@ Cyborgx37 ¡Ups! Quise decir retroceder. Fijo.
Spencer Rathbun
En ese caso, el motor usa retroceso o no lo hace. Prácticamente no hay forma de restringir el retroceso restringiendo la entrada.
JDB todavía recuerda a Monica
2
@JDB: "todos los motores modernos usan retroceso". - Quizás eso fuera cierto en 2013, pero ya no .
Kevin
@Kevin - seguro. tú ganas.
JDB todavía recuerda a Monica el
3

No creo que puedas reconocer tales expresiones regulares, al menos no todas o no sin limitar restrictivamente su expresividad. Si realmente le importan los ReDoS, trataría de aislarlos y eliminar su procesamiento con un tiempo de espera. También es posible que existan implementaciones de RegEx que le permitan limitar su cantidad máxima de retroceso.

Bergi
fuente
2
Creo que estás malinterpretando la pregunta. Mientras lo leía, el OP es, literalmente, preguntando cómo se puede reconocer una expresión regular mal, no cómo se puede escribir un programa para hacerlo. Como, "He escrito esta expresión regular, pero ¿cómo puedo saber si podría ser malvada?"
ruakh
Uh, podrías tener razón. Entonces solo puedo recomendar el artículo sobre retroceso catastrófico que @DanielHilgarth ya vinculó en los comentarios.
Bergi
2
@ 0x90: Porque no considero, por ejemplo, a*o \*ser "vulnerable".
ruakh
1
@ 0x90 a*no es vulnerable en absoluto. Mientras tanto, a{0,1000}a{0,1000}hay una expresión regular catastrófica esperando que suceda. Incluso a?a?puede tener resultados desagradables en las condiciones adecuadas.
JDB todavía recuerda a Monica
2
@ 0x90 - El retroceso catastrófico es un peligro siempre que tenga dos expresiones donde una es idéntica o un subconjunto de la otra, donde la longitud de la expresión es variable y donde se colocan de tal manera que uno podría ceder uno o más caracteres al otros a través del retroceso. Por ejemplo, a*b*c*$es seguro, pero a*b*[ac]*$peligroso, porque a*podría ceder caracteres a [ac]*si bestá ausente y la coincidencia inicial falla (p aaaaaaaaaaaccccccccccd. Ej .).
JDB todavía recuerda a Monica
0

Hay algunas formas en las que puedo pensar en que podría implementar algunas reglas de simplificación ejecutándolas en pequeñas entradas de prueba o analizando la estructura de la expresión regular.

  • (a+)+ se puede reducir usando algún tipo de regla para reemplazar operadores redundantes (a+)
  • ([a-zA-Z]+)* también podría simplificarse con nuestra nueva regla de combinación de redundancia para ([a-zA-Z]*)

La computadora podría ejecutar pruebas ejecutando las pequeñas subexpresiones de la expresión regular contra secuencias generadas aleatoriamente de los caracteres relevantes o secuencias de caracteres, y ver en qué grupos terminan todos. Para la primera, la computadora es como, oye la expresión regular quiere unos, así que intentémoslo 6aaaxaaq. Luego ve que todas las a, y solo el primer grupom terminan en un grupo, y concluye que no importa cuántas a se pongan, no importará, ya que se +pone todo en el grupo. El segundo es como, oye, la expresión regular quiere un montón de letras, así que intentémoslo con -fg0uj=, y luego ve que nuevamente cada grupo está en un grupo, por lo que se deshace del +al final.

Ahora necesitamos una nueva regla para manejar las siguientes: la regla de eliminar opciones irrelevantes.

  • Con (a|aa)+, la computadora le echa un vistazo y dice, nos gusta ese gran segundo, pero podemos usar ese primero para llenar más espacios, obtengamos tantos aa como podamos, y veamos si podemos obtener algo más. después de que terminemos. Podría ejecutarlo contra otra cadena de prueba, como `eaaa @ a ~ aa '. para determinar eso.

  • Puede protegerse (a|a?)+haciendo que la computadora se dé cuenta de que las cadenas que coinciden con a?no son los droides que estamos buscando, porque como siempre puede coincidir en cualquier lugar, decidimos que no nos gustan las cosas como (a?)+y las desechamos.

  • Protegemos (.*a){x}al hacer que se dé cuenta de que los personajes emparejados por aya habrían sido capturados .*. Luego desechamos esa parte y usamos otra regla para reemplazar los cuantificadores redundantes en (.*){x}.

Si bien implementar un sistema como este sería muy complicado, este es un problema complicado y puede ser necesaria una solución complicada. También debe usar técnicas que otras personas hayan mencionado, como permitirle a la expresión regular una cantidad limitada de recursos de ejecución antes de matarla si no termina.

AJMansfield
fuente
1
"ser como", reconocer lo que algo "quiere", "intentar" conjeturas, "ver" y sacar conclusiones ("darse cuenta", "determinar") son problemas no triviales que son difíciles de implementar algorítmicamente para las computadoras ... Y los ejemplos de prueba son nada en lo que confiar, preferiría necesitar algún tipo de prueba.
Bergi
@Bergi Lo que quise decir con los ejemplos de prueba es que se toma una pequeña parte de una expresión regular completa y se ejecuta en una cadena de prueba, como una forma sencilla de determinar cómo se comporta. Por supuesto, solo está probando fragmentos que ha examinado y ya sabe que no hacen cosas raras en los casos de prueba.
AJMansfield